From 5df965d0b43e546e60e36ac24270735499d57f22 Mon Sep 17 00:00:00 2001 From: oratis Date: Tue, 2 Jun 2026 01:17:40 +0800 Subject: [PATCH 1/3] fix(desktop): kill startup cat flash, clear titlebar, clickable file cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues from live tauri:dev review: 1. Startup B&W "cat" flash — in dev, Vite injects index.css via JS, so the first paint was unstyled and the BrandMark (no explicit size, fill=currentColor) ballooned to a full-size black silhouette on white. Add render-blocking critical CSS to index.html (dark surface + pinned .mark box) so the first paint matches the app — no flash. 2. Transparent titlebar overlapped content (sidebar brand / chat-header pills / inspector head clipped). Add a 30px top inset on .app-shell so all columns clear the macOS traffic-light/title region (box-sizing is global border-box; the native title strip stays OS-draggable; no app-region drag so chat text stays selectable). 3. File outputs now get a clickable card. ToolCard gains an `onOpen` affordance; Repl passes it for any tool with a `file_path` (Read/Write/ Edit), wired through App → renderScreen → ReplScreen to fp.open — clicking loads the file into the right-side preview panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/App.tsx | 4 +++ apps/desktop/src/components/ToolCard.tsx | 23 ++++++++++++-- apps/desktop/src/index.css | 32 ++++++++++++++++++++ apps/desktop/src/index.html | 38 ++++++++++++++++++++++++ apps/desktop/src/screens/Repl.tsx | 18 ++++++++++- 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9a10987..454c25d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -212,6 +212,7 @@ export function App(): JSX.Element { () => setSessionEpoch((k) => k + 1), handleInspector, resumedMessages, + (path) => void fp.open(path), )} {fp.isOpen && ( @@ -257,6 +258,7 @@ function renderScreen( onTurnComplete: () => void, onInspector: (patch: Partial) => void, initialMessages?: Msg[], + onOpenFile?: (path: string) => void, ): JSX.Element { switch (screen) { case 'chat': @@ -267,6 +269,7 @@ function renderScreen( onTurnComplete={onTurnComplete} initialMessages={initialMessages} onInspector={onInspector} + onOpenFile={onOpenFile} /> ); case 'sessions': @@ -292,6 +295,7 @@ function renderScreen( onTurnComplete={onTurnComplete} initialMessages={initialMessages} onInspector={onInspector} + onOpenFile={onOpenFile} /> ); } 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..546b513 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; } /* ──────────────────────────────────────────────────────────────────── */ @@ -775,6 +782,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; 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/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' && ( From a5a08c323b479e8f85df916d31d5e57db04ff6c4 Mon Sep 17 00:00:00 2001 From: oratis Date: Tue, 2 Jun 2026 22:26:41 +0800 Subject: [PATCH 2/3] fix(desktop): actionable error when a file tool receives empty args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When DeepSeek emits a Write/Edit/Read call with no arguments (typically output-token truncation on a large file before the args stream), the tool returned a cryptic "missing file_path". Now it explains the likely cause (ran out of output tokens — raise Effort / smaller file) or lists the keys that did arrive, which both helps the user and gives the model a usable hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/lib/mac-tools.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/mac-tools.ts b/apps/desktop/src/lib/mac-tools.ts index 716d31d..c5b526f 100644 --- a/apps/desktop/src/lib/mac-tools.ts +++ b/apps/desktop/src/lib/mac-tools.ts @@ -41,6 +41,20 @@ function pickBool(input: Record, ...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, }; } From bd1c713ca2cefc6e605dcf83c2adfd012c3403f6 Mon Sep 17 00:00:00 2001 From: oratis Date: Tue, 2 Jun 2026 22:26:50 +0800 Subject: [PATCH 3/3] feat(desktop): session titles + archive/delete (Claude Code parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar sessions showed raw ids and had no management actions. - Titles: title a brand-new session from its first user message (mac-agent sessionSetTitle) so the sidebar shows a human label immediately instead of the id; the Rust read-time derive remains the fallback. - Archive / Delete: new session_delete + session_archive Rust commands (archive moves the .jsonl into sessions/archived/, excluded from the list; delete removes it — both guarded against path-traversal ids) + tauri-api wrappers. Sidebar rows reveal 🗄/🗑 on hover (delete confirms); removing the active session resets the chat via a new onSessionRemoved callback. - Freshness: the sidebar now polls (8s) + reloads on active-session change so titles and new sessions surface without a remount. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src-tauri/src/commands.rs | 39 ++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 6 ++- apps/desktop/src/App.tsx | 8 +++ apps/desktop/src/components/Sidebar.tsx | 70 ++++++++++++++++++++++++- apps/desktop/src/index.css | 33 ++++++++++++ apps/desktop/src/lib/mac-agent.ts | 22 +++++++- apps/desktop/src/lib/tauri-api.ts | 10 ++++ 7 files changed, 183 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index a85b57f..b21e1dd 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -382,6 +382,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 454c25d..960f6d0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -203,6 +203,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( diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 040ae10..4aa69d6 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,6 +51,7 @@ export function Sidebar({ onPickSession, onNewSession, onSwitchProject, + onSessionRemoved, }: SidebarProps): JSX.Element { const [sessions, setSessions] = useState([]); const [now, setNow] = useState(Math.floor(Date.now() / 1000)); @@ -56,9 +65,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,6 +89,29 @@ 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 grouped: Record = { Today: [], Yesterday: [], @@ -204,6 +244,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/index.css b/apps/desktop/src/index.css index 546b513..d8672ef 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -364,6 +364,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) */ 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 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 });