diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 6b45794f..13d3b3e6 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -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, } diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 5577b983..ea766691 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -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) diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py index 5b9c9642..7a1a3599 100644 --- a/backend/apps/swarm/entities/dashboards.py +++ b/backend/apps/swarm/entities/dashboards.py @@ -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, }, } diff --git a/backend/tests/test_phase1_stress.py b/backend/tests/test_phase1_stress.py index 0877ccaa..f399900a 100644 --- a/backend/tests/test_phase1_stress.py +++ b/backend/tests/test_phase1_stress.py @@ -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. --------------------------------------------------------------------------- diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index a1b50e51..ffa977c4 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -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) diff --git a/e2e/tests/combinatorial-flows.spec.ts b/e2e/tests/combinatorial-flows.spec.ts index 73013bbd..9f3a1341 100644 --- a/e2e/tests/combinatorial-flows.spec.ts +++ b/e2e/tests/combinatorial-flows.spec.ts @@ -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(() => {}); diff --git a/e2e/tests/deep-coverage.spec.ts b/e2e/tests/deep-coverage.spec.ts index 8b26f19a..f9ac2a13 100644 --- a/e2e/tests/deep-coverage.spec.ts +++ b/e2e/tests/deep-coverage.spec.ts @@ -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); diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index f8eb1c2d..6d12db4c 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -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( - ({ 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( if (newAgentBounce) onNewAgentBounceEnd?.(); onNewAgent(); }} - onAddNote={onAddNote} onAddBrowser={onAddBrowser} onAddApp={handleOpenViewPicker} onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 437e013b..a6530708 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -20,7 +20,6 @@ import type { CardPosition, ViewCardPosition, BrowserCardPosition, - NotePosition, WorkflowCardPosition, WorkflowsHubPosition, } from '@/shared/state/dashboardLayoutSlice'; @@ -50,7 +49,6 @@ interface DashboardCanvasProps { viewCards: Record; browserCards: Record; keepAliveBrowserCards: Record; - notes: Record; workflowCards: Record; workflowsHub: WorkflowsHubPosition | null; outputs: Record; @@ -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 = ({ viewCards, browserCards, keepAliveBrowserCards, - notes, workflowCards, workflowsHub, outputs, @@ -124,7 +119,6 @@ const DashboardCanvas: React.FC = ({ highlightedCardId, autoFocusSessionId, focusedCardId, - pendingFocusNoteId, multiDragDelta, shakeDirection, neighborDirections, @@ -159,7 +153,6 @@ const DashboardCanvas: React.FC = ({ onAddView, onHistoryResume, onAddBrowser, - onAddNote, onNewAgentBounceEnd, onFitToView, onTidy, @@ -261,7 +254,6 @@ const DashboardCanvas: React.FC = ({ browserCards={browserCards} workflowCards={workflowCards} workflowsHub={workflowsHub} - notes={notes} expandedSessionIds={expandedSessionIds} outputs={outputs} dashboardId={dashboardId} @@ -287,7 +279,6 @@ const DashboardCanvas: React.FC = ({ 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 = ({ }} onApplications={() => setAppsWindowOpen((v) => !v)} onAddBrowser={onAddBrowser} - onAddNote={onAddNote} /> )} @@ -399,7 +389,6 @@ const DashboardCanvas: React.FC = ({ viewCards={viewCards} browserCards={browserCards} keepAliveBrowserCards={keepAliveBrowserCards} - notes={notes} workflowCards={workflowCards} workflowsHub={workflowsHub} outputs={outputs} @@ -410,7 +399,6 @@ const DashboardCanvas: React.FC = ({ highlightedCardId={highlightedCardId} autoFocusSessionId={autoFocusSessionId} focusedCardId={focusedCardId} - pendingFocusNoteId={pendingFocusNoteId} multiDragDelta={multiDragDelta} shakeDirection={shakeDirection} spawnOriginsRef={spawnOriginsRef} @@ -459,7 +447,6 @@ const DashboardCanvas: React.FC = ({ onAddView={onAddView} onHistoryResume={onHistoryResume} onAddBrowser={onAddBrowser} - onAddNote={onAddNote} onNewAgentBounceEnd={onNewAgentBounceEnd} onFitToView={onFitToView} onTidy={onTidy} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 469875bd..d0bcc1fa 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -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; browserCards: Record; keepAliveBrowserCards: Record; - notes: Record; workflowCards: Record; workflowsHub: WorkflowsHubPosition | null; outputs: Record; @@ -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>; @@ -67,7 +63,6 @@ const DashboardCardLayer: React.FC = ({ viewCards, browserCards, keepAliveBrowserCards, - notes, workflowCards, workflowsHub, outputs, @@ -78,7 +73,6 @@ const DashboardCardLayer: React.FC = ({ highlightedCardId, autoFocusSessionId, focusedCardId, - pendingFocusNoteId, multiDragDelta, shakeDirection, spawnOriginsRef, @@ -239,30 +233,6 @@ const DashboardCardLayer: React.FC = ({ onBringToFront={onBringToFront} /> ))} - {Object.values(notes).map((n) => ( - - ))} {workflowsHub && ( ; workflowCards: Record; workflowsHub: WorkflowsHubPosition | null; - notes: Record; expandedSessionIds: string[]; outputs: Record; dashboardId: string | undefined; @@ -51,7 +50,6 @@ const DashboardHeader: React.FC = ({ browserCards, workflowCards, workflowsHub, - notes, expandedSessionIds, outputs, dashboardId, @@ -194,7 +192,7 @@ const DashboardHeader: React.FC = ({ 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 })); }} /> diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 7c3c39b5..868bb33f 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -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 = ({ onAddView, onHistoryResume, onAddBrowser, - onAddNote, onNewAgentBounceEnd, onFitToView, onTidy, @@ -108,7 +106,6 @@ const DashboardOverlays: React.FC = ({ onAddView={onAddView} onHistoryResume={onHistoryResume} onAddBrowser={onAddBrowser} - onAddNote={onAddNote} dashboardId={dashboardId} newAgentBounce={newAgentBounce} canvasEmpty={canvasEmpty} diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx deleted file mode 100644 index f315027e..00000000 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ /dev/null @@ -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 = { - 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 }[] = [ - { 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 = { - 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 = ({ - 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(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 ( - 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 */} - - e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}> - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> - - {isMinimized && ( - - {content.trim() || 'Note'} - - )} - e.stopPropagation()} - > - { 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)' } }} - > - - - - - - {showColorPicker && ( - 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 ( - { - 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)' }, - }} - /> - ); - })} - - )} - - {/* Editable content. Fullscreen = focus-writing mode: reading-size type in a centered column, like Bear/Arc, not 12px lost in a 2800px card. */} - {!isMinimized && ( - -