Skip to content

feat(sessions): fork a session — the what-if branch - #67

Merged
ndemianc merged 2 commits into
developfrom
feat/session-fork
Aug 15, 2026
Merged

feat(sessions): fork a session — the what-if branch#67
ndemianc merged 2 commits into
developfrom
feat/session-fork

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

The largest designed-but-unbuilt piece of the triad. §313:

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.

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":

event travels why
user / agent / assistant this is the conversation being forked
title ✅ then overridden otherwise the fork is a second row with an identical name
end that's the original's terminal state — copying it renders a live fork as done while you type into it
label lifecycle and pinning are the original's. A fork of an archived session must arrive visible, and must not silently take a second pin

Verified against a fixture that is sealed and archived and pinned:

original : {"title":"Idempotent refunds","turns":2,"state":"done","lifecycle":"archived","pinned":true}
fork     : {"title":"Idempotent refunds (fork)","turns":2,"state":"active","lifecycle":"active","pinned":false}
original after fork (must be unchanged): …identical…

Provenance

The copy records forkedFrom in its meta — §4 provenance, and what a later branch-graph would draw 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 doesn't know it was forked rather than failing to read it. There's a test asserting an ordinary session never gains the key.

A fork is a resume, into a copy

So the handler calls resumeSession() on the new id rather than duplicating the transcript replay, the budget planning, or the honest "resumed from a summary" note. fork() has already made the copy live.

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 #65 already covered this — only the threshold moved.

Tests

8 new in sessions.test.js. Verified non-vacuous:

bypass result
copy the end event 20/25
copy label events 20/25
drop the (fork) suffix 21/25
drop provenance 22/25
don't 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 — so it created N junk forks and matched none. Hoisted.

Still open in the triad

Per-turn fork (needs the transcript picker), the branch-graph visualisation, memory-set export, and the four docs still marked "Status: proposed".

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.
Copilot AI lite review requested due to automatic review settings August 15, 2026 01:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds “fork session” support to the LevelCode AI sessions subsystem: creating a new live session that copies the source conversation while intentionally not inheriting terminal/lifecycle metadata, enabling “what-if” branching without mutating the original.

Changes:

  • Implement sessions.fork() to create a new session seeded from an existing session’s transcript, with provenance recorded as meta.forkedFrom and with end/label events excluded.
  • Add a new Fork action to session cards across both webviews and route the action through extension.js to reuse the existing resume path.
  • Update experience docs and expand/adjust tests to cover event-selection rules, provenance, and UI action presence.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
extensions/levelcode-ai/test/webviewCss.test.js Updates the session-card narrow-pane guard test for the action row; currently needs tightening to assert the intended 420/400 collapse thresholds.
extensions/levelcode-ai/test/sessionsUi.test.js Updates UI unit test to expect the new fork action in the session-card action row.
extensions/levelcode-ai/test/sessions.test.js Adds fork-focused lifecycle tests (state/lifecycle/pin isolation, provenance, “fork becomes live”, etc.).
extensions/levelcode-ai/sessionStore.js Extends session meta line creation to optionally include forkedFrom only when forking.
extensions/levelcode-ai/sessions.js Adds fork(id) implementation and exports it from the sessions manager.
extensions/levelcode-ai/media/sessionsView.html Adds Fork action button and updates label-collapse container-query threshold (now 400px).
extensions/levelcode-ai/media/chat.html Adds Fork action button and updates label-collapse container-query threshold (now 420px).
extensions/levelcode-ai/extension.js Handles sessionAction: fork by forking then resuming the new session id.
docs/levelcode-sessions-experience.md Marks fork-from-end as shipped and documents the event-selection/provenance rules.
Suppressed comments (1)

extensions/levelcode-ai/test/webviewCss.test.js:221

  • This CSS guard test is now too permissive and the inline rationale is outdated. The comment still talks about “five labelled buttons”, and the regex allows any 3xx/4xx collapse width, which wouldn’t catch an accidental regression away from the intended 420px (chat.html) / 400px (sessionsView.html) thresholds described in this PR.
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.
	//

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread extensions/levelcode-ai/media/sessionsView.html Outdated
Comment thread extensions/levelcode-ai/media/chat.html Outdated
…is-commented

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.
@ndemianc

Copy link
Copy Markdown
Contributor Author

Both correct, fixed in 5cf3f13 — and going to fix the wording turned up a real bug behind it.

I measured the shipped styling in headless Chrome with all six labels rendered:

six labelled buttons threshold was
chat.html 434px 420
sessionsView.html 363px 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. Precisely 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 more useful part

The test was asserting a regex shape (3[0-9]{2}px), which is why it happily accepted a threshold that overflows. It now parses the value and asserts the relationship:

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

So a seventh button fails until someone re-measures, and the message says why rather than pointing at a magic number.

Boundary verified: 433 rejected, 434 accepted, and the 420 that shipped in this PR rejected. All 32 suites green.

@ndemianc
ndemianc merged commit c8d7a15 into develop Aug 15, 2026
1 check passed
@ndemianc
ndemianc deleted the feat/session-fork branch August 15, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants