mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 05:24:50 +02:00
[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:
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user