From 82e76fc6506b498f74018dd4354afe56115671ef Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 14 Aug 2026 21:23:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(sessions):=20fork=20a=20session=20?= =?UTF-8?q?=E2=80=94=20the=20what-if=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest designed-but-unbuilt piece of the triad. §313: "resume a copy from any point, leaving the original intact — the what-if-I'd-told-it-to-do-X-instead branch." Fork-from-END, as §411 settles the scoping: per-turn fork needs a transcript picker, which is separate work. WHICH EVENTS TRAVEL is the entire design, and the answer is not "all of them": user / agent / assistant YES — this is the conversation being forked title YES, then overridden, so the fork is not a second row with an identical name end NO — that is the ORIGINAL's terminal state. Copying it renders a live fork as `done` while you type label NO — lifecycle and pinning are the original's. A fork of an archived session must arrive visible, and must not silently take a second pin The copy records `forkedFrom` in its meta — §4 provenance, and what a later branch-graph draws from. The key is written ONLY on a fork, so every existing session file stays byte-identical and an older build reading a forked session simply does not know it was forked rather than failing to read it. A fork IS a resume, into a copy, so the handler calls resumeSession() on the new id rather than duplicating the replay, the budget planning, or the honest "resumed from a summary" note. The sixth card button: labels now collapse below 420px (chat) / 400px (panel), up from 360/340 — six labelled buttons need ~370px against five at ~300px, and the row is nowrap so overshoot overflows rather than wraps. The guard added with export already covered this; only the threshold moved. Tests: 8 in sessions.test.js against a fixture that is sealed AND archived AND pinned — every state a fork must not inherit. Verified non-vacuous: copy the end event 20/25 copy label events 20/25 drop the (fork) suffix 21/25 drop provenance 22/25 do not go live 23/25 All 32 suites green. One test bug worth recording: `m.list().find(e => e.id === m.fork(id))` calls fork() once per row against a list fetched before any fork existed. Hoisted. --- docs/levelcode-sessions-experience.md | 2 +- extensions/levelcode-ai/extension.js | 10 +++ extensions/levelcode-ai/media/chat.html | 6 +- .../levelcode-ai/media/sessionsView.html | 6 +- extensions/levelcode-ai/sessionStore.js | 14 ++- extensions/levelcode-ai/sessions.js | 52 ++++++++++- extensions/levelcode-ai/test/sessions.test.js | 89 +++++++++++++++++++ .../levelcode-ai/test/sessionsUi.test.js | 4 +- .../levelcode-ai/test/webviewCss.test.js | 4 +- 9 files changed, 173 insertions(+), 14 deletions(-) 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..fdcb04e 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -1112,7 +1112,7 @@ `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. */ .sesscard { container-type: inline-size; } - @container (max-width: 360px) { .sesscard .sesslbl { display: none; } } + @container (max-width: 420px) { .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 +3351,8 @@ check: '', trash: '', star: '', - copy: '' + copy: '', + fork: '' }; function sessActBtn(act, label, svg, cls){ return ''; @@ -3377,6 +3378,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..f789b24 100644 --- a/extensions/levelcode-ai/media/sessionsView.html +++ b/extensions/levelcode-ai/media/sessionsView.html @@ -91,7 +91,7 @@ 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. */ .sesscard { container-type: inline-size; } - @container (max-width: 340px) { .sesscard .sesslbl { display: none; } } + @container (max-width: 400px) { .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 +151,8 @@ check: '', trash: '', star: '', - copy: '' + copy: '', + fork: '' }; function sessActBtn(act, label, svg, cls){ return ''; @@ -177,6 +178,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..6382712 100644 --- a/extensions/levelcode-ai/test/webviewCss.test.js +++ b/extensions/levelcode-ai/test/webviewCss.test.js @@ -214,7 +214,7 @@ 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', () => { +test('SESSION CARD: six 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. @@ -228,7 +228,7 @@ test('SESSION CARD: five nowrap action buttons cannot overflow a narrow pane', ( for (const [where, sheet] of both) { 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/, + assert.match(sheet, /@container\s*\(max-width:\s*[34][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'); assert.match(sheet, /\.sesscard \.sessacts \{[^}]*flex-wrap:\s*nowrap/, where + ': the row stopped being nowrap, so this guard is now testing the wrong failure'); From 5cf3f1342e9ac2e9fb364a8a285630be2af892f5 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 14 Aug 2026 21:32:39 -0400 Subject: [PATCH 2/2] fix(sessions): the label-collapse thresholds were too low, not just mis-commented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged the comments: they still said "a fifth button" and "five labelled buttons ... ~300px" next to a row that now renders six. Correct — and going to fix the wording turned up a real bug behind it. MEASURED the shipped styling in headless Chrome, six labels 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 The thresholds were 420 and 400. So chat.html had a 14px band — content widths between 420 and 434 — where the labels were still painted and the nowrap row spilled out of the card. Exactly the failure the guard was added to prevent, reintroduced by raising the button count without re-measuring. Now 460 and 390, both above the need with room for a wider system font. The comments are rewritten with the real numbers and a note to re-measure when adding a button. The test is rewritten to assert the RELATIONSHIP rather than a regex shape. It now parses the threshold and requires it to be >= the measured need, so a seventh button fails until someone re-measures, and the message says why: chat.html: labels collapse at 420px but six labelled buttons need 434px — between those widths the labels paint and the nowrap row overflows the card Boundary verified: 433 rejected, 434 accepted, and the 420 that shipped rejected. All 32 suites green. --- extensions/levelcode-ai/media/chat.html | 12 ++++-- .../levelcode-ai/media/sessionsView.html | 10 +++-- .../levelcode-ai/test/webviewCss.test.js | 41 ++++++++++++------- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index fdcb04e..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: 420px) { .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; } diff --git a/extensions/levelcode-ai/media/sessionsView.html b/extensions/levelcode-ai/media/sessionsView.html index f789b24..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: 400px) { .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; } diff --git a/extensions/levelcode-ai/test/webviewCss.test.js b/extensions/levelcode-ai/test/webviewCss.test.js index 6382712..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: six 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*[34][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'); } });