diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 24cb996e..cd8cc1b9 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -3410,10 +3410,18 @@ class AgentManager: async def reconcile_on_startup(self) -> None: """Mark any stale running sessions as stopped.""" for sid, data in _load_all_session_data(): + dirty = False if data.get("status") in ("running", "waiting_approval"): data["status"] = "stopped" - _save_session(sid, data) + dirty = True logger.info(f"Marked stale session {sid} as stopped") + # Mode migration: Chat was merged into Ask. Rewrite mode="chat" + # so old sessions keep loading after the chat.json file is gone. + if data.get("mode") == "chat": + data["mode"] = "ask" + dirty = True + if dirty: + _save_session(sid, data) async def persist_all_sessions(self) -> None: """Flush every in-memory session to JSON files (for graceful shutdown).""" diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index c50e8c48..22ede85c 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -42,10 +42,21 @@ class BrowserCardPosition(BaseModel): spawned_by: 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): cards: dict[str, CardPosition] = Field(default_factory=dict) view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict) browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict) + notes: dict[str, NotePosition] = Field(default_factory=dict) expanded_session_ids: list[str] = Field(default_factory=list) diff --git a/backend/apps/modes/models.py b/backend/apps/modes/models.py index 83a8b571..b639660a 100644 --- a/backend/apps/modes/models.py +++ b/backend/apps/modes/models.py @@ -41,26 +41,6 @@ class ModeUpdate(BaseModel): BUILTIN_MODES: list[Mode] = [ - Mode( - id="chat", - name="Chat", - description="Lightweight conversational mode. Minimal tools, no MCPs by default — perfect for quick greetings, questions, and ideation without spinning up the full agent.", - system_prompt=( - "You are in Chat mode — a lightweight conversational assistant. Keep " - "responses short and natural. You do NOT have file-editing or shell " - "access in this mode; if the user asks for something that requires " - "real work (writing code, running commands, hitting an MCP), tell " - "them to switch to Agent mode. If the user asks something that " - "requires an MCP server (email, calendar, etc.), you can use " - "MCPSearch and MCPActivate to bring it in — those are the only " - "external capabilities available to you here." - ), - tools=["AskUserQuestion", "WebFetch", "WebSearch"], - default_next_mode=None, - is_builtin=True, - icon="chat_bubble", - color="#60a5fa", - ), Mode( id="agent", name="Agent", @@ -75,9 +55,18 @@ BUILTIN_MODES: list[Mode] = [ Mode( id="ask", name="Ask", - description="Answer questions about the codebase. Read-only, no edits or changes.", - system_prompt="Answer questions about the codebase. Do not make any edits or changes.", - tools=["Read", "Glob", "Grep", "AskUserQuestion"], + description="Read-only conversation. Browse the codebase, search the web, and discuss ideas — but no edits, shells, or file writes.", + system_prompt=( + "You are in Ask mode — a read-only assistant. Keep responses " + "natural and conversational. You CAN read files, search the " + "codebase, and search/fetch the web. You CANNOT edit files, run " + "shell commands, or otherwise modify anything; if the user asks " + "for real work (writing code, running commands), tell them to " + "switch to Agent mode. You may use MCPSearch and MCPActivate to " + "bring in additional read-only data sources (email, calendar, " + "etc.) when relevant." + ), + tools=["Read", "Glob", "Grep", "AskUserQuestion", "WebFetch", "WebSearch"], default_next_mode=None, is_builtin=True, icon="question_answer", diff --git a/backend/apps/modes/modes.py b/backend/apps/modes/modes.py index 37aabd90..ebf399ef 100644 --- a/backend/apps/modes/modes.py +++ b/backend/apps/modes/modes.py @@ -14,6 +14,21 @@ from backend.config.paths import MODES_DIR as DATA_DIR @asynccontextmanager async def modes_lifespan(): os.makedirs(DATA_DIR, exist_ok=True) + # One-time migration: Chat was merged into Ask. Remove a stale built-in + # chat.json if it still has its is_builtin=True signature so users don't + # see two near-identical modes in the picker. Leave alone if a user has + # diverged it (we don't want to wipe customizations). + chat_path = os.path.join(DATA_DIR, "chat.json") + if os.path.exists(chat_path): + try: + import json as _json + with open(chat_path) as _f: + _data = _json.load(_f) + if _data.get("is_builtin") is True and _data.get("id") == "chat": + os.remove(chat_path) + logger.info("Removed deprecated built-in chat.json (merged into ask)") + except Exception: + logger.exception("Failed to inspect chat.json during migration") for builtin in BUILTIN_MODES: path = os.path.join(DATA_DIR, f"{builtin.id}.json") if not os.path.exists(path): diff --git a/frontend/src/app/components/DynamicIsland.tsx b/frontend/src/app/components/DynamicIsland.tsx index 44f00ea7..bac1572f 100644 --- a/frontend/src/app/components/DynamicIsland.tsx +++ b/frontend/src/app/components/DynamicIsland.tsx @@ -19,12 +19,16 @@ import { stopAgent, dismissAgentNotification, dismissAllFinishedNotifications, + clearSessionMessages, ApprovalRequest, AgentSession, HistorySession, } from '@/shared/state/agentsSlice'; +import { API_BASE, getAuthToken } from '@/shared/config'; +import { store } from '@/shared/state/store'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/ApprovalBar'; +import GlobalSearchPalette from '@/app/components/GlobalSearchPalette'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; // --------------------------------------------------------------------------- @@ -201,6 +205,73 @@ const DynamicIsland: React.FC = () => { const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds); const [userExpanded, setUserExpanded] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + + // Global Cmd/Ctrl+K — open search palette from anywhere. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setSearchOpen((prev) => !prev); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + // Global Cmd/Ctrl+L — clear the chat (Claude Code convention). Resolves + // the target session in priority order: + // 1) the session whose chat input currently has focus (when typing inside + // a contentEditable card body, the data-session-id climbs the DOM) + // 2) state.agents.activeSessionId (last touched chat) + // 3) a single visible session if there's exactly one + // No-op if none of those resolve. Hits the same /clear endpoint as the + // /clear slash command and dispatches clearSessionMessages so the visible + // transcript matches the now-empty SDK context. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey)) return; + if (e.shiftKey || e.altKey) return; + if (e.key.toLowerCase() !== 'l') return; + + // Walk up from activeElement looking for an agent-card marker. + // Falls back to Redux's activeSessionId, then to the only session + // if it's unambiguous. + let target: string | null = null; + const ae = document.activeElement as HTMLElement | null; + if (ae) { + let el: HTMLElement | null = ae; + while (el) { + if (el.getAttribute?.('data-select-type') === 'agent-card') { + const sid = el.getAttribute('data-select-id'); + if (sid) { target = sid; break; } + } + el = el.parentElement; + } + } + const state = store.getState(); + if (!target) { + const activeId = state.agents.activeSessionId; + if (activeId) target = activeId; + } + if (!target) { + const ids = Object.keys(state.agents.sessions); + if (ids.length === 1) target = ids[0]; + } + if (!target) return; + + e.preventDefault(); + const headers: Record = { 'Content-Type': 'application/json' }; + try { + const tok = getAuthToken(); + if (tok) headers['Authorization'] = `Bearer ${tok}`; + } catch { /* unauthenticated dev mode */ } + fetch(`${API_BASE}/agents/sessions/${target}/clear`, { method: 'POST', headers }).catch(() => {}); + dispatch(clearSessionMessages(target)); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [dispatch]); // ---- Derived data ---- @@ -460,7 +531,7 @@ const DynamicIsland: React.FC = () => { > {islandState === 'idle' && ( - + setSearchOpen(true)} /> )} {islandState === 'compact' && ( { + setSearchOpen(false)} /> ); }; // --------------------------------------------------------------------------- -// Idle pill — disabled search bar +// Idle pill — clickable search bar (opens GlobalSearchPalette). // --------------------------------------------------------------------------- -const IdlePill: React.FC<{ c: ReturnType }> = ({ c }) => ( +const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform); +const SEARCH_HOTKEY = isMac ? '⌘K' : 'Ctrl+K'; + +const IdlePill: React.FC<{ c: ReturnType; onClick: () => void }> = ({ c, onClick }) => ( - + { e.stopPropagation(); onClick(); }} sx={{ display: 'flex', alignItems: 'center', @@ -529,20 +605,34 @@ const IdlePill: React.FC<{ c: ReturnType }> = ({ c }) => px: 1.25, height: 24, userSelect: 'none', - cursor: 'default', + cursor: 'pointer', + transition: 'background 0.15s', + '&:hover': { bgcolor: 'rgba(255,255,255,0.04)' }, }} > - + - Search... + Search… + + + {SEARCH_HOTKEY} diff --git a/frontend/src/app/components/GlobalSearchPalette.tsx b/frontend/src/app/components/GlobalSearchPalette.tsx new file mode 100644 index 00000000..495c34ac --- /dev/null +++ b/frontend/src/app/components/GlobalSearchPalette.tsx @@ -0,0 +1,374 @@ +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import Box from '@mui/material/Box'; +import InputBase from '@mui/material/InputBase'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import SearchIcon from '@mui/icons-material/Search'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { searchHistory, resumeSession, HistorySession } from '@/shared/state/agentsSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface Props { + open: boolean; + onClose: () => void; +} + +interface DashboardResult { + kind: 'dashboard'; + id: string; + name: string; +} + +interface SessionResult { + kind: 'session'; + id: string; + name: string; + dashboardId: string | null; + status: string; + closedAt: string | null; +} + +type Result = DashboardResult | SessionResult; + +const GlobalSearchPalette: React.FC = ({ open, onClose }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + + const dashboards = useAppSelector((s) => s.dashboards.items); + const sessions = useAppSelector((s) => s.agents.sessions); + const history = useAppSelector((s) => s.agents.history); + const searchResults = useAppSelector((s) => s.agents.historySearch.results); + const searchLoading = useAppSelector((s) => s.agents.historySearch.loading); + const searchQuery = useAppSelector((s) => s.agents.historySearch.query); + + // Debounced session/history search. + useEffect(() => { + if (!open) return; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + dispatch(searchHistory({ q: query.trim(), limit: 30, offset: 0 })); + }, 120); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, open, dispatch]); + + // Reset on open + autofocus. + useEffect(() => { + if (open) { + setQuery(''); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 30); + } + }, [open]); + + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + // Build results: dashboards first, then sessions. Sessions come from + // `historySearch.results` (closed) plus active in-memory sessions + // (not in history yet). + const results = useMemo(() => { + const q = query.trim().toLowerCase(); + const dashboardResults: DashboardResult[] = Object.values(dashboards) + .filter((d) => !q || d.name.toLowerCase().includes(q)) + .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')) + .slice(0, 5) + .map((d) => ({ kind: 'dashboard', id: d.id, name: d.name })); + + // Merge active in-memory sessions with history search results, dedupe by id. + const sessionMap = new Map(); + // Active in-memory sessions + for (const s of Object.values(sessions)) { + if (q && !(s.name || '').toLowerCase().includes(q)) continue; + sessionMap.set(s.id, { + kind: 'session', + id: s.id, + name: s.name || 'Untitled', + dashboardId: s.dashboard_id || null, + status: s.status, + closedAt: null, + }); + } + // When the query is empty, fall back to recent history rather than the + // (potentially huge) history dump — matches what the user sees on init. + const historyPool: HistorySession[] = q ? searchResults : Object.values(history).slice(0, 20); + for (const h of historyPool) { + if (sessionMap.has(h.id)) continue; + if (q && !(h.name || '').toLowerCase().includes(q)) continue; + sessionMap.set(h.id, { + kind: 'session', + id: h.id, + name: h.name || 'Untitled', + dashboardId: h.dashboard_id || null, + status: h.status, + closedAt: h.closed_at, + }); + } + const sessionResults = Array.from(sessionMap.values()).slice(0, 30); + return [...dashboardResults, ...sessionResults]; + }, [query, dashboards, sessions, history, searchResults]); + + const handleSelect = useCallback((r: Result) => { + if (r.kind === 'dashboard') { + navigate(`/dashboard/${r.id}`); + } else { + // Session: navigate to its dashboard (if any), focus the card. + // For closed sessions, resume first so the card can render. + if (r.dashboardId) { + navigate(`/dashboard/${r.dashboardId}`); + if (r.closedAt) { + // Closed history session — resume so it lands back in `sessions` + // and the dashboard layout can place a card for it. + dispatch(resumeSession({ sessionId: r.id })).then(() => { + dispatch(setPendingFocusAgentId(r.id)); + }); + } else { + dispatch(setPendingFocusAgentId(r.id)); + } + } else if (r.closedAt) { + // No dashboard — just resume; the resumed session will land in some + // dashboard if it had one, otherwise it'll be orphan and we can't + // really "navigate" anywhere meaningful. + dispatch(resumeSession({ sessionId: r.id })); + } + } + onClose(); + }, [navigate, dispatch, onClose]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (results[selectedIndex]) handleSelect(results[selectedIndex]); + } + }, [results, selectedIndex, handleSelect, onClose]); + + if (!open) return null; + + // Group results visually. Sections collapse if empty. + const dashSection = results.filter((r): r is DashboardResult => r.kind === 'dashboard'); + const sessSection = results.filter((r): r is SessionResult => r.kind === 'session'); + + // Map item index → flat results index for keyboard nav. + const flatIndexOf = (r: Result) => results.indexOf(r); + const isStillSearching = !!query.trim() && searchLoading && searchQuery !== query.trim(); + + return ( + <> + {/* Backdrop */} + + {/* Palette */} + + {/* Input row */} + + + setQuery(e.target.value)} + placeholder="Search dashboards and chats…" + fullWidth + sx={{ + fontSize: '1.05rem', + fontFamily: c.font.sans, + color: c.text.primary, + '& input::placeholder': { color: c.text.muted, opacity: 1 }, + }} + /> + {isStillSearching && } + + esc + + + + {/* Results */} + + {results.length === 0 ? ( + + {query.trim() ? 'No matches' : 'No dashboards or chats yet'} + + ) : ( + <> + {dashSection.length > 0 && ( + + )} + {dashSection.map((r) => { + const idx = flatIndexOf(r); + return ( + } + title={r.name} + subtitle="Dashboard" + selected={idx === selectedIndex} + onClick={() => handleSelect(r)} + onMouseEnter={() => setSelectedIndex(idx)} + c={c} + /> + ); + })} + {sessSection.length > 0 && ( + + )} + {sessSection.map((r) => { + const idx = flatIndexOf(r); + const dashName = r.dashboardId ? dashboards[r.dashboardId]?.name : null; + const subtitle = [ + dashName && `in ${dashName}`, + r.closedAt ? 'closed' : r.status, + ].filter(Boolean).join(' · '); + return ( + } + title={r.name} + subtitle={subtitle} + selected={idx === selectedIndex} + onClick={() => handleSelect(r)} + onMouseEnter={() => setSelectedIndex(idx)} + c={c} + /> + ); + })} + + )} + + + {/* Hint footer */} + + ↑↓ navigate + ↵ open + esc close + + + + ); +}; + +const SectionHeader: React.FC<{ label: string; c: ReturnType }> = ({ label, c }) => ( + + {label} + +); + +interface RowProps { + icon: React.ReactNode; + title: string; + subtitle?: string; + selected: boolean; + onClick: () => void; + onMouseEnter: () => void; + c: ReturnType; +} + +const ResultRow: React.FC = ({ icon, title, subtitle, selected, onClick, onMouseEnter, c }) => ( + + + {icon} + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + +); + +export default GlobalSearchPalette; diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 885f894b..602676c4 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -46,15 +46,19 @@ async function handleSlashCommand(cmd: string, sessionId: string): Promise(({ onSend, disabled, mode, e.preventDefault(); return; } + // Cmd/Ctrl+L → clear the chat (matches Claude Code's convention). + // Empties the editor + visible transcript, and resets the SDK + // session id server-side so the next message starts in fresh context. + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'l' && !e.shiftKey && !e.altKey) { + e.preventDefault(); + if (sessionId) { + handleSlashCommand('/clear', sessionId).catch(() => { /* surfaced via context_status */ }); + dispatch(clearSessionMessages(sessionId)); + } + const editor = editorRef.current; + if (editor) { + editor.innerHTML = ''; + updateHasContent(); + } + return; + } if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) { e.preventDefault(); handleSend(); @@ -982,6 +1003,12 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, ref={editorRef} contentEditable={!disabled} suppressContentEditableWarning + spellCheck + // autoCorrect/autoCapitalize are no-ops on Chromium but harmless, + // and make the input behave correctly if anyone runs the web build + // on iOS Safari. + autoCorrect="on" + autoCapitalize="sentences" onInput={handleInput} onClick={handleEditorClick} onKeyDown={handleKeyDown} @@ -989,14 +1016,14 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, style={{ width: '100%', minHeight: '1.5em', - maxHeight: 200, + maxHeight: 220, overflowY: 'auto', background: 'transparent', border: 'none', outline: 'none', color: c.text.primary, - fontSize: '0.875rem', - lineHeight: '1.5', + fontSize: '0.95rem', + lineHeight: '1.55', fontFamily: 'inherit', wordBreak: 'break-word', whiteSpace: 'pre-wrap', @@ -1011,7 +1038,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, right: 0, padding: `${hasAttachments ? 4 : 10}px 12px`, color: c.text.tertiary, - fontSize: '0.875rem', + fontSize: '0.95rem', lineHeight: '1.5', fontFamily: 'inherit', pointerEvents: 'none', @@ -1051,7 +1078,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, }} > {modeConf.icon} - + {modeConf.label} @@ -1064,6 +1091,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} slotProps={{ paper: menuPaperProps }} + autoFocus + MenuListProps={{ autoFocusItem: true, disablePadding: false }} > {modesArr.map((m) => { const icon = ICON_MAP[m.icon] || ICON_MAP.smart_toy; @@ -1104,7 +1133,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, transition: 'background 0.15s', }} > - + {(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()} @@ -1117,6 +1146,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} slotProps={{ paper: menuPaperProps }} + autoFocus + MenuListProps={{ autoFocusItem: true }} > {Object.entries(allModelOptions.grouped).map(([prov, models]) => { // Non-interactive provider headers followed by their models. @@ -1225,11 +1256,11 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, transition: 'background 0.15s', }} > - - + + {current.label} - + (({ onSend, disabled, mode, anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} slotProps={{ paper: menuPaperProps }} + autoFocus + MenuListProps={{ autoFocusItem: true }} > diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 4f2ef27a..df2ba513 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -40,6 +40,9 @@ import { setGlowingAgentCard, clearGlowingAgentCard, clearPendingFocusBrowserId, + addNote, + removeNote, + clearPendingFocusNoteId, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, @@ -53,6 +56,7 @@ import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state import AgentCard from './AgentCard'; import DashboardViewCard from './DashboardViewCard'; import BrowserCard from './BrowserCard'; +import NoteCard from './NoteCard'; import CanvasControls from './CanvasControls'; import CardSearchPalette from './CardSearchPalette'; import DirectionHints from './DirectionHints'; @@ -103,6 +107,8 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const cards = useAppSelector((state) => state.dashboardLayout.cards); const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); + 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); @@ -138,6 +144,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true cards, viewCards, browserCards, + notes, ); const toolbarRef = useRef(null); @@ -319,6 +326,10 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true 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 }; } return undefined; }, []); @@ -714,7 +725,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true skipInitialSave.current = false; return; } - const payload = { dashboardId, cards, viewCards, browserCards, expandedSessionIds }; + const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds }; pendingSaveRef.current = payload; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { @@ -723,7 +734,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true saveTimerRef.current = null; captureNow(); }, 500); - }, [isActive, cards, viewCards, browserCards, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); + }, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); useEffect(() => { return () => { @@ -791,6 +802,8 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true dispatch(removeViewCard(id)); } else if (type === 'browser') { dispatch(removeBrowserCard(id)); + } else if (type === 'note') { + dispatch(removeNote(id)); } } selection.deselectAll(); @@ -1266,6 +1279,28 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }, 200); }, [dispatch, browserHomepage, expandedSessionIds, canvas.actions, handleHighlightCard]); + const handleAddNote = useCallback(() => { + trackEvent('dashboard.note_added'); + const prevIds = new Set(Object.keys(store.getState().dashboardLayout.notes)); + dispatch(addNote({ expandedSessionIds })); + setTimeout(() => { + const allNotes = store.getState().dashboardLayout.notes; + const newId = Object.keys(allNotes).find((id) => !prevIds.has(id)); + if (newId) { + const note = allNotes[newId]; + canvas.actions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], 1.15, true); + handleHighlightCard(newId); + } + }, 200); + }, [dispatch, expandedSessionIds, canvas.actions, 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)) { @@ -1937,6 +1972,32 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true onBringToFront={handleBringToFront} /> ))} + {Object.values(notes).map((n) => ( + + ))} {/* Marquee selection rectangle */} {selection.marquee && (
= ({ dashboardId, isActive = true onAddView={handleAddView} onHistoryResume={handleHistoryResume} onAddBrowser={handleAddBrowser} + onAddNote={handleAddNote} dashboardId={dashboardId} newAgentBounce={newAgentBounce} onNewAgentBounceEnd={() => setNewAgentBounce(false)} diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index d95eb65c..342782cb 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -39,13 +39,14 @@ interface Props { onAddView: (outputId: string) => void; onHistoryResume: (sessionId: string) => void; onAddBrowser: () => void; + onAddNote: () => void; dashboardId?: string; newAgentBounce?: boolean; onNewAgentBounceEnd?: () => void; } const TOOLBAR_OWNER_ID = '__toolbar__'; -const BTN = 40; +const BTN = 44; const WarmTooltip = styled( ({ className, ...props }: React.ComponentProps & { className?: string }) => ( @@ -85,7 +86,7 @@ function formatRelativeTime(dateStr: string | null): string { } const DashboardToolbar = React.forwardRef( - ({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId, newAgentBounce, onNewAgentBounceEnd }, ref) => { + ({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, onAddNote, dashboardId, newAgentBounce, onNewAgentBounceEnd }, ref) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const elementSelection = useElementSelection(); @@ -358,9 +359,7 @@ const DashboardToolbar = React.forwardRef( } }, [handleHistoryLoadMore]); - const placeholderItems = [ - { icon: StickyNote2OutlinedIcon, label: 'Add Notes', sub: 'Coming soon' }, - ]; + const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = []; return ( ( border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.xl}px`, boxShadow: c.shadow.lg, - padding: isExpanded ? '4px' : '6px', + padding: isExpanded ? '6px' : '5px', userSelect: 'none' as const, overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden', - width: viewPickerOpen ? 480 : isExpanded ? 360 : undefined, + width: viewPickerOpen ? 580 : isExpanded ? 540 : undefined, }} > {inputOpen ? ( -
+
( + + Add note + Sticky note on the canvas + + } + > + + + + + {placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => ( = { + 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 across, gentle on the eye in +// both light and dark themes (notes use a single bg per color in either). +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; + zoom?: number; + panX?: number; + panY?: 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) => 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, zoom = 1, panX = 0, panY = 0, + 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; + + // ---- Drag (whole note via header) ---- + 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 panRef = useRef({ panX, panY }); + panRef.current = { panX, panY }; + const zoomRef = useRef(zoom); + zoomRef.current = zoom; + + const [showColorPicker, setShowColorPicker] = useState(false); + const textareaRef = useRef(null); + + useEffect(() => { + if (autoFocus && textareaRef.current) { + // Defer to next frame so the card has mounted in the right position. + const t = setTimeout(() => textareaRef.current?.focus(), 50); + return () => clearTimeout(t); + } + }, [autoFocus]); + + const handleDragPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + dragState.current = { + startX: e.clientX, startY: e.clientY, + origX: cardX, origY: cardY, + startPanX: panRef.current.panX, startPanY: panRef.current.panY, + }; + lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; + didDrag.current = false; + setIsDragging(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + onDragStart?.(noteId, 'note'); + }, [cardX, cardY, noteId, onDragStart]); + + 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 z = zoomRef.current; + const panDx = (panRef.current.panX - ds.startPanX) / z; + const panDy = (panRef.current.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]); + + useEffect(() => { + if (isDragging && didDrag.current) recomputeDragPos(); + }, [panX, panY, 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 handleDragPointerUp = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const z = zoomRef.current; + const panDx = (panRef.current.panX - dragState.current.startPanX) / z; + const panDy = (panRef.current.panY - dragState.current.startPanY) / z; + const dx = (e.clientX - dragState.current.startX) / z - panDx; + const dy = (e.clientY - dragState.current.startY) / z - panDy; + if (didDrag.current) { + let finalX = dragState.current.origX + dx; + let finalY = dragState.current.origY + dy; + if (!e.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); + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + }, [dispatch, noteId, onDragEnd]); + + // ---- Resize ---- + 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 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 }; + }, + [zoom], + ); + + 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(removeNote(noteId)); + }; + + 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 ( + onBringToFront?.(noteId, 'note')} + onClick={(e: React.MouseEvent) => { + if (justDraggedRef.current) return; + onCardSelect?.(noteId, 'note', e.shiftKey); + }} + sx={{ + position: 'absolute', + left: displayX, + top: displayY, + width: displayW, + height: displayH, + borderRadius: `${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: (isDragging || isResizing) ? 999999 : cardZOrder, + display: 'flex', + flexDirection: 'column', + '&:hover .note-controls': { opacity: 1 }, + }} + > + {/* Drag header — thin strip at the top */} + + 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)' } }} + > + + + + e.stopPropagation()} + > + + + + + + + {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 */} + +