[eric] global ⌘K search + ⌘L clear chat + sticky notes on canvas + merge Chat into Ask + chat composer/dock sizing + spellcheck

This commit is contained in:
ciregenz
2026-04-29 02:41:01 -04:00
parent f755548b93
commit b7c93e2251
14 changed files with 1233 additions and 59 deletions
+9 -1
View File
@@ -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)."""
+11
View File
@@ -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)
+12 -23
View File
@@ -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",
+15
View File
@@ -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):
+98 -8
View File
@@ -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<string, string> = { '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 = () => {
>
<AnimatePresence mode="wait">
{islandState === 'idle' && (
<IdlePill key="idle" c={c} />
<IdlePill key="idle" c={c} onClick={() => setSearchOpen(true)} />
)}
{islandState === 'compact' && (
<CompactPill
@@ -505,23 +576,28 @@ const DynamicIsland: React.FC = () => {
</AnimatePresence>
</motion.div>
</motion.div>
<GlobalSearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} />
</>
);
};
// ---------------------------------------------------------------------------
// Idle pill — disabled search bar
// Idle pill — clickable search bar (opens GlobalSearchPalette).
// ---------------------------------------------------------------------------
const IdlePill: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ 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<typeof useClaudeTokens>; onClick: () => void }> = ({ c, onClick }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.2 }}
>
<Tooltip title="Coming soon" arrow placement="bottom">
<Tooltip title={`Search (${SEARCH_HOTKEY})`} arrow placement="bottom">
<Box
onClick={(e) => { e.stopPropagation(); onClick(); }}
sx={{
display: 'flex',
alignItems: 'center',
@@ -529,20 +605,34 @@ const IdlePill: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) =>
px: 1.25,
height: 24,
userSelect: 'none',
cursor: 'default',
cursor: 'pointer',
transition: 'background 0.15s',
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
}}
>
<SearchIcon sx={{ fontSize: 13, color: c.text.ghost, flexShrink: 0 }} />
<SearchIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
<Typography
sx={{
color: c.text.ghost,
color: c.text.muted,
fontSize: '0.66rem',
fontWeight: 400,
lineHeight: 1,
whiteSpace: 'nowrap',
flex: 1,
}}
>
Search...
Search…
</Typography>
<Typography
sx={{
color: c.text.ghost,
fontSize: '0.6rem',
fontFamily: c.font.mono,
lineHeight: 1,
opacity: 0.7,
}}
>
{SEARCH_HOTKEY}
</Typography>
</Box>
</Tooltip>
@@ -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<Props> = ({ open, onClose }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<Result[]>(() => {
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<string, SessionResult>();
// 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 */}
<Box
onClick={onClose}
sx={{ position: 'fixed', inset: 0, zIndex: 1400, bgcolor: 'rgba(0,0,0,0.35)' }}
/>
{/* Palette */}
<Box
onKeyDown={handleKeyDown}
sx={{
position: 'fixed',
top: '13%',
left: '50%',
transform: 'translateX(-50%)',
width: 680,
maxHeight: 560,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: c.shadow.lg,
zIndex: 1401,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Input row */}
<Box sx={{ display: 'flex', alignItems: 'center', px: 2.25, py: 1.75, borderBottom: `1px solid ${c.border.subtle}` }}>
<SearchIcon sx={{ fontSize: '1.4rem', color: c.text.muted, mr: 1.5 }} />
<InputBase
inputRef={inputRef}
value={query}
onChange={(e) => 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 && <CircularProgress size={16} sx={{ color: c.text.muted, ml: 1 }} />}
<Typography sx={{ fontSize: '0.78rem', color: c.text.ghost, ml: 1.5, fontFamily: c.font.mono }}>
esc
</Typography>
</Box>
{/* Results */}
<Box sx={{ overflowY: 'auto', flex: 1 }}>
{results.length === 0 ? (
<Typography sx={{ px: 2, py: 3, fontSize: '0.85rem', color: c.text.muted, textAlign: 'center' }}>
{query.trim() ? 'No matches' : 'No dashboards or chats yet'}
</Typography>
) : (
<>
{dashSection.length > 0 && (
<SectionHeader label="Dashboards" c={c} />
)}
{dashSection.map((r) => {
const idx = flatIndexOf(r);
return (
<ResultRow
key={`d-${r.id}`}
icon={<DashboardIcon sx={{ fontSize: 19, color: c.accent.primary }} />}
title={r.name}
subtitle="Dashboard"
selected={idx === selectedIndex}
onClick={() => handleSelect(r)}
onMouseEnter={() => setSelectedIndex(idx)}
c={c}
/>
);
})}
{sessSection.length > 0 && (
<SectionHeader label="Chats" c={c} />
)}
{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 (
<ResultRow
key={`s-${r.id}`}
icon={<ChatBubbleOutlineIcon sx={{ fontSize: 16, color: c.text.muted }} />}
title={r.name}
subtitle={subtitle}
selected={idx === selectedIndex}
onClick={() => handleSelect(r)}
onMouseEnter={() => setSelectedIndex(idx)}
c={c}
/>
);
})}
</>
)}
</Box>
{/* Hint footer */}
<Box
sx={{
px: 1.75,
py: 0.75,
borderTop: `1px solid ${c.border.subtle}`,
display: 'flex',
gap: 1.5,
alignItems: 'center',
color: c.text.ghost,
fontSize: '0.7rem',
}}
>
<span>↑↓ navigate</span>
<span>↵ open</span>
<span>esc close</span>
</Box>
</Box>
</>
);
};
const SectionHeader: React.FC<{ label: string; c: ReturnType<typeof useClaudeTokens> }> = ({ label, c }) => (
<Typography
sx={{
px: 2.25,
pt: 1.5,
pb: 0.5,
fontSize: '0.72rem',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: c.text.ghost,
}}
>
{label}
</Typography>
);
interface RowProps {
icon: React.ReactNode;
title: string;
subtitle?: string;
selected: boolean;
onClick: () => void;
onMouseEnter: () => void;
c: ReturnType<typeof useClaudeTokens>;
}
const ResultRow: React.FC<RowProps> = ({ icon, title, subtitle, selected, onClick, onMouseEnter, c }) => (
<Box
onClick={onClick}
onMouseEnter={onMouseEnter}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.25,
py: 1.25,
cursor: 'pointer',
bgcolor: selected ? c.bg.secondary : 'transparent',
}}
>
<Box sx={{ flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 22 }}>
{icon}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontSize: '0.95rem',
fontWeight: 500,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{title}
</Typography>
{subtitle && (
<Typography
sx={{
fontSize: '0.78rem',
color: c.text.muted,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mt: 0.25,
}}
>
{subtitle}
</Typography>
)}
</Box>
</Box>
);
export default GlobalSearchPalette;
+44 -11
View File
@@ -46,15 +46,19 @@ async function handleSlashCommand(cmd: string, sessionId: string): Promise<boole
window.dispatchEvent(new CustomEvent('openswarm:context-drawer', { detail: { sessionId, open: true } }));
return true;
}
// Note: API_BASE already ends in `/api`, so don't double it up — the
// /compact and /clear handlers in main.py are mounted at the absolute
// path `/api/agents/sessions/{id}/compact` (not under the `agents`
// SubApp), so the request URL is `${API_BASE}/agents/...`.
if (cmd === '/compact') {
try {
await fetch(`${API_BASE}/api/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
} catch { /* errors flow through context_status WS event */ }
return true;
}
if (cmd === '/clear') {
try {
await fetch(`${API_BASE}/api/agents/sessions/${sessionId}/clear`, { method: 'POST', headers });
await fetch(`${API_BASE}/agents/sessions/${sessionId}/clear`, { method: 'POST', headers });
} catch { /* same */ }
return true;
}
@@ -72,6 +76,7 @@ import {
} from '@/app/components/richEditorUtils';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchModes } from '@/shared/state/modesSlice';
import { clearSessionMessages } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export interface AttachedImage {
@@ -595,6 +600,22 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ onSend, disabled, mode,
}}
>
{modeConf.icon}
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: 'inherit', lineHeight: 1 }}>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: 'inherit', lineHeight: 1 }}>
{modeConf.label}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
@@ -1064,6 +1091,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ onSend, disabled, mode,
transition: 'background 0.15s',
}}
>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
{(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
@@ -1117,6 +1146,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ onSend, disabled, mode,
transition: 'background 0.15s',
}}
>
<PsychologyOutlinedIcon sx={{ fontSize: 13, opacity: 0.7 }} />
<Typography sx={{ fontSize: '0.72rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
<PsychologyOutlinedIcon sx={{ fontSize: 14, opacity: 0.7 }} />
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
{current.label}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
<KeyboardArrowDownIcon sx={{ fontSize: 15, color: 'inherit', opacity: 0.7 }} />
</Box>
<Menu
anchorEl={thinkingAnchor}
@@ -1238,6 +1269,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
autoFocus
MenuListProps={{ autoFocusItem: true }}
>
<MenuItem disabled sx={{ opacity: '1 !important', py: 0.5, px: 1.5, minHeight: 'auto', pointerEvents: 'none' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: c.text.tertiary }}>
+64 -2
View File
@@ -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<DashboardProps> = ({ 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<DashboardProps> = ({ dashboardId, isActive = true
cards,
viewCards,
browserCards,
notes,
);
const toolbarRef = useRef<HTMLDivElement>(null);
@@ -319,6 +326,10 @@ const DashboardInner: React.FC<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ dashboardId, isActive = true
onBringToFront={handleBringToFront}
/>
))}
{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}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
cmdHeld={canvas.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={handleCardSelect}
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
onBringToFront={handleBringToFront}
/>
))}
{/* Marquee selection rectangle */}
{selection.marquee && (
<div
@@ -1969,6 +2030,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
onAddView={handleAddView}
onHistoryResume={handleHistoryResume}
onAddBrowser={handleAddBrowser}
onAddNote={handleAddNote}
dashboardId={dashboardId}
newAgentBounce={newAgentBounce}
onNewAgentBounceEnd={() => setNewAgentBounce(false)}
@@ -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<typeof Tooltip> & { className?: string }) => (
@@ -85,7 +86,7 @@ function formatRelativeTime(dateStr: string | null): string {
}
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
({ 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<HTMLDivElement, Props>(
}
}, [handleHistoryLoadMore]);
const placeholderItems = [
{ icon: StickyNote2OutlinedIcon, label: 'Add Notes', sub: 'Coming soon' },
];
const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = [];
return (
<MotionBox
@@ -374,14 +373,14 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
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 ? (
<div style={{ width: '100%', minHeight: 44, paddingBottom: 0, marginBottom: -4 }}>
<div style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }}>
<ChatInput
onSend={handleSend}
mode={mode}
@@ -752,6 +751,40 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>Add note</Box>
<Box sx={{ opacity: 0.6, fontSize: '0.7rem', mt: '1px' }}>Sticky note on the canvas</Box>
</Box>
}
>
<Box
role="button"
aria-label="Add note"
tabIndex={0}
onClick={onAddNote}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
}}
>
<StickyNote2OutlinedIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
<WarmTooltip
key={label}
@@ -0,0 +1,421 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import PaletteOutlinedIcon from '@mui/icons-material/PaletteOutlined';
import {
setNotePosition,
setNoteSize,
removeNote,
updateNoteContent,
setNoteColor,
NoteColor,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
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 across, gentle on the eye in
// both light and dark themes (notes use a single bg per color in either).
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;
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<Props> = ({
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<HTMLTextAreaElement>(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 (
<Box
data-select-type="note-card"
data-select-id={noteId}
data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })}
onPointerDownCapture={() => 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 */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onPointerCancel={handleDragPointerUp}
sx={{
height: HEADER_H,
flexShrink: 0,
cursor: isDragging ? 'grabbing' : 'grab',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 0.75,
touchAction: 'none',
}}
>
<Box
className="note-controls"
sx={{ display: 'flex', alignItems: 'center', gap: 0.25, opacity: 0, transition: 'opacity 0.15s' }}
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
className="note-controls"
sx={{ opacity: 0, transition: 'opacity 0.15s' }}
onPointerDown={(e) => e.stopPropagation()}
>
<IconButton
size="small"
onClick={handleRemove}
sx={{ p: 0.25, color: palette.text, opacity: 0.55, '&:hover': { opacity: 1, bgcolor: 'rgba(0,0,0,0.06)' } }}
>
<CloseIcon 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 */}
<Box sx={{ flex: 1, p: 1, pt: 0.25, display: 'flex', 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: '0.85rem',
lineHeight: 1.45,
padding: 0,
}}
/>
</Box>
{/* Resize handles */}
{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 NoteCard;
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice';
export type CardType = 'agent' | 'view' | 'browser';
export type CardType = 'agent' | 'view' | 'browser' | 'note';
export interface SelectedCard {
id: string;
@@ -41,6 +41,7 @@ export function useDashboardSelection(
cards: Record<string, CardPosition>,
viewCards: Record<string, ViewCardPosition>,
browserCards: Record<string, BrowserCardPosition> = {},
notes: Record<string, NotePosition> = {},
) {
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
@@ -135,6 +136,19 @@ 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');
}
}
if (shiftKey) {
const base = selectionBeforeMarqueeRef.current;
const next = new Map(base);
@@ -150,7 +164,7 @@ export function useDashboardSelection(
return intersecting;
},
[cards, viewCards, browserCards],
[cards, viewCards, browserCards, notes],
);
const handleCanvasMouseDown = useCallback(
+16
View File
@@ -504,6 +504,13 @@ const agentsSlice = createSlice({
state.activeSessionId = action.payload;
},
clearSessionMessages(state, action: PayloadAction<string>) {
const session = state.sessions[action.payload];
if (session) {
session.messages = [];
}
},
toggleExpandSession(state, action: PayloadAction<string>) {
const idx = state.expandedSessionIds.indexOf(action.payload);
if (idx >= 0) {
@@ -1092,6 +1099,14 @@ const agentsSlice = createSlice({
if (!state.expandedSessionIds.includes(session.id)) {
state.expandedSessionIds.push(session.id);
}
// Keep this session pinned across the next fetchSessions strip.
// Without this, an in-flight fetchSessions that returned before the
// resume races with the resume reducer and removes the just-resumed
// session (since closed/stopped sessions don't survive the strip
// unless they're in trackedNotificationIds, drafts, or active).
if (!state.trackedNotificationIds.includes(session.id)) {
state.trackedNotificationIds.push(session.id);
}
})
.addCase(fetchSession.fulfilled, (state, action) => {
const session = action.payload;
@@ -1138,6 +1153,7 @@ const agentsSlice = createSlice({
export const {
createDraftSession,
setActiveSession,
clearSessionMessages,
toggleExpandSession,
expandSession,
collapseSession,
@@ -56,10 +56,27 @@ export interface BrowserCardPosition {
spawned_by?: string | null;
}
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;
export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
glowingAgentCards: Record<string, { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }>;
@@ -72,12 +89,14 @@ export interface DashboardLayoutState {
// to the new card's id. Dashboard.tsx watches it and pans/zooms the canvas
// to center on the new card, then dispatches clearPendingFocusBrowserId.
pendingFocusBrowserId: string | null;
pendingFocusNoteId: string | null;
}
const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
notes: {},
closedCardPositions: {},
glowingBrowserCards: {},
glowingAgentCards: {},
@@ -86,12 +105,14 @@ const initialState: DashboardLayoutState = {
loading: false,
initialized: false,
pendingFocusBrowserId: null,
pendingFocusNoteId: null,
};
interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
}
@@ -123,6 +144,7 @@ export const fetchLayout = createAsyncThunk(
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: browserCards as Record<string, BrowserCardPosition>,
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
},
@@ -143,6 +165,7 @@ export const saveLayout = createAsyncThunk(
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
notes: payload.notes,
expanded_session_ids: payload.expandedSessionIds,
},
}),
@@ -178,6 +201,9 @@ function collectOccupiedRects(
for (const c of Object.values(state.browserCards)) {
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
}
for (const n of Object.values(state.notes)) {
rects.push({ x: n.x, y: n.y, w: n.width, h: n.height });
}
return rects;
}
@@ -243,7 +269,7 @@ const dashboardLayoutSlice = createSlice({
bringToFront(
state,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' }>,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>,
) {
const { id, type } = action.payload;
const z = state.nextZOrder++;
@@ -253,6 +279,9 @@ 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 {
const card = state.browserCards[id];
if (card) card.zOrder = z;
@@ -605,7 +634,7 @@ const dashboardLayoutSlice = createSlice({
moveCards(
state,
action: PayloadAction<{
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' }>;
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>;
dx: number;
dy: number;
}>,
@@ -624,6 +653,12 @@ 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 {
const card = state.browserCards[item.id];
if (card) {
@@ -634,6 +669,65 @@ const dashboardLayoutSlice = createSlice({
}
},
addNote(
state,
action: PayloadAction<{ x?: number; y?: number; expandedSessionIds?: string[]; color?: NoteColor }>,
) {
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: '',
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];
},
clearPendingFocusNoteId(state) {
state.pendingFocusNoteId = null;
},
replaceDraftId(
state,
action: PayloadAction<{ oldId: string; newId: string }>
@@ -692,12 +786,14 @@ const dashboardLayoutSlice = createSlice({
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.notes = {};
state.closedCardPositions = {};
state.glowingBrowserCards = {};
state.glowingAgentCards = {};
state.persistedExpandedSessionIds = [];
state.nextZOrder = 1;
state.initialized = false;
state.pendingFocusNoteId = null;
},
},
@@ -712,6 +808,7 @@ const dashboardLayoutSlice = createSlice({
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.notes = action.payload.notes || {};
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
// Ensure all cards have a zOrder and compute nextZOrder from persisted data
@@ -728,6 +825,10 @@ const dashboardLayoutSlice = createSlice({
if (!c.zOrder) c.zOrder = 0;
if (c.zOrder > maxZ) maxZ = c.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) => {
@@ -781,6 +882,13 @@ export const {
fadeGlowingAgentCard,
clearGlowingAgentCard,
clearPendingFocusBrowserId,
addNote,
setNotePosition,
setNoteSize,
updateNoteContent,
setNoteColor,
removeNote,
clearPendingFocusNoteId,
resetLayout,
} = dashboardLayoutSlice.actions;
File diff suppressed because one or more lines are too long