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