diff --git a/docs/levelcode-sessions-experience.md b/docs/levelcode-sessions-experience.md
index 49692d6..82f5788 100644
--- a/docs/levelcode-sessions-experience.md
+++ b/docs/levelcode-sessions-experience.md
@@ -310,7 +310,7 @@ Global rule: **nothing animates on scroll at all** — the sparkline lives in th
Beyond the card and the search, the "wow" list — each grounded in the JSONL substrate so it's cheap to build:
-- **Fork a session** (`⇧⏎`) — resume a *copy* from any point, leaving the original intact. The "what if I'd told it to do X instead" branch. Cursor's checkpoints rewind *files*; forking rewinds the *conversation*. Both, together, is new.
+- **Fork a session** ✅ *(fork-from-end shipped)* — resume a *copy*, leaving the original intact. `sessions.fork()` seeds a new session with the original's conversation and **drops its `end` and `label` events**: a copied `end` would render a live fork as `done` while you typed into it, and a copied `label` would have a fork of an archived session born invisible in the default Active scope, or silently taking a second pin. The copy is titled `… (fork)`, records `forkedFrom` in its meta (§4 provenance, and what a later branch-graph would draw from), and becomes live — a fork *is* a resume, into a copy, so it reuses the resume path wholesale. Per-turn fork still awaits the transcript picker (§411). The "what if I'd told it to do X instead" branch. Cursor's checkpoints rewind *files*; forking rewinds the *conversation*. Both, together, is new.
- **Files-touched search & chips** (§4.2/4.4) — find work by what it changed. The structural lead over Cursor.
- **Activity sparkline** (§4.2) — session *shape* at a glance.
- **The honesty layer as UI** — interrupted / reloaded / summarized states are pills and strips, not silence (§4.5, §4.7).
diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js
index 0747ab4..1693941 100644
--- a/extensions/levelcode-ai/extension.js
+++ b/extensions/levelcode-ai/extension.js
@@ -936,6 +936,16 @@ async function handleSessionAction(action, id) {
return;
}
if (action === 'export') { await exportSession(id); return; }
+ if (action === 'fork') {
+ // A fork IS a resume, into a copy — so it reuses resumeSession wholesale rather than
+ // duplicating the transcript replay, the budget planning, or the "resumed from a summary"
+ // note. m.fork() has already made the copy live.
+ const forkId = m.fork(id);
+ if (!forkId) { vscode.window.showErrorMessage('Could not fork that session — it may have been deleted.'); return; }
+ dbg('sessions.fork', { from: id, to: forkId });
+ await resumeSession(forkId);
+ return;
+ }
if (action === 'restore') { m.restore(id); refreshSessions(); return; }
if (action === 'pin') { const cur = (m.list().find((e) => e.id === id) || {}).pinned; m.setPinned(id, !cur); refreshSessions(); return; }
if (action === 'rename') {
diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html
index 4bc16e0..f558aa2 100644
--- a/extensions/levelcode-ai/media/chat.html
+++ b/extensions/levelcode-ai/media/chat.html
@@ -1107,12 +1107,16 @@
.sesscard .sessline2 { margin-top: 4px; min-height: 26px; display: flex; align-items: center; }
.sesscard .sesssub { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
/* The action row is nowrap by design (the card is two fixed-height lines — nothing may reflow), so
- a fifth button cannot be allowed to overflow a narrow sidebar. Below the width where five
- labelled buttons fit, the labels drop and the row becomes icon-only: ~184px instead of ~300px.
+ a button that does not fit OVERFLOWS the card rather than wrapping. Six labelled buttons
+ (Rename · Fork · Copy · Done · Delete · Pin) measure 434px at this styling; icon-only they are
+ ~200px. The threshold therefore has to sit ABOVE 434, not below it — at 420 there was a band of
+ widths where the labels were still painted and the row spilled out of the card. 460 leaves room
+ for a wider system font.
`title` + `aria-label` are on every button already, so nothing is lost to a pointer or a screen
- reader — only to the eye, and only when there is no room for it. */
+ reader — only to the eye, and only when there is no room for it.
+ ADDING A BUTTON? Re-measure and raise this; test/webviewCss.test.js pins the floor. */
.sesscard { container-type: inline-size; }
- @container (max-width: 360px) { .sesscard .sesslbl { display: none; } }
+ @container (max-width: 460px) { .sesscard .sesslbl { display: none; } }
.sesscard .sessacts { display: none; align-items: center; gap: 6px; flex-wrap: nowrap; }
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -3351,7 +3355,8 @@
check: '',
trash: '',
star: '',
- copy: ''
+ copy: '',
+ fork: ''
};
function sessActBtn(act, label, svg, cls){
return '';
@@ -3377,6 +3382,7 @@
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
const acts = ''
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
+ + sessActBtn('fork', 'Fork', SESS_IC.fork)
+ sessActBtn('export', 'Copy', SESS_IC.copy)
+ sessActBtn('done', 'Done', SESS_IC.check)
+ sessActBtn('delete', 'Delete', SESS_IC.trash)
diff --git a/extensions/levelcode-ai/media/sessionsView.html b/extensions/levelcode-ai/media/sessionsView.html
index 80530ed..d249e66 100644
--- a/extensions/levelcode-ai/media/sessionsView.html
+++ b/extensions/levelcode-ai/media/sessionsView.html
@@ -87,11 +87,13 @@
left intact. Sidebar buttons are label-only (icon hidden) to stay compact in a narrow panel. */
.sesscard .sessline2 { margin: 2px 0 0 16px; min-height: 22px; display: flex; align-items: center; }
.sesscard .sesssub { font-size: 12px; color: var(--cc-text3); }
- /* Same reasoning as chat.html: the row is nowrap, so a fifth button must not overflow a narrow
- pane. Below the width where five labelled buttons fit, drop to icons — title + aria-label are
- already on every button, so only the eye loses anything, and only when there is no room. */
+ /* Same reasoning as chat.html, different numbers — this pane's buttons are smaller (22px tall,
+ 8px padding, 11px text, no border), so six labelled ones measure 363px rather than 434px. The
+ threshold sits above that; below it the row drops to icons, keeping title + aria-label, so only
+ the eye loses anything and only when there is no room.
+ ADDING A BUTTON? Re-measure and raise this; test/webviewCss.test.js pins the floor. */
.sesscard { container-type: inline-size; }
- @container (max-width: 340px) { .sesscard .sesslbl { display: none; } }
+ @container (max-width: 390px) { .sesscard .sesslbl { display: none; } }
.sesscard .sessacts { display: none; align-items: center; gap: 4px; flex-wrap: nowrap; }
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -151,7 +153,8 @@
check: '',
trash: '',
star: '',
- copy: ''
+ copy: '',
+ fork: ''
};
function sessActBtn(act, label, svg, cls){
return '';
@@ -177,6 +180,7 @@
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
const acts = ''
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
+ + sessActBtn('fork', 'Fork', SESS_IC.fork)
+ sessActBtn('export', 'Copy', SESS_IC.copy)
+ sessActBtn('done', 'Done', SESS_IC.check)
+ sessActBtn('delete', 'Delete', SESS_IC.trash)
diff --git a/extensions/levelcode-ai/sessionStore.js b/extensions/levelcode-ai/sessionStore.js
index b8f4afb..300d19b 100644
--- a/extensions/levelcode-ai/sessionStore.js
+++ b/extensions/levelcode-ai/sessionStore.js
@@ -74,9 +74,15 @@ function indexFile(root, slug) { return path.join(root, slug, INDEX_NAME); }
// ── event encoding & parsing (pure) ──────────────────────────────────────────────────────────────
/** The birth line — written once, so even a one-message crash leaves a listable session (design §4). */
-function metaLine(id, projectPath, createdAtIso, title) {
- return { kind: 'meta', v: SCHEMA_V, id: String(id), project: String(projectPath == null ? '' : projectPath),
+function metaLine(id, projectPath, createdAtIso, title, forkedFrom) {
+ const m = { kind: 'meta', v: SCHEMA_V, id: String(id), project: String(projectPath == null ? '' : projectPath),
createdAt: String(createdAtIso), title: title == null ? null : String(title) };
+ // Provenance for a forked session, and ONLY for one — the key is absent on an ordinary session so
+ // every existing file and expectation stays byte-identical. Unknown meta keys are ignored by
+ // parseSession and deriveEntry, so this is additive in both directions: an older build reading a
+ // forked session simply does not know it was forked, rather than failing to read it.
+ if (forkedFrom != null && String(forkedFrom)) { m.forkedFrom = String(forkedFrom); }
+ return m;
}
function encodeEvent(event) { return JSON.stringify(event) + '\n'; }
@@ -151,9 +157,9 @@ function deriveEntry(meta, events) {
// ── writing (append-only) ────────────────────────────────────────────────────────────────────────
/** Create a session: its directory + the meta birth line. Idempotent-ish (a re-create just rewrites meta). */
-function createSession(root, slug, id, projectPath, createdAtIso, title) {
+function createSession(root, slug, id, projectPath, createdAtIso, title, forkedFrom) {
fs.mkdirSync(projectDir(root, slug), { recursive: true });
- fs.writeFileSync(sessionFile(root, slug, id), encodeEvent(metaLine(id, projectPath, createdAtIso, title)));
+ fs.writeFileSync(sessionFile(root, slug, id), encodeEvent(metaLine(id, projectPath, createdAtIso, title, forkedFrom)));
return sessionFile(root, slug, id);
}
diff --git a/extensions/levelcode-ai/sessions.js b/extensions/levelcode-ai/sessions.js
index d17581e..b133591 100644
--- a/extensions/levelcode-ai/sessions.js
+++ b/extensions/levelcode-ai/sessions.js
@@ -120,6 +120,56 @@ function createSessions(opts) {
note: planner.describeResume(plan, turnsSummarized) };
}
+ /**
+ * FORK a session (experience doc §6): a NEW session seeded with a copy of this one's conversation,
+ * leaving the original completely untouched — "the what-if-I'd-told-it-to-do-X-instead branch".
+ *
+ * Fork-from-END, deliberately. §411 settles the scoping question: "fork-from-end first; per-turn
+ * fork rides the transcript picker later." Forking from an arbitrary turn needs a UI for choosing
+ * the turn, and that is a different piece of work from the copy itself.
+ *
+ * WHICH EVENTS TRAVEL is the whole design here, and the answer is not "all of them":
+ * • `user` / `agent` / `assistant` — YES. This is the conversation; it is the thing being forked.
+ * • `title` — YES, then overridden below, so the fork is recognisable in a
+ * list where it would otherwise be a second row with the
+ * identical name.
+ * • `end` — NO. That is the original's terminal state. Copying it would
+ * make a live fork claim it had already finished, and
+ * deriveEntry would render it `done` while you typed into it.
+ * • `label` — NO. Lifecycle and pinning belong to the ORIGINAL. A fork of
+ * an archived session must arrive active, or it is born
+ * invisible in the default Active scope; a fork of a pinned
+ * one must not silently take up a second pin.
+ *
+ * Returns the new id, or null if the source is gone/unreadable. The caller resumes it — a fork IS a
+ * resume, just into a copy — so nothing here duplicates the replay logic.
+ */
+ function fork(id) {
+ let s;
+ try { s = store.readSession(store.sessionFile(root, slug, id)); }
+ catch (e) { return null; }
+
+ const src = store.deriveEntry(s.meta, s.events);
+ const newId = store.newSessionId(clock());
+ try {
+ // The meta records what it came from — provenance is the honesty guarantee everywhere else
+ // in this system (§4), and it is also what a later branch-graph would draw from.
+ store.createSession(root, slug, newId, projectPath, iso(), null, id);
+ const file = store.sessionFile(root, slug, newId);
+ for (const e of (Array.isArray(s.events) ? s.events : [])) {
+ if (!e || e.kind === 'end' || e.kind === 'label') { continue; }
+ store.appendEvent(file, e);
+ }
+ // Last, so it wins: deriveEntry takes the LATEST title event.
+ const base = src.title || 'Untitled';
+ store.appendEvent(file, events.titleEvent(/\(fork\)\s*$/.test(base) ? base : base + ' (fork)', iso()));
+ live = { id: newId, file };
+ if (state) { try { state.set('liveSessionId', newId); } catch (e2) { /* convenience */ } }
+ reindexId(newId);
+ return newId;
+ } catch (e) { return null; }
+ }
+
// Append-only lifecycle edits (§4.9): each writes one event, then refreshes that id's index row. All
// best-effort (return false rather than throw) — a History edit must never disrupt anything.
function appendTo(id, event) {
@@ -282,7 +332,7 @@ function createSessions(opts) {
function liveId() { return live ? live.id : null; }
- return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, list, liveId };
+ return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, list, liveId };
}
module.exports = { createSessions };
diff --git a/extensions/levelcode-ai/test/sessions.test.js b/extensions/levelcode-ai/test/sessions.test.js
index b15cc26..53f0ce1 100644
--- a/extensions/levelcode-ai/test/sessions.test.js
+++ b/extensions/levelcode-ai/test/sessions.test.js
@@ -337,4 +337,93 @@ test('MEMORY: supersedeFact drops a stale fact from the digest but keeps it (res
assert.strictEqual(m.digest().facts.length, 1, 'Keep/Confirm restores it to the digest');
});
+// ---- Fork (experience doc §6, "the what-if-I'd-told-it-to-do-X-instead branch") -----------------
+
+/** A sealed, archived, pinned session with two turns — every state a fork must NOT inherit. */
+function forkFixture() {
+ const root = freshRoot(), slug = 'proj';
+ const m = createSessions({ root, slug, projectPath: '/proj', memory: false });
+ m.ensure();
+ m.recordTurn(turn('add idempotency', 'refund.rb'), 'opus');
+ m.recordTurn(turn('now add retries', 'retry.rb'), 'opus');
+ const id = m.liveId();
+ m.rename(id, 'Idempotent refunds');
+ m.archive(id);
+ m.setPinned(id, true);
+ m.seal('done');
+ return { root, slug, m, id };
+}
+
+test('FORK: the copy carries the conversation and the original is left completely alone', () => {
+ const { m, id } = forkFixture();
+ const before = JSON.stringify(m.list().find((e) => e.id === id));
+
+ const forkId = m.fork(id);
+ assert.ok(forkId && forkId !== id, 'fork must produce a NEW session, not reuse the id');
+
+ const fork = m.list().find((e) => e.id === forkId);
+ assert.strictEqual(fork.turns, 2, 'the conversation did not come across');
+ assert.deepStrictEqual(fork.filesEdited, ['refund.rb', 'retry.rb'], 'the derived work came across too');
+
+ assert.strictEqual(JSON.stringify(m.list().find((e) => e.id === id)), before,
+ 'forking mutated the original — the whole promise is that it does not');
+});
+
+test('FORK: state, lifecycle and pinning belong to the ORIGINAL and do not travel', () => {
+ // The event-selection rules, which are the entire design of fork(). A copied `end` would make a
+ // live fork render as `done` while you typed into it; a copied `label` would have a fork of an
+ // archived session born invisible in the default Active scope, or silently taking a second pin.
+ const { m, id } = forkFixture();
+ const forkId = m.fork(id); // hoisted: inside find() this runs once per row
+ const fork = m.list().find((e) => e.id === forkId);
+ assert.strictEqual(fork.state, 'active', "the original's terminal state was copied");
+ assert.strictEqual(fork.lifecycle, 'active', 'a fork of an archived session must arrive visible');
+ assert.strictEqual(fork.pinned, false, 'pinning is the original\'s, not the copy\'s');
+});
+
+test('FORK: the copy is recognisable in a list, and marked only once', () => {
+ const { m, id } = forkFixture();
+ const firstId = m.fork(id);
+ const first = m.list().find((e) => e.id === firstId);
+ assert.strictEqual(first.title, 'Idempotent refunds (fork)', 'two identical rows would be unreadable');
+
+ // Forking a fork must not stutter into "x (fork) (fork)".
+ const secondId = m.fork(first.id);
+ const second = m.list().find((e) => e.id === secondId);
+ assert.strictEqual(second.title, 'Idempotent refunds (fork)');
+});
+
+test('FORK: provenance is recorded, and only on a fork', () => {
+ const { root, slug, m, id } = forkFixture();
+ const forkId = m.fork(id);
+ const meta = (f) => store.readSession(store.sessionFile(root, slug, f)).meta;
+ assert.strictEqual(meta(forkId).forkedFrom, id, 'a fork must know where it came from (§4 provenance)');
+ assert.ok(!('forkedFrom' in meta(id)),
+ 'an ordinary session gained the key — every existing file must stay byte-identical');
+});
+
+test('FORK: the copy becomes live, so the next turn appends to it and not the original', () => {
+ const { m, id } = forkFixture();
+ const forkId = m.fork(id);
+ assert.strictEqual(m.liveId(), forkId, 'a fork IS a resume, into a copy');
+
+ m.recordTurn(turn('try it differently', 'other.rb'), 'opus');
+ assert.strictEqual(m.list().find((e) => e.id === forkId).turns, 3, 'the new turn landed on the fork');
+ assert.strictEqual(m.list().find((e) => e.id === id).turns, 2, 'and NOT on the original');
+});
+
+test('FORK: a missing source fails soft, and an empty session forks without inventing turns', () => {
+ const { m } = forkFixture();
+ assert.strictEqual(m.fork('does-not-exist'), null, 'a gone session must not throw into the UI');
+ assert.strictEqual(m.fork(''), null);
+
+ const root2 = freshRoot();
+ const m2 = createSessions({ root: root2, slug: 'p', projectPath: '/p', memory: false });
+ m2.ensure();
+ const emptyId = m2.liveId();
+ const forkId = m2.fork(emptyId);
+ assert.ok(forkId, 'a session with no turns is still forkable');
+ assert.strictEqual(m2.list().find((e) => e.id === forkId).turns, 0);
+});
+
console.log('sessions: ' + n + ' tests passed');
diff --git a/extensions/levelcode-ai/test/sessionsUi.test.js b/extensions/levelcode-ai/test/sessionsUi.test.js
index 6190321..0ad9ba5 100644
--- a/extensions/levelcode-ai/test/sessionsUi.test.js
+++ b/extensions/levelcode-ai/test/sessionsUi.test.js
@@ -74,11 +74,11 @@ test('CARD (state): interrupted gets the warn class; an unpinned card has no pin
assert.match(h, /data-act="pin"[^>]*aria-label="Pin"/, 'and the toggle offers Pin');
});
-test('CARD (actions): five row icon buttons (rename/export/done/delete/pin); clicking the card body resumes', () => {
+test('CARD (actions): six row icon buttons (rename/fork/export/done/delete/pin); clicking the card body resumes', () => {
const e = { id: 's3', title: 't', updatedAt: msAgo(3600), turns: 41, model: 'anthropic/claude-opus-5',
state: 'done', filesEdited: ['refund.rb', 'lock.rb'] };
const h = P.sessCardHtml(e, NOW, esc, escAttr);
- for (const a of ['rename', 'export', 'done', 'delete', 'pin']) { assert.match(h, new RegExp('data-act="' + a + '"'), a + ' action present'); }
+ for (const a of ['rename', 'fork', 'export', 'done', 'delete', 'pin']) { assert.match(h, new RegExp('data-act="' + a + '"'), a + ' action present'); }
assert.ok(!/data-act="resume"/.test(h), 'no Resume button — clicking the card body resumes (default action)');
assert.match(h, /class="sessline2">.*class="sesssub">/, 'file·time and the actions share line 2 (swap on hover)');
assert.ok(!/sessrich|sessbtn|sesschip|sessmeta/.test(h), 'not the old drawer/chip/meta markup');
diff --git a/extensions/levelcode-ai/test/webviewCss.test.js b/extensions/levelcode-ai/test/webviewCss.test.js
index 851b4df..b3e2c38 100644
--- a/extensions/levelcode-ai/test/webviewCss.test.js
+++ b/extensions/levelcode-ai/test/webviewCss.test.js
@@ -214,24 +214,35 @@ test('an approved MCP tool call folds its run-node into the approval chip (one r
assert.ok(/mcpMergePending = null;/.test(line), 'and closes a stale merge window on any other row');
});
-test('SESSION CARD: five nowrap action buttons cannot overflow a narrow pane', () => {
- // The card is two fixed-height lines and the action row is `flex-wrap: nowrap`, so buttons that
- // do not fit do not wrap — they overflow. Five LABELLED buttons need roughly 300px; a sidebar is
- // routinely narrower. The labels therefore have to disappear before that happens.
+test('SESSION CARD: the label-collapse threshold is above the width the labels actually need', () => {
+ // The card is two fixed-height lines and the action row is `flex-wrap: nowrap`, so buttons that do
+ // not fit do not wrap — they OVERFLOW the card.
//
- // This is pinned because the failure is invisible in a wide window: whoever adds a sixth button
- // will not see it break, and the person who does see it will be a user with a narrow sidebar.
- const both = [
- ['chat.html', css],
- ['sessionsView.html', fs.readFileSync(path.join(__dirname, '..', 'media', 'sessionsView.html'), 'utf8')]
- ];
- for (const [where, sheet] of both) {
+ // The numbers below were MEASURED in headless Chrome against the shipped button styling, with all
+ // six labels (Rename · Fork · Copy · Done · Delete · Pin) rendered:
+ //
+ // chat.html 434px padding 0 9px, 11.5px text, gap 6, 1px border
+ // sessionsView.html 363px padding 0 8px, 11px text, gap 4, no border
+ //
+ // A threshold BELOW those leaves a band of widths where the labels are still painted and the row
+ // spills out of the card — which is exactly what shipped at 420/400 until this test existed. The
+ // assertion is therefore on the RELATIONSHIP, not on a magic number: raise a threshold freely,
+ // but never below what the labels need. Add a seventh button and this fails until you re-measure.
+ const NEEDS = { 'chat.html': 434, 'sessionsView.html': 363 };
+ const sheets = {
+ 'chat.html': css,
+ 'sessionsView.html': fs.readFileSync(path.join(__dirname, '..', 'media', 'sessionsView.html'), 'utf8')
+ };
+ for (const [where, sheet] of Object.entries(sheets)) {
assert.match(sheet, /\.sesscard\s*\{[^}]*container-type:\s*inline-size/,
- where + ': the card must be a container for the query below to resolve against IT rather than the viewport');
- assert.match(sheet, /@container\s*\(max-width:\s*3[0-9]{2}px\)\s*\{\s*\.sesscard \.sesslbl\s*\{\s*display:\s*none/,
- where + ': no width at which the labels collapse — a narrow pane will overflow');
+ where + ': the card must be a container, or the query resolves against the viewport instead');
+ const m = /@container\s*\(max-width:\s*(\d+)px\)\s*\{\s*\.sesscard \.sesslbl\s*\{\s*display:\s*none/.exec(sheet);
+ assert.ok(m, where + ': no width at which the labels collapse — a narrow pane will overflow');
+ assert.ok(Number(m[1]) >= NEEDS[where],
+ where + ': labels collapse at ' + m[1] + 'px but six labelled buttons need ' + NEEDS[where]
+ + 'px — between those widths the labels paint and the nowrap row overflows the card');
assert.match(sheet, /\.sesscard \.sessacts \{[^}]*flex-wrap:\s*nowrap/,
- where + ': the row stopped being nowrap, so this guard is now testing the wrong failure');
+ where + ': the row stopped being nowrap, so this guard now tests the wrong failure');
}
});