diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns index fb56d3b..d7c0ea1 100644 Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index a85b57f..a839406 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -198,7 +198,14 @@ pub fn session_read(id: String) -> Result, String> { let Ok(v) = serde_json::from_str::(line) else { continue; // tolerate a partial trailing line }; - if v.get("type").and_then(|t| t.as_str()) == Some("message") { + // Desktop sessions tag messages with type:"message"; CLI/headless sessions + // write bare {role, content} lines with no type. Accept both, skip meta. + let t = v.get("type").and_then(|t| t.as_str()); + let is_role_msg = matches!( + v.get("role").and_then(|r| r.as_str()), + Some("user") | Some("assistant") + ); + if t == Some("message") || (t.is_none() && is_role_msg) { out.push(v); } } @@ -250,34 +257,37 @@ fn derive_session_title(path: &std::path::Path) -> Option { let Ok(v) = serde_json::from_str::(line) else { continue; }; - match v.get("type").and_then(|t| t.as_str()) { - // Manual title wins — return immediately. - Some("session_meta") => { - if let Some(t) = v.get("title").and_then(|t| t.as_str()) { - let t = t.trim(); - if !t.is_empty() { - return Some(clean_title(t)); - } + let line_type = v.get("type").and_then(|t| t.as_str()); + // Manual title on the session_meta header wins — return immediately. + if line_type == Some("session_meta") { + if let Some(t) = v.get("title").and_then(|t| t.as_str()) { + let t = t.trim(); + if !t.is_empty() { + return Some(clean_title(t)); } } - Some("message") if v.get("role").and_then(|r| r.as_str()) == Some("user") => { - if from_user.is_none() { - if let Some(content) = v.get("content").and_then(|c| c.as_array()) { - for block in content { - if block.get("type").and_then(|t| t.as_str()) == Some("text") { - if let Some(txt) = block.get("text").and_then(|t| t.as_str()) { - let title = clean_title(txt); - if !title.is_empty() { - from_user = Some(title); - break; - } - } + continue; + } + // First user message → title. Desktop tags type:"message"; CLI/headless + // sessions write bare {role,content} with no type — accept both. + let is_msg = line_type == Some("message") || line_type.is_none(); + if is_msg + && v.get("role").and_then(|r| r.as_str()) == Some("user") + && from_user.is_none() + { + if let Some(content) = v.get("content").and_then(|c| c.as_array()) { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("text") { + if let Some(txt) = block.get("text").and_then(|t| t.as_str()) { + let title = clean_title(txt); + if !title.is_empty() { + from_user = Some(title); + break; } } } } } - _ => {} } } from_user @@ -382,6 +392,45 @@ pub fn list_sessions() -> Result, String> { Ok(out) } +/// Reject session ids that could escape the sessions directory. +fn safe_session_id(id: &str) -> Result<(), String> { + if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") { + return Err(format!("invalid session id: {id}")); + } + Ok(()) +} + +/// Permanently delete a session's JSONL file. +#[tauri::command] +pub fn session_delete(id: String) -> Result<(), String> { + safe_session_id(&id)?; + let Some(home) = dirs::home_dir() else { + return Err("no home directory".into()); + }; + let path = home + .join(".deepcode") + .join("sessions") + .join(format!("{id}.jsonl")); + std::fs::remove_file(&path).map_err(|e| format!("delete {}: {}", path.display(), e)) +} + +/// Archive a session by moving its JSONL into sessions/archived/ — excluded from +/// list_sessions but recoverable from disk. +#[tauri::command] +pub fn session_archive(id: String) -> Result<(), String> { + safe_session_id(&id)?; + let Some(home) = dirs::home_dir() else { + return Err("no home directory".into()); + }; + let dir = home.join(".deepcode").join("sessions"); + let archived = dir.join("archived"); + std::fs::create_dir_all(&archived) + .map_err(|e| format!("mkdir {}: {}", archived.display(), e))?; + let from = dir.join(format!("{id}.jsonl")); + let to = archived.join(format!("{id}.jsonl")); + std::fs::rename(&from, &to).map_err(|e| format!("archive {}: {}", from.display(), e)) +} + /// Path to the `deepcode` CLI so the GUI can drop users into it for advanced /// workflows. Resolves a globally-installed `deepcode` on PATH (npm i -g /// deepcode-cli). Bundling the CLI inside the .app is separate future work. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 75d7863..331d998 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -17,8 +17,8 @@ mod tools; use commands::{ append_allow_matcher, cli_path, get_app_info, get_settings_path, list_plugins, list_sessions, list_skills, load_keybindings, load_settings_file, open_url, read_credentials, - save_credentials, save_keybindings, save_settings_file, session_append, session_create, - session_read, session_set_title, + save_credentials, save_keybindings, save_settings_file, session_append, session_archive, + session_create, session_delete, session_read, session_set_title, }; use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; use tauri::Manager; @@ -46,6 +46,8 @@ pub fn run() { session_append, session_read, session_set_title, + session_delete, + session_archive, list_sessions, list_plugins, list_skills, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9a10987..8306447 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -28,11 +28,7 @@ import { SettingsScreen } from './screens/Settings.js'; import { SkillsScreen } from './screens/Skills.js'; import type { ScreenName } from './types/screens.js'; import type { UpdateInfo } from './types/global.js'; -import { - emptyInspectorData, - type InspectorData, - type InspectorSection, -} from './types/inspector.js'; +import { emptyInspectorData, type InspectorData } from './types/inspector.js'; export function App(): JSX.Element { const [hasKey, setHasKey] = useState(null); @@ -44,13 +40,15 @@ export function App(): JSX.Element { // Reconstructed messages for a resumed session; seeded into ReplScreen on its // next remount. Cleared when starting a fresh session. const [resumedMessages, setResumedMessages] = useState(undefined); - // Right inspector: 48 px rail by default, 320 px panel when expanded. - const [inspectorExpanded, setInspectorExpanded] = useState(false); - // Which section to scroll to when expanding via a rail hint icon (null = top). - const [inspectorFocus, setInspectorFocus] = useState(null); + // Right side is an activity bar (48 px rail) that's always present; exactly + // one panel opens to its left at a time (VS Code model). `inspectorOpen` + // tracks the Inspector panel; the file panel tracks its own tabs + a collapse + // flag so it can be hidden without discarding open files. + const [inspectorOpen, setInspectorOpen] = useState(false); const [inspector, setInspector] = useState(() => emptyInspectorData()); - // Right-side file panel (§3.11): opens between chat and the inspector rail. + // Right-side file panel (§3.11): opens to the left of the rail. const fp = useFilePanel(); + const [filesCollapsed, setFilesCollapsed] = useState(false); // Drag the panel's left edge to resize (320–800px, persisted by the hook). const onFilePanelResizeStart = useCallback( @@ -69,13 +67,32 @@ export function App(): JSX.Element { [fp.state.width, fp.setWidth], ); - // Expand the inspector, optionally scrolling to a section. Bumps focus to a - // fresh value each time so re-clicking the same icon re-scrolls. - const expandInspector = useCallback((section?: InspectorSection) => { - setInspectorExpanded(true); - setInspectorFocus(section ?? null); + // Exactly one right panel at a time. Opening one closes the other; clicking + // an active icon closes its panel. + const filesVisible = fp.isOpen && !filesCollapsed; + + const toggleInspector = useCallback(() => { + setFilesCollapsed(true); // a visible inspector hides the file panel + setInspectorOpen((v) => !v); }, []); + const toggleFiles = useCallback(() => { + setInspectorOpen(false); + if (fp.isOpen) setFilesCollapsed((c) => !c); + else void fp.openViaPicker(); // no tabs yet — let the user pick a file + }, [fp.isOpen, fp.openViaPicker]); + + // Open a specific file (chat tool card / inspector recent files): surface the + // file panel and step the inspector aside for it. + const openFile = useCallback( + (path: string) => { + setInspectorOpen(false); + setFilesCollapsed(false); + void fp.open(path); + }, + [fp.open], + ); + // Merge the slice ReplScreen lifts up (usage / model / mode / files / todos). // Stable identity so ReplScreen's sync effect doesn't refire every render. const handleInspector = useCallback((patch: Partial) => { @@ -114,10 +131,10 @@ export function App(): JSX.Element { // Re-registered when that context changes so it never reads stale state. useEffect(() => { return registerShortcut('meta+\\', () => { - if (fp.isOpen && fp.state.view === 'diff') fp.toggleDiffMode(); - else setInspectorExpanded((v) => !v); + if (filesVisible && fp.state.view === 'diff') fp.toggleDiffMode(); + else toggleInspector(); }); - }, [fp.isOpen, fp.state.view, fp.toggleDiffMode]); + }, [filesVisible, fp.state.view, fp.toggleDiffMode, toggleInspector]); async function handlePickProject(path: string): Promise { await saveProjectPath(path); @@ -159,11 +176,11 @@ export function App(): JSX.Element { const usedTokens = inspector.usage.inputTokens + inspector.usage.outputTokens; const contextFill = usedTokens > 0 ? usedTokens / contextWindowFor(inspector.model) : undefined; - // When the file panel is open it inserts a 4th column and the inspector stays - // a 48px rail (§3.11); otherwise the 3-column shell with an optional 320px - // inspector panel. + // The rail is always the last 48px column. A panel (file OR inspector) opens + // to its left, widening the grid so it squeezes chat rather than overlaying. + const inspectorShowing = inspectorOpen && !filesVisible; const shellClass = - 'app-shell' + (fp.isOpen ? ' file-open' : inspectorExpanded ? ' inspector-open' : ''); + 'app-shell' + (filesVisible ? ' file-open' : inspectorShowing ? ' inspector-open' : ''); return (
@@ -203,6 +220,14 @@ export function App(): JSX.Element { setActiveSessionId(null); setSessionEpoch((k) => k + 1); }} + onSessionRemoved={() => { + // The active session was archived/deleted — reset to a fresh chat. + clearAgentHistory(); + setResumedMessages(undefined); + setActiveSessionId(null); + setScreen('repl'); + setSessionEpoch((k) => k + 1); + }} />
{renderScreen( @@ -212,9 +237,10 @@ export function App(): JSX.Element { () => setSessionEpoch((k) => k + 1), handleInspector, resumedMessages, + openFile, )}
- {fp.isOpen && ( + {filesVisible ? ( {}} onResizeStart={onFilePanelResizeStart} /> - )} - {inspectorExpanded && !fp.isOpen ? ( + ) : inspectorShowing ? ( setInspectorExpanded(false)} - onOpenFile={(path) => void fp.open(path)} - /> - ) : ( - setScreen('settings')} - settingsActive={SETTINGS_FAMILY.includes(screen)} - planCount={planCount} - contextFill={contextFill} + focusSection={null} + onCollapse={() => setInspectorOpen(false)} + onOpenFile={openFile} /> - )} + ) : null} + setScreen('settings')} + />
); } @@ -257,6 +284,7 @@ function renderScreen( onTurnComplete: () => void, onInspector: (patch: Partial) => void, initialMessages?: Msg[], + onOpenFile?: (path: string) => void, ): JSX.Element { switch (screen) { case 'chat': @@ -267,6 +295,7 @@ function renderScreen( onTurnComplete={onTurnComplete} initialMessages={initialMessages} onInspector={onInspector} + onOpenFile={onOpenFile} /> ); case 'sessions': @@ -292,6 +321,7 @@ function renderScreen( onTurnComplete={onTurnComplete} initialMessages={initialMessages} onInspector={onInspector} + onOpenFile={onOpenFile} /> ); } diff --git a/apps/desktop/src/components/InspectorRail.tsx b/apps/desktop/src/components/InspectorRail.tsx index 7399505..c70a93b 100644 --- a/apps/desktop/src/components/InspectorRail.tsx +++ b/apps/desktop/src/components/InspectorRail.tsx @@ -1,97 +1,69 @@ -// Right-column collapsed inspector rail (48 px). -// Design spec screen #3 (line ~1220). +// Right-column activity bar (48 px) — always present on the far right. // -// Per the spec the rail is intentionally minimal: it hints at the inspector's -// contents with four small icons (▤ Plan · ◐ Context · 📁 Recent files · -// ⓘ Session info) and nothing else but the ‹ expand chevron and a ⚙ Settings -// shortcut. Clicking ‹ — or any of the four hint icons — expands the 320 px -// panel (the icon picks which section to scroll to). The settings cog is the -// rail's one piece of navigation; everything else (Permissions / MCP / Plugins -// / Skills / About) lives inside the Settings shell's left nav. - -import type { InspectorSection } from '../types/inspector.js'; +// Each icon opens its OWN distinct right-side panel (VS Code activity-bar +// model), so it's no longer "everything opens the inspector": +// • ⓘ Inspector — plan / context / recent files / session (toggles the panel) +// • ▤ Files — the file preview panel (Source / Diff / History) +// • ⚙ Settings — the Settings shell (a main-area screen, not a right panel) +// The active panel's icon is highlighted; clicking it again closes the panel. interface InspectorRailProps { - /** Plan items pending — shown as a badge on ▤. */ + /** Inspector panel is the visible right panel. */ + inspectorActive: boolean; + /** File preview panel is the visible right panel. */ + filesActive: boolean; + /** On any settings-family screen (highlights the cog). */ + settingsActive: boolean; + /** Plan items pending — badge on the Inspector icon. */ planCount?: number; - /** Context fill 0..1 — drives the ◐ color (mint if < 0.6, warn ≥ 0.8). */ + /** Context fill 0..1 — tints the Inspector icon (warn ≥ 0.8). */ contextFill?: number; - /** Expand the rail into the 320 px panel, optionally focusing a section. */ - onExpand: (section?: InspectorSection) => void; - /** Open the Settings shell. */ + onToggleInspector: () => void; + onToggleFiles: () => void; onSettings: () => void; - /** Highlight the cog when the user is on any settings-family screen. */ - settingsActive: boolean; } export function InspectorRail({ + inspectorActive, + filesActive, + settingsActive, planCount, contextFill, - onExpand, + onToggleInspector, + onToggleFiles, onSettings, - settingsActive, }: InspectorRailProps): JSX.Element { const ctxColor = contextFill === undefined - ? 'var(--text-2)' + ? undefined : contextFill > 0.8 ? 'var(--warn)' : contextFill > 0.6 - ? 'var(--text-1)' - : 'var(--accent)'; + ? 'var(--text-0)' + : undefined; + + const ctxTitle = contextFill === undefined ? '' : ` · context ${Math.round(contextFill * 100)}%`; return ( ); } + +/** Inspector — info/details glyph. */ +function IconInspector(): JSX.Element { + return ( + + ); +} + +/** Files — document with a folded corner + text lines. */ +function IconFiles(): JSX.Element { + return ( + + ); +} + +/** Settings — gear. */ +function IconSettings(): JSX.Element { + return ( + + ); +} diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 040ae10..6377842 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -6,7 +6,13 @@ import { useCallback, useEffect, useState } from 'react'; import { projectName } from '../lib/project.js'; -import { listSessions, sessionSetTitle, type SessionMeta } from '../lib/tauri-api.js'; +import { + listSessions, + sessionArchive, + sessionDelete, + sessionSetTitle, + type SessionMeta, +} from '../lib/tauri-api.js'; import { BrandMark } from './BrandMark.js'; interface SidebarProps { @@ -18,6 +24,8 @@ interface SidebarProps { onNewSession: () => void; /** Triggers a re-show of the folder picker so the user can switch projects. */ onSwitchProject: () => void; + /** Called after the active session is archived/deleted so the parent resets. */ + onSessionRemoved?: (id: string) => void; } type Bucket = 'Today' | 'Yesterday' | 'Earlier'; @@ -43,12 +51,14 @@ export function Sidebar({ onPickSession, onNewSession, onSwitchProject, + onSessionRemoved, }: SidebarProps): JSX.Element { const [sessions, setSessions] = useState([]); const [now, setNow] = useState(Math.floor(Date.now() / 1000)); // Inline rename: which session is being edited + its draft title. const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); + const [query, setQuery] = useState(''); const reload = useCallback(() => { void listSessions() @@ -56,9 +66,17 @@ export function Sidebar({ .catch(() => setSessions([])); }, []); + // Reload on mount + whenever the active session changes, then poll so a + // session's auto-derived title (set on its first message) and freshly-created + // sessions surface without needing a remount. useEffect(() => { reload(); - const t = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 30_000); + }, [reload, activeSessionId]); + useEffect(() => { + const t = setInterval(() => { + setNow(Math.floor(Date.now() / 1000)); + reload(); + }, 8_000); return () => clearInterval(t); }, [reload]); @@ -72,12 +90,41 @@ export function Sidebar({ } } + async function handleArchive(id: string): Promise { + try { + await sessionArchive(id); + if (id === activeSessionId) onSessionRemoved?.(id); + reload(); + } catch { + /* ignore — session stays listed */ + } + } + + async function handleDelete(id: string, label: string): Promise { + if (!window.confirm(`Delete session "${label}"? This permanently removes its history.`)) { + return; + } + try { + await sessionDelete(id); + if (id === activeSessionId) onSessionRemoved?.(id); + reload(); + } catch { + /* ignore — session stays listed */ + } + } + + const q = query.trim().toLowerCase(); + const visible = q + ? sessions.filter( + (s) => (s.title || '').toLowerCase().includes(q) || s.id.toLowerCase().includes(q), + ) + : sessions; const grouped: Record = { Today: [], Yesterday: [], Earlier: [], }; - for (const s of sessions) { + for (const s of visible) { grouped[bucketFor(s.updated_at_secs, now)].push(s); } @@ -88,68 +135,18 @@ export function Sidebar({ DeepCode - {/* Active project chip */} -
-
- Project -
-
+ 📁 + {projectName(projectPath)} + -
+ ⇄ +
+ {sessions.length > 0 && ( +
+ + setQuery(e.target.value)} + spellCheck={false} + /> + {query && ( + + )} +
+ )} + + {q && visible.length === 0 && ( +
No sessions match “{query}”.
+ )} + {(['Today', 'Yesterday', 'Earlier'] as const).map((bucket) => { const items = grouped[bucket]; if (items.length === 0) return null; @@ -204,6 +228,32 @@ export function Sidebar({ {s.title?.trim() ? s.title : shortTitle(s.id)} )} {relTime(s.updated_at_secs, now)} + {editingId !== s.id && ( + + + + + )} ))} diff --git a/apps/desktop/src/components/ToolCard.tsx b/apps/desktop/src/components/ToolCard.tsx index f011ed3..52286cc 100644 --- a/apps/desktop/src/components/ToolCard.tsx +++ b/apps/desktop/src/components/ToolCard.tsx @@ -22,14 +22,31 @@ interface ToolCardProps { body?: ReactNode; /** If true, body is a diff (line-by-line; preserves whitespace strictly). */ diff?: boolean; + /** + * If set, the target becomes a clickable "open preview" affordance — used for + * file tools (Read/Write/Edit) to load the file into the right-side panel. + */ + onOpen?: () => void; } -export function ToolCard({ name, target, status, body, diff }: ToolCardProps): JSX.Element { +export function ToolCard({ name, target, status, body, diff, onOpen }: ToolCardProps): JSX.Element { return ( -
+
▸ {name} - {target && {target}} + {target && + (onOpen ? ( + + ) : ( + {target} + ))} {status && {status.label}}
{body !== undefined &&
{body}
} diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index eaa7283..420a762 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -256,6 +256,13 @@ select { grid-template-rows: 1fr; height: 100vh; background: var(--bg-1); + /* Clear the macOS transparent titlebar (titleBarStyle:Transparent + + hiddenTitle) so the traffic lights + native drag region don't overlap the + sidebar brand / chat-header pills / inspector head. box-sizing is + border-box globally, so the grid fits within 100vh − this inset. The native + title region (top ~28px) stays OS-draggable; we deliberately do NOT add + -webkit-app-region:drag to the shell (it would make chat text unselectable). */ + padding-top: 30px; } /* ──────────────────────────────────────────────────────────────────── */ @@ -265,7 +272,9 @@ select { .sidebar { background: var(--bg-0); border-right: 1px solid var(--line); - padding: 18px 14px; + /* Top padding stays small — the shell's 30px titlebar inset already clears + the traffic lights, so extra top padding here is pure dead space. */ + padding: 4px 14px 14px; overflow-y: auto; display: flex; flex-direction: column; @@ -274,7 +283,7 @@ select { display: flex; align-items: center; gap: 10px; - padding: 4px 6px 18px; + padding: 2px 6px 14px; } .sidebar .brand-row .name { font-weight: 700; @@ -302,6 +311,95 @@ select { .sidebar .new-btn:hover { background: rgba(77, 107, 254, 0.18); } +/* Session search (Claude-Code-style filter) */ +.sidebar .sb-search { + display: flex; + align-items: center; + gap: 6px; + margin: 2px 4px 6px; + padding: 6px 9px; + background: var(--bg-0); + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); +} +.sidebar .sb-search:focus-within { + border-color: var(--line); +} +.sidebar .sb-search-icon { + color: var(--text-3); + font-size: 13px; + flex-shrink: 0; +} +.sidebar .sb-search input { + flex: 1; + min-width: 0; + background: transparent; + border: 0; + outline: none; + color: var(--text-0); + font: inherit; + font-size: 12px; +} +.sidebar .sb-search input::placeholder { + color: var(--text-3); +} +.sidebar .sb-search-clear { + border: 0; + background: transparent; + color: var(--text-3); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0 2px; +} +.sidebar .sb-search-clear:hover { + color: var(--text-0); +} +.sidebar .sb-search-empty { + color: var(--text-3); + font-size: 11.5px; + padding: 8px; + text-align: center; +} +/* Compact active-project row (replaces the big PROJECT box). */ +.sidebar .sb-project { + display: flex; + align-items: center; + gap: 7px; + margin: 0 4px 10px; + padding: 6px 8px; + border-radius: var(--radius-sm); + color: var(--text-1); + font-size: 12.5px; +} +.sidebar .sb-project:hover { + background: var(--bg-1); +} +.sidebar .sb-project-icon { + font-size: 13px; + flex-shrink: 0; +} +.sidebar .sb-project-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-0); + font-weight: 500; +} +.sidebar .sb-project-switch { + border: 0; + background: transparent; + color: var(--text-3); + cursor: pointer; + font-size: 12px; + padding: 2px; + flex-shrink: 0; +} +.sidebar .sb-project-switch:hover { + color: var(--text-0); +} .sidebar .new-btn kbd { color: var(--text-3); font-family: inherit; @@ -357,6 +455,39 @@ select { margin-left: auto; flex-shrink: 0; } +/* Hover-revealed per-session actions (archive / delete) — they replace the + relative-time meta on hover so the row doesn't get crowded. */ +.sidebar .item .row-actions { + display: none; + margin-left: auto; + gap: 2px; + flex-shrink: 0; +} +.sidebar .item:hover .row-actions { + display: inline-flex; +} +.sidebar .item:hover .meta { + display: none; +} +.sidebar .item .row-act { + border: none; + background: transparent; + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 2px 4px; + border-radius: 5px; + opacity: 0.7; + filter: grayscale(0.3); +} +.sidebar .item .row-act:hover { + background: var(--bg-3); + opacity: 1; + filter: none; +} +.sidebar .item .row-act.danger:hover { + background: rgba(255, 84, 112, 0.18); +} /* ──────────────────────────────────────────────────────────────────── */ /* Inspector rail (right column) */ @@ -365,7 +496,7 @@ select { .inspector-rail { background: var(--bg-1); border-left: 1px solid var(--line); - padding: 14px 0; + padding: 6px 0; gap: 10px; display: flex; flex-direction: column; @@ -426,25 +557,26 @@ select { /* ──────────────────────────────────────────────────────────────────── */ .app-shell.inspector-open { - grid-template-columns: 240px 1fr 320px; + grid-template-columns: 240px 1fr 320px 48px; } .inspector { background: var(--bg-0); border-left: 1px solid var(--line); - padding: 18px; + /* No top padding — the sticky head supplies its own; keeps the title aligned + with the brand / chat-header just under the titlebar inset. */ + padding: 0 18px 18px; overflow-y: auto; } .inspector .inspector-head { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 4px; /* Sticky so a section scrolled into view via a rail icon lands just below. */ position: sticky; - top: -18px; - margin: -18px -18px 4px; - padding: 18px 18px 8px; + top: 0; + margin: 0 -18px 4px; + padding: 8px 18px 8px; background: var(--bg-0); z-index: 1; } @@ -653,7 +785,7 @@ select { background: var(--bg-1); } .chat-header { - padding: 12px 22px; + padding: 6px 22px; border-bottom: 1px solid var(--line); display: flex; align-items: center; @@ -678,10 +810,12 @@ select { .chat-stream { flex: 1; overflow-y: auto; - padding: 22px; + padding: 20px 0; display: flex; flex-direction: column; - gap: 16px; + gap: 2px; + /* Claude-Code-style centered, readable column rather than full-bleed. */ + --stream-max: 760px; } .chat-stream::-webkit-scrollbar { width: 8px; @@ -691,54 +825,53 @@ select { border-radius: 4px; } -/* Message rows */ +/* Message rows — Claude-Code style: a centered readable column, no heavy + avatar chips. The role reads from a subtle label + (for the user) a soft + bubble; the assistant is plain prose. */ .msg { - display: flex; - gap: 12px; + display: block; + width: 100%; + max-width: var(--stream-max); + margin: 0 auto; + padding: 10px 22px; } .msg .avatar { - width: 28px; - height: 28px; - border-radius: 8px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; -} -.msg.user .avatar { - background: var(--bg-3); - color: var(--text-1); -} -.msg.assistant .avatar { - background: linear-gradient(135deg, var(--brand) 0%, #6b86ff 100%); - color: #fff; -} -.msg.system .avatar { - background: var(--bg-2); - color: var(--text-2); - font-size: 11px; + display: none; } .msg .body { - flex: 1; min-width: 0; } .msg .author { - font-size: 10.5px; + font-size: 11px; color: var(--text-3); - margin-bottom: 4px; - letter-spacing: 0.5px; - text-transform: uppercase; + margin-bottom: 5px; + letter-spacing: 0.3px; font-weight: 600; } .msg .content { - color: var(--text-0); - font-size: 13.5px; - line-height: 1.65; + color: var(--text-1); + font-size: 14px; + line-height: 1.7; white-space: pre-wrap; word-break: break-word; } +/* User turns get a soft bubble; assistant + system are plain prose. */ +.msg.user .body { + background: var(--bg-2); + border: 1px solid var(--line-soft); + border-radius: 12px; + padding: 10px 14px; +} +.msg.user .author { + display: none; +} +.msg.assistant .content { + color: var(--text-0); +} +.msg.system { + padding-top: 4px; + padding-bottom: 4px; +} .msg .content code { background: var(--bg-3); color: #b4c2ff; @@ -775,6 +908,31 @@ select { text-overflow: ellipsis; white-space: nowrap; } +/* File-tool cards: the target is a clickable "open preview" affordance. */ +.tool-card .tc-head .tc-open { + border: none; + background: transparent; + font: inherit; + cursor: pointer; + color: var(--brand); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + border-radius: 5px; + max-width: 100%; +} +.tool-card .tc-head .tc-open:hover { + background: var(--brand-tint); + color: #b4c2ff; +} +.tool-card .tc-head .tc-open .tc-open-caret { + color: var(--text-3); + font-weight: 700; +} +.tool-card .tc-head .tc-open:hover .tc-open-caret { + color: var(--brand); +} .tool-card .tc-head .badge { margin-left: auto; flex-shrink: 0; @@ -818,6 +976,14 @@ select { padding: 14px 18px 16px; background: var(--bg-1); } +/* Keep the composer aligned with the centered message column (Claude-Code feel) + rather than spanning full width. 760px matches .msg's --stream-max. */ +.composer .box, +.composer .ctx-bar { + max-width: 760px; + width: 100%; + margin-inline: auto; +} .composer .box { border: 1px solid var(--line); border-radius: 12px; diff --git a/apps/desktop/src/index.html b/apps/desktop/src/index.html index 0f79bce..0a2dd74 100644 --- a/apps/desktop/src/index.html +++ b/apps/desktop/src/index.html @@ -4,6 +4,44 @@ DeepCode + +
diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts index edc642a..e3ad426 100644 --- a/apps/desktop/src/lib/mac-agent.ts +++ b/apps/desktop/src/lib/mac-agent.ts @@ -15,7 +15,17 @@ import { runAgent } from '@deepcode/core/dist/agent.js'; import { DeepSeekProvider, EFFORT_PARAMS } from '@deepcode/core/dist/providers/deepseek.js'; import type { AgentEvent, Effort, Mode, ToolHandler } from '@deepcode/core/dist/types.js'; import { MAC_TOOLS } from './mac-tools.js'; -import { readCredentials, sessionAppend, sessionCreate } from './tauri-api.js'; +import { readCredentials, sessionAppend, sessionCreate, sessionSetTitle } from './tauri-api.js'; + +/** First non-empty line of the user message, trimmed to a sidebar-friendly length. */ +function sessionTitleFrom(userMessage: string): string { + const firstLine = + userMessage + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? userMessage.trim(); + return firstLine.slice(0, 60); +} // Local minimal ToolRegistry — same shape as @deepcode/core's, without // the BUILTIN_TOOLS top-level import that drags in fs. @@ -132,6 +142,7 @@ export async function startAgentTurn(args: StartTurnArgs): Promise, ...keys: string[]): boolean | return undefined; } +/** + * Diagnostic suffix for "missing required arg" errors. An empty input almost + * always means the model's tool call was cut off at the output-token limit + * before it emitted any arguments (DeepSeek caps output at ~8k) — surface that + * clearly so the user (and the model, which sees this error) can react. + */ +function describeInput(input: Record): string { + const keys = Object.keys(input); + if (keys.length === 0) { + return ' — the call arrived with NO arguments. The model likely ran out of output tokens before emitting them; raise Effort (try Max) or write a smaller file / split into multiple writes.'; + } + return ` (received keys: ${keys.join(', ')})`; +} + // ────────────────────────────────────────────────────────────────────────── // Read // ────────────────────────────────────────────────────────────────────────── @@ -65,7 +79,7 @@ export const MacReadTool: ToolHandler = { try { const filePath = pickStr(input, 'file_path', 'filePath', 'path'); if (!filePath) { - return { content: 'Error: missing file_path', isError: true }; + return { content: `Error: missing file_path${describeInput(input)}`, isError: true }; } const r = (await invoke('tool_read', { filePath, @@ -111,7 +125,7 @@ export const MacWriteTool: ToolHandler = { const filePath = pickStr(input, 'file_path', 'filePath', 'path'); const content = pickStr(input, 'content', 'text', 'body') ?? ''; if (!filePath) { - return { content: 'Error: missing file_path', isError: true }; + return { content: `Error: missing file_path${describeInput(input)}`, isError: true }; } await invoke('tool_write', { filePath, content }); const lines = content.split('\n').length; @@ -154,7 +168,7 @@ export const MacEditTool: ToolHandler = { const replaceAll = pickBool(input, 'replace_all', 'replaceAll') ?? false; if (!filePath || oldStr === undefined || newStr === undefined) { return { - content: 'Error: missing file_path / old_string / new_string', + content: `Error: missing file_path / old_string / new_string${describeInput(input)}`, isError: true, }; } diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index 8c54b04..be8c1db 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -167,6 +167,16 @@ export async function sessionSetTitle(id: string, title: string): Promise await invoke('session_set_title', { id, title }); } +/** Permanently delete a session's JSONL file. */ +export async function sessionDelete(id: string): Promise { + await invoke('session_delete', { id }); +} + +/** Archive a session (moved to sessions/archived/, hidden from the list). */ +export async function sessionArchive(id: string): Promise { + await invoke('session_archive', { id }); +} + /** Append one JSON message line to a session's JSONL file. */ export async function sessionAppend(id: string, message: Record): Promise { await invoke('session_append', { id, message }); diff --git a/apps/desktop/src/preview-app.html b/apps/desktop/src/preview-app.html new file mode 100644 index 0000000..5f727d7 --- /dev/null +++ b/apps/desktop/src/preview-app.html @@ -0,0 +1,12 @@ + + + + + + DeepCode — full-app layout preview (dev only) + + +
+ + + diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx new file mode 100644 index 0000000..e34886e --- /dev/null +++ b/apps/desktop/src/preview-app.tsx @@ -0,0 +1,119 @@ +// DEV-ONLY full-app layout preview. Renders with a mocked Tauri +// `invoke` so the whole shell (sidebar + chat + composer + inspector) shows in +// a plain browser — lets us screenshot + iterate on the layout without the +// Tauri backend or a rebuild. Not in the prod bundle (build input = index.html). + +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +import { installTauriShim } from './lib/window-shim.js'; +import './index.css'; + +const now = Math.floor(Date.now() / 1000); +const MOCK_SESSIONS = [ + { + id: '2026-06-02-aaa111', + path: '', + size_bytes: 900, + updated_at_secs: now - 3600, + title: '制作一个打飞机的小游戏', + }, + { + id: '2026-06-02-bbb222', + path: '', + size_bytes: 700, + updated_at_secs: now - 7200, + title: '写一个超级马里奥的小游戏', + }, + { + id: '2026-06-01-ccc333', + path: '', + size_bytes: 500, + updated_at_secs: now - 90_000, + title: '重构 auth 模块并加单测', + }, + { + id: '2026-05-31-ddd444', + path: '', + size_bytes: 300, + updated_at_secs: now - 180_000, + title: 'hi', + }, +]; +const MOCK_MESSAGES = [ + { type: 'message', role: 'user', content: [{ type: 'text', text: '制作一个打飞机的小游戏' }] }, + { + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: '好的,我来创建一个 HTML5 打飞机射击游戏,包含玩家飞机、敌机、子弹和计分。', + }, + { + type: 'tool_use', + id: 't1', + name: 'Write', + input: { file_path: '/Users/oratis/Projects/DeepCode/test/打飞机.html' }, + }, + ], + }, + { type: 'message', role: 'user', content: [{ type: 'text', text: '加一个 boss 关卡' }] }, +]; + +// Mock the Tauri invoke bridge before the app calls it (no invoke runs at import). +(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { + invoke: async (cmd: string) => { + switch (cmd) { + case 'load_settings_file': + return { projectPath: '/Users/oratis/Projects/DeepCode/test' }; + case 'read_credentials': + return { api_key: 'sk-mock', base_url: 'https://api.deepseek.com/v1' }; + case 'get_app_info': + return { version: '0.1.6', platform: 'macos', home_dir: '/Users/oratis' }; + case 'get_settings_path': + return '/Users/oratis/.deepcode/settings.json'; + case 'list_sessions': + return MOCK_SESSIONS; + case 'session_read': + return MOCK_MESSAGES; + case 'load_keybindings': + return {}; + case 'list_plugins': + case 'list_skills': + return []; + // The file picker (⌘O / Files-with-no-tabs) goes through the dialog plugin. + case 'plugin:dialog|open': + return '/Users/oratis/Projects/DeepCode/test/打飞机.html'; + // toolRead unwraps `.content` (see lib/tauri-api.ts). + case 'tool_read': + return { + content: [ + '', + '', + ' ', + ' ', + ' 打飞机', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'), + }; + default: + console.warn('[preview] unmocked invoke:', cmd); + return null; + } + }, + transformCallback: (cb: unknown) => cb, +}; + +installTauriShim(); +const rootEl = document.getElementById('root'); +if (rootEl) createRoot(rootEl).render(); diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 869de67..a833980 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -64,6 +64,8 @@ interface ReplScreenProps { * model, mode, recent files, or the todo list change. */ onInspector?: (patch: Partial) => void; + /** Open a file (from a tool card's "preview" affordance) in the file panel. */ + onOpenFile?: (path: string) => void; } /** Tools whose file_path we surface in the inspector's Recent files section. */ @@ -207,6 +209,7 @@ export function ReplScreen({ onTurnComplete, initialMessages, onInspector, + onOpenFile, }: ReplScreenProps): JSX.Element { const [messages, setMessages] = useState(() => initialMessages && initialMessages.length > 0 @@ -607,7 +610,14 @@ export function ReplScreen({
{messages.map((m, i) => - renderMessage(m, i, pendingApproval, handleApproval, i === activeAssistantIdx), + renderMessage( + m, + i, + pendingApproval, + handleApproval, + i === activeAssistantIdx, + onOpenFile, + ), )} {busy && !pendingApproval && !pendingQuestion && ( @@ -825,6 +835,7 @@ function renderMessage( pendingApproval: PendingApproval | null, onApproval: (decision: 'allow' | 'deny' | 'always') => void, isActive: boolean, + onOpenFile?: (path: string) => void, ): JSX.Element | null { if (m.role === 'user') { return ( @@ -877,6 +888,11 @@ function renderMessage( t.status === 'running' ? '… running' : t.status === 'ok' ? '✓ done' : '✕ error', }} body={t.resultText ? truncate(t.resultText, 1500) : undefined} + onOpen={ + onOpenFile && typeof t.input?.file_path === 'string' + ? () => onOpenFile(String(t.input.file_path)) + : undefined + } /> {/* Inline approval — appears right under the relevant tool card */} {pendingApproval && pendingApproval.toolName === t.name && t.status === 'running' && (