From ba302575224e1e8bb98780174535a7fdd96092ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 10:03:27 +0000 Subject: [PATCH 1/4] Add workspace tab bar with session chrome Co-authored-by: Gao Yu --- src/lib/ChatPane.svelte | 36 --- src/lib/Composer.svelte | 227 ++++++++------- src/lib/Sidebar.svelte | 285 +++++++++---------- src/lib/chat.svelte.ts | 9 +- src/lib/chat.test.ts | 9 + src/lib/i18n/messages/chat.ts | 8 +- src/lib/i18n/messages/shell.ts | 48 +++- src/lib/session.svelte.ts | 56 +++- src/lib/session.test.ts | 48 ++++ src/lib/types.ts | 5 + src/lib/workbench/Mosaic.svelte | 38 ++- src/lib/workbench/TabChromePopover.svelte | 305 +++++++++++++++++++++ src/lib/workbench/TabGlyph.svelte | 97 +++++++ src/lib/workbench/WorkspaceTabs.svelte | 279 +++++++++++++++++++ src/lib/workbench/tabChrome.test.ts | 94 +++++++ src/lib/workbench/tabChrome.ts | 104 +++++++ src/lib/workbench/workspaceStore.svelte.ts | 43 +++ src/lib/workbench/workspaceStore.test.ts | 76 +++++ src/lib/workbench/workspaces.test.ts | 45 +++ src/lib/workbench/workspaces.ts | 40 ++- src/routes/+page.svelte | 136 +++++---- 21 files changed, 1618 insertions(+), 370 deletions(-) create mode 100644 src/lib/workbench/TabChromePopover.svelte create mode 100644 src/lib/workbench/TabGlyph.svelte create mode 100644 src/lib/workbench/WorkspaceTabs.svelte create mode 100644 src/lib/workbench/tabChrome.test.ts create mode 100644 src/lib/workbench/tabChrome.ts create mode 100644 src/lib/workbench/workspaceStore.test.ts diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index f2d52f0..13c980b 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -30,9 +30,6 @@ import { buildSetApprovalModeOp, needsClaudeYoloRespawn, type ApprovalMode, type ApproveOp } from '$lib/approval'; import { focusTrap } from '$lib/focusTrap'; import { - captureScreenshot, - startScreenRecording, - stopScreenRecording, processVideo, claudeSessions, gitCheckpointCapture, @@ -96,7 +93,6 @@ type PickedRef = WebRef & { id: number }; let webRefs = $state([]); let refSeq = 0; - let recording = $state(false); let scroller = $state(null); let composerEl = $state(null); let composerRef = $state<{ insertToken: (t: string) => void } | undefined>(); @@ -397,35 +393,6 @@ } } - async function screenshot() { - try { - const path = await captureScreenshot(); - if (path) { - attachments.push({ path, image: true }); - composerEl?.focus(); - } - } catch (e) { - await message(String(e), { title: 'JuCode', kind: 'error' }); - } - } - - async function toggleRecord() { - try { - if (!recording) { - await startScreenRecording(); - recording = true; - } else { - recording = false; - const path = await stopScreenRecording(); - await attachVideo(path); - composerEl?.focus(); - } - } catch (e) { - recording = false; - await message(String(e), { title: 'JuCode', kind: 'error' }); - } - } - // Serialize a picked element into model-readable context. Inserted in place of // its inline token on submit. function formatWebRef(r: PickedRef): string { @@ -816,13 +783,10 @@ bind:attachments bind:videos bind:el={composerEl} - {recording} onSubmit={submit} onStop={stop} onSteer={() => send({ op: 'steer' })} onPick={pickFiles} - onScreenshot={screenshot} - onRecord={toggleRecord} onModel={openModelPicker} onModelSelect={selectRow} onModelEffort={setEffort} diff --git a/src/lib/Composer.svelte b/src/lib/Composer.svelte index be50c7f..e025b23 100644 --- a/src/lib/Composer.svelte +++ b/src/lib/Composer.svelte @@ -1,5 +1,5 @@ @@ -209,6 +230,7 @@
barDblClick(e, leaf)} role="tablist" tabindex="-1">
{#each leaf.tabs as tab (tab.id)} + {@const chrome = decorate?.(tab) ?? null}
tabPointerDown(e, tab)} + ondblclick={(e) => onTabRename?.(tab, e)} + oncontextmenu={(e) => onTabContext?.(tab, e)} onkeydown={(e) => e.key === 'Enter' && onchange(activateTab(layout, tab.id))} > - - {label(tab)} + {#if chrome && (chrome.icon || chrome.color)} + + {:else} + + {/if} + {label(tab)} + + + diff --git a/src/lib/workbench/TabGlyph.svelte b/src/lib/workbench/TabGlyph.svelte new file mode 100644 index 0000000..975057a --- /dev/null +++ b/src/lib/workbench/TabGlyph.svelte @@ -0,0 +1,97 @@ + + + + {#if Lucide} + + {:else if icon?.kind === 'slug' && isEmojiSlug(icon.value)} + {icon.value.trim()} + {:else if badge} + {badge} + {:else if icon?.kind === 'svg'} + + {@html icon.markup} + {:else} + + {/if} + + + diff --git a/src/lib/workbench/WorkspaceTabs.svelte b/src/lib/workbench/WorkspaceTabs.svelte new file mode 100644 index 0000000..48ae3e3 --- /dev/null +++ b/src/lib/workbench/WorkspaceTabs.svelte @@ -0,0 +1,279 @@ + + +
+
+ {#each workspaces as w (w.id)} + + {/each} + +
+
+
+ +{#if menuFor && menuWs} + onRename(menuWs.id, n)} + onColor={(c) => onChrome(menuWs.id, { color: c })} + onIcon={(i) => onChrome(menuWs.id, { icon: i })} + onDelete={!menuWs.isDefault && workspaces.length > 1 + ? () => { + const id = menuWs.id; + menuFor = null; + onDelete(id); + } + : undefined} + deleteLabel={t('shell.workspace.delete')} + onClose={() => (menuFor = null)} + /> +{/if} + + diff --git a/src/lib/workbench/tabChrome.test.ts b/src/lib/workbench/tabChrome.test.ts new file mode 100644 index 0000000..6433d53 --- /dev/null +++ b/src/lib/workbench/tabChrome.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { BUILTIN_ICONS, isEmojiSlug, normalizeColor, parseTabIcon, sanitizeSvg } from './tabChrome'; + +const PATH_SVG = ''; + +describe('sanitizeSvg', () => { + it('accepts a simple path svg', () => { + expect(sanitizeSvg(PATH_SVG)).toBe(PATH_SVG); + expect(sanitizeSvg(` ${PATH_SVG} `)).toBe(PATH_SVG); + }); + + it('accepts shapes, groups and a title', () => { + const svg = + 'ok'; + expect(sanitizeSvg(svg)).toBe(svg); + }); + + it('rejects script and other executable elements', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + + it('rejects event handlers and dangerous attribute values', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + + it('rejects non-svg roots, comments and oversized markup', () => { + expect(sanitizeSvg('
hi
')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg(``)).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); +}); + +describe('parseTabIcon', () => { + it('accepts a known builtin id and rejects unknown ones', () => { + expect(parseTabIcon({ kind: 'builtin', id: 'rocket' })).toEqual({ kind: 'builtin', id: 'rocket' }); + expect(parseTabIcon({ kind: 'builtin', id: 'not-an-icon' })).toBeUndefined(); + expect(BUILTIN_ICONS).toContain('rocket'); + }); + + it('trims slugs and caps their length', () => { + expect(parseTabIcon({ kind: 'slug', value: ' 🚀 ' })).toEqual({ kind: 'slug', value: '🚀' }); + expect(parseTabIcon({ kind: 'slug', value: ' ' })).toBeUndefined(); + expect(parseTabIcon({ kind: 'slug', value: 'x'.repeat(33) })).toBeUndefined(); + expect(parseTabIcon({ kind: 'slug', value: 'x'.repeat(32) })).toEqual({ kind: 'slug', value: 'x'.repeat(32) }); + }); + + it('sanitizes svg icons and rejects dirty markup', () => { + expect(parseTabIcon({ kind: 'svg', markup: PATH_SVG })).toEqual({ kind: 'svg', markup: PATH_SVG }); + expect(parseTabIcon({ kind: 'svg', markup: '' })).toBeUndefined(); + }); + + it('rejects garbage shapes', () => { + expect(parseTabIcon(null)).toBeUndefined(); + expect(parseTabIcon('rocket')).toBeUndefined(); + expect(parseTabIcon({ kind: 'nope' })).toBeUndefined(); + }); +}); + +describe('normalizeColor', () => { + it('accepts #rgb and #rrggbb, lowercased', () => { + expect(normalizeColor('#ABC')).toBe('#abc'); + expect(normalizeColor(' #2563eb ')).toBe('#2563eb'); + }); + it('rejects everything else', () => { + expect(normalizeColor('red')).toBeUndefined(); + expect(normalizeColor('#12345')).toBeUndefined(); + expect(normalizeColor('rgb(1,2,3)')).toBeUndefined(); + expect(normalizeColor(42)).toBeUndefined(); + }); +}); + +describe('isEmojiSlug', () => { + it('treats short non-ascii slugs as text', () => { + expect(isEmojiSlug('🚀')).toBe(true); + expect(isEmojiSlug('👩‍💻')).toBe(true); + expect(isEmojiSlug('火')).toBe(true); + }); + it('ascii names and long strings are not emoji', () => { + expect(isEmojiSlug('rocket')).toBe(false); + expect(isEmojiSlug('')).toBe(false); + expect(isEmojiSlug('这是一个很长的中文说明文字啊')).toBe(false); + }); +}); diff --git a/src/lib/workbench/tabChrome.ts b/src/lib/workbench/tabChrome.ts new file mode 100644 index 0000000..410ba60 --- /dev/null +++ b/src/lib/workbench/tabChrome.ts @@ -0,0 +1,104 @@ +// Tab chrome shared by workspaces and sessions: an optional tag color plus an +// optional icon (builtin lucide id, free-form slug/emoji, or pasted SVG). +// Pure data + node-safe validation — no DOM, so the SVG sanitizer is a strict +// string allowlist that rejects (returns null) instead of stripping. + +export type TabIcon = + | { kind: 'builtin'; id: string } + | { kind: 'slug'; value: string } + | { kind: 'svg'; markup: string }; + +export const BUILTIN_ICONS = [ + 'layers', 'folder', 'code', 'bug', 'rocket', 'terminal', 'globe', + 'star', 'home', 'file', 'git-branch', 'bot', 'sparkles', 'zap', + 'heart', 'bookmark', 'box', 'cpu', 'database', 'message-square', + 'search', 'shield', 'target', 'wrench' +] as const; + +export const TAB_COLORS = [ + '#6d3bd7', '#2563eb', '#0891b2', '#059669', '#ca8a04', + '#ea580c', '#dc2626', '#db2777', '#9333ea', '#64748b' +]; + +const MAX_SLUG = 32; +const MAX_SVG = 8192; + +/** Elements a stored icon SVG may contain (shapes + grouping only). */ +const SVG_TAGS = new Set([ + 'svg', 'g', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'title', 'defs' +]); +/** Presentation attributes kept on those elements. Anything else is dirty. */ +const SVG_ATTRS = new Set([ + 'xmlns', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', + 'stroke-linejoin', 'stroke-dasharray', 'fill-rule', 'clip-rule', 'opacity', + 'fill-opacity', 'stroke-opacity', 'd', 'width', 'height', 'cx', 'cy', 'r', + 'rx', 'ry', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'points', 'transform' +]); + +/** + * Validate user-pasted SVG markup with a string allowlist (no DOM, so it runs + * under vitest's node environment). Returns the trimmed markup when every tag + * and attribute is on the allowlist, null when anything looks dirty — the + * result is stored and later rendered via {@html}, so reject, never repair. + */ +export function sanitizeSvg(markup: string): string | null { + const s = markup.trim(); + if (!s || s.length > MAX_SVG) return null; + if (!/^]/i.test(s) || !/<\/svg>$/i.test(s)) return null; + // Comments, CDATA and processing instructions can smuggle markup past a + // tag-level scan — reject them outright. + if (/ +
-
-
- {chat?.title ?? 'JuCode'} - {#if chat}{project}{/if} -
-
-
+ workspaces.rename(id, name)} + onChrome={(id, chrome) => workspaces.setChrome(id, chrome)} + onDelete={deleteWorkspace} + />
{#if store.loaded && projects.length === 0} @@ -822,6 +873,9 @@ emptyText={t('dock.dock.empty')} focused={focusedLeaf} onFocus={onLeafFocus} + decorate={tileChrome} + onTabContext={openTileChrome} + onTabRename={openTileChrome} > {#snippet panel(tab)} {@const sid = chatSessionOf(tab.panel)} @@ -898,6 +952,20 @@ (taskDialogFor = null)} onCreated={openTaskProject} /> {/if} + {#if sessionChromeFor && chromeSession} + store.renameSession(chromeSession.id, n)} + onColor={(c) => store.setSessionChrome(chromeSession.id, { color: c })} + onIcon={(i) => store.setSessionChrome(chromeSession.id, { icon: i })} + onClose={() => (sessionChromeFor = null)} + /> + {/if} + {#if showPalette}
{/each} -
diff --git a/src/lib/workbench/canvas.test.ts b/src/lib/workbench/canvas.test.ts index 98fe48d..84436a9 100644 --- a/src/lib/workbench/canvas.test.ts +++ b/src/lib/workbench/canvas.test.ts @@ -83,6 +83,16 @@ describe('reconcileLayout', () => { expect(chatSessionsIn(next)).toEqual(['live']); }); + it('keeps a persisted 2-chat split intact when both session ids are live', () => { + // Restore with persisted tab ids re-spawns sessions under the same ids, + // so a workspace switch (or restart) must not collapse the split. + const base = singleLeafLayout([chatTab('live-a')]); + const split = splitLeaf(base, leavesOf(base.root)[0].id, 'right', chatTab('live-b')).layout; + const next = reconcileLayout(serializeLayout(split), ['live-a', 'live-b'], 'live-a'); + expect(next).toEqual(split); + expect(chatSessionsIn(next)).toEqual(['live-a', 'live-b']); + }); + it('re-seeds one chat leaf when every persisted chat session is dead', () => { const layout = openChatTab(dockOnlyLayout(), null, 'old-run'); const next = reconcileLayout(serializeLayout(layout), ['fresh'], 'fresh'); diff --git a/src/lib/workbench/canvas.ts b/src/lib/workbench/canvas.ts index f497802..b540ba1 100644 --- a/src/lib/workbench/canvas.ts +++ b/src/lib/workbench/canvas.ts @@ -57,8 +57,10 @@ export function openChatTab( /** * Build the canvas from a persisted layout blob when a workspace loads: - * - chat tiles whose session no longer exists this run are dropped (session - * ids are minted per run, so most restarts land here); + * - chat tiles whose session is not live are dropped — desktop session ids + * are stable across restore when the saved tabs carry `id` (see + * SavedProject.tabs), so only truly missing sessions (and legacy files + * saved without ids) lose their tile; * - a layout left without any chat tile gets one seeded for `seedSessionId` — * an old dock-only layout keeps its panel arrangement and gains a chat leaf * on the left (the pre-canvas shape, chat | panels); diff --git a/src/lib/workbench/tabChrome.test.ts b/src/lib/workbench/tabChrome.test.ts index 6433d53..9000297 100644 --- a/src/lib/workbench/tabChrome.test.ts +++ b/src/lib/workbench/tabChrome.test.ts @@ -33,6 +33,11 @@ describe('sanitizeSvg', () => { expect(sanitizeSvg('')).toBeNull(); }); + it('rejects url(...) paint servers (external resource references)', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + it('rejects non-svg roots, comments and oversized markup', () => { expect(sanitizeSvg('
hi
')).toBeNull(); expect(sanitizeSvg('')).toBeNull(); diff --git a/src/lib/workbench/tabChrome.ts b/src/lib/workbench/tabChrome.ts index 410ba60..913be2c 100644 --- a/src/lib/workbench/tabChrome.ts +++ b/src/lib/workbench/tabChrome.ts @@ -63,7 +63,8 @@ export function sanitizeSvg(markup: string): string | null { const name = a[1].toLowerCase(); if (name.startsWith('on') || !SVG_ATTRS.has(name)) return null; const value = (a[2] ?? a[3] ?? a[4] ?? '').toLowerCase(); - if (value.includes('javascript:') || value.includes('data:')) return null; + // url(...) paint servers can reference external resources. + if (value.includes('javascript:') || value.includes('data:') || value.includes('url(')) return null; } } if (/[<>]/.test(s.slice(last))) return null; diff --git a/src/lib/workbench/workspaces.test.ts b/src/lib/workbench/workspaces.test.ts index 539f69d..2b7f86e 100644 --- a/src/lib/workbench/workspaces.test.ts +++ b/src/lib/workbench/workspaces.test.ts @@ -48,6 +48,39 @@ describe('workspaces file', () => { expect(projects).toEqual([good]); }); + it('drops dirty chrome on nested session tabs (an inactive workspace must not carry it back)', () => { + const dirty = JSON.stringify({ + version: 1, + active: 'a', + workspaces: [ + { + id: 'a', + name: 'A', + layout: null, + projects: [ + { + ...proj('p1'), + tabs: [ + { + id: 't1', + sid: 's', + title: 'T', + color: 'red', + icon: { kind: 'svg', markup: '' }, + extra: 'kept' + } + ] + } + ] + } + ] + }); + const tab = parseWorkspacesFile(dirty)?.workspaces[0].projects[0].tabs?.[0] as unknown as Record; + expect(tab.icon).toBeUndefined(); + expect(tab.color).toBeUndefined(); + expect(tab).toMatchObject({ id: 't1', sid: 's', title: 'T', extra: 'kept' }); + }); + it('keeps version 1 and parses an old file without chrome, promoting the first workspace to default', () => { expect(WORKSPACES_VERSION).toBe(1); const old = JSON.stringify({ diff --git a/src/lib/workbench/workspaces.ts b/src/lib/workbench/workspaces.ts index 99381c2..d28e721 100644 --- a/src/lib/workbench/workspaces.ts +++ b/src/lib/workbench/workspaces.ts @@ -72,15 +72,34 @@ export function serializeWorkspaces(file: WorkspacesFile): string { const isStr = (v: unknown): v is string => typeof v === 'string' && v.length > 0; +type SavedTab = NonNullable[number]; + +/** Re-validate a saved tab's chrome (the file is user-editable; an inactive + * workspace must not carry a dirty SVG back into it). Invalid color/icon are + * dropped; every other field rides along untouched. */ +function sanitizeTab(t: SavedTab): SavedTab { + const { color, icon, ...rest } = t; + const c = normalizeColor(color); + const i = parseTabIcon(icon); + return { ...rest, ...(c ? { color: c } : {}), ...(i ? { icon: i } : {}) }; +} + /** Keep only structurally valid saved projects (id/name/path present); the * optional fields (tabs / worktree / lastBackend) ride along untouched — - * SessionStore.restore() already tolerates their absence. */ + * SessionStore.restore() already tolerates their absence — except tab + * chrome, which is re-validated. */ export function sanitizeProjects(raw: unknown): SavedProject[] { if (!Array.isArray(raw)) return []; - return raw.filter((p): p is SavedProject => { - const o = p as Record; - return !!o && isStr(o.id) && isStr(o.name) && isStr(o.path); - }); + return raw + .filter((p): p is SavedProject => { + const o = p as Record; + return !!o && isStr(o.id) && isStr(o.name) && isStr(o.path); + }) + .map((p) => + Array.isArray(p.tabs) + ? { ...p, tabs: p.tabs.filter((t) => !!t && typeof t === 'object').map(sanitizeTab) } + : p + ); } /** diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2b20e68..f34827a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -48,6 +48,7 @@ reconcileLayout } from '$lib/workbench/canvas'; import { tuiBackendOf, tuiPanelKind, tuiTabTitle } from '$lib/workbench/tuiTab'; + import type { WorkspaceEntry } from '$lib/workbench/workspaces'; import type { TabIcon } from '$lib/workbench/tabChrome'; import Mosaic from '$lib/workbench/Mosaic.svelte'; import WorkspaceTabs from '$lib/workbench/WorkspaceTabs.svelte'; @@ -460,25 +461,43 @@ // A workspace is a saved set of projects + its canvas layout. Switching // swaps the whole session tree: snapshot the current one into its // workspace, close all live engine sessions, then restore the target's - // projects (resume by id) and rebuild the canvas. + // projects (resume by id) and rebuild the canvas. Swaps must never + // overlap: `wsBusy` rejects (and disables in the tab bar) new transitions + // while one is in flight; the generation token is belt-and-braces so a + // stale restore can never persist its tree over a newer one. + let wsGen = 0; + let wsBusy = $state(false); + /** Swap the live session tree to `entry`'s projects and rebuild the canvas. */ + async function swapToWorkspace(entry: WorkspaceEntry) { + const gen = ++wsGen; + wsBusy = true; + try { + tilesReady = false; // gate the tile effects during the swap + for (const p of [...store.projects]) store.removeProject(p); + store.loaded = false; // re-gate the persist effect during the swap + await store.restore(entry.projects); + if (gen !== wsGen) return; + initTiles(); + } finally { + if (gen === wsGen) wsBusy = false; + } + } async function switchWorkspace(id: string) { - if (id === workspaces.activeId) return; + if (id === workspaces.activeId || wsBusy) return; workspaces.updateProjects(store.serialize()); const entry = workspaces.setActive(id); if (!entry) return; - tilesReady = false; // gate the tile effects during the swap - for (const p of [...store.projects]) store.removeProject(p); - store.loaded = false; // re-gate the persist effect during the swap - await store.restore(entry.projects); - initTiles(); + await swapToWorkspace(entry); } async function newWorkspace() { + if (wsBusy) return; const entry = workspaces.create(t('shell.workspace.nth', { n: workspaces.workspaces.length + 1 })); if (entry) await switchWorkspace(entry.id); } /** Delete a workspace (confirmed). Deleting the active one swaps the live * session tree to the default workspace returned by the store. */ async function deleteWorkspace(id: string) { + if (wsBusy) return; const ws = workspaces.workspaces.find((w) => w.id === id); if (!ws) return; if (ws.isDefault) { @@ -489,14 +508,10 @@ title: t('shell.workspace.delete'), kind: 'warning' }); - if (!ok) return; + if (!ok || wsBusy) return; // a swap may have started under the dialog const next = workspaces.remove(id); if (!next) return; // removed an inactive workspace — nothing to swap - tilesReady = false; - for (const p of [...store.projects]) store.removeProject(p); - store.loaded = false; - await store.restore(next.projects); - initTiles(); + await swapToWorkspace(next); } // ---------- session tab chrome (color / icon / rename) ---------- @@ -849,6 +864,7 @@ workspaces={workspaces.workspaces} activeId={workspaces.activeId} shifted={!showSidebar} + busy={wsBusy} onSwitch={switchWorkspace} onNew={newWorkspace} onRename={(id, name) => workspaces.rename(id, name)} From 897b0db6ebd255ebdd8c9d3d77e1f214cb980694 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 10:30:47 +0000 Subject: [PATCH 4/4] Keep restored session ids and ACP agents Co-authored-by: Gao Yu --- src/lib/session.svelte.ts | 48 ++++++++++++++++++------ src/lib/session.test.ts | 58 ++++++++++++++++++++++++++++- src/lib/workbench/tabChrome.test.ts | 7 ++++ src/lib/workbench/tabChrome.ts | 4 ++ 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index bce0efc..333007a 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -25,7 +25,15 @@ export interface SavedProject { id: string; name: string; path: string; - tabs?: ({ id?: string; sid?: string; title: string; backend?: string; archived?: boolean } & SavedTabChrome)[]; + tabs?: ({ + id?: string; + sid?: string; + title: string; + backend?: string; + /** backend 为 'acp' 时:驱动该会话的 registry agent(重启动/恢复时必需)。 */ + acpAgent?: { id: string; name: string }; + archived?: boolean; + } & SavedTabChrome)[]; /** 并行任务 worktree 项目的元数据(isWorktree/mainRepoPath/branch/baseBranch/slug)。 */ worktree?: WorktreeMeta; /** 本项目最近一次新建会话所用的引擎后端(缺省 = jucode)。 */ @@ -287,9 +295,10 @@ export class SessionStore { backend: BackendId = 'jucode', archived = false, chrome?: SavedTabChrome, - reuseId?: string + reuseId?: string, + acpAgent?: { id: string; name: string } ) { - const s = this.#newSession(backend, undefined, reuseId); + const s = this.#newSession(backend, backend === 'acp' ? acpAgent : undefined, reuseId); if (title) s.chat.title = title; s.archived = archived; if (chrome?.color) s.color = chrome.color; @@ -321,9 +330,10 @@ export class SessionStore { title: string, backend: BackendId = 'jucode', archived = false, - chrome?: SavedTabChrome + chrome?: SavedTabChrome, + acpAgent?: { id: string; name: string } ) { - const s = this.#newSession(backend, undefined, reuseId); + const s = this.#newSession(backend, backend === 'acp' ? acpAgent : undefined, reuseId); if (title) s.chat.title = title; s.archived = archived; if (chrome?.color) s.color = chrome.color; @@ -561,8 +571,11 @@ export class SessionStore { /** Snapshot of the layout + open tabs for persistence. Every session is * written (empty windows survive a workspace switch under their desktop * id); `sid` only when the engine actually persisted the conversation — - * never `/resume` one it didn't. The backend id is only written when it - * isn't the default, so pre-existing layouts stay byte-identical. */ + * never `/resume` one it didn't. A restored session keeps its `sid` even + * while its replayed transcript is still empty (replay is async and may + * fail; the engine-side conversation exists regardless). The backend id + * is only written when it isn't the default, so pre-existing layouts stay + * byte-identical; 'acp' tabs also carry their agent so restore can respawn. */ serialize(): SavedProject[] { return this.projects.map((p) => ({ id: p.id, @@ -574,9 +587,10 @@ export class SessionStore { tabs: p.sessions .map((s) => ({ id: s.id, - ...(s.chat.resumable ? { sid: s.chat.sessionId } : {}), + ...(s.chat.sessionId && (s.chat.resumable || s.restored) ? { sid: s.chat.sessionId } : {}), title: s.chat.title, ...(s.backendId !== 'jucode' ? { backend: s.backendId } : {}), + ...(s.backendId === 'acp' && s.acpAgent ? { acpAgent: s.acpAgent } : {}), ...(s.archived ? { archived: true } : {}), ...(s.color ? { color: s.color } : {}), ...(s.icon ? { icon: s.icon } : {}), @@ -619,7 +633,19 @@ export class SessionStore { // Tabs saved before multi-backend support carry no backend field → // jucode (normalizeBackendId maps unknown/missing to the default). // Chrome fields are re-validated here (the file is user-editable). - const backend = normalizeBackendId(t.backend); + let backend = normalizeBackendId(t.backend); + // An 'acp' tab needs its agent back to respawn; older files carry + // none on the tab → fall back to the project's last agent. Without + // any, never spawn a bare 'acp' (create_session rejects it). + const savedAgent = + t.acpAgent && typeof t.acpAgent.id === 'string' && typeof t.acpAgent.name === 'string' + ? { id: t.acpAgent.id, name: t.acpAgent.name } + : undefined; + let acpAgent = backend === 'acp' ? (savedAgent ?? proj.lastAcpAgent) : undefined; + if (backend === 'acp' && !acpAgent) { + backend = 'jucode'; + acpAgent = undefined; + } const chrome = { color: normalizeColor(t.color), icon: parseTabIcon(t.icon), @@ -628,8 +654,8 @@ export class SessionStore { // With a conversation to resume, resume it; an empty window spawns // fresh. Both keep the saved desktop id (pre-id files mint anew). const id = t.sid - ? this.restoreSession(proj, t.sid, t.title, backend, !!t.archived, chrome, t.id) - : this.#spawnSaved(proj, t.id!, t.title, backend, !!t.archived, chrome); + ? this.restoreSession(proj, t.sid, t.title, backend, !!t.archived, chrome, t.id, acpAgent) + : this.#spawnSaved(proj, t.id!, t.title, backend, !!t.archived, chrome, acpAgent); if (!first && !t.archived) first = id; } } diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index 32da056..1ea02ea 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -7,7 +7,8 @@ vi.mock('./protocol', () => ({ sendOp: vi.fn(() => Promise.resolve()), projectRoot: vi.fn(() => Promise.resolve('/tmp/demo')), writeConfig: vi.fn(() => Promise.resolve()), - git: vi.fn(() => Promise.resolve('')) + git: vi.fn(() => Promise.resolve('')), + claudeSessionTranscript: vi.fn(() => Promise.resolve([])) })); import { SessionStore } from './session.svelte'; @@ -177,6 +178,61 @@ describe('SessionStore lifecycle', () => { ]); }); + it('a restored session serializes its sid even before the replay lands', () => { + const store = new SessionStore(); + const p = proj(); + store.projects.push(p); + // claude restore pins chat.sessionId immediately; the transcript replay is + // async (and may fail), so messages are still empty here. + const id = store.restoreSession(p, 'sid-r', 'old', 'claude'); + const s = p.sessions[0]; + expect(s.restored).toBe(true); + expect(s.chat.sessionId).toBe('sid-r'); + expect(s.chat.messages.some((m) => m.kind === 'user')).toBe(false); + expect(s.chat.resumable).toBe(false); + const tab = store.serialize()[0].tabs![0]; + expect(tab).toEqual({ id, sid: 'sid-r', title: 'old', backend: 'claude' }); + }); + + it('serialize includes the acp agent and restore reapplies it on the session', async () => { + const store = new SessionStore(); + const p = proj(); + store.projects.push(p); + const agent = { id: 'gemini', name: 'Gemini CLI' }; + const id = store.addSession(p, undefined, 'acp', agent); + const snap = store.serialize(); + expect(snap[0].tabs).toEqual([{ id, title: 'New session', backend: 'acp', acpAgent: agent }]); + + const store2 = new SessionStore(); + await store2.restore(snap); + const s = store2.projects[0].sessions[0]; + expect(s.backendId).toBe('acp'); + expect(s.acpAgent).toEqual(agent); + expect(s.chat.acpAgentId).toBe('gemini'); + expect(s.chat.acpAgentName).toBe('Gemini CLI'); + // The spawn carried the agent option so create_session can look it up. + const call = (createSession as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(-1)!; + expect(call[2]).toBe('acp'); + expect((call[3] as { agent?: string }).agent).toBe('gemini'); + }); + + it('acp tabs without a saved agent fall back to the project lastAcpAgent', async () => { + const agent = { id: 'g', name: 'G' }; + const store = new SessionStore(); + await store.restore([ + { + id: 'p1', + name: 'p1', + path: '/tmp/p1', + lastBackend: 'acp', + lastAcpAgent: agent, + tabs: [{ id: 't1', title: 'A', backend: 'acp' }] + } + ]); + expect(store.projects[0].sessions[0].acpAgent).toEqual(agent); + expect(store.projects[0].sessions[0].backendId).toBe('acp'); + }); + it('restore seeds a default project when nothing is saved', async () => { const store = new SessionStore(); await store.restore([]); diff --git a/src/lib/workbench/tabChrome.test.ts b/src/lib/workbench/tabChrome.test.ts index 9000297..0a28252 100644 --- a/src/lib/workbench/tabChrome.test.ts +++ b/src/lib/workbench/tabChrome.test.ts @@ -38,6 +38,13 @@ describe('sanitizeSvg', () => { expect(sanitizeSvg('')).toBeNull(); }); + it('rejects entity-encoded attribute values (url( bypass)', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + it('rejects non-svg roots, comments and oversized markup', () => { expect(sanitizeSvg('
hi
')).toBeNull(); expect(sanitizeSvg('')).toBeNull(); diff --git a/src/lib/workbench/tabChrome.ts b/src/lib/workbench/tabChrome.ts index 913be2c..c3d4963 100644 --- a/src/lib/workbench/tabChrome.ts +++ b/src/lib/workbench/tabChrome.ts @@ -63,6 +63,10 @@ export function sanitizeSvg(markup: string): string | null { const name = a[1].toLowerCase(); if (name.startsWith('on') || !SVG_ATTRS.has(name)) return null; const value = (a[2] ?? a[3] ?? a[4] ?? '').toLowerCase(); + // Entity sequences (( → '(', ( …) decode in the browser and + // would smuggle url(...) past the raw-string checks below. No allowed + // attribute legitimately needs '&', so reject it outright — never repair. + if (value.includes('&')) return null; // url(...) paint servers can reference external resources. if (value.includes('javascript:') || value.includes('data:') || value.includes('url(')) return null; }