[eric] notes: delete the sticky-note feature end to end

This commit is contained in:
ciregenz
2026-07-29 20:32:24 -07:00
parent c10db35ebc
commit 162eacc543
28 changed files with 34 additions and 855 deletions
-1
View File
@@ -547,7 +547,6 @@ async def duplicate_dashboard(dashboard_id: str):
"cards": new_cards,
"view_cards": source_layout.get("view_cards", {}) or {},
"browser_cards": new_browser_cards,
"notes": source_layout.get("notes", {}) or {},
"expanded_session_ids": new_expanded,
}
-11
View File
@@ -50,16 +50,6 @@ class BrowserCardPosition(BaseModel):
docked_to: Optional[str] = None
class NotePosition(BaseModel):
note_id: str
x: float = 0
y: float = 0
width: float = 240
height: float = 200
content: str = ""
color: str = "yellow"
class DashboardLayout(BaseModel):
model_config = ConfigDict(extra="allow")
cards: dict[str, CardPosition] = Field(default_factory=dict)
@@ -67,7 +57,6 @@ class DashboardLayout(BaseModel):
browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict)
workflow_cards: dict = Field(default_factory=dict)
workflows_hub: Optional[dict] = None
notes: dict[str, NotePosition] = Field(default_factory=dict)
expanded_session_ids: list[str] = Field(default_factory=list)
+2 -2
View File
@@ -57,7 +57,7 @@ class DashboardExportable:
expanded = [b for b in (ctx.bundle_id_for(EntityType.session, s) for s in (layout.get("expanded_session_ids") or [])) if b]
return {"name": self.p_data.get("name") or "Dashboard", "layout": {
**layout, "cards": cards, "view_cards": view_cards,
"browser_cards": browser_cards, "notes": layout.get("notes") or {},
"browser_cards": browser_cards,
"expanded_session_ids": expanded,
}}
@@ -114,7 +114,7 @@ class DashboardExportable:
"updated_at": now,
"layout": {
**layout, "cards": cards, "view_cards": view_cards,
"browser_cards": browser_cards, "notes": layout.get("notes") or {},
"browser_cards": browser_cards,
"expanded_session_ids": expanded,
},
}
-57
View File
@@ -2,7 +2,6 @@
- Message.client_message_id round-trip (optimistic dedupe)
- Mode migration: 'chat' -> 'ask' on reconcile + lifespan deletion
- DashboardLayout notes round-trip
"""
from __future__ import annotations
@@ -186,62 +185,6 @@ def test_reconcile_idempotent():
assert mtime_after_first == mtime_after_second, "reconcile must be idempotent"
# --------------------------------------------------------------------------- Group 6, Notes layout serialization ---------------------------------------------------------------------------
def test_dashboard_layout_notes_round_trip():
from backend.apps.dashboards.models import DashboardLayout, NotePosition
n = NotePosition(note_id="n1", x=100, y=200, content="todo: ship",
color="yellow", width=240, height=200)
layout = DashboardLayout(notes={"n1": n})
dumped = layout.model_dump(mode="json")
assert "notes" in dumped
assert dumped["notes"]["n1"]["content"] == "todo: ship"
rehydrated = DashboardLayout.model_validate(dumped)
assert rehydrated.notes["n1"].content == "todo: ship"
assert rehydrated.notes["n1"].color == "yellow"
def test_dashboard_layout_legacy_no_notes():
"""Older dashboard JSON without 'notes' must still load cleanly."""
from backend.apps.dashboards.models import DashboardLayout
legacy = {
"cards": {}, "view_cards": {}, "browser_cards": {},
"expanded_session_ids": [],
}
layout = DashboardLayout.model_validate(legacy)
assert layout.notes == {}
def test_notes_stress_many_round_trips():
"""500 notes with random colors / positions must all serialize."""
from backend.apps.dashboards.models import DashboardLayout, NotePosition
notes = {}
colors = ["yellow", "pink", "blue", "green", "purple", "gray"]
for i in range(500):
nid = f"n{i}"
notes[nid] = NotePosition(
note_id=nid,
x=random.uniform(-5000, 5000),
y=random.uniform(-5000, 5000),
width=random.uniform(160, 600),
height=random.uniform(120, 600),
content="x" * random.randint(0, 5000),
color=random.choice(colors),
)
layout = DashboardLayout(notes=notes)
dumped = layout.model_dump(mode="json")
rehydrated = DashboardLayout.model_validate(dumped)
assert len(rehydrated.notes) == 500
for nid, orig in notes.items():
assert rehydrated.notes[nid].content == orig.content
assert rehydrated.notes[nid].color == orig.color
# --------------------------------------------------------------------------- Group 7, Concurrent send_message dedupe stress Real-world scenario: user mashes Enter quickly. 50 concurrent sends each with a unique client_message_id must produce 50 echoed messages carrying the right ids. Pure pydantic / asyncio test, no real agent loop. ---------------------------------------------------------------------------
+1 -1
View File
@@ -302,7 +302,7 @@ def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, mo
"cards": {sid1: {"session_id": sid1}, sid2: {"session_id": sid2}},
"view_cards": {},
"browser_cards": {bkey: {"browser_id": bkey, "url": "u", "spawned_by": None}},
"notes": {}, "expanded_session_ids": [sid1],
"expanded_session_ids": [sid1],
}}))
raw, _ = closure.build_bundle(EntityType.dashboard, did)
+5 -9
View File
@@ -55,18 +55,17 @@ test.describe('combinatorial user flows', () => {
await el.click({ timeout: 8_000 });
return el;
};
// The bottom dashboard toolbar (New Agent / Add note / Add App / Browser) only
// mounts when a dashboard is active; a clean CI profile has none, so create one
// via the sidebar "+". Idempotent: returns early if the toolbar is already up.
// The bottom spawn pill only mounts when a dashboard is active; a clean CI profile has none, so create one via the sidebar "+". Idempotent: returns early if the pill is already up.
const ensureDashboardActive = async () => {
const spawnPill = page.getByText('Ask me anything...', { exact: true });
const toggle = page.locator('[data-onboarding="sidebar-toggle"]');
if ((await toggle.getAttribute('aria-expanded')) === 'false') await toggle.click({ timeout: 5_000 }).catch(() => {});
await clickMust(page.locator('[data-onboarding="sidebar-dashboards"]'), 'sidebar dashboards');
if (await page.getByRole('button', { name: 'Add note' }).isVisible().catch(() => false)) return;
if (await spawnPill.isVisible().catch(() => false)) return;
const createBtn = page.locator('[data-onboarding="sidebar-dashboards"] button').first();
if (await createBtn.count()) await createBtn.click({ timeout: 5_000 }).catch(() => {});
await expect.poll(() => page.url(), { timeout: 8_000 }).toMatch(/\/dashboard\//);
await expect(page.getByRole('button', { name: 'Add note' }), 'dashboard toolbar never mounted').toBeVisible({ timeout: 10_000 });
await expect(spawnPill, 'dashboard spawn pill never mounted').toBeVisible({ timeout: 10_000 });
};
const errorsSince = (mark: number) => errors.slice(mark).filter((e) => !CONSOLE_WHITELIST.some((rx) => rx.test(e.text)));
const assertNoNew = (mark: number, label: string) => {
@@ -301,12 +300,9 @@ test.describe('combinatorial user flows', () => {
assertNoNew(mark, 'Browser card mount (webview)');
});
test('dashboard toolbar: Add note + Add App + History each mount their surfaces', async () => {
test('dashboard toolbar: Add App + History each mount their surfaces', async () => {
const mark = errors.length;
await ensureDashboardActive();
await clickMust(page.getByRole('button', { name: 'Add note' }), 'toolbar Add note');
assertNoNew(mark, 'Add note mount');
await clickMust(page.getByRole('button', { name: 'Add App' }), 'toolbar Add App');
// Picker is a dialog; closing via Escape is enough.
await page.keyboard.press('Escape').catch(() => {});
-7
View File
@@ -183,13 +183,6 @@ test.describe('deep interactive coverage', () => {
await page.waitForTimeout(500);
});
test('Add note mounts (sticky)', async ({}, info) => {
await safeClick(page.getByRole('button', { name: 'Add note' }) as any, 'Add note');
await page.waitForTimeout(1500);
await page.screenshot({ path: info.outputPath('note.png') });
noNewCrashes('Add note mount');
});
test('Add App picker opens', async ({}, info) => {
await safeClick(page.getByRole('button', { name: 'Add App' }) as any, 'Add App');
await page.waitForTimeout(1500);
@@ -41,7 +41,6 @@ interface Props {
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
onAddNote: () => void;
dashboardId?: string;
newAgentBounce?: boolean;
canvasEmpty?: boolean;
@@ -71,7 +70,7 @@ function formatRelativeTime(dateStr: string | null): string {
}
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, onAddNote, dashboardId, newAgentBounce, canvasEmpty, onNewAgentBounceEnd, prefillPrompt, prefillMode }, ref) => {
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId, newAgentBounce, canvasEmpty, onNewAgentBounceEnd, prefillPrompt, prefillMode }, ref) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
@@ -608,7 +607,6 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
if (newAgentBounce) onNewAgentBounceEnd?.();
onNewAgent();
}}
onAddNote={onAddNote}
onAddBrowser={onAddBrowser}
onAddApp={handleOpenViewPicker}
onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())}
@@ -20,7 +20,6 @@ import type {
CardPosition,
ViewCardPosition,
BrowserCardPosition,
NotePosition,
WorkflowCardPosition,
WorkflowsHubPosition,
} from '@/shared/state/dashboardLayoutSlice';
@@ -50,7 +49,6 @@ interface DashboardCanvasProps {
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
keepAliveBrowserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
outputs: Record<string, Output>;
@@ -60,7 +58,6 @@ interface DashboardCanvasProps {
highlightedCardId: string | null;
autoFocusSessionId: string | null;
focusedCardId: string | null;
pendingFocusNoteId: string | null;
multiDragDelta: { dx: number; dy: number } | null;
shakeDirection: Direction | null;
neighborDirections: NeighborDirections;
@@ -95,7 +92,6 @@ interface DashboardCanvasProps {
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
onAddNote: () => void;
onNewAgentBounceEnd: () => void;
onFitToView: () => void;
onTidy: () => void;
@@ -114,7 +110,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
viewCards,
browserCards,
keepAliveBrowserCards,
notes,
workflowCards,
workflowsHub,
outputs,
@@ -124,7 +119,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
highlightedCardId,
autoFocusSessionId,
focusedCardId,
pendingFocusNoteId,
multiDragDelta,
shakeDirection,
neighborDirections,
@@ -159,7 +153,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onAddView,
onHistoryResume,
onAddBrowser,
onAddNote,
onNewAgentBounceEnd,
onFitToView,
onTidy,
@@ -261,7 +254,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
browserCards={browserCards}
workflowCards={workflowCards}
workflowsHub={workflowsHub}
notes={notes}
expandedSessionIds={expandedSessionIds}
outputs={outputs}
dashboardId={dashboardId}
@@ -287,7 +279,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
cards={cards}
viewCards={viewCards}
browserCards={browserCards}
notes={notes}
workflowCards={workflowCards}
outputs={outputs}
selectedIds={Array.from(selection.selectedIds.keys())}
@@ -297,7 +288,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
}}
onApplications={() => setAppsWindowOpen((v) => !v)}
onAddBrowser={onAddBrowser}
onAddNote={onAddNote}
/>
)}
@@ -399,7 +389,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
viewCards={viewCards}
browserCards={browserCards}
keepAliveBrowserCards={keepAliveBrowserCards}
notes={notes}
workflowCards={workflowCards}
workflowsHub={workflowsHub}
outputs={outputs}
@@ -410,7 +399,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
highlightedCardId={highlightedCardId}
autoFocusSessionId={autoFocusSessionId}
focusedCardId={focusedCardId}
pendingFocusNoteId={pendingFocusNoteId}
multiDragDelta={multiDragDelta}
shakeDirection={shakeDirection}
spawnOriginsRef={spawnOriginsRef}
@@ -459,7 +447,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onAddView={onAddView}
onHistoryResume={onHistoryResume}
onAddBrowser={onAddBrowser}
onAddNote={onAddNote}
onNewAgentBounceEnd={onNewAgentBounceEnd}
onFitToView={onFitToView}
onTidy={onTidy}
@@ -3,7 +3,6 @@ import { AnimatePresence } from 'framer-motion';
import AgentCard from '../cards/AgentCard';
import DashboardViewCard from '../cards/DashboardViewCard';
import BrowserCard from '../cards/BrowserCard';
import NoteCard from '../cards/NoteCard';
import WorkflowsAppCard from '@/app/pages/Workflows/app/WorkflowsAppCard';
import RunMonitor from '@/app/pages/Workflows/app/RunMonitor';
import {
@@ -13,7 +12,6 @@ import {
type CardPosition,
type ViewCardPosition,
type BrowserCardPosition,
type NotePosition,
type WorkflowCardPosition,
type WorkflowsHubPosition,
} from '@/shared/state/dashboardLayoutSlice';
@@ -33,7 +31,6 @@ interface DashboardCardLayerProps {
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
keepAliveBrowserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
outputs: Record<string, Output>;
@@ -44,7 +41,6 @@ interface DashboardCardLayerProps {
highlightedCardId: string | null;
autoFocusSessionId: string | null;
focusedCardId: string | null;
pendingFocusNoteId: string | null;
multiDragDelta: { dx: number; dy: number } | null;
shakeDirection: Direction | null;
spawnOriginsRef: RefObject<Record<string, SpawnOrigin>>;
@@ -67,7 +63,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
viewCards,
browserCards,
keepAliveBrowserCards,
notes,
workflowCards,
workflowsHub,
outputs,
@@ -78,7 +73,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
highlightedCardId,
autoFocusSessionId,
focusedCardId,
pendingFocusNoteId,
multiDragDelta,
shakeDirection,
spawnOriginsRef,
@@ -239,30 +233,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onBringToFront={onBringToFront}
/>
))}
{Object.values(notes).map((n) => (
<NoteCard
key={`note-${n.note_id}`}
noteId={n.note_id}
cardX={n.x}
cardY={n.y}
cardWidth={n.width}
cardHeight={n.height}
cardZOrder={n.zOrder ?? 0}
getCanvasState={getCanvasState}
cmdHeld={cmdHeld}
content={n.content}
color={n.color}
isSelected={selection.isSelected(n.note_id)}
isHighlighted={highlightedCardId === n.note_id}
multiDragDelta={multiDragDelta}
autoFocus={pendingFocusNoteId === n.note_id}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onBringToFront={onBringToFront}
/>
))}
{workflowsHub && (
<WorkflowsAppCard
cardX={workflowsHub.x}
@@ -13,7 +13,7 @@ import DashboardGlyph from './DashboardGlyph';
import ShareButton from '@/app/components/share/ShareButton';
import type { AgentSession } from '@/shared/state/agentsSlice';
import { saveLayout, viewCardKey } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import type { CanvasActions } from '../hooks/interaction/useCanvasControls';
import { friendlyStatusLabel } from '@/shared/statusLabel';
@@ -26,7 +26,6 @@ interface DashboardHeaderProps {
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
outputs: Record<string, Output>;
dashboardId: string | undefined;
@@ -51,7 +50,6 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
browserCards,
workflowCards,
workflowsHub,
notes,
expandedSessionIds,
outputs,
dashboardId,
@@ -194,7 +192,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
onOpen={() => {
// Layout saves are debounced, so a just-added app/agent card may not be on disk yet. The export reads disk, flush the live layout now so Share captures the current board, not a stale one.
if (!dashboardId) return;
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds }));
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, expandedSessionIds }));
}}
/>
</Box>
@@ -49,7 +49,6 @@ interface DashboardOverlaysProps {
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
onAddNote: () => void;
onNewAgentBounceEnd: () => void;
onFitToView: () => void;
onTidy: () => void;
@@ -84,7 +83,6 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
onAddView,
onHistoryResume,
onAddBrowser,
onAddNote,
onNewAgentBounceEnd,
onFitToView,
onTidy,
@@ -108,7 +106,6 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
onAddView={onAddView}
onHistoryResume={onHistoryResume}
onAddBrowser={onAddBrowser}
onAddNote={onAddNote}
dashboardId={dashboardId}
newAgentBounce={newAgentBounce}
canvasEmpty={canvasEmpty}
@@ -1,479 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import PaletteOutlinedIcon from '@mui/icons-material/PaletteOutlined';
import {
setNotePosition,
setNoteSize,
removeNote,
updateNoteContent,
setNoteColor,
recordClosedCard,
toggleMinimizeCard,
setTiledCard,
clearTiledCard,
clearCardWindowState,
NoteColor,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import WindowControls from './WindowControls';
import { openCardContextMenu } from '../desktop/CardContextMenu';
import { useTiledStyle } from './tileZones';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
const MIN_W = 160;
const MIN_H = 120;
const HEADER_H = 18;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
// Hand-tuned palette: distinct enough to skim, gentle in both themes.
const NOTE_PALETTE: Record<NoteColor, { bg: string; border: string; text: string }> = {
yellow: { bg: '#FBE89C', border: '#E0C95A', text: '#3a2e0a' },
pink: { bg: '#F8C3D0', border: '#DB94A6', text: '#3a131e' },
blue: { bg: '#B6D7F0', border: '#86B5D8', text: '#0e2a3d' },
green: { bg: '#C7E5B5', border: '#94C376', text: '#1c3210' },
purple: { bg: '#D8C5EE', border: '#A98BCB', text: '#23123e' },
gray: { bg: '#DEDDD6', border: '#A8A6A0', text: '#262522' },
};
interface Props {
noteId: string;
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
content: string;
color: NoteColor;
cardZOrder?: number;
autoFocus?: boolean;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note', shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser' | 'note') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser' | 'note') => void;
}
const NoteCard: React.FC<Props> = ({
noteId, cardX, cardY, cardWidth, cardHeight, getCanvasState,
isSelected = false, isHighlighted = false, multiDragDelta, content, color,
cardZOrder = 0, autoFocus, onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const palette = NOTE_PALETTE[color] || NOTE_PALETTE.yellow;
const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[noteId]);
const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[noteId]);
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const [showColorPicker, setShowColorPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (autoFocus && textareaRef.current) {
// Defer so the card has mounted in its final position.
const t = setTimeout(() => textareaRef.current?.focus(), 50);
return () => clearTimeout(t);
}
}, [autoFocus]);
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
if (tileZone) return;
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: cs.panX, startPanY: cs.panY,
};
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone */ }
onDragStart?.(noteId, 'note');
}, [cardX, cardY, noteId, onDragStart, getCanvasState, tileZone]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
if (!ds || !didDrag.current) return;
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - ds.startPanX) / z;
const panDy = (cs.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove, getCanvasState]);
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
useEffect(() => {
if (!isDragging) return;
const onPanChange = () => {
if (didDrag.current) recomputeDragPos();
};
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
}, [isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
recomputeDragPos();
}, [recomputeDragPos]);
const finalizeDrag = useCallback((clientX: number, clientY: number, shiftKey: boolean) => {
if (!dragState.current) return;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (clientX - dragState.current.startX) / z - panDx;
const dy = (clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setNotePosition({ noteId, x: finalX, y: finalY }));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
}, [dispatch, noteId, onDragEnd, getCanvasState]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
finalizeDrag(e.clientX, e.clientY, e.shiftKey);
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ }
}, [finalizeDrag]);
const abortDrag = useCallback(() => {
if (!dragState.current) return;
finalizeDrag(lastPointerRef.current.clientX, lastPointerRef.current.clientY, true);
}, [finalizeDrag]);
useDragEndBackstops(isDragging, finalizeDrag, abortDrag);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
} | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const handleResizeDown = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = {
dir, startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight,
};
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[cardX, cardY, cardWidth, cardHeight],
);
const computeResize = useCallback(
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
if (dir.includes('e')) newW = origW + dx;
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
if (dir.includes('s')) newH = origH + dy;
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[getCanvasState],
);
const handleResizeMove = useCallback(
(e: React.PointerEvent) => {
const result = computeResize(e);
if (result) setLocalResize(result);
},
[computeResize],
);
const handleResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const result = computeResize(e);
if (result) {
dispatch(setNotePosition({ noteId, x: result.x, y: result.y }));
dispatch(setNoteSize({ noteId, width: result.w, height: result.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [computeResize, dispatch, noteId]);
const handleRemove = (e?: React.MouseEvent) => {
e?.stopPropagation();
dispatch(clearCardWindowState(noteId));
dispatch(recordClosedCard({ kind: 'note', id: noteId }));
dispatch(removeNote(noteId));
};
const onMinimize = () => dispatch(toggleMinimizeCard({ cardId: noteId }));
const onTile = (zone: string) => {
if (zone === 'restore') dispatch(clearTiledCard(noteId));
else dispatch(setTiledCard({ cardId: noteId, zone }));
};
// Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter.
const [tileTick, setTileTick] = useState(0);
useEffect(() => {
if (!tileZone) return undefined;
const onPan = (): void => setTileTick((t) => t + 1);
window.addEventListener('openswarm:canvas-pan-changed', onPan);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, noteId);
const isFullscreen = tileZone === 'fullscreen';
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
return (
<Box
className="osw-card"
data-select-type="note-card"
data-select-id={noteId}
onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, {
items: [
{ label: 'Full Screen', onClick: () => onTile('fullscreen') },
{ label: 'Minimize', onClick: onMinimize },
{ label: 'Delete note', danger: true, onClick: () => handleRemove() },
],
})}
data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })}
onPointerDownCapture={(e: React.PointerEvent) => {
onBringToFront?.(noteId, 'note');
// Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path. Pass the target so a textarea press selects without yanking the camera.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false, e.target);
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(noteId, 'note', e.shiftKey);
}}
sx={{
position: 'absolute',
left: tiledStyle ? tiledStyle.left : displayX,
top: tiledStyle ? tiledStyle.top : displayY,
width: tiledStyle ? tiledStyle.width : (isMinimized ? 190 : displayW),
height: tiledStyle ? tiledStyle.height : (isMinimized ? 32 : displayH),
transform: tiledStyle ? tiledStyle.transform : undefined,
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
// contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale).
contain: 'layout style',
willChange: 'transform',
borderRadius: isFullscreen ? '12px' : `${c.radius.md}px`,
bgcolor: palette.bg,
border: isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${palette.border}`,
boxShadow: isHighlighted
? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.sm,
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder,
display: 'flex',
flexDirection: 'column',
'&:hover .note-controls': { opacity: 1 },
}}
>
{/* Drag header */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onPointerCancel={abortDrag}
onLostPointerCapture={abortDrag}
sx={{
height: isMinimized ? '100%' : HEADER_H,
flexShrink: 0,
cursor: isDragging ? 'grabbing' : 'grab',
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 0.75,
touchAction: 'none',
}}
>
<Box onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}>
<WindowControls onClose={() => handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} />
</Box>
{isMinimized && (
<Box sx={{ flex: 1, minWidth: 0, fontSize: '0.8125rem', color: palette.text, opacity: 0.75, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{content.trim() || 'Note'}
</Box>
)}
<Box
className="note-controls"
sx={{ ml: 'auto', opacity: 0, transition: 'opacity 0.15s', display: isMinimized ? 'none' : 'flex' }}
onPointerDown={(e) => e.stopPropagation()}
>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); setShowColorPicker((v) => !v); }}
sx={{ p: 0.25, color: palette.text, opacity: 0.55, '&:hover': { opacity: 1, bgcolor: 'rgba(0,0,0,0.06)' } }}
>
<PaletteOutlinedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Box>
</Box>
{showColorPicker && (
<Box
onPointerDown={(e) => e.stopPropagation()}
sx={{
position: 'absolute',
top: HEADER_H + 2,
left: 8,
display: 'flex',
gap: 0.5,
p: 0.75,
bgcolor: 'rgba(255,255,255,0.95)',
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.sm}px`,
boxShadow: c.shadow.md,
zIndex: 10,
}}
>
{(Object.keys(NOTE_PALETTE) as NoteColor[]).map((key) => {
const p = NOTE_PALETTE[key];
const active = key === color;
return (
<Box
key={key}
onClick={(e) => {
e.stopPropagation();
dispatch(setNoteColor({ noteId, color: key }));
setShowColorPicker(false);
}}
sx={{
width: 16, height: 16, borderRadius: '50%',
bgcolor: p.bg,
border: active ? `2px solid #3b82f6` : `1px solid ${p.border}`,
cursor: 'pointer',
transition: 'transform 0.1s',
'&:hover': { transform: 'scale(1.15)' },
}}
/>
);
})}
</Box>
)}
{/* Editable content. Fullscreen = focus-writing mode: reading-size type in a centered column, like Bear/Arc, not 12px lost in a 2800px card. */}
{!isMinimized && (
<Box sx={{ flex: 1, p: 1, pt: 0.25, display: 'flex', justifyContent: 'center', minHeight: 0 }}>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => dispatch(updateNoteContent({ noteId, content: e.target.value }))}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
placeholder="Type a note…"
spellCheck
style={{
flex: 1,
border: 'none',
outline: 'none',
resize: 'none',
background: 'transparent',
color: palette.text,
fontFamily: c.font.sans,
fontSize: isFullscreen ? 'clamp(1.1rem, 1.3vw, 1.5rem)' : '0.85rem',
lineHeight: isFullscreen ? 1.6 : 1.45,
padding: isFullscreen ? '4vh 0 0' : 0,
maxWidth: isFullscreen ? 'min(72ch, 82%)' : undefined,
}}
/>
</Box>
)}
{/* Resize handles */}
{!isMinimized && HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
onPointerCancel={handleResizeUp}
sx={{
position: 'absolute',
cursor: CURSOR_MAP[dir],
zIndex: 5,
touchAction: 'none',
...sx,
}}
/>
))}
</Box>
);
};
export default React.memo(NoteCard);
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
// One right-click menu for every canvas entity (chats, browsers, notes, apps, workflow cards,
// One right-click menu for every canvas entity (chats, browsers, apps, workflow cards,
// minimized pills). Cards call openCardContextMenu with their items; this overlay renders the
// native-feeling glass menu (SpacesStrip grammar) and closes on outside press / Esc / item click.
export interface CardMenuItem {
@@ -4,7 +4,6 @@ import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import LanguageIcon from '@mui/icons-material/Language';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import SettingsIcon from '@mui/icons-material/Settings';
import AppsRoundedIcon from '@mui/icons-material/AppsRounded';
@@ -17,7 +16,6 @@ import type {
CardPosition,
ViewCardPosition,
BrowserCardPosition,
NotePosition,
WorkflowCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
@@ -27,14 +25,12 @@ interface DesktopDockProps {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
workflowCards: Record<string, WorkflowCardPosition>;
outputs: Record<string, Output>;
selectedIds: string[];
onFocusCard: (id: string, rect: CardRect) => void;
onApplications: () => void;
onAddBrowser: () => void;
onAddNote: () => void;
}
const TILE = 30;
@@ -46,14 +42,12 @@ function DesktopDock({
cards,
viewCards,
browserCards,
notes,
workflowCards,
outputs,
selectedIds,
onFocusCard,
onApplications,
onAddBrowser,
onAddNote,
}: DesktopDockProps): React.ReactElement | null {
const dispatch = useAppDispatch();
const dockBodyRef = useRef<HTMLDivElement | null>(null);
@@ -92,8 +86,8 @@ function DesktopDock({
const hoverTimer = useRef<number | null>(null);
const entries = useMemo<DockEntry[]>(
() => buildDockEntries({ sessions, cards, viewCards, browserCards, notes, workflowCards, outputs }),
[sessions, cards, viewCards, browserCards, notes, workflowCards, outputs],
() => buildDockEntries({ sessions, cards, viewCards, browserCards, workflowCards, outputs }),
[sessions, cards, viewCards, browserCards, workflowCards, outputs],
);
const beginHover = useCallback(
@@ -201,11 +195,10 @@ function DesktopDock({
{entries.length > 0 && (
<Box sx={{ width: TILE - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />
)}
{/* The og toolbar's actions, dock-resident: browser, workflow, note, then settings + apps below their own divider. New-chat lives in the spawn pill, history on the top island. */}
{/* The og toolbar's actions, dock-resident: browser, workflow, then settings + apps below their own divider. New-chat lives in the spawn pill, history on the top island. */}
{([
{ label: 'New browser', icon: <LanguageIcon sx={{ fontSize: 17, color: '#e8e8ee' }} />, act: onAddBrowser },
{ label: 'Workflows', icon: <EventRepeatIcon sx={{ fontSize: 16, color: '#e8e8ee' }} />, act: () => dispatch(openWorkflowsApp()) },
{ label: 'New note', icon: <StickyNote2OutlinedIcon sx={{ fontSize: 16, color: '#e8e8ee' }} />, act: onAddNote },
{ label: 'Settings', icon: <SettingsIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />, act: () => dispatch(openSettingsModal(undefined)), divider: true },
{ label: 'Applications', icon: <AppsRoundedIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />, act: onApplications, bg: 'linear-gradient(135deg, #3d3d46, #232329)' },
] as { label: string; icon: React.ReactNode; act: () => void; divider?: boolean; bg?: string }[]).map((a) => (
@@ -10,14 +10,12 @@ import CircularProgress from '@mui/material/CircularProgress';
import { useVoice } from '@/shared/voice/voiceContext';
import { useAppSelector } from '@/shared/hooks';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import LanguageIcon from '@mui/icons-material/Language';
interface DesktopSpawnPillProps {
onOpenComposer: () => void;
onAddNote: () => void;
onAddBrowser: () => void;
onAddApp: () => void;
onWorkflows: () => void;
@@ -25,7 +23,6 @@ interface DesktopSpawnPillProps {
}
const MENU_ITEMS: Array<{ key: string; label: string; icon: React.ElementType }> = [
{ key: 'note', label: 'Add note', icon: StickyNote2OutlinedIcon },
{ key: 'browser', label: 'Browser', icon: LanguageIcon },
{ key: 'app', label: 'Add app', icon: GridViewRoundedIcon },
{ key: 'workflows', label: 'Workflows', icon: EventRepeatIcon },
@@ -35,7 +32,6 @@ const MENU_ITEMS: Array<{ key: string; label: string; icon: React.ElementType }>
/** Collapsed desktop composer: one dark pill that spawns an agent; + tucks the add actions away. */
function DesktopSpawnPill({
onOpenComposer,
onAddNote,
onAddBrowser,
onAddApp,
onWorkflows,
@@ -60,7 +56,6 @@ function DesktopSpawnPill({
}, [menuOpen]);
const actions: Record<string, () => void> = {
note: onAddNote,
browser: onAddBrowser,
app: onAddApp,
workflows: onWorkflows,
@@ -26,8 +26,8 @@ const HELP_SYSTEM_PROMPT = [
'Answer questions about using OpenSwarm clearly and briefly, with the exact clicks or keys.',
'What you know about the app:',
'- Dashboards work like macOS Spaces: resting the cursor on the very top edge of the window reveals the spaces bar to switch dashboards or add one with +.',
'- The canvas holds cards: agent chats, browsers, notes, built apps, and workflows. Cards have mac-style traffic lights; the green dot goes full screen, hovering it offers halves, quarters, and thirds.',
'- The dark dock on the left creates chats, browsers, workflows, and notes, and opens History, Settings, and Apps.',
'- The canvas holds cards: agent chats, browsers, built apps, and workflows. Cards have mac-style traffic lights; the green dot goes full screen, hovering it offers halves, quarters, and thirds.',
'- The dark dock on the left creates chats, browsers, and workflows, and opens History, Settings, and Apps.',
'- Cmd+K searches everything. Dictation: hold the mic in the Help pill (or the mic key) to talk; the words land where the cursor is.',
'- Settings (gear in the dock): Account, General (agent defaults), Appearance (theme, accent colors, text size), Privacy, Advanced, plus Models (connect Claude, ChatGPT, or Gemini subscriptions or API keys), Skills, Tools, Commands, and Usage.',
'- Workflows run agents on a schedule; open them from the dock calendar icon.',
@@ -1,7 +1,6 @@
import React from 'react';
import LanguageIcon from '@mui/icons-material/Language';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import EditNoteIcon from '@mui/icons-material/EditNote';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import { MessageCircle } from 'lucide-react';
import { pickIcon } from '../canvas/DashboardGlyph';
@@ -11,7 +10,6 @@ import type {
CardPosition,
ViewCardPosition,
BrowserCardPosition,
NotePosition,
WorkflowCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
@@ -40,7 +38,6 @@ export interface DockSlices {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
workflowCards: Record<string, WorkflowCardPosition>;
outputs: Record<string, Output>;
}
@@ -60,7 +57,7 @@ function hueFor(name: string): string {
return AGENT_TILE_HUES[Math.abs(h) % AGENT_TILE_HUES.length];
}
export function buildDockEntries({ sessions, cards, viewCards, browserCards, notes, workflowCards, outputs }: DockSlices): DockEntry[] {
export function buildDockEntries({ sessions, cards, viewCards, browserCards, workflowCards, outputs }: DockSlices): DockEntry[] {
const list: DockEntry[] = [];
for (const card of Object.values(cards)) {
const session = sessions[card.session_id];
@@ -101,17 +98,6 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, not
thumbnail: output?.thumbnail,
});
}
for (const note of Object.values(notes)) {
const firstLine = (note.content || '').split('\n')[0].trim();
list.push({
id: note.note_id,
label: firstLine || 'Note',
rect: note,
tileBg: 'linear-gradient(135deg, #f2d270, #e0b23e)',
icon: <EditNoteIcon sx={{ fontSize: 18, color: '#7a5d10' }} />,
snippet: (note.content || '').slice(0, 140),
});
}
for (const [cardKey, wf] of Object.entries(workflowCards)) {
list.push({
id: cardKey,
@@ -13,7 +13,7 @@ export interface ContentBounds {
maxY: number;
}
// Bounding box over agent + view + browser cards (notes intentionally excluded, same as before). Returns undefined for an empty canvas.
// Bounding box over agent + view + browser cards. Returns undefined for an empty canvas.
export function computeContentBounds(
cards: Record<string, CardPosition>,
viewCards: Record<string, ViewCardPosition>,
@@ -17,10 +17,6 @@ export function getCardRect(id: string, type: CardType):
const bc = layoutState.browserCards[id];
if (!bc) return undefined;
return { x: bc.x, y: bc.y, width: bc.width, height: bc.height };
} else if (type === 'note') {
const n = layoutState.notes[id];
if (!n) return undefined;
return { x: n.x, y: n.y, width: n.width, height: n.height };
} else if (type === 'workflow') {
const wc = layoutState.workflowCards[id];
if (!wc) return undefined;
@@ -1,5 +1,5 @@
import { closeSession } from '@/shared/state/agentsSlice';
import { removeNote, removeWorkflowCard, closeWorkflowsHub, recordClosedCard } from '@/shared/state/dashboardLayoutSlice';
import { removeWorkflowCard, closeWorkflowsHub, recordClosedCard } from '@/shared/state/dashboardLayoutSlice';
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
import { removeBrowserCardsCleanly } from '@/shared/browserTeardown';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
@@ -20,9 +20,6 @@ export function deleteSelectedCards(selectedIds: Map<string, CardType>, dispatch
} else if (type === 'browser') {
dispatch(recordClosedCard({ kind: 'browser', id }));
browserIds.push(id);
} else if (type === 'note') {
dispatch(recordClosedCard({ kind: 'note', id }));
dispatch(removeNote(id));
} else if (type === 'workflow') {
dispatch(recordClosedCard({ kind: 'workflow', id }));
dispatch(removeWorkflowCard(id));
@@ -1,4 +1,4 @@
import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { store } from '@/shared/state/store';
import { useAppDispatch } from '@/shared/hooks';
@@ -7,14 +7,10 @@ import {
tidyLayout,
addViewCard,
addBrowserCard,
addNote,
clearPendingFocusNoteId,
DEFAULT_VIEW_CARD_W,
DEFAULT_VIEW_CARD_H,
DEFAULT_BROWSER_CARD_W,
DEFAULT_BROWSER_CARD_H,
DEFAULT_NOTE_W,
DEFAULT_NOTE_H,
EXPANDED_CARD_MIN_H,
} from '@/shared/state/dashboardLayoutSlice';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
@@ -26,7 +22,6 @@ type Selection = ReturnType<typeof useDashboardSelection>;
interface UseDashboardCardActionsArgs {
expandedSessionIds: string[];
browserHomepage: string;
pendingFocusNoteId: string | null;
selection: Selection;
canvasActions: CanvasActions;
getCardRect: (id: string, type: CardType) => { x: number; y: number; width: number; height: number } | undefined;
@@ -39,7 +34,6 @@ interface UseDashboardCardActionsArgs {
export function useDashboardCardActions({
expandedSessionIds,
browserHomepage,
pendingFocusNoteId,
selection,
canvasActions,
getCardRect,
@@ -78,29 +72,6 @@ export function useDashboardCardActions({
dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds, x: pos.x, y: pos.y }));
}, [dispatch, browserHomepage, expandedSessionIds, getSpawnPlacement]);
const handleAddNote = useCallback(() => {
report('dashboard', 'note_added');
const prevIds = new Set(Object.keys(store.getState().dashboardLayout.notes));
const pos = getSpawnPlacement(DEFAULT_NOTE_W, DEFAULT_NOTE_H);
dispatch(addNote({ expandedSessionIds, x: pos.x, y: pos.y }));
setTimeout(() => {
const allNotes = store.getState().dashboardLayout.notes;
const newId = Object.keys(allNotes).find((id) => !prevIds.has(id));
if (newId) {
const note = allNotes[newId];
canvasActions.revealCards([{ x: note.x, y: note.y, width: note.width, height: note.height }]);
handleHighlightCard(newId);
}
}, 200);
}, [dispatch, expandedSessionIds, getSpawnPlacement, canvasActions, handleHighlightCard]);
// Auto-clear pendingFocusNoteId after the note has had a chance to mount + autofocus.
useEffect(() => {
if (!pendingFocusNoteId) return;
const t = setTimeout(() => dispatch(clearPendingFocusNoteId()), 800);
return () => clearTimeout(t);
}, [pendingFocusNoteId, dispatch]);
const handleHistoryResume = useCallback((sessionId: string) => {
dispatch(resumeSession({ sessionId })).then((action) => {
if (resumeSession.fulfilled.match(action)) {
@@ -157,7 +128,6 @@ export function useDashboardCardActions({
return {
handleAddView,
handleAddBrowser,
handleAddNote,
handleHistoryResume,
handleFitToView,
handleTidy,
@@ -34,7 +34,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards, keepAliveBrowserCards,
workflowCards, workflowItems, workflowOpenCards, workflowsHub,
pendingFocusWorkflowId, pendingFocusWorkflowsHub,
notes, pendingFocusNoteId, layoutInitialized, persistedExpandedSessionIds,
layoutInitialized, persistedExpandedSessionIds,
zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats,
autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards,
} = useDashboardSelectors(dashboardId);
@@ -74,7 +74,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
cards,
viewCards,
browserCards,
notes,
workflowCards,
workflowsHub,
);
@@ -200,7 +199,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
browserCards,
workflowCards,
workflowsHub,
notes,
expandedSessionIds,
captureNow,
});
@@ -281,14 +279,12 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
const {
handleAddView,
handleAddBrowser,
handleAddNote,
handleHistoryResume,
handleFitToView,
handleTidy,
} = useDashboardCardActions({
expandedSessionIds,
browserHomepage,
pendingFocusNoteId,
selection,
canvasActions: canvas.actions,
getCardRect,
@@ -332,10 +328,10 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
return {
c, dashboardId, dashboardName, canvas, selection, sessions, sessionList,
cards, viewCards, browserCards, keepAliveBrowserCards, notes, outputs, glowingAgentCards,
cards, viewCards, browserCards, keepAliveBrowserCards, outputs, glowingAgentCards,
workflowCards, workflowsHub,
expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId,
focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection,
focusedCardId, multiDragDelta, shakeDirection,
neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce, canvasEmpty,
toolbarRef, spawnOriginsRef, revealSpawnedRef, measuredHeightsRef, getCanvasState,
toolbarPrefill,
@@ -360,7 +356,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
onAddView: handleAddView,
onHistoryResume: handleHistoryResume,
onAddBrowser: handleAddBrowser,
onAddNote: handleAddNote,
onNewAgentBounceEnd: () => {
bounceDismissedRef.current = true;
setNewAgentBounce(false);
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import { viewCardKey } from '@/shared/state/dashboardLayoutSlice';
export type { CardType } from '@/shared/state/dashboardLayoutSlice';
@@ -43,7 +43,6 @@ export function useDashboardSelection(
cards: Record<string, CardPosition>,
viewCards: Record<string, ViewCardPosition>,
browserCards: Record<string, BrowserCardPosition> = {},
notes: Record<string, NotePosition> = {},
workflowCards: Record<string, WorkflowCardPosition> = {},
workflowsHub: WorkflowsHubPosition | null = null,
) {
@@ -78,11 +77,10 @@ export function useDashboardSelection(
for (const card of Object.values(cards)) next.set(card.session_id, 'agent');
for (const vc of Object.values(viewCards)) next.set(viewCardKey(vc.output_id, vc.instance), 'view');
for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser');
for (const n of Object.values(notes)) next.set(n.note_id, 'note');
for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow');
if (workflowsHub) next.set('workflows-hub', 'workflows-hub');
setSelectedIds(next);
}, [cards, viewCards, browserCards, notes, workflowCards, workflowsHub]);
}, [cards, viewCards, browserCards, workflowCards, workflowsHub]);
const selectCard = useCallback(
(id: string, type: CardType, shiftKey: boolean) => {
@@ -157,19 +155,6 @@ export function useDashboardSelection(
}
}
for (const n of Object.values(notes)) {
if (
rectsIntersect(rect, {
x: n.x,
y: n.y,
width: n.width,
height: n.height,
})
) {
intersecting.set(n.note_id, 'note');
}
}
for (const wc of Object.values(workflowCards)) {
if (
rectsIntersect(rect, {
@@ -210,7 +195,7 @@ export function useDashboardSelection(
return intersecting;
},
[cards, viewCards, browserCards, notes, workflowCards, workflowsHub],
[cards, viewCards, browserCards, workflowCards, workflowsHub],
);
const handleCanvasMouseDown = useCallback(
@@ -33,8 +33,6 @@ export function useDashboardSelectors(dashboardId: string) {
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
const workflowItems = useAppSelector((state) => state.workflows.items);
const workflowOpenCards = useAppSelector((state) => state.workflows.openCards);
const notes = useAppSelector((state) => state.dashboardLayout.notes);
const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId);
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds);
const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity);
@@ -61,8 +59,6 @@ export function useDashboardSelectors(dashboardId: string) {
workflowsHub,
pendingFocusWorkflowId,
pendingFocusWorkflowsHub,
notes,
pendingFocusNoteId,
layoutInitialized,
persistedExpandedSessionIds,
zoomSensitivity,
@@ -14,13 +14,11 @@ function dashboardSignature(s: {
cards: Record<string, unknown>;
viewCards: Record<string, unknown>;
browserCards: Record<string, unknown>;
notes: Record<string, unknown>;
}): string {
return [
...Object.keys(s.cards),
...Object.keys(s.viewCards),
...Object.keys(s.browserCards),
...Object.keys(s.notes),
].sort().join(',');
}
@@ -5,7 +5,6 @@ import {
type CardPosition,
type ViewCardPosition,
type BrowserCardPosition,
type NotePosition,
type WorkflowCardPosition,
type WorkflowsHubPosition,
} from '@/shared/state/dashboardLayoutSlice';
@@ -19,7 +18,6 @@ interface UseLayoutSaveArgs {
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
captureNow: () => void;
}
@@ -34,7 +32,6 @@ export function useLayoutSave({
browserCards,
workflowCards,
workflowsHub,
notes,
expandedSessionIds,
captureNow,
}: UseLayoutSaveArgs) {
@@ -50,7 +47,7 @@ export function useLayoutSave({
skipInitialSave.current = false;
return;
}
const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds };
const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, expandedSessionIds };
pendingSaveRef.current = payload;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
@@ -59,7 +56,7 @@ export function useLayoutSave({
saveTimerRef.current = null;
captureNow();
}, 500);
}, [isActive, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
}, [isActive, cards, viewCards, browserCards, workflowCards, workflowsHub, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
useEffect(() => {
return () => {
@@ -31,7 +31,7 @@ export const WORKFLOW_CARD_GAP = 140;
const GRID_ORIGIN = { x: 40, y: 100 };
const GRID_COLS_FALLBACK = 4;
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' | 'workflows-monitor';
export type CardType = 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub' | 'workflows-monitor';
export interface CardPosition {
session_id: string;
@@ -111,29 +111,11 @@ export interface WorkflowsHubPosition {
fullscreen?: boolean;
}
export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray';
export interface NotePosition {
note_id: string;
x: number;
y: number;
width: number;
height: number;
content: string;
color: NoteColor;
zOrder: number;
}
export const DEFAULT_NOTE_W = 240;
export const DEFAULT_NOTE_H = 200;
// One entry in the Ctrl/Cmd+Shift+T "reopen last closed" stack: a full snapshot for browser/view/workflow/note/tab, just the session id for an agent (its session is brought back via resumeSession).
// One entry in the Ctrl/Cmd+Shift+T "reopen last closed" stack: a full snapshot for browser/view/workflow/tab, just the session id for an agent (its session is brought back via resumeSession).
export type ClosedCard =
| { uid: string; kind: 'browser'; closedAt: number; card: BrowserCardPosition }
| { uid: string; kind: 'view'; closedAt: number; card: ViewCardPosition }
| { uid: string; kind: 'workflow'; closedAt: number; card: WorkflowCardPosition }
| { uid: string; kind: 'note'; closedAt: number; note: NotePosition }
| { uid: string; kind: 'tab'; closedAt: number; browserId: string; index: number; tab: BrowserTab }
| { uid: string; kind: 'agent'; closedAt: number; sessionId: string; position: CardPosition | null };
@@ -147,13 +129,12 @@ export interface DashboardLayoutState {
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
/** Session-global LIFO undo stack for Ctrl/Cmd+Shift+T; survives dashboard switches (resetLayout leaves it alone). */
recentlyClosed: ClosedCard[];
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
glowingAgentCards: Record<string, { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }>;
/** Window controls: cards collapsed to a title pill (many at once). Keyed by any card id (session/note/browser/view/workflow). */
/** Window controls: cards collapsed to a title pill (many at once). Keyed by any card id (session/browser/view/workflow). */
minimizedCards: Record<string, boolean>;
/** macOS-style tiling: card id -> zone ('fullscreen' | 'fill' | 'left'|'right'|'top'|'bottom' | 'tl'|'tr'|'bl'|'br' | 't3l'|'t3c'|'t3r'). A tiled card renders at that viewport region (webview stays mounted); 'fullscreen' also hides the app chrome. */
tiledCards: Record<string, string>;
@@ -167,7 +148,6 @@ export interface DashboardLayoutState {
pendingFocusBrowserId: string | null;
// Set when a view card is opened from outside the canvas (sidebar app click / toolbar picker) so the dashboard fits+highlights it on arrival; holds the card key.
pendingFocusViewCardId: string | null;
pendingFocusNoteId: string | null;
/** Transient: snapshot stand-ins for off-screen webviews; never rides the layout PUT. */
suspendedBrowserCards: Record<string, { dataUrl: string; capturedAt: number }>;
/** Transient: spawned cards that are about to be removed; surfaces the fade + Keep pill. */
@@ -203,7 +183,6 @@ const initialState: DashboardLayoutState = {
browserCards: {},
workflowCards: {},
workflowsHub: null,
notes: {},
closedCardPositions: {},
recentlyClosed: [],
glowingBrowserCards: {},
@@ -217,7 +196,6 @@ const initialState: DashboardLayoutState = {
saveArmed: false,
pendingFocusBrowserId: null,
pendingFocusViewCardId: null,
pendingFocusNoteId: null,
suspendedBrowserCards: {},
endingBrowserCards: {},
activeViewCardId: null,
@@ -236,7 +214,6 @@ interface LayoutPayload {
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
}
@@ -273,7 +250,6 @@ export const fetchLayout = createAsyncThunk(
browserCards: browserCards as Record<string, BrowserCardPosition>,
workflowCards: (layout.workflow_cards ?? {}) as Record<string, WorkflowCardPosition>,
workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null,
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
},
@@ -299,7 +275,6 @@ export const saveLayout = createAsyncThunk(
browser_cards: payload.browserCards,
workflow_cards: payload.workflowCards,
workflows_hub: payload.workflowsHub,
notes: payload.notes,
expanded_session_ids: payload.expandedSessionIds,
},
}),
@@ -350,10 +325,6 @@ function collectOccupiedRects(
if (state.workflowsHub) {
rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height });
}
for (const n of Object.values(state.notes)) {
if (exclude?.type === 'note' && exclude.id === n.note_id) continue;
rects.push({ x: n.x, y: n.y, w: n.width, h: n.height });
}
return rects;
}
@@ -527,7 +498,7 @@ export function placeInParentColumn(
return placeBesideCard(state, parentCard, newW, newH, expandedSessionIds, exclude);
}
// Where a user-created card (chat/app/browser/note) should land. Resolved in the UI layer where selection + viewport are known, then handed to the add reducers as an explicit x/y. `beside` (the currently selected card) docks the new card to its right, stacking under that column (collision-aware); `viewportCenter` (canvas-space center of what the user is looking at) drops it dead-center "in front of you", overlapping whatever's there. With neither, falls back to the legacy top-left grid scan.
// Where a user-created card (chat/app/browser) should land. Resolved in the UI layer where selection + viewport are known, then handed to the add reducers as an explicit x/y. `beside` (the currently selected card) docks the new card to its right, stacking under that column (collision-aware); `viewportCenter` (canvas-space center of what the user is looking at) drops it dead-center "in front of you", overlapping whatever's there. With neither, falls back to the legacy top-left grid scan.
export interface SpawnAnchor {
beside?: { x: number; y: number; width: number; height: number };
viewportCenter?: { x: number; y: number };
@@ -677,12 +648,10 @@ const dashboardLayoutSlice = createSlice({
for (const c of Object.values(state.viewCards)) tally(c.zOrder);
for (const c of Object.values(state.browserCards)) tally(c.zOrder);
for (const c of Object.values(state.workflowCards)) tally(c.zOrder);
for (const n of Object.values(state.notes)) tally(n.zOrder);
if (state.workflowsHub) tally(state.workflowsHub.zOrder);
if (state.workflowsMonitorCard) tally(state.workflowsMonitorCard.zOrder);
if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0;
else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0;
else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0;
else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0;
else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0;
else if (type === 'workflows-monitor') currentZ = state.workflowsMonitorCard?.zOrder ?? 0;
@@ -696,9 +665,6 @@ const dashboardLayoutSlice = createSlice({
} else if (type === 'view') {
const card = state.viewCards[id];
if (card) card.zOrder = z;
} else if (type === 'note') {
const note = state.notes[id];
if (note) note.zOrder = z;
} else if (type === 'workflow') {
const card = state.workflowCards[id];
if (card) card.zOrder = z;
@@ -1472,12 +1438,6 @@ const dashboardLayoutSlice = createSlice({
card.x += dx;
card.y += dy;
}
} else if (item.type === 'note') {
const note = state.notes[item.id];
if (note) {
note.x += dx;
note.y += dy;
}
} else if (item.type === 'workflow') {
const card = state.workflowCards[item.id];
if (card) {
@@ -1499,67 +1459,6 @@ const dashboardLayoutSlice = createSlice({
}
},
addNote(
state,
action: PayloadAction<{ x?: number; y?: number; expandedSessionIds?: string[]; color?: NoteColor; content?: string }>,
) {
const id = `note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
let posX: number, posY: number;
if (action.payload.x != null && action.payload.y != null) {
posX = action.payload.x;
posY = action.payload.y;
} else {
const rects = collectOccupiedRects(state, action.payload.expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_NOTE_W, DEFAULT_NOTE_H);
posX = pos.x;
posY = pos.y;
}
state.notes[id] = {
note_id: id,
x: posX,
y: posY,
width: DEFAULT_NOTE_W,
height: DEFAULT_NOTE_H,
content: action.payload.content ?? '',
color: action.payload.color || 'yellow',
zOrder: state.nextZOrder++,
};
state.pendingFocusNoteId = id;
},
setNotePosition(state, action: PayloadAction<{ noteId: string; x: number; y: number }>) {
const n = state.notes[action.payload.noteId];
if (n) { n.x = action.payload.x; n.y = action.payload.y; }
},
setNoteSize(state, action: PayloadAction<{ noteId: string; width: number; height: number }>) {
const n = state.notes[action.payload.noteId];
if (n) {
n.width = Math.max(160, action.payload.width);
n.height = Math.max(120, action.payload.height);
}
},
updateNoteContent(state, action: PayloadAction<{ noteId: string; content: string }>) {
const n = state.notes[action.payload.noteId];
if (n) n.content = action.payload.content;
},
setNoteColor(state, action: PayloadAction<{ noteId: string; color: NoteColor }>) {
const n = state.notes[action.payload.noteId];
if (n) n.color = action.payload.color;
},
removeNote(state, action: PayloadAction<string>) {
delete state.notes[action.payload];
delete state.tiledCards[action.payload];
delete state.minimizedCards[action.payload];
},
clearPendingFocusNoteId(state) {
state.pendingFocusNoteId = null;
},
// Snapshot a card onto the reopen stack RIGHT BEFORE it's closed (the data must still be in state). Dispatch only from genuine user closes, not programmatic teardown.
recordClosedCard(
state,
@@ -1575,8 +1474,6 @@ const dashboardLayoutSlice = createSlice({
entry = { uid, kind, closedAt, card: { ...state.viewCards[id] } };
} else if (kind === 'workflow' && state.workflowCards[id]) {
entry = { uid, kind, closedAt, card: { ...state.workflowCards[id] } };
} else if (kind === 'note' && state.notes[id]) {
entry = { uid, kind, closedAt, note: { ...state.notes[id] } };
} else if (kind === 'agent') {
entry = { uid, kind, closedAt, sessionId: id, position: state.cards[id] ? { ...state.cards[id] } : null };
} else if (kind === 'tab' && browserId && state.browserCards[browserId]) {
@@ -1603,8 +1500,6 @@ const dashboardLayoutSlice = createSlice({
state.viewCards[viewCardKey(entry.card.output_id, entry.card.instance)] = { ...entry.card, zOrder };
} else if (entry.kind === 'workflow') {
state.workflowCards[entry.card.workflow_id] = { ...entry.card, zOrder };
} else if (entry.kind === 'note') {
state.notes[entry.note.note_id] = { ...entry.note, zOrder };
} else if (entry.kind === 'tab') {
const card = state.browserCards[entry.browserId];
if (card) {
@@ -1699,7 +1594,6 @@ const dashboardLayoutSlice = createSlice({
state.browserCards = keptBrowsers;
state.workflowCards = {};
state.workflowsHub = null;
state.notes = {};
state.closedCardPositions = {};
state.glowingBrowserCards = {};
state.glowingAgentCards = {};
@@ -1707,7 +1601,6 @@ const dashboardLayoutSlice = createSlice({
state.nextZOrder = 1;
state.initialized = false;
state.saveArmed = false;
state.pendingFocusNoteId = null;
state.suspendedBrowserCards = keptSuspended;
state.endingBrowserCards = {};
state.pendingFocusWorkflowId = null;
@@ -1743,7 +1636,6 @@ const dashboardLayoutSlice = createSlice({
state.browserCards = { ...incoming, ...keptAlive };
state.workflowCards = action.payload.workflowCards || {};
state.workflowsHub = action.payload.workflowsHub || null;
state.notes = action.payload.notes || {};
} else {
const occupied = collectOccupiedRects(state, action.payload.expandedSessionIds);
addMissingCards(state.cards, action.payload.cards, occupied);
@@ -1754,7 +1646,6 @@ const dashboardLayoutSlice = createSlice({
}
addMissingCards(state.workflowCards, action.payload.workflowCards || {}, occupied);
if (!state.workflowsHub && action.payload.workflowsHub) state.workflowsHub = action.payload.workflowsHub;
addMissingCards(state.notes, action.payload.notes || {}, occupied);
}
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
@@ -1775,10 +1666,6 @@ const dashboardLayoutSlice = createSlice({
if (!w.zOrder) w.zOrder = 0;
if (w.zOrder > maxZ) maxZ = w.zOrder;
}
for (const n of Object.values(state.notes)) {
if (!n.zOrder) n.zOrder = 0;
if (n.zOrder > maxZ) maxZ = n.zOrder;
}
state.nextZOrder = maxZ + 1;
})
.addCase(fetchLayout.rejected, (state) => {
@@ -1903,13 +1790,6 @@ export const {
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,
addNote,
setNotePosition,
setNoteSize,
updateNoteContent,
setNoteColor,
removeNote,
clearPendingFocusNoteId,
recordClosedCard,
restoreClosedCard,
popClosedCard,
@@ -1942,7 +1822,7 @@ export const selectFullscreenCardId = (state: { dashboardLayout: DashboardLayout
if (!entry) return null;
const id = entry[0];
// Belt over the reducer hygiene: an entry whose card is gone (any removal path) must not hold the app in fullscreen.
const exists = id in s.cards || id in s.viewCards || id in s.browserCards || id in s.notes || id in s.workflowCards;
const exists = id in s.cards || id in s.viewCards || id in s.browserCards || id in s.workflowCards;
return exists ? id : null;
};