From fda392f2d8fc4988cbee101b64d316767ff7fe5b Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 30 May 2026 14:04:22 +0800 Subject: [PATCH] fix(desktop): load history when resuming a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a past session in the sidebar showed an empty chat — the conversation never loaded. Root cause: `sessions.resume()` in window-shim was a stub (`{ history: [], sessionId: '' }`), there was no Rust command to read a session's messages (only create/append/list), and ReplScreen always started fresh with sessionId 'default'. Wire the full path: - Rust: add `session_read(id)` — parses the session JSONL, returns its message lines (skips the session_meta header + any partial trailing line). - tauri-api: `sessionRead(id)`; window-shim: implement `resume({id})` to read the messages, adopt them into the agent (new mac-agent `resumeSession` sets the module history + id so the next turn continues with full context AND appends to the same file), and return them. - repl-stream: `storedToMsgs()` reconstructs the chat view from stored messages — assistant text + tool_use become a finalized turn, the following user tool_result blocks attach to the cards by tool_use_id; thinking dropped. - App: onPickSession now awaits resume, seeds ReplScreen (remounted via key) with the reconstructed messages; new-session / switch-project / ⌘N clear it. Adds storedToMsgs tests (reconstruction + errored-result). Verified live in tauri dev: selecting a session now renders its prior conversation, and a follow-up message continues it. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src-tauri/src/commands.rs | 34 ++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 3 +- apps/desktop/src/App.tsx | 43 ++++++++++++++++++-- apps/desktop/src/lib/mac-agent.ts | 13 ++++++ apps/desktop/src/lib/repl-stream.test.ts | 50 +++++++++++++++++++++++ apps/desktop/src/lib/repl-stream.ts | 51 ++++++++++++++++++++++++ apps/desktop/src/lib/tauri-api.ts | 13 ++++++ apps/desktop/src/lib/window-shim.ts | 16 ++++++-- apps/desktop/src/screens/Repl.tsx | 34 ++++++++++++---- 9 files changed, 242 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index b853b51..6a8c87e 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -171,6 +171,40 @@ pub fn session_append(id: String, message: serde_json::Value) -> Result<(), Stri .map_err(|e| format!("write {}: {}", path.display(), e)) } +/// Read a session's JSONL and return its message lines (skipping the +/// `session_meta` header and any unparseable lines). Each returned value is the +/// stored message object as written by session_append: `{ type, role, content, +/// timestamp }`. Returns an empty vec if the file doesn't exist. +#[tauri::command] +pub fn session_read(id: String) -> Result, String> { + let Some(home) = dirs::home_dir() else { + return Err("no home directory".into()); + }; + let path = home + .join(".deepcode") + .join("sessions") + .join(format!("{}.jsonl", id)); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(format!("read {}: {}", path.display(), e)), + }; + let mut out = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + 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") { + out.push(v); + } + } + Ok(out) +} + fn format_date(secs: u64) -> String { // Simple YYYY-MM-DD; days since epoch math is enough for filename use. let days = secs / 86_400; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2d80747..c543762 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -17,7 +17,7 @@ mod tools; use commands::{ append_allow_matcher, cli_path, get_app_info, get_settings_path, list_sessions, load_keybindings, load_settings_file, open_url, read_credentials, save_credentials, - save_keybindings, save_settings_file, session_append, session_create, + save_keybindings, save_settings_file, session_append, session_create, session_read, }; use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; use tauri::Manager; @@ -43,6 +43,7 @@ pub fn run() { save_keybindings, session_create, session_append, + session_read, list_sessions, cli_path, open_url, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0ddb6c1..bab4ddf 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -10,6 +10,7 @@ import { UpdateBanner } from './components/UpdateBanner.js'; import { registerShortcut } from './lib/keyboard.js'; import { clearHistory as clearAgentHistory } from './lib/mac-agent.js'; import { loadProjectPath, saveProjectPath } from './lib/project.js'; +import { storedToMsgs, type Msg } from './lib/repl-stream.js'; import { onUpdateDownloaded, startUpdaterPolling } from './lib/updater.js'; import { AboutScreen } from './screens/About.js'; import { MCPManagerScreen } from './screens/MCPManager.js'; @@ -30,6 +31,9 @@ export function App(): JSX.Element { const [screen, setScreen] = useState('repl'); const [activeSessionId, setActiveSessionId] = useState(null); const [sessionEpoch, setSessionEpoch] = useState(0); + // Reconstructed messages for a resumed session; seeded into ReplScreen on its + // next remount. Cleared when starting a fresh session. + const [resumedMessages, setResumedMessages] = useState(undefined); useEffect(() => { void window.deepcode.creds.load().then((c) => setHasKey(c.hasKey)); @@ -41,6 +45,7 @@ export function App(): JSX.Element { // Global keyboard shortcuts that mirror the sidebar hints. const offN = registerShortcut('meta+n', () => { clearAgentHistory(); + setResumedMessages(undefined); setActiveSessionId(null); setScreen('repl'); setSessionEpoch((k) => k + 1); @@ -98,12 +103,22 @@ export function App(): JSX.Element { key={`sb-${sessionEpoch}`} projectPath={projectPath} activeSessionId={activeSessionId} - onPickSession={(id) => { + onPickSession={async (id) => { + // Load the session's stored messages, adopt them into the agent, and + // remount ReplScreen seeded with the reconstructed conversation. + try { + const { history } = await window.deepcode.sessions.resume({ id }); + setResumedMessages(storedToMsgs(history as Parameters[0])); + } catch { + setResumedMessages(undefined); // fall back to a fresh view + } setActiveSessionId(id); setScreen('repl'); + setSessionEpoch((k) => k + 1); }} onNewSession={() => { clearAgentHistory(); + setResumedMessages(undefined); setActiveSessionId(null); setScreen('repl'); // Force ReplScreen to remount with a clean message history @@ -114,13 +129,20 @@ export function App(): JSX.Element { // the in-memory conversation so the next session starts // fresh in the new project's cwd. clearAgentHistory(); + setResumedMessages(undefined); setProjectPath(null); setActiveSessionId(null); setSessionEpoch((k) => k + 1); }} />
- {renderScreen(screen, setScreen, projectPath, () => setSessionEpoch((k) => k + 1))} + {renderScreen( + screen, + setScreen, + projectPath, + () => setSessionEpoch((k) => k + 1), + resumedMessages, + )}
setScreen(s)} contextFill={undefined} /> @@ -132,11 +154,18 @@ function renderScreen( setScreen: (s: ScreenName) => void, projectPath: string, onTurnComplete: () => void, + initialMessages?: Msg[], ): JSX.Element { switch (screen) { case 'chat': // 'chat' folded into 'repl' — the new shell has only the REPL surface. - return ; + return ( + + ); case 'sessions': return setScreen('repl')} onNew={() => setScreen('repl')} />; case 'plugins': @@ -153,6 +182,12 @@ function renderScreen( return ; case 'repl': default: - return ; + return ( + + ); } } diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts index 7cf3c96..37dd5c8 100644 --- a/apps/desktop/src/lib/mac-agent.ts +++ b/apps/desktop/src/lib/mac-agent.ts @@ -66,6 +66,19 @@ export function clearSession(): void { history = []; } +/** + * Resume an existing session: adopt its id + loaded history so the next turn + * continues that conversation (with full context) and appends to its JSONL + * rather than starting a new file. + */ +export function resumeSession( + sessionId: string, + loadedHistory: import('@deepcode/core/dist/types.js').StoredMessage[], +): void { + currentSessionId = sessionId; + history = loadedHistory; +} + async function ensureProvider(): Promise { if (provider) return provider; const creds = await readCredentials(); diff --git a/apps/desktop/src/lib/repl-stream.test.ts b/apps/desktop/src/lib/repl-stream.test.ts index 1bc8bce..3381f29 100644 --- a/apps/desktop/src/lib/repl-stream.test.ts +++ b/apps/desktop/src/lib/repl-stream.test.ts @@ -6,6 +6,7 @@ import { finalizeStreaming, lastAssistantIndex, pickTarget, + storedToMsgs, type Msg, type ToolInvocation, } from './repl-stream.js'; @@ -87,6 +88,55 @@ describe('repl-stream mutators', () => { expect(lastAssistantIndex([{ role: 'system', text: 'only' }])).toBe(-1); }); + it('storedToMsgs reconstructs a resumed conversation (text + tool cards + results)', () => { + const stored = [ + { role: 'user' as const, content: [{ type: 'text', text: 'read a.txt' }] }, + { + role: 'assistant' as const, + content: [ + { type: 'thinking', text: 'internal — should be dropped' }, + { type: 'text', text: 'Reading it.' }, + { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a.txt' } }, + ], + }, + { + role: 'user' as const, + content: [ + { type: 'tool_result', tool_use_id: 't1', content: 'file body', is_error: false }, + ], + }, + { role: 'assistant' as const, content: [{ type: 'text', text: 'Done.' }] }, + ]; + const msgs = storedToMsgs(stored); + expect(msgs).toHaveLength(3); // user, assistant(+tool), assistant + expect(msgs[0]).toEqual({ role: 'user', text: 'read a.txt' }); + const a1 = msgs[1]; + if (a1?.role !== 'assistant') throw new Error('expected assistant'); + expect(a1.turn.text).toBe('Reading it.'); // thinking dropped + expect(a1.turn.streaming).toBe(false); + expect(a1.turn.tools[0]).toMatchObject({ + toolId: 't1', + name: 'Read', + target: 'a.txt', + status: 'ok', + resultText: 'file body', + }); + expect(msgs[2]).toMatchObject({ role: 'assistant', turn: { text: 'Done.' } }); + }); + + it('storedToMsgs marks errored tool results', () => { + const msgs = storedToMsgs([ + { role: 'assistant', content: [{ type: 'tool_use', id: 'x', name: 'Bash', input: {} }] }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'x', content: 'boom', is_error: true }], + }, + ]); + const a = msgs[0]; + if (a?.role !== 'assistant') throw new Error('expected assistant'); + expect(a.turn.tools[0]).toMatchObject({ status: 'err', resultText: 'boom' }); + }); + it('pickTarget surfaces the most relevant field', () => { expect(pickTarget({ file_path: '/a/b.ts' })).toBe('/a/b.ts'); expect(pickTarget({ command: 'ls -la' })).toBe('ls -la'); diff --git a/apps/desktop/src/lib/repl-stream.ts b/apps/desktop/src/lib/repl-stream.ts index 87b3446..0997550 100644 --- a/apps/desktop/src/lib/repl-stream.ts +++ b/apps/desktop/src/lib/repl-stream.ts @@ -117,6 +117,57 @@ export function finalizeStreaming(msgs: Msg[]): Msg[] { ); } +/** A stored message line (role + content blocks) as persisted to a session. */ +export interface StoredLine { + role: 'user' | 'assistant'; + content: Array>; +} + +/** + * Reconstruct the chat view (Msg[]) from a session's stored messages, so picking + * a past session re-renders its conversation. Mirrors the live stream reducers + * in batch: assistant text + tool_use become a turn; the following user message's + * tool_result blocks attach to those cards by tool_use_id. Thinking blocks are + * dropped (they were streaming-only). All turns are non-streaming (finalized). + */ +export function storedToMsgs(stored: StoredLine[]): Msg[] { + let msgs: Msg[] = []; + for (const m of stored) { + if (m.role === 'assistant') { + const texts: string[] = []; + const tools: ToolInvocation[] = []; + for (const b of m.content) { + if (b.type === 'text' && typeof b.text === 'string') { + texts.push(b.text); + } else if (b.type === 'tool_use') { + const input = (b.input as Record) ?? {}; + tools.push({ + toolId: String(b.id ?? ''), + name: String(b.name ?? '?'), + input, + target: pickTarget(input), + status: 'running', + }); + } + } + msgs.push({ role: 'assistant', turn: { text: texts.join('\n'), tools, streaming: false } }); + } else { + const texts: string[] = []; + for (const b of m.content) { + if (b.type === 'text' && typeof b.text === 'string') { + texts.push(b.text); + } else if (b.type === 'tool_result') { + const id = String(b.tool_use_id ?? ''); + const content = typeof b.content === 'string' ? b.content : ''; + msgs = attachToolResult(msgs, id, content, b.is_error ? 'err' : 'ok'); + } + } + if (texts.length > 0) msgs.push({ role: 'user', text: texts.join('\n') }); + } + } + return msgs; +} + /** Pick a human-readable target from a tool's input for the card header. */ export function pickTarget(input: Record): string | undefined { for (const k of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) { diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index 816ea9e..6216e51 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -135,6 +135,19 @@ export async function sessionAppend(id: string, message: Record await invoke('session_append', { id, message }); } +/** A stored message line as written to a session's JSONL. */ +export interface StoredMessageLine { + type?: string; + role: 'user' | 'assistant'; + content: Array>; + timestamp?: string; +} + +/** Read a session's message lines (meta header + unparseable lines skipped). */ +export async function sessionRead(id: string): Promise { + return (await invoke('session_read', { id })) as StoredMessageLine[]; +} + export async function cliPath(): Promise { return (await invoke('cli_path')) as string | null; } diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index b491db9..3ab7028 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -4,7 +4,7 @@ import type { AgentEvent, Mode } from '@deepcode/core/dist/types.js'; import type { DeepCodeAPI } from '../types/global.js'; -import { abortAgentTurn, clearHistory, startAgentTurn } from './mac-agent.js'; +import { abortAgentTurn, clearHistory, resumeSession, startAgentTurn } from './mac-agent.js'; import { appendAllowMatcher, getAppInfo, @@ -13,6 +13,7 @@ import { openUrl, readCredentials, saveCredentials, + sessionRead, } from './tauri-api.js'; // In-memory event bus: every agent.start() call ID maps to an array of @@ -70,8 +71,17 @@ export function installTauriShim(): void { updatedAt: new Date(r.updated_at_secs * 1000).toISOString(), })); }, - async resume() { - return { history: [], sessionId: '' }; + async resume({ id }) { + // Read the session's stored messages and adopt them into the agent so + // the conversation continues with full context + appends to this file. + const lines = await sessionRead(id); + const history = lines.map((l) => ({ + role: l.role, + content: l.content, + timestamp: l.timestamp ?? '', + })) as unknown as import('@deepcode/core/dist/types.js').StoredMessage[]; + resumeSession(id, history); + return { history, sessionId: id }; }, }, plugins: { diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index a3ed9c0..6d5daa6 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -51,6 +51,12 @@ interface ReplScreenProps { projectPath: string; /** Called after each turn ends so the parent can refresh the sidebar. */ onTurnComplete?: () => void; + /** + * Pre-seed the chat with a resumed session's reconstructed messages. The + * parent remounts ReplScreen (via key) when this changes, so it's only read + * on mount. + */ + initialMessages?: Msg[]; } // ─── Types ──────────────────────────────────────────────────────────── @@ -171,13 +177,27 @@ interface PendingApproval { // ─── Component ──────────────────────────────────────────────────────── -export function ReplScreen({ projectPath, onTurnComplete }: ReplScreenProps): JSX.Element { - const [messages, setMessages] = useState([ - { - role: 'system', - text: `DeepCode is ready in ${projectPath}. Ask anything about your codebase — I can Read / Write / Edit / Bash / Grep / Glob your files.`, - }, - ]); +export function ReplScreen({ + projectPath, + onTurnComplete, + initialMessages, +}: ReplScreenProps): JSX.Element { + const [messages, setMessages] = useState(() => + initialMessages && initialMessages.length > 0 + ? [ + { + role: 'system', + text: 'Resumed session — earlier conversation loaded below.', + }, + ...initialMessages, + ] + : [ + { + role: 'system', + text: `DeepCode is ready in ${projectPath}. Ask anything about your codebase — I can Read / Write / Edit / Bash / Grep / Glob your files.`, + }, + ], + ); const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); const [activeTurnId, setActiveTurnId] = useState(null);