Skip to content

Commit 4bac127

Browse files
cursoragentgaoyu06
andcommitted
Keep mosaic session ids across workspace restore
- Persist the desktop session id on every saved tab (sid only when the engine persisted the conversation); restore reuses the id, spawning empty windows fresh, so a saved chat split survives switches/restarts - Serialize workspace swaps with a busy flag + generation token; disable the workspace tab bar while one is in flight - Re-validate nested tab chrome in sanitizeProjects; reject url(...) attribute values in sanitizeSvg - Use i18n strings for workspace/sidebar aria-labels Co-authored-by: Gao Yu <gaoyu06@users.noreply.github.com>
1 parent 6734300 commit 4bac127

12 files changed

Lines changed: 235 additions & 55 deletions

src/lib/Sidebar.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
<div class="sess-head">
121121
<span>{t('shell.sessionsByProject')}</span>
122122
<div class="sess-actions">
123-
<button class:on={searchOpen} onclick={toggleSearch} aria-label="search sessions" title={t('shell.searchSessions')}><Search size={14} /></button>
123+
<button class:on={searchOpen} onclick={toggleSearch} aria-label={t('shell.searchSessions')} title={t('shell.searchSessions')}><Search size={14} /></button>
124124
<button onclick={onNewProject} aria-label="new project" title={t('shell.newProjectTitle')}><Plus size={15} /></button>
125125
</div>
126126
</div>

src/lib/backends/session-backend.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ describe('SessionStore × backends', () => {
8989
const snap = store.serialize();
9090
expect(snap[0].lastBackend).toBe('codex');
9191
expect(snap[0].tabs).toEqual([
92-
{ sid: 'sid-0', title: 't0' },
93-
{ sid: 'sid-1', title: 't1', backend: 'codex' }
92+
{ id: p.sessions[0].id, sid: 'sid-0', title: 't0' },
93+
{ id: p.sessions[1].id, sid: 'sid-1', title: 't1', backend: 'codex' }
9494
]);
9595
});
9696

src/lib/session.svelte.ts

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ export interface SavedTabChrome {
1717
titleLocked?: boolean;
1818
}
1919

20-
// The persisted shape of a project + its open tabs (engine session id + title).
20+
// The persisted shape of a project + its open tabs. `id` is the desktop
21+
// session id (stable across restore so layout chat tiles keep matching);
22+
// `sid` is the engine conversation to resume, present only when the engine
23+
// actually persisted one.
2124
export interface SavedProject {
2225
id: string;
2326
name: string;
2427
path: string;
25-
tabs?: ({ sid: string; title: string; backend?: string; archived?: boolean } & SavedTabChrome)[];
28+
tabs?: ({ id?: string; sid?: string; title: string; backend?: string; archived?: boolean } & SavedTabChrome)[];
2629
/** 并行任务 worktree 项目的元数据(isWorktree/mainRepoPath/branch/baseBranch/slug)。 */
2730
worktree?: WorktreeMeta;
2831
/** 本项目最近一次新建会话所用的引擎后端(缺省 = jucode)。 */
@@ -84,9 +87,11 @@ export class SessionStore {
8487

8588
/** Builds a session record (chat + per-session adapter) and registers the
8689
* adapter with the op router. `acpAgent` (acp backend only) records which
87-
* registry agent backs the session, for spawn opts and display. */
88-
#newSession(backendId: BackendId, acpAgent?: { id: string; name: string }): Session {
89-
const id = this.uid();
90+
* registry agent backs the session, for spawn opts and display. `reuseId`
91+
* re-applies a persisted desktop id (layout chat tiles key on it) unless
92+
* this run already spawned it. */
93+
#newSession(backendId: BackendId, acpAgent?: { id: string; name: string }, reuseId?: string): Session {
94+
const id = reuseId && !this.allSessions.some((s) => s.id === reuseId) ? reuseId : this.uid();
9095
const chat = new ChatState();
9196
chat.backendId = backendId;
9297
if (acpAgent) {
@@ -281,9 +286,10 @@ export class SessionStore {
281286
title: string,
282287
backend: BackendId = 'jucode',
283288
archived = false,
284-
chrome?: SavedTabChrome
289+
chrome?: SavedTabChrome,
290+
reuseId?: string
285291
) {
286-
const s = this.#newSession(backend);
292+
const s = this.#newSession(backend, undefined, reuseId);
287293
if (title) s.chat.title = title;
288294
s.archived = archived;
289295
if (chrome?.color) s.color = chrome.color;
@@ -306,6 +312,31 @@ export class SessionStore {
306312
return s.id;
307313
}
308314

315+
/** Spawn a fresh session for a persisted tab that has no engine
316+
* conversation to resume (never sent a turn), reusing the saved desktop
317+
* id so layout chat tiles keep matching across workspace switches. */
318+
#spawnSaved(
319+
project: Project,
320+
reuseId: string,
321+
title: string,
322+
backend: BackendId = 'jucode',
323+
archived = false,
324+
chrome?: SavedTabChrome
325+
) {
326+
const s = this.#newSession(backend, undefined, reuseId);
327+
if (title) s.chat.title = title;
328+
s.archived = archived;
329+
if (chrome?.color) s.color = chrome.color;
330+
if (chrome?.icon) s.icon = chrome.icon;
331+
if (chrome?.titleLocked) s.chat.titleLocked = true;
332+
project.sessions.push(s);
333+
// Same rationale as addSession: pin a resumable uuid for claude.
334+
const extra = backend === 'claude' ? { session_id: newUuid() } : undefined;
335+
if (extra) s.chat.sessionId = extra.session_id;
336+
this.#spawn(s, project.path, undefined, extra).catch((e) => this.#engineFailed(s.chat, e));
337+
return s.id;
338+
}
339+
309340
/** Best-effort transcript replay for a resumed claude session: the session
310341
* file's user/assistant text becomes the message list (caps.transcriptReplay).
311342
* Failures are silent — `--resume` already restored the engine-side context,
@@ -527,9 +558,11 @@ export class SessionStore {
527558
dispatch(id, { op: 'command', input: '/resume' });
528559
}
529560

530-
/** Snapshot of the layout + open tabs for persistence. The backend id is
531-
* only written when it isn't the default, so pre-existing layouts stay
532-
* byte-identical. */
561+
/** Snapshot of the layout + open tabs for persistence. Every session is
562+
* written (empty windows survive a workspace switch under their desktop
563+
* id); `sid` only when the engine actually persisted the conversation —
564+
* never `/resume` one it didn't. The backend id is only written when it
565+
* isn't the default, so pre-existing layouts stay byte-identical. */
533566
serialize(): SavedProject[] {
534567
return this.projects.map((p) => ({
535568
id: p.id,
@@ -539,9 +572,9 @@ export class SessionStore {
539572
...(p.lastBackend && p.lastBackend !== 'jucode' ? { lastBackend: p.lastBackend } : {}),
540573
...(p.lastBackend === 'acp' && p.lastAcpAgent ? { lastAcpAgent: p.lastAcpAgent } : {}),
541574
tabs: p.sessions
542-
.filter((s) => s.chat.resumable)
543575
.map((s) => ({
544-
sid: s.chat.sessionId,
576+
id: s.id,
577+
...(s.chat.resumable ? { sid: s.chat.sessionId } : {}),
545578
title: s.chat.title,
546579
...(s.backendId !== 'jucode' ? { backend: s.backendId } : {}),
547580
...(s.archived ? { archived: true } : {}),
@@ -582,15 +615,21 @@ export class SessionStore {
582615
this.projects.push(proj);
583616
if (proj.stale) continue;
584617
for (const t of p.tabs ?? []) {
585-
if (!t.sid) continue;
618+
if (!t.sid && !t.id) continue;
586619
// Tabs saved before multi-backend support carry no backend field →
587620
// jucode (normalizeBackendId maps unknown/missing to the default).
588621
// Chrome fields are re-validated here (the file is user-editable).
589-
const id = this.restoreSession(proj, t.sid, t.title, normalizeBackendId(t.backend), !!t.archived, {
622+
const backend = normalizeBackendId(t.backend);
623+
const chrome = {
590624
color: normalizeColor(t.color),
591625
icon: parseTabIcon(t.icon),
592626
titleLocked: !!t.titleLocked
593-
});
627+
};
628+
// With a conversation to resume, resume it; an empty window spawns
629+
// fresh. Both keep the saved desktop id (pre-id files mint anew).
630+
const id = t.sid
631+
? this.restoreSession(proj, t.sid, t.title, backend, !!t.archived, chrome, t.id)
632+
: this.#spawnSaved(proj, t.id!, t.title, backend, !!t.archived, chrome);
594633
if (!first && !t.archived) first = id;
595634
}
596635
}

src/lib/session.test.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,13 @@ describe('SessionStore lifecycle', () => {
6767
const store = new SessionStore();
6868
const p = proj();
6969
store.projects.push(p);
70-
store.addSession(p);
70+
const id = store.addSession(p);
7171
p.sessions[0].chat.sessionId = 'sid-0';
7272
p.sessions[0].chat.title = 'kept';
7373
p.sessions[0].chat.messages.push({ kind: 'user', text: 'hi' });
74-
store.archiveSession(p.sessions[0].id);
74+
store.archiveSession(id);
7575
const snap = store.serialize();
76-
expect(snap[0].tabs).toEqual([{ sid: 'sid-0', title: 'kept', archived: true }]);
76+
expect(snap[0].tabs).toEqual([{ id, sid: 'sid-0', title: 'kept', archived: true }]);
7777
});
7878

7979
it('a flagged resume failure makes the next claude restart come up fresh', () => {
@@ -149,21 +149,31 @@ describe('SessionStore lifecycle', () => {
149149
expect(msgs[msgs.length - 1]).toMatchObject({ kind: 'error', text: expect.stringContaining('已暂停自动重启') });
150150
});
151151

152-
it('serialize keeps only resumable tabs (session id + a real turn)', () => {
152+
it('serialize writes every tab with its desktop id; sid only when resumable', () => {
153153
const store = new SessionStore();
154154
const p = proj();
155155
store.projects.push(p);
156156
store.addSession(p);
157157
store.addSession(p);
158-
p.sessions[0].chat.sessionId = 'sid-0';
159-
p.sessions[0].chat.title = 'first';
160-
p.sessions[0].chat.messages.push({ kind: 'user', text: 'hi' });
161-
// second session has an id but no user turn → never persisted by the engine,
162-
// so it's dropped (resuming it would fail with "No such file").
163-
p.sessions[1].chat.sessionId = 'sid-1';
158+
const [a, b] = p.sessions;
159+
a.chat.sessionId = 'sid-0';
160+
a.chat.title = 'first';
161+
a.chat.messages.push({ kind: 'user', text: 'hi' });
162+
// second session has an engine id but no user turn → never persisted by
163+
// the engine, so no sid is written (resuming it would fail); the tab
164+
// still survives as an empty window under its desktop id.
165+
b.chat.sessionId = 'sid-1';
164166
const snap = store.serialize();
165167
expect(snap).toEqual([
166-
{ id: 'p1', name: 'p1', path: '/tmp/p1', tabs: [{ sid: 'sid-0', title: 'first' }] }
168+
{
169+
id: 'p1',
170+
name: 'p1',
171+
path: '/tmp/p1',
172+
tabs: [
173+
{ id: a.id, sid: 'sid-0', title: 'first' },
174+
{ id: b.id, title: 'New session' }
175+
]
176+
}
167177
]);
168178
});
169179

@@ -186,6 +196,46 @@ describe('SessionStore lifecycle', () => {
186196
expect(store.loaded).toBe(true);
187197
});
188198

199+
it('restore reuses persisted desktop ids so a saved chat split still matches', async () => {
200+
const store = new SessionStore();
201+
await store.restore([
202+
{
203+
id: 'p1',
204+
name: 'p1',
205+
path: '/tmp/p1',
206+
tabs: [
207+
{ id: 'live-a', sid: 's-a', title: 'A' },
208+
{ id: 'live-b', title: 'B' } // empty window — nothing to resume
209+
]
210+
}
211+
]);
212+
expect(store.projects[0].sessions.map((s) => s.id)).toEqual(['live-a', 'live-b']);
213+
expect(store.activeId).toBe('live-a');
214+
await Promise.resolve(); // let the spawn continuations run
215+
// The resumable tab resumes; the empty one spawns fresh with no /resume.
216+
expect(sendOp).toHaveBeenCalledWith('live-a', { op: 'command', input: '/resume s-a' });
217+
expect(sendOp).not.toHaveBeenCalledWith('live-b', expect.anything());
218+
expect(store.projects[0].sessions[1].chat.title).toBe('B');
219+
});
220+
221+
it('restore mints a fresh id when the persisted one is already live', async () => {
222+
const store = new SessionStore();
223+
await store.restore([
224+
{
225+
id: 'p1',
226+
name: 'p1',
227+
path: '/tmp/p1',
228+
tabs: [
229+
{ id: 'dup', sid: 's-a', title: 'A' },
230+
{ id: 'dup', sid: 's-b', title: 'B' }
231+
]
232+
}
233+
]);
234+
const ids = store.projects[0].sessions.map((s) => s.id);
235+
expect(ids[0]).toBe('dup');
236+
expect(ids[1]).not.toBe('dup');
237+
});
238+
189239
it('serialize includes tab chrome only when set, and restore re-applies it', async () => {
190240
const store = new SessionStore();
191241
const p = proj();
@@ -198,6 +248,7 @@ describe('SessionStore lifecycle', () => {
198248
const snap = store.serialize();
199249
expect(snap[0].tabs).toEqual([
200250
{
251+
id,
201252
sid: 'sid-0',
202253
title: 'Release train',
203254
color: '#db2777',

src/lib/workbench/WorkspaceTabs.svelte

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
workspaces,
1515
activeId,
1616
shifted = false,
17+
busy = false,
1718
onSwitch,
1819
onNew,
1920
onRename,
@@ -24,6 +25,8 @@
2425
activeId: string;
2526
/** Sidebar hidden: pad past the traffic lights / toggle overlay. */
2627
shifted?: boolean;
28+
/** A workspace swap is in flight: ignore switch / new / delete clicks. */
29+
busy?: boolean;
2730
onSwitch: (id: string) => void;
2831
onNew: () => void;
2932
onRename: (id: string, name: string) => void;
@@ -77,8 +80,8 @@
7780
tabindex="0"
7881
aria-selected={w.id === activeId}
7982
title={w.isDefault ? t('shell.workspace.defaultBadge') : w.name}
80-
onclick={() => renaming !== w.id && onSwitch(w.id)}
81-
onkeydown={(e) => e.key === 'Enter' && onSwitch(w.id)}
83+
onclick={() => !busy && renaming !== w.id && onSwitch(w.id)}
84+
onkeydown={(e) => e.key === 'Enter' && !busy && onSwitch(w.id)}
8285
oncontextmenu={(e) => openMenu(w, e)}
8386
>
8487
<TabGlyph
@@ -103,7 +106,7 @@
103106
{/if}
104107
<button
105108
class="wsbtn chev"
106-
aria-label="workspace menu"
109+
aria-label={t('shell.workspace.menu')}
107110
title={t('shell.workspace.menu')}
108111
onclick={(e) => {
109112
e.stopPropagation();
@@ -116,8 +119,9 @@
116119
{#if !w.isDefault}
117120
<button
118121
class="wsbtn close"
119-
aria-label="delete workspace"
122+
aria-label={t('shell.workspace.delete')}
120123
title={t('shell.workspace.delete')}
124+
disabled={busy}
121125
onclick={(e) => {
122126
e.stopPropagation();
123127
onDelete(w.id);
@@ -129,7 +133,7 @@
129133
{/if}
130134
</div>
131135
{/each}
132-
<button class="wsadd" aria-label="new workspace" title={t('shell.workspace.new')} onclick={onNew}>
136+
<button class="wsadd" aria-label={t('shell.workspace.new')} title={t('shell.workspace.new')} disabled={busy} onclick={onNew}>
133137
<Plus size={13} />
134138
</button>
135139
</div>

src/lib/workbench/canvas.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ describe('reconcileLayout', () => {
8383
expect(chatSessionsIn(next)).toEqual(['live']);
8484
});
8585

86+
it('keeps a persisted 2-chat split intact when both session ids are live', () => {
87+
// Restore with persisted tab ids re-spawns sessions under the same ids,
88+
// so a workspace switch (or restart) must not collapse the split.
89+
const base = singleLeafLayout([chatTab('live-a')]);
90+
const split = splitLeaf(base, leavesOf(base.root)[0].id, 'right', chatTab('live-b')).layout;
91+
const next = reconcileLayout(serializeLayout(split), ['live-a', 'live-b'], 'live-a');
92+
expect(next).toEqual(split);
93+
expect(chatSessionsIn(next)).toEqual(['live-a', 'live-b']);
94+
});
95+
8696
it('re-seeds one chat leaf when every persisted chat session is dead', () => {
8797
const layout = openChatTab(dockOnlyLayout(), null, 'old-run');
8898
const next = reconcileLayout(serializeLayout(layout), ['fresh'], 'fresh');

src/lib/workbench/canvas.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ export function openChatTab(
5757

5858
/**
5959
* Build the canvas from a persisted layout blob when a workspace loads:
60-
* - chat tiles whose session no longer exists this run are dropped (session
61-
* ids are minted per run, so most restarts land here);
60+
* - chat tiles whose session is not live are dropped — desktop session ids
61+
* are stable across restore when the saved tabs carry `id` (see
62+
* SavedProject.tabs), so only truly missing sessions (and legacy files
63+
* saved without ids) lose their tile;
6264
* - a layout left without any chat tile gets one seeded for `seedSessionId` —
6365
* an old dock-only layout keeps its panel arrangement and gains a chat leaf
6466
* on the left (the pre-canvas shape, chat | panels);

src/lib/workbench/tabChrome.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ describe('sanitizeSvg', () => {
3333
expect(sanitizeSvg('<svg><path href="#x" d="M0 0"/></svg>')).toBeNull();
3434
});
3535

36+
it('rejects url(...) paint servers (external resource references)', () => {
37+
expect(sanitizeSvg('<svg><path fill="url(#grad)" d="M0 0"/></svg>')).toBeNull();
38+
expect(sanitizeSvg('<svg><rect x="0" y="0" stroke="URL(http://evil)"/></svg>')).toBeNull();
39+
});
40+
3641
it('rejects non-svg roots, comments and oversized markup', () => {
3742
expect(sanitizeSvg('<div>hi</div>')).toBeNull();
3843
expect(sanitizeSvg('<svg><!-- sneaky --><path d="M0 0"/></svg>')).toBeNull();

src/lib/workbench/tabChrome.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export function sanitizeSvg(markup: string): string | null {
6363
const name = a[1].toLowerCase();
6464
if (name.startsWith('on') || !SVG_ATTRS.has(name)) return null;
6565
const value = (a[2] ?? a[3] ?? a[4] ?? '').toLowerCase();
66-
if (value.includes('javascript:') || value.includes('data:')) return null;
66+
// url(...) paint servers can reference external resources.
67+
if (value.includes('javascript:') || value.includes('data:') || value.includes('url(')) return null;
6768
}
6869
}
6970
if (/[<>]/.test(s.slice(last))) return null;

0 commit comments

Comments
 (0)