Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<serde_json::Value>, 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::<serde_json::Value>(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;
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,6 +43,7 @@ pub fn run() {
save_keybindings,
session_create,
session_append,
session_read,
list_sessions,
cli_path,
open_url,
Expand Down
43 changes: 39 additions & 4 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +31,9 @@ export function App(): JSX.Element {
const [screen, setScreen] = useState<ScreenName>('repl');
const [activeSessionId, setActiveSessionId] = useState<string | null>(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<Msg[] | undefined>(undefined);

useEffect(() => {
void window.deepcode.creds.load().then((c) => setHasKey(c.hasKey));
Expand All @@ -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);
Expand Down Expand Up @@ -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<typeof storedToMsgs>[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
Expand All @@ -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);
}}
/>
<main className="chat-main" key={`main-${sessionEpoch}`}>
{renderScreen(screen, setScreen, projectPath, () => setSessionEpoch((k) => k + 1))}
{renderScreen(
screen,
setScreen,
projectPath,
() => setSessionEpoch((k) => k + 1),
resumedMessages,
)}
</main>
<InspectorRail activeScreen={screen} onChange={(s) => setScreen(s)} contextFill={undefined} />
</div>
Expand All @@ -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 <ReplScreen projectPath={projectPath} onTurnComplete={onTurnComplete} />;
return (
<ReplScreen
projectPath={projectPath}
onTurnComplete={onTurnComplete}
initialMessages={initialMessages}
/>
);
case 'sessions':
return <SessionsScreen onPick={() => setScreen('repl')} onNew={() => setScreen('repl')} />;
case 'plugins':
Expand All @@ -153,6 +182,12 @@ function renderScreen(
return <AboutScreen />;
case 'repl':
default:
return <ReplScreen projectPath={projectPath} onTurnComplete={onTurnComplete} />;
return (
<ReplScreen
projectPath={projectPath}
onTurnComplete={onTurnComplete}
initialMessages={initialMessages}
/>
);
}
}
13 changes: 13 additions & 0 deletions apps/desktop/src/lib/mac-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeepSeekProvider> {
if (provider) return provider;
const creds = await readCredentials();
Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/lib/repl-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
finalizeStreaming,
lastAssistantIndex,
pickTarget,
storedToMsgs,
type Msg,
type ToolInvocation,
} from './repl-stream.js';
Expand Down Expand Up @@ -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');
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/lib/repl-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
}

/**
* 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<string, unknown>) ?? {};
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, unknown>): string | undefined {
for (const k of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) {
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/lib/tauri-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ export async function sessionAppend(id: string, message: Record<string, unknown>
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<Record<string, unknown>>;
timestamp?: string;
}

/** Read a session's message lines (meta header + unparseable lines skipped). */
export async function sessionRead(id: string): Promise<StoredMessageLine[]> {
return (await invoke('session_read', { id })) as StoredMessageLine[];
}

export async function cliPath(): Promise<string | null> {
return (await invoke('cli_path')) as string | null;
}
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/lib/window-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@

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';

Check warning on line 7 in apps/desktop/src/lib/window-shim.ts

View workflow job for this annotation

GitHub Actions / Typecheck + Lint + Test (macos-latest)

'clearHistory' is defined but never used. Allowed unused vars must match /^_/u
import {
appendAllowMatcher,

Check warning on line 9 in apps/desktop/src/lib/window-shim.ts

View workflow job for this annotation

GitHub Actions / Typecheck + Lint + Test (macos-latest)

'appendAllowMatcher' is defined but never used. Allowed unused vars must match /^_/u
getAppInfo,
listSessions,
loadSettingsFile,
openUrl,
readCredentials,
saveCredentials,
sessionRead,
} from './tauri-api.js';

// In-memory event bus: every agent.start() call ID maps to an array of
Expand Down Expand Up @@ -70,8 +71,17 @@
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: {
Expand Down
Loading
Loading