From 0e217b9daecb75db4a4d264453c3fa30641cea17 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 02:31:43 +0100 Subject: [PATCH 01/10] =?UTF-8?q?feat(electrobun):=20terminal=20pane=20?= =?UTF-8?q?=E2=80=94=20discriminated=20union=20tabs,=20agent=20preview,=20?= =?UTF-8?q?collapse/expand,=20bash=20output=20ring=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 10 + ui-electrobun/src/mainview/Terminal.svelte | 199 ++++++++++-------- .../src/mainview/TerminalTabs.svelte | 62 +++++- .../src/mainview/app-state.svelte.ts | 3 + .../src/mainview/project-state.svelte.ts | 40 +++- .../src/mainview/project-state.types.ts | 11 +- 6 files changed, 221 insertions(+), 104 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8537c2c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "mcp__agor-launcher__agor-status", + "mcp__agor-launcher__agor-kill-stale", + "mcp__agor-launcher__agor-stop", + "mcp__agor-launcher__agor-start" + ] + } +} diff --git a/ui-electrobun/src/mainview/Terminal.svelte b/ui-electrobun/src/mainview/Terminal.svelte index b2fa5cc..d7cf397 100644 --- a/ui-electrobun/src/mainview/Terminal.svelte +++ b/ui-electrobun/src/mainview/Terminal.svelte @@ -2,23 +2,23 @@ import { onMount, onDestroy } from 'svelte'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; - // CanvasAddon and ImageAddon require xterm ^5.0.0 — disabled on xterm 6.x - // xterm 6's default renderer uses DOM (no Canvas/WebGL needed) - // TODO: re-enable when @xterm/addon-canvas releases a 6.x-compatible version - // import { CanvasAddon } from '@xterm/addon-canvas'; - // import { ImageAddon } from '@xterm/addon-image'; import { appRpc } from './rpc.ts'; import { fontStore } from './font-store.svelte.ts'; import { themeStore } from './theme-store.svelte.ts'; import { getXtermTheme } from './themes.ts'; + import { appState } from './app-state.svelte.ts'; interface Props { sessionId: string; /** Working directory to open the shell in. */ cwd?: string; + /** When true, terminal is read-only (agent preview). No PTY, no stdin. */ + readonly?: boolean; + /** Project ID — required for agent preview to read bash output. */ + projectId?: string; } - let { sessionId, cwd }: Props = $props(); + let { sessionId, cwd, readonly = false, projectId }: Props = $props(); let termEl: HTMLDivElement; let term: Terminal; @@ -26,8 +26,10 @@ let unsubFont: (() => void) | null = null; let ro: ResizeObserver | null = null; let destroyed = false; - // Fix #5: Store listener cleanup functions to prevent leaks let listenerCleanups: Array<() => void> = []; + /** Index into bashOutputLines — tracks how many lines have been written to xterm. */ + let writtenIndex = 0; + let bashPollTimer: ReturnType | null = null; /** Decode a base64 string from the daemon into a Uint8Array. */ function decodeBase64(b64: string): Uint8Array { @@ -37,6 +39,17 @@ return bytes; } + /** Write new bash output lines to the readonly terminal. */ + function flushBashLines(): void { + if (!projectId || !term) return; + const t = appState.project.getState(projectId).terminals; + const lines = t.bashOutputLines; + while (writtenIndex < lines.length) { + term.writeln(lines[writtenIndex]); + writtenIndex++; + } + } + onMount(() => { const currentTheme = themeStore.currentTheme; const termFamily = fontStore.termFontFamily || 'JetBrains Mono, Fira Code, monospace'; @@ -46,20 +59,21 @@ theme: getXtermTheme(currentTheme), fontFamily: termFamily, fontSize: termSize, - cursorBlink: true, + cursorBlink: !readonly, allowProposedApi: true, scrollback: 5000, + disableStdin: readonly, }); + if (readonly) { + term.attachCustomKeyEventHandler(() => false); + } + fitAddon = new FitAddon(); term.loadAddon(fitAddon); - // NOTE: CanvasAddon and ImageAddon MUST be loaded AFTER term.open() - // because they access _linkifier2 which is created during open() const openAndLoadAddons = () => { term.open(termEl); - // xterm 6.x uses improved default DOM renderer — no Canvas/WebGL addon needed - // Re-enable when addons release 6.x-compatible versions fitAddon.fit(); }; @@ -78,99 +92,102 @@ } // ── Read cursor/scrollback settings ───────────────────────────────── - - void (async () => { - try { - const { settings } = await appRpc.request['settings.getAll']({}); - if (settings['cursor_style']) { - const style = settings['cursor_style']; - if (style === 'block' || style === 'underline' || style === 'bar') { - term.options.cursorStyle = style; + if (!readonly) { + void (async () => { + try { + const { settings } = await appRpc.request['settings.getAll']({}); + if (settings['cursor_style']) { + const style = settings['cursor_style']; + if (style === 'block' || style === 'underline' || style === 'bar') { + term.options.cursorStyle = style; + } } - } - if (settings['cursor_blink'] === 'false') { - term.options.cursorBlink = false; - } - if (settings['scrollback']) { - const sb = parseInt(settings['scrollback'], 10); - if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb; - } - } catch { /* non-critical — use defaults */ } - })(); + if (settings['cursor_blink'] === 'false') { + term.options.cursorBlink = false; + } + if (settings['scrollback']) { + const sb = parseInt(settings['scrollback'], 10); + if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb; + } + } catch { /* non-critical — use defaults */ } + })(); + } // ── Subscribe to terminal font changes ───────────────────────────────── - unsubFont = fontStore.onTermFontChange((family: string, size: number) => { term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace'; term.options.fontSize = size; fitAddon.fit(); - appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {}); + if (!readonly) { + appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {}); + } }); - // ── Connect to PTY daemon (fire-and-forget from onMount) ─────────────── + if (readonly) { + // ── Agent preview mode: read bash output from project state ────────── + flushBashLines(); + bashPollTimer = setInterval(flushBashLines, 500); + } else { + // ── Connect to PTY daemon (fire-and-forget from onMount) ───────────── + void (async () => { + let effectiveCwd = cwd; + try { + const { settings } = await appRpc.request['settings.getAll']({}); + if (!effectiveCwd && settings['default_cwd']) { + effectiveCwd = settings['default_cwd']; + } + } catch { /* use provided or defaults */ } - void (async () => { - // Read default_shell and default_cwd from settings if not provided - let effectiveCwd = cwd; - try { - const { settings } = await appRpc.request['settings.getAll']({}); - if (!effectiveCwd && settings['default_cwd']) { - effectiveCwd = settings['default_cwd']; + const { cols, rows } = term; + const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd }); + if (!result?.ok) { + term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`); + term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m'); } - // default_shell is handled by agor-ptyd, not needed in create params - } catch { /* use provided or defaults */ } + })(); - const { cols, rows } = term; - const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd }); - if (!result?.ok) { - term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`); - term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m'); - } - })(); + // ── Receive output from daemon ───────────────────────────────────────── + const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => { + if (destroyed || sid !== sessionId) return; + term.write(decodeBase64(data)); + }; + appRpc.addMessageListener('pty.output', outputHandler); - // ── Receive output from daemon ───────────────────────────────────────── + const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => { + if (destroyed || sid !== sessionId) return; + term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`); + }; + appRpc.addMessageListener('pty.closed', closedHandler); - const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => { - if (destroyed || sid !== sessionId) return; - term.write(decodeBase64(data)); - }; - appRpc.addMessageListener('pty.output', outputHandler); + listenerCleanups.push( + () => appRpc.removeMessageListener?.('pty.output', outputHandler), + () => appRpc.removeMessageListener?.('pty.closed', closedHandler), + ); - const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => { - if (destroyed || sid !== sessionId) return; - term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`); - }; - appRpc.addMessageListener('pty.closed', closedHandler); - - // Fix #5: Store cleanup functions for message listeners - listenerCleanups.push( - () => appRpc.removeMessageListener?.('pty.output', outputHandler), - () => appRpc.removeMessageListener?.('pty.closed', closedHandler), - ); - - // ── Send user input to daemon ────────────────────────────────────────── - - // Feature 5: Max terminal paste chunk (64KB) — truncate with warning - const MAX_PASTE_CHUNK = 64 * 1024; - term.onData((data: string) => { - let payload = data; - if (payload.length > MAX_PASTE_CHUNK) { - console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`); - payload = payload.slice(0, MAX_PASTE_CHUNK); - term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m'); - } - appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => { - console.error('[pty.write] error:', err); + // ── Send user input to daemon ────────────────────────────────────────── + const MAX_PASTE_CHUNK = 64 * 1024; + term.onData((data: string) => { + let payload = data; + if (payload.length > MAX_PASTE_CHUNK) { + console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`); + payload = payload.slice(0, MAX_PASTE_CHUNK); + term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m'); + } + appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => { + console.error('[pty.write] error:', err); + }); }); + + // ── Sync resize events to daemon ─────────────────────────────────────── + term.onResize(({ cols: c, rows: r }) => { + appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {}); + }); + } + + // ── ResizeObserver: re-fit on container resize + visibility ──────────── + ro = new ResizeObserver(() => { + requestAnimationFrame(() => { fitAddon.fit(); }); }); - - // ── Sync resize events to daemon ─────────────────────────────────────── - - term.onResize(({ cols: c, rows: r }) => { - appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {}); - }); - - ro = new ResizeObserver(() => { fitAddon.fit(); }); ro.observe(termEl); }); @@ -178,12 +195,14 @@ destroyed = true; unsubFont?.(); ro?.disconnect(); - // Fix #5: Clean up all message listeners to prevent leaks + if (bashPollTimer) clearInterval(bashPollTimer); for (const cleanup of listenerCleanups) { try { cleanup(); } catch { /* ignore */ } } listenerCleanups = []; - appRpc.request['pty.close']({ sessionId }).catch(() => {}); + if (!readonly) { + appRpc.request['pty.close']({ sessionId }).catch(() => {}); + } term?.dispose(); }); diff --git a/ui-electrobun/src/mainview/TerminalTabs.svelte b/ui-electrobun/src/mainview/TerminalTabs.svelte index 7eec51f..132191d 100644 --- a/ui-electrobun/src/mainview/TerminalTabs.svelte +++ b/ui-electrobun/src/mainview/TerminalTabs.svelte @@ -40,10 +40,23 @@ blurTerminal(); appState.project.terminals.toggleExpanded(projectId); } + + function togglePreview() { + blurTerminal(); + appState.project.terminals.toggleAgentPreview(projectId); + } + + function hasPreview(): boolean { + return getTerminals().tabs.some(t => t.kind === 'agentPreview'); + } - -
+ +
+ +
+ No terminals — click + to add one +
{#each getTerminals().tabs as tab (tab.id)} {#if getTerminals().mounted.has(tab.id)}
- +
{/if} {/each} @@ -108,7 +142,6 @@ .term-wrapper { display: flex; flex-direction: column; - flex: 1; min-height: 0; } @@ -169,6 +202,7 @@ } .term-tab:hover { color: var(--ctp-text); } .term-tab.active { color: var(--ctp-text); border-bottom-color: var(--accent); } + .term-tab.preview { font-style: italic; } .tab-label { pointer-events: none; } .tab-close { @@ -190,7 +224,7 @@ } .tab-close:hover { background: var(--ctp-surface1); color: var(--ctp-red); } - .tab-add { + .tab-add, .tab-eye { align-self: center; width: 1.25rem; height: 1.25rem; @@ -206,7 +240,9 @@ justify-content: center; margin-left: 0.125rem; } - .tab-add:hover { background: var(--ctp-surface0); color: var(--ctp-text); } + .tab-add:hover, .tab-eye:hover { background: var(--ctp-surface0); color: var(--ctp-text); } + .tab-eye svg { width: 0.75rem; height: 0.75rem; } + .tab-eye.active { color: var(--ctp-blue); border-color: var(--ctp-blue); } /* Terminal panes — fill remaining space below tab bar */ .term-panes { @@ -220,4 +256,14 @@ inset: 0; flex-direction: column; } + + .term-empty { + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + color: var(--ctp-overlay0); + font-size: 0.8125rem; + font-style: italic; + } diff --git a/ui-electrobun/src/mainview/app-state.svelte.ts b/ui-electrobun/src/mainview/app-state.svelte.ts index 595b3a4..69be688 100644 --- a/ui-electrobun/src/mainview/app-state.svelte.ts +++ b/ui-electrobun/src/mainview/app-state.svelte.ts @@ -52,6 +52,7 @@ import { getProjectState, getActiveTab, isTabActivated, setActiveTab, addTerminalTab, closeTerminalTab, activateTerminalTab, toggleTerminalExpanded, + toggleAgentPreview, appendBashOutput, setFileState, setFileMulti, nextFileRequestToken, getFileRequestToken, setCommsState, setCommsMulti, setTaskState, setTaskMulti, nextTaskPollToken, getTaskPollToken, @@ -136,6 +137,8 @@ export const appState = { closeTab: closeTerminalTab, activateTab: activateTerminalTab, toggleExpanded: toggleTerminalExpanded, + toggleAgentPreview: toggleAgentPreview, + appendBashOutput: appendBashOutput, }, files: { diff --git a/ui-electrobun/src/mainview/project-state.svelte.ts b/ui-electrobun/src/mainview/project-state.svelte.ts index 755c664..355c7ac 100644 --- a/ui-electrobun/src/mainview/project-state.svelte.ts +++ b/ui-electrobun/src/mainview/project-state.svelte.ts @@ -26,15 +26,19 @@ let _version = $state(0); // ── Factory ─────────────────────────────────────────────────────────────── +const MAX_BASH_OUTPUT_LINES = 500; + function createProjectState(projectId: string): ProjectState { const firstTabId = `${projectId}-t1`; return { terminals: { - tabs: [{ id: firstTabId, title: 'shell 1' }], + tabs: [{ kind: 'pty', id: firstTabId, title: 'shell 1' }], activeTabId: firstTabId, expanded: true, nextId: 2, mounted: new Set([firstTabId]), + bashOutputLines: [], + bashLinesVersion: 0, }, files: { childrenCache: new Map(), @@ -127,7 +131,7 @@ export function setActiveTab(projectId: string, tab: ProjectTab): void { export function addTerminalTab(projectId: string): void { const t = ensureProject(projectId).terminals; const id = `${projectId}-t${t.nextId}`; - t.tabs = [...t.tabs, { id, title: `shell ${t.nextId}` }]; + t.tabs = [...t.tabs, { kind: 'pty', id, title: `shell ${t.nextId}` }]; t.nextId++; t.activeTabId = id; t.mounted.add(id); @@ -160,6 +164,38 @@ export function toggleTerminalExpanded(projectId: string): void { bump(); } +export function toggleAgentPreview(projectId: string): void { + const t = ensureProject(projectId).terminals; + const existing = t.tabs.find(tab => tab.kind === 'agentPreview'); + if (existing) { + // Remove the preview tab + t.tabs = t.tabs.filter(tab => tab.kind !== 'agentPreview'); + t.mounted.delete(existing.id); + if (t.activeTabId === existing.id) { + const next = t.tabs[0]; + t.activeTabId = next?.id ?? ''; + } + } else { + // Add a preview tab + const id = `${projectId}-preview`; + t.tabs = [...t.tabs, { kind: 'agentPreview', id, title: 'Agent Preview' }]; + t.activeTabId = id; + t.mounted.add(id); + if (!t.expanded) t.expanded = true; + } + bump(); +} + +export function appendBashOutput(projectId: string, line: string): void { + const t = ensureProject(projectId).terminals; + t.bashOutputLines.push(line); + if (t.bashOutputLines.length > MAX_BASH_OUTPUT_LINES) { + t.bashOutputLines.splice(0, t.bashOutputLines.length - MAX_BASH_OUTPUT_LINES); + } + t.bashLinesVersion++; + bump(); +} + // ── File actions ────────────────────────────────────────────────────────── export function setFileState( diff --git a/ui-electrobun/src/mainview/project-state.types.ts b/ui-electrobun/src/mainview/project-state.types.ts index d45fa74..4dd8cc9 100644 --- a/ui-electrobun/src/mainview/project-state.types.ts +++ b/ui-electrobun/src/mainview/project-state.types.ts @@ -10,10 +10,9 @@ export type { ProjectTab }; // ── Terminal ────────────────────────────────────────────────────────────── -export interface TermTab { - id: string; - title: string; -} +export type TermTab = + | { kind: 'pty'; id: string; title: string } + | { kind: 'agentPreview'; id: string; title: string }; export interface TerminalState { tabs: TermTab[]; @@ -21,6 +20,10 @@ export interface TerminalState { expanded: boolean; nextId: number; mounted: Set; + /** Ring buffer of bash tool_call output lines for agent preview. */ + bashOutputLines: string[]; + /** Bumped on every append — drives polling in readonly terminal. */ + bashLinesVersion: number; } // ── Files ───────────────────────────────────────────────────────────────── From 485abb4774f5f48ef0ebfd2f4f1e9081532884df Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 02:43:54 +0100 Subject: [PATCH 02/10] =?UTF-8?q?feat(electrobun):=20session=20continuity?= =?UTF-8?q?=20=E2=80=94=20Claude=20JSONL=20listing,=20resume/continue=20si?= =?UTF-8?q?decar=20support,=20session=20picker=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sidecar/claude-runner.ts | 22 +- ui-electrobun/src/bun/claude-sessions.ts | 129 +++++++ .../src/bun/handlers/agent-handlers.ts | 14 +- ui-electrobun/src/bun/sidecar-manager.ts | 10 + ui-electrobun/src/mainview/AgentPane.svelte | 13 + ui-electrobun/src/mainview/ProjectCard.svelte | 2 + .../src/mainview/SessionPicker.svelte | 333 ++++++++++++++++++ .../src/mainview/agent-store.svelte.ts | 88 +++++ ui-electrobun/src/shared/pty-rpc-schema.ts | 19 + 9 files changed, 626 insertions(+), 4 deletions(-) create mode 100644 ui-electrobun/src/bun/claude-sessions.ts create mode 100644 ui-electrobun/src/mainview/SessionPicker.svelte diff --git a/sidecar/claude-runner.ts b/sidecar/claude-runner.ts index 6fa6a30..01250b4 100644 --- a/sidecar/claude-runner.ts +++ b/sidecar/claude-runner.ts @@ -41,7 +41,10 @@ interface QueryMessage { cwd?: string; maxTurns?: number; maxBudgetUsd?: number; + /** @deprecated Use resumeMode='resume' + resumeSessionId instead. */ resumeSessionId?: string; + /** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */ + resumeMode?: 'new' | 'continue' | 'resume'; permissionMode?: string; settingSources?: string[]; systemPrompt?: string; @@ -74,7 +77,7 @@ async function handleMessage(msg: Record) { } async function handleQuery(msg: QueryMessage) { - const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg; + const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, resumeMode, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg; if (sessions.has(sessionId)) { send({ type: 'error', sessionId, message: 'Session already running' }); @@ -112,6 +115,20 @@ async function handleQuery(msg: QueryMessage) { return; } + // Build resume/continue options based on resumeMode + let resumeOpt: string | undefined; + let continueOpt: boolean | undefined; + if (resumeMode === 'continue') { + continueOpt = true; + log(`Session ${sessionId}: continuing most recent session`); + } else if (resumeMode === 'resume' && resumeSessionId) { + resumeOpt = resumeSessionId; + log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`); + } else if (resumeSessionId && !resumeMode) { + // Legacy: direct resumeSessionId without resumeMode + resumeOpt = resumeSessionId; + } + const q = query({ prompt, options: { @@ -121,7 +138,8 @@ async function handleQuery(msg: QueryMessage) { env: cleanEnv, maxTurns: maxTurns ?? undefined, maxBudgetUsd: maxBudgetUsd ?? undefined, - resume: resumeSessionId ?? undefined, + resume: resumeOpt, + continue: continueOpt, allowedTools: [ 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit', diff --git a/ui-electrobun/src/bun/claude-sessions.ts b/ui-electrobun/src/bun/claude-sessions.ts new file mode 100644 index 0000000..95ed501 --- /dev/null +++ b/ui-electrobun/src/bun/claude-sessions.ts @@ -0,0 +1,129 @@ +/** + * Claude session listing — reads Claude SDK session files from disk. + * + * Sessions stored as JSONL at ~/.claude/projects//.jsonl + * where = absolute path with non-alphanumeric chars replaced by '-'. + */ + +import { join } from "path"; +import { homedir } from "os"; +import { readdirSync, readFileSync, statSync } from "fs"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ClaudeSessionInfo { + sessionId: string; + summary: string; + lastModified: number; + fileSize: number; + firstPrompt: string; + model: string; +} + +// ── Implementation ─────────────────────────────────────────────────────────── + +function encodeCwd(cwd: string): string { + return cwd.replace(/[^a-zA-Z0-9]/g, "-"); +} + +/** + * List Claude sessions for a project CWD. + * Reads the first 5 lines of each .jsonl file to extract metadata. + * Returns sessions sorted by lastModified descending. + */ +export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] { + const encoded = encodeCwd(cwd); + const sessionsDir = join(homedir(), ".claude", "projects", encoded); + + let entries: string[]; + try { + entries = readdirSync(sessionsDir); + } catch (err: unknown) { + // ENOENT or permission error — no sessions yet + if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; + console.warn("[claude-sessions] readdir error:", err); + return []; + } + + const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl")); + const results: ClaudeSessionInfo[] = []; + + for (const file of jsonlFiles) { + try { + const filePath = join(sessionsDir, file); + const stat = statSync(filePath); + const sessionId = file.replace(/\.jsonl$/, ""); + + // Read first 5 lines for metadata extraction + const content = readFileSync(filePath, "utf-8"); + const lines = content.split("\n").slice(0, 5); + + let firstPrompt = ""; + let model = ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + + // Extract model from init/system messages + if (!model && parsed.model) { + model = String(parsed.model); + } + + // Extract first user prompt + if (!firstPrompt && parsed.role === "user") { + const content = parsed.content; + if (typeof content === "string") { + firstPrompt = content; + } else if (Array.isArray(content)) { + // Content blocks format + const textBlock = content.find( + (b: Record) => b.type === "text", + ); + if (textBlock?.text) firstPrompt = String(textBlock.text); + } + } + + // Also check for message type wrappers + if (!firstPrompt && parsed.type === "human" && parsed.message?.content) { + const mc = parsed.message.content; + if (typeof mc === "string") { + firstPrompt = mc; + } else if (Array.isArray(mc)) { + const textBlock = mc.find( + (b: Record) => b.type === "text", + ); + if (textBlock?.text) firstPrompt = String(textBlock.text); + } + } + } catch { + // Skip malformed JSONL lines + continue; + } + } + + // Truncate first prompt for display + if (firstPrompt.length > 120) { + firstPrompt = firstPrompt.slice(0, 117) + "..."; + } + + results.push({ + sessionId, + summary: firstPrompt || "(no prompt found)", + lastModified: stat.mtimeMs, + fileSize: stat.size, + firstPrompt: firstPrompt || "", + model: model || "unknown", + }); + } catch { + // Skip corrupt/unreadable files + continue; + } + } + + // Sort by lastModified descending (newest first) + results.sort((a, b) => b.lastModified - a.lastModified); + + return results; +} diff --git a/ui-electrobun/src/bun/handlers/agent-handlers.ts b/ui-electrobun/src/bun/handlers/agent-handlers.ts index cff7d9f..969a63a 100644 --- a/ui-electrobun/src/bun/handlers/agent-handlers.ts +++ b/ui-electrobun/src/bun/handlers/agent-handlers.ts @@ -5,6 +5,7 @@ import type { SidecarManager } from "../sidecar-manager.ts"; import type { SessionDb } from "../session-db.ts"; import type { SearchDb } from "../search-db.ts"; +import { listClaudeSessions } from "../claude-sessions.ts"; export function createAgentHandlers( sidecarManager: SidecarManager, @@ -13,13 +14,13 @@ export function createAgentHandlers( searchDb?: SearchDb, ) { return { - "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record) => { + "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId }: Record) => { try { const result = sidecarManager.startSession( sessionId as string, provider as string, prompt as string, - { cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record, + { cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId } as Record, ); if (result.ok) { @@ -169,5 +170,14 @@ export function createAgentHandlers( return { messages: [] }; } }, + + "session.listClaude": ({ cwd }: { cwd: string }) => { + try { + return { sessions: listClaudeSessions(cwd) }; + } catch (err) { + console.error("[session.listClaude]", err); + return { sessions: [] }; + } + }, }; } diff --git a/ui-electrobun/src/bun/sidecar-manager.ts b/ui-electrobun/src/bun/sidecar-manager.ts index ee8c6c9..053be66 100644 --- a/ui-electrobun/src/bun/sidecar-manager.ts +++ b/ui-electrobun/src/bun/sidecar-manager.ts @@ -39,6 +39,10 @@ export interface StartSessionOptions { extraEnv?: Record; additionalDirectories?: string[]; worktreeName?: string; + /** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */ + resumeMode?: "new" | "continue" | "resume"; + /** Required when resumeMode='resume' — the Claude SDK session ID to resume. */ + resumeSessionId?: string; } type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void; @@ -283,6 +287,12 @@ export class SidecarManager { if (options.worktreeName) { queryMsg.worktreeName = options.worktreeName; } + if (options.resumeMode && options.resumeMode !== "new") { + queryMsg.resumeMode = options.resumeMode; + } + if (options.resumeSessionId) { + queryMsg.resumeSessionId = options.resumeSessionId; + } dbg(`Sending query: ${JSON.stringify(queryMsg).slice(0, 200)}...`); this.writeToProcess(sessionId, queryMsg); diff --git a/ui-electrobun/src/mainview/AgentPane.svelte b/ui-electrobun/src/mainview/AgentPane.svelte index 5a6bf47..a22c1eb 100644 --- a/ui-electrobun/src/mainview/AgentPane.svelte +++ b/ui-electrobun/src/mainview/AgentPane.svelte @@ -1,6 +1,7 @@ + +
+ + +
+ + + {#if sessions.length > 0} + + {/if} + + + +
+ {#if loading} +
Loading...
+ {:else if sessions.length === 0} +
No previous sessions
+ {:else} + {#each sessions as s (s.sessionId)} + + {/each} + {/if} +
+
+
+ + diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts index 9697913..ae62a88 100644 --- a/ui-electrobun/src/mainview/agent-store.svelte.ts +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -47,6 +47,21 @@ interface StartOptions { extraEnv?: Record; additionalDirectories?: string[]; worktreeName?: string; + /** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */ + resumeMode?: 'new' | 'continue' | 'resume'; + /** Required when resumeMode='resume' — the Claude SDK session ID to resume. */ + resumeSessionId?: string; +} + +// ── Claude session listing types ────────────────────────────────────────────── + +export interface ClaudeSessionInfo { + sessionId: string; + summary: string; + lastModified: number; + fileSize: number; + firstPrompt: string; + model: string; } // ── Toast callback (set by App.svelte) ──────────────────────────────────────── @@ -297,6 +312,8 @@ function ensureListeners() { scheduleCleanup(session.sessionId, session.projectId); // Fix #14 (Codex audit): Enforce max sessions per project on completion enforceMaxSessions(session.projectId); + // Invalidate Claude session cache so picker refreshes + invalidateSessionCache(session.projectId); } }); @@ -567,6 +584,8 @@ async function _startAgentInner( permissionMode: permissionMode, claudeConfigDir: options.claudeConfigDir, extraEnv: validateExtraEnv(options.extraEnv), + resumeMode: options.resumeMode, + resumeSessionId: options.resumeSessionId, }); if (!result.ok) { @@ -828,6 +847,75 @@ function enforceMaxSessions(projectId: string): void { } } +// ── Session continuity API ──────────────────────────────────────────────────── + +// Claude session list cache per project (keyed by projectId) +const claudeSessionCache = new Map(); +const CACHE_TTL_MS = 30_000; // 30 seconds + +/** + * List Claude SDK sessions from disk for a project CWD. + * Cached for 30 seconds; invalidated on agent completion. + */ +export async function listProjectSessions(projectId: string, cwd: string): Promise { + const cached = claudeSessionCache.get(projectId); + if (cached && (Date.now() - cached.fetchedAt) < CACHE_TTL_MS) { + return cached.sessions; + } + + try { + const result = await appRpc.request['session.listClaude']({ cwd }); + const sessions = (result?.sessions ?? []) as ClaudeSessionInfo[]; + claudeSessionCache.set(projectId, { sessions, fetchedAt: Date.now() }); + return sessions; + } catch (err) { + console.error('[listProjectSessions] error:', err); + return []; + } +} + +/** Invalidate the Claude session cache for a project. */ +export function invalidateSessionCache(projectId: string): void { + claudeSessionCache.delete(projectId); +} + +/** + * Continue the most recent Claude session for a project. + * Uses SDK `continue: true` — picks up where the last session left off. + */ +export async function continueLastSession( + projectId: string, + provider: string, + prompt: string, + cwd: string, + options: Omit = {}, +): Promise<{ ok: boolean; error?: string }> { + return startAgent(projectId, provider, prompt, { + ...options, + cwd, + resumeMode: 'continue', + }); +} + +/** + * Resume a specific Claude session by its SDK session ID. + */ +export async function resumeSession( + projectId: string, + provider: string, + sdkSessionId: string, + prompt: string, + cwd: string, + options: Omit = {}, +): Promise<{ ok: boolean; error?: string }> { + return startAgent(projectId, provider, prompt, { + ...options, + cwd, + resumeMode: 'resume', + resumeSessionId: sdkSessionId, + }); +} + // NOTE: Do NOT call ensureListeners() at module load — appRpc may not be // initialized yet (setAppRpc runs in main.ts after module imports resolve). // Listeners are registered lazily on first startAgent/getSession/sendPrompt call. diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index 297b2f4..9dde67c 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -389,6 +389,10 @@ export type PtyRPCRequests = { extraEnv?: Record; additionalDirectories?: string[]; worktreeName?: string; + /** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific session). */ + resumeMode?: "new" | "continue" | "resume"; + /** Required when resumeMode='resume' — the Claude SDK session ID to resume. */ + resumeSessionId?: string; }; response: { ok: boolean; error?: string }; }; @@ -477,6 +481,21 @@ export type PtyRPCRequests = { }; }; + /** List Claude SDK sessions from disk for a project CWD. */ + "session.listClaude": { + params: { cwd: string }; + response: { + sessions: Array<{ + sessionId: string; + summary: string; + lastModified: number; + fileSize: number; + firstPrompt: string; + model: string; + }>; + }; + }; + // ── btmsg RPC ────────────────────────────────────────────────────────── /** Register an agent in btmsg. */ From 6e3853e0a1d7d9f01cff353694e2d1f8b8b111a8 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 03:19:16 +0100 Subject: [PATCH 03/10] fix(electrobun): session parser reads 50 lines (was 5), dots use getProjectsForGroup (was undefined group.projects) --- ui-electrobun/src/bun/claude-sessions.ts | 25 ++++++++++++++++--- ui-electrobun/src/mainview/App.svelte | 3 ++- .../src/mainview/workspace-store.svelte.ts | 5 ++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/ui-electrobun/src/bun/claude-sessions.ts b/ui-electrobun/src/bun/claude-sessions.ts index 95ed501..55658c7 100644 --- a/ui-electrobun/src/bun/claude-sessions.ts +++ b/ui-electrobun/src/bun/claude-sessions.ts @@ -54,9 +54,10 @@ export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] { const stat = statSync(filePath); const sessionId = file.replace(/\.jsonl$/, ""); - // Read first 5 lines for metadata extraction + // Read first 50 lines for metadata extraction (Claude sessions have + // many system/queue/hook events before the first user message) const content = readFileSync(filePath, "utf-8"); - const lines = content.split("\n").slice(0, 5); + const lines = content.split("\n").slice(0, 50); let firstPrompt = ""; let model = ""; @@ -85,7 +86,20 @@ export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] { } } - // Also check for message type wrappers + // Claude session format: type="user" with message.content + if (!firstPrompt && parsed.type === "user" && parsed.message?.content) { + const mc = parsed.message.content; + if (typeof mc === "string") { + firstPrompt = mc; + } else if (Array.isArray(mc)) { + const textBlock = mc.find( + (b: Record) => b.type === "text", + ); + if (textBlock?.text) firstPrompt = String(textBlock.text); + } + } + + // Also check "human" wrapper (older format) if (!firstPrompt && parsed.type === "human" && parsed.message?.content) { const mc = parsed.message.content; if (typeof mc === "string") { @@ -97,6 +111,11 @@ export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] { if (textBlock?.text) firstPrompt = String(textBlock.text); } } + + // Extract model from assistant messages + if (!model && parsed.type === "assistant" && parsed.message?.model) { + model = String(parsed.message.model); + } } catch { // Skip malformed JSONL lines continue; diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index 4147e94..dc40181 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -20,6 +20,7 @@ getProjects, getGroups, getActiveGroupId, + getProjectsForGroup, getActiveGroup, getFilteredProjects, getTotalCostDerived, getTotalTokensDerived, setActiveGroup, @@ -383,7 +384,7 @@ title="{group.name} (Ctrl+{i + 1})" > diff --git a/ui-electrobun/src/mainview/workspace-store.svelte.ts b/ui-electrobun/src/mainview/workspace-store.svelte.ts index 242231d..4800660 100644 --- a/ui-electrobun/src/mainview/workspace-store.svelte.ts +++ b/ui-electrobun/src/mainview/workspace-store.svelte.ts @@ -76,6 +76,11 @@ export function getActiveGroup(): Group { export function getFilteredProjects(): Project[] { return projects.filter(p => (p.groupId ?? 'dev') === activeGroupId); } + +/** Get projects for a specific group (used by GroupStatusDots). */ +export function getProjectsForGroup(groupId: string): Project[] { + return projects.filter(p => (p.groupId ?? 'dev') === groupId); +} export function getTotalCostDerived(): number { return projects.reduce((s, p) => s + p.costUsd, 0); } From 31a33356514d96a0d4bf3d97fb0de1b4b486b214 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 03:26:16 +0100 Subject: [PATCH 04/10] fix(sidecar): sanitize JSONL session files before resume (strips empty text blocks with cache_control) --- sidecar/claude-runner.ts | 111 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/sidecar/claude-runner.ts b/sidecar/claude-runner.ts index 01250b4..43b5a6b 100644 --- a/sidecar/claude-runner.ts +++ b/sidecar/claude-runner.ts @@ -8,7 +8,8 @@ import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; -import { query, type Query } from '@anthropic-ai/claude-agent-sdk'; +import { query, listSessions, type Query } from '@anthropic-ai/claude-agent-sdk'; +import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; const rl = createInterface({ input: stdin }); @@ -121,12 +122,17 @@ async function handleQuery(msg: QueryMessage) { if (resumeMode === 'continue') { continueOpt = true; log(`Session ${sessionId}: continuing most recent session`); + // Sanitize the most recent session file before SDK reads it + sanitizeSessionFiles(cwd || process.cwd()); } else if (resumeMode === 'resume' && resumeSessionId) { resumeOpt = resumeSessionId; log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`); + // Sanitize the specific session file + sanitizeSessionFile(cwd || process.cwd(), resumeSessionId); } else if (resumeSessionId && !resumeMode) { // Legacy: direct resumeSessionId without resumeMode resumeOpt = resumeSessionId; + sanitizeSessionFile(cwd || process.cwd(), resumeSessionId); } const q = query({ @@ -238,5 +244,108 @@ if (claudePath) { log('WARNING: Claude CLI not found — agent sessions will fail'); } +// ── Session sanitizer ────────────────────────────────────────────────────── +// Fixes "cache_control cannot be set for empty text blocks" API error on resume. +// The SDK stores empty text blocks with cache_control during streaming; +// the API rejects them on replay. We strip them before the SDK reads the file. + +function encodeCwd(cwdPath: string): string { + return cwdPath.replace(/[^a-zA-Z0-9]/g, '-'); +} + +function sanitizeSessionFile(cwdPath: string, sdkSessionId: string): void { + const encoded = encodeCwd(cwdPath); + const filePath = join(homedir(), '.claude', 'projects', encoded, `${sdkSessionId}.jsonl`); + try { + sanitizeJsonlFile(filePath); + } catch (err) { + log(`sanitize: could not clean ${filePath}: ${err}`); + } +} + +function sanitizeSessionFiles(cwdPath: string): void { + // For 'continue' mode, sanitize the most recent session + try { + const sessions = listSessions({ dir: cwdPath, limit: 1 }); + if (sessions && sessions.length > 0) { + sanitizeSessionFile(cwdPath, (sessions[0] as Record).sessionId as string); + } + } catch (err) { + log(`sanitize: listSessions failed: ${err}`); + // Fallback: try to find and sanitize the most recent .jsonl file + const encoded = encodeCwd(cwdPath); + const dir = join(homedir(), '.claude', 'projects', encoded); + try { + const files = readdirSync(dir).filter(f => f.endsWith('.jsonl')); + if (files.length > 0) { + // Sort by mtime descending + files.sort((a, b) => { + try { + return statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs; + } catch { return 0; } + }); + sanitizeJsonlFile(join(dir, files[0])); + } + } catch { /* dir doesn't exist */ } + } +} + +function sanitizeJsonlFile(filePath: string): void { + if (!existsSync(filePath)) return; + + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + let modified = false; + + const cleaned = lines.map(line => { + if (!line.trim()) return line; + try { + const obj = JSON.parse(line); + if (cleanContentBlocks(obj)) { + modified = true; + return JSON.stringify(obj); + } + } catch { /* skip unparseable lines */ } + return line; + }); + + if (modified) { + writeFileSync(filePath, cleaned.join('\n')); + log(`sanitize: cleaned empty text blocks from ${filePath}`); + } +} + +function cleanContentBlocks(obj: Record): boolean { + let changed = false; + + // Check message.content array + const msg = obj.message as Record | undefined; + if (msg?.content && Array.isArray(msg.content)) { + const before = msg.content.length; + msg.content = (msg.content as Array>).filter(block => { + // Remove empty text blocks (with or without cache_control) + if (block.type === 'text' && (!block.text || !(block.text as string).trim())) { + return false; + } + return true; + }); + if (msg.content.length !== before) changed = true; + } + + // Also check top-level content array (some formats) + if (obj.content && Array.isArray(obj.content)) { + const before = (obj.content as unknown[]).length; + obj.content = (obj.content as Array>).filter(block => { + if (block.type === 'text' && (!block.text || !(block.text as string).trim())) { + return false; + } + return true; + }); + if ((obj.content as unknown[]).length !== before) changed = true; + } + + return changed; +} + log('Sidecar started'); send({ type: 'ready' }); From 18b9c7c3b58ea460bfa2c7e2b3cca84c5d4e36c2 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 03:35:02 +0100 Subject: [PATCH 05/10] =?UTF-8?q?fix(electrobun):=20session=20resume=20use?= =?UTF-8?q?s=20pending=20mode=20=E2=80=94=20user=20types=20prompt=20first,?= =?UTF-8?q?=20no=20empty=20string=20to=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-electrobun/src/mainview/AgentPane.svelte | 5 ++- .../src/mainview/SessionPicker.svelte | 13 ++++--- .../src/mainview/agent-store.svelte.ts | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/ui-electrobun/src/mainview/AgentPane.svelte b/ui-electrobun/src/mainview/AgentPane.svelte index a22c1eb..8992352 100644 --- a/ui-electrobun/src/mainview/AgentPane.svelte +++ b/ui-electrobun/src/mainview/AgentPane.svelte @@ -3,6 +3,7 @@ import ChatInput from "./ChatInput.svelte"; import SessionPicker from "./SessionPicker.svelte"; import type { AgentMessage, AgentStatus } from "./agent-store.svelte.ts"; + import { getPendingResume } from "./agent-store.svelte.ts"; import { t } from "./i18n.svelte.ts"; interface Props { @@ -55,6 +56,8 @@ return () => observer.disconnect(); }); + function getPendingResumeForProject() { return projectId ? getPendingResume(projectId) : undefined; } + function handleSend() { const text = promptText.trim(); if (!text) return; @@ -236,7 +239,7 @@ {model} {provider} {contextPct} - placeholder={t("agent.prompt.placeholder")} + placeholder={getPendingResumeForProject() ? 'Type to resume session...' : t("agent.prompt.placeholder")} onSend={handleSend} onInput={(v) => (promptText = v)} /> diff --git a/ui-electrobun/src/mainview/SessionPicker.svelte b/ui-electrobun/src/mainview/SessionPicker.svelte index b6da90e..3558900 100644 --- a/ui-electrobun/src/mainview/SessionPicker.svelte +++ b/ui-electrobun/src/mainview/SessionPicker.svelte @@ -1,7 +1,7 @@ diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts index ae62a88..9764fb8 100644 --- a/ui-electrobun/src/mainview/agent-store.svelte.ts +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -127,6 +127,33 @@ function validateExtraEnv(env: Record | undefined): Record sessionId for lookup const projectSessionMap = new Map(); +// Pending resume: when the user selects a session to resume, we store the +// resume options here. The next startAgent() call reads and clears them. +// This avoids sending an empty prompt — the user types their message first. +interface PendingResume { + mode: 'continue' | 'resume'; + sdkSessionId?: string; +} +const pendingResumes = new Map(); + +/** Set a pending resume for a project — next startAgent will use these options. */ +export function setPendingResume(projectId: string, mode: 'continue' | 'resume', sdkSessionId?: string): void { + pendingResumes.set(projectId, { mode, sdkSessionId }); + bump(); +} + +/** Check if a project has a pending resume. */ +export function getPendingResume(projectId: string): PendingResume | undefined { + void _v; + return pendingResumes.get(projectId); +} + +/** Clear pending resume (called after startAgent consumes it). */ +export function clearPendingResume(projectId: string): void { + pendingResumes.delete(projectId); + bump(); +} + // Map sessionId -> reactive session state let sessions = $state>({}); @@ -522,6 +549,16 @@ async function _startAgentInner( // If there's an existing done/error session for this project, clear it first clearSession(projectId); + // Check for pending resume (user selected a session in the picker) + const pending = pendingResumes.get(projectId); + if (pending) { + pendingResumes.delete(projectId); + if (!options.resumeMode) { + options.resumeMode = pending.mode; + options.resumeSessionId = pending.sdkSessionId; + } + } + const sessionId = `${projectId}-${Date.now()}`; // Read settings defaults if not explicitly provided (Fix #5) From 634e4658c3b36df619ffcc871cc02886ceb491d9 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 03:44:43 +0100 Subject: [PATCH 06/10] fix(sidecar): remove allowedTools/settingSources/systemPrompt restrictions (caused 400 from empty cache_control text blocks) --- sidecar/claude-runner.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/sidecar/claude-runner.ts b/sidecar/claude-runner.ts index 43b5a6b..f4da594 100644 --- a/sidecar/claude-runner.ts +++ b/sidecar/claude-runner.ts @@ -146,16 +146,9 @@ async function handleQuery(msg: QueryMessage) { maxBudgetUsd: maxBudgetUsd ?? undefined, resume: resumeOpt, continue: continueOpt, - allowedTools: [ - 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit', - ], permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default', allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions', - settingSources: settingSources ?? ['user', 'project'], - systemPrompt: systemPrompt - ? systemPrompt - : { type: 'preset' as const, preset: 'claude_code' as const }, + ...(systemPrompt ? { systemPrompt } : {}), model: model ?? undefined, additionalDirectories: additionalDirectories ?? undefined, extraArgs: worktreeName ? { worktree: worktreeName } : undefined, From 78d77080cc08259bc87193d990857f5824f87144 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 03:54:14 +0100 Subject: [PATCH 07/10] =?UTF-8?q?fix(electrobun):=20+New=20button=20no=20l?= =?UTF-8?q?onger=20sends=20empty=20prompt=20=E2=80=94=20waits=20for=20user?= =?UTF-8?q?=20input=20like=20Continue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-electrobun/src/mainview/SessionPicker.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui-electrobun/src/mainview/SessionPicker.svelte b/ui-electrobun/src/mainview/SessionPicker.svelte index 3558900..fcd4d30 100644 --- a/ui-electrobun/src/mainview/SessionPicker.svelte +++ b/ui-electrobun/src/mainview/SessionPicker.svelte @@ -87,7 +87,8 @@ function handleNew() { open = false; clearPendingResume(projectId); - onNewSession?.(''); + // Don't start agent — just clear any pending resume so next Send creates fresh session + // The user types their prompt, then hits Send } From 1afbe66fd7850a1acd64738c75fb23ca39e1227e Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 03:59:22 +0100 Subject: [PATCH 08/10] fix(electrobun): preserve conversation history when resuming session (don't clearSession on resume) --- .../src/mainview/agent-store.svelte.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts index 9764fb8..092c945 100644 --- a/ui-electrobun/src/mainview/agent-store.svelte.ts +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -546,9 +546,6 @@ async function _startAgentInner( prompt: string, options: StartOptions, ): Promise<{ ok: boolean; error?: string }> { - // If there's an existing done/error session for this project, clear it first - clearSession(projectId); - // Check for pending resume (user selected a session in the picker) const pending = pendingResumes.get(projectId); if (pending) { @@ -559,6 +556,13 @@ async function _startAgentInner( } } + const isResume = options.resumeMode === 'continue' || options.resumeMode === 'resume'; + + // If resuming, keep existing session messages; if new, clear old session + if (!isResume) { + clearSession(projectId); + } + const sessionId = `${projectId}-${Date.now()}`; // Read settings defaults if not explicitly provided (Fix #5) @@ -587,25 +591,38 @@ async function _startAgentInner( } } catch { /* use provided or defaults */ } - // Create reactive session state + // Create reactive session state — carry forward messages if resuming + const existingSessionId = projectSessionMap.get(projectId); + const existingMessages = (isResume && existingSessionId && sessions[existingSessionId]) + ? [...sessions[existingSessionId].messages] + : []; + + // Add the new user prompt to messages + const newUserMsg = { + id: `${sessionId}-user-0`, + seqId: nextSeqId(sessionId), + role: 'user' as const, + content: prompt, + timestamp: Date.now(), + }; + sessions[sessionId] = { sessionId, projectId, provider, status: 'running', - messages: [{ - id: `${sessionId}-user-0`, - seqId: nextSeqId(sessionId), - role: 'user', - content: prompt, - timestamp: Date.now(), - }], + messages: [...existingMessages, newUserMsg], costUsd: 0, inputTokens: 0, outputTokens: 0, model: defaultModel ?? 'claude-opus-4-5', }; + // Clean up the old session entry if resuming (we moved messages to the new one) + if (isResume && existingSessionId && existingSessionId !== sessionId) { + delete sessions[existingSessionId]; + } + projectSessionMap.set(projectId, sessionId); bump(); // Force re-render — new session created resetStallTimer(sessionId, projectId); From cb7fba6130e1c1b89c65421e11a7b5b216803aad Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 04:09:00 +0100 Subject: [PATCH 09/10] =?UTF-8?q?feat(electrobun):=20load=20full=20session?= =?UTF-8?q?=20history=20from=20JSONL=20when=20resuming=20=E2=80=94=20conve?= =?UTF-8?q?rsation=20stays=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-electrobun/src/bun/claude-sessions.ts | 111 ++++++++++++++++++ .../src/bun/handlers/agent-handlers.ts | 10 ++ .../src/mainview/SessionPicker.svelte | 14 ++- .../src/mainview/agent-store.svelte.ts | 45 +++++++ ui-electrobun/src/shared/pty-rpc-schema.ts | 15 +++ 5 files changed, 190 insertions(+), 5 deletions(-) diff --git a/ui-electrobun/src/bun/claude-sessions.ts b/ui-electrobun/src/bun/claude-sessions.ts index 55658c7..39a9825 100644 --- a/ui-electrobun/src/bun/claude-sessions.ts +++ b/ui-electrobun/src/bun/claude-sessions.ts @@ -146,3 +146,114 @@ export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] { return results; } + +// ── Message types for display ─────────────────────────────────────────────── + +export interface SessionMessage { + id: string; + role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system'; + content: string; + timestamp: number; + model?: string; + toolName?: string; +} + +/** + * Load conversation messages from a Claude JSONL session file. + * Extracts user prompts and assistant responses for display. + */ +export function loadClaudeSessionMessages(cwd: string, sdkSessionId: string): SessionMessage[] { + const encoded = encodeCwd(cwd); + const filePath = join(homedir(), ".claude", "projects", encoded, `${sdkSessionId}.jsonl`); + + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch { + return []; + } + + const messages: SessionMessage[] = []; + const lines = content.split("\n"); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + + // User messages + if (obj.type === "user" && obj.message?.content) { + const mc = obj.message.content; + let text = ""; + if (typeof mc === "string") { + text = mc; + } else if (Array.isArray(mc)) { + const tb = mc.find((b: Record) => b.type === "text"); + if (tb?.text) text = String(tb.text); + } + if (text) { + messages.push({ + id: obj.uuid || `user-${messages.length}`, + role: "user", + content: text, + timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), + }); + } + } + + // Assistant messages + if (obj.type === "assistant" && obj.message?.content) { + const mc = obj.message.content; + let text = ""; + if (Array.isArray(mc)) { + for (const block of mc) { + if (block.type === "text" && block.text) { + text += block.text; + } else if (block.type === "tool_use") { + messages.push({ + id: block.id || `tool-${messages.length}`, + role: "tool_call", + content: `${block.name}(${JSON.stringify(block.input || {}).slice(0, 200)})`, + timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), + toolName: block.name, + }); + } + } + } + if (text) { + messages.push({ + id: obj.uuid || obj.message?.id || `asst-${messages.length}`, + role: "assistant", + content: text, + timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), + model: obj.message?.model, + }); + } + } + + // Tool results + if (obj.type === "tool_result" || (obj.role === "tool" && obj.content)) { + const rc = obj.content; + let text = ""; + if (typeof rc === "string") { + text = rc; + } else if (Array.isArray(rc)) { + const tb = rc.find((b: Record) => b.type === "text"); + if (tb?.text) text = String(tb.text); + } + if (text && text.length > 0) { + messages.push({ + id: obj.uuid || `result-${messages.length}`, + role: "tool_result", + content: text.length > 500 ? text.slice(0, 497) + "..." : text, + timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), + }); + } + } + } catch { + continue; + } + } + + return messages; +} diff --git a/ui-electrobun/src/bun/handlers/agent-handlers.ts b/ui-electrobun/src/bun/handlers/agent-handlers.ts index 969a63a..4e7493c 100644 --- a/ui-electrobun/src/bun/handlers/agent-handlers.ts +++ b/ui-electrobun/src/bun/handlers/agent-handlers.ts @@ -179,5 +179,15 @@ export function createAgentHandlers( return { sessions: [] }; } }, + + "session.loadMessages": ({ cwd, sdkSessionId }: { cwd: string; sdkSessionId: string }) => { + try { + const { loadClaudeSessionMessages } = require("../claude-sessions.ts"); + return { messages: loadClaudeSessionMessages(cwd, sdkSessionId) }; + } catch (err) { + console.error("[session.loadMessages]", err); + return { messages: [] }; + } + }, }; } diff --git a/ui-electrobun/src/mainview/SessionPicker.svelte b/ui-electrobun/src/mainview/SessionPicker.svelte index fcd4d30..0d916a9 100644 --- a/ui-electrobun/src/mainview/SessionPicker.svelte +++ b/ui-electrobun/src/mainview/SessionPicker.svelte @@ -1,7 +1,7 @@