Skip to content

Commit c8d7a15

Browse files
authored
Merge pull request #67 from levelcodeai/feat/session-fork
feat(sessions): fork a session — the what-if branch
2 parents a86120a + 5cf3f13 commit c8d7a15

9 files changed

Lines changed: 209 additions & 33 deletions

File tree

docs/levelcode-sessions-experience.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ Global rule: **nothing animates on scroll at all** — the sparkline lives in th
310310

311311
Beyond the card and the search, the "wow" list — each grounded in the JSONL substrate so it's cheap to build:
312312

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. Cursor's checkpoints rewind *files*; forking rewinds the *conversation*. Both, together, is new.
313+
- **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.
314314
- **Files-touched search & chips** (§4.2/4.4) — find work by what it changed. The structural lead over Cursor.
315315
- **Activity sparkline** (§4.2) — session *shape* at a glance.
316316
- **The honesty layer as UI** — interrupted / reloaded / summarized states are pills and strips, not silence (§4.5, §4.7).

extensions/levelcode-ai/extension.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,16 @@ async function handleSessionAction(action, id) {
936936
return;
937937
}
938938
if (action === 'export') { await exportSession(id); return; }
939+
if (action === 'fork') {
940+
// A fork IS a resume, into a copy — so it reuses resumeSession wholesale rather than
941+
// duplicating the transcript replay, the budget planning, or the "resumed from a summary"
942+
// note. m.fork() has already made the copy live.
943+
const forkId = m.fork(id);
944+
if (!forkId) { vscode.window.showErrorMessage('Could not fork that session — it may have been deleted.'); return; }
945+
dbg('sessions.fork', { from: id, to: forkId });
946+
await resumeSession(forkId);
947+
return;
948+
}
939949
if (action === 'restore') { m.restore(id); refreshSessions(); return; }
940950
if (action === 'pin') { const cur = (m.list().find((e) => e.id === id) || {}).pinned; m.setPinned(id, !cur); refreshSessions(); return; }
941951
if (action === 'rename') {

extensions/levelcode-ai/media/chat.html

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,12 +1107,16 @@
11071107
.sesscard .sessline2 { margin-top: 4px; min-height: 26px; display: flex; align-items: center; }
11081108
.sesscard .sesssub { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
11091109
/* The action row is nowrap by design (the card is two fixed-height lines — nothing may reflow), so
1110-
a fifth button cannot be allowed to overflow a narrow sidebar. Below the width where five
1111-
labelled buttons fit, the labels drop and the row becomes icon-only: ~184px instead of ~300px.
1110+
a button that does not fit OVERFLOWS the card rather than wrapping. Six labelled buttons
1111+
(Rename · Fork · Copy · Done · Delete · Pin) measure 434px at this styling; icon-only they are
1112+
~200px. The threshold therefore has to sit ABOVE 434, not below it — at 420 there was a band of
1113+
widths where the labels were still painted and the row spilled out of the card. 460 leaves room
1114+
for a wider system font.
11121115
`title` + `aria-label` are on every button already, so nothing is lost to a pointer or a screen
1113-
reader — only to the eye, and only when there is no room for it. */
1116+
reader — only to the eye, and only when there is no room for it.
1117+
ADDING A BUTTON? Re-measure and raise this; test/webviewCss.test.js pins the floor. */
11141118
.sesscard { container-type: inline-size; }
1115-
@container (max-width: 360px) { .sesscard .sesslbl { display: none; } }
1119+
@container (max-width: 460px) { .sesscard .sesslbl { display: none; } }
11161120
.sesscard .sessacts { display: none; align-items: center; gap: 6px; flex-wrap: nowrap; }
11171121
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
11181122
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -3351,7 +3355,8 @@
33513355
check: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M13.6572 3.13573C13.8583 2.9465 14.175 2.95614 14.3643 3.15722C14.5535 3.35831 14.5438 3.675 14.3428 3.86425L5.84277 11.8642C5.64597 12.0494 5.33756 12.0446 5.14648 11.8535L1.64648 8.35351C1.45121 8.15824 1.45121 7.84174 1.64648 7.64647C1.84174 7.45121 2.15825 7.45121 2.35351 7.64647L5.50976 10.8027L13.6572 3.13573Z"/></svg>',
33523356
trash: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg>',
33533357
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>',
3354-
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>'
3358+
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>',
3359+
fork: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.5 2a2 2 0 0 0-.5 3.93v1.32c0 1.1.9 2 2 2h1.5V11a2 2 0 1 0 1 0V9.25H10c1.1 0 2-.9 2-2V5.93A2 2 0 1 0 11 5.93v1.32a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5.93A2 2 0 0 0 4.5 2zm0 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM8 12a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>'
33553360
};
33563361
function sessActBtn(act, label, svg, cls){
33573362
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
@@ -3377,6 +3382,7 @@
33773382
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
33783383
const acts = '<span class="sessacts">'
33793384
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
3385+
+ sessActBtn('fork', 'Fork', SESS_IC.fork)
33803386
+ sessActBtn('export', 'Copy', SESS_IC.copy)
33813387
+ sessActBtn('done', 'Done', SESS_IC.check)
33823388
+ sessActBtn('delete', 'Delete', SESS_IC.trash)

extensions/levelcode-ai/media/sessionsView.html

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,13 @@
8787
left intact. Sidebar buttons are label-only (icon hidden) to stay compact in a narrow panel. */
8888
.sesscard .sessline2 { margin: 2px 0 0 16px; min-height: 22px; display: flex; align-items: center; }
8989
.sesscard .sesssub { font-size: 12px; color: var(--cc-text3); }
90-
/* Same reasoning as chat.html: the row is nowrap, so a fifth button must not overflow a narrow
91-
pane. Below the width where five labelled buttons fit, drop to icons — title + aria-label are
92-
already on every button, so only the eye loses anything, and only when there is no room. */
90+
/* Same reasoning as chat.html, different numbers — this pane's buttons are smaller (22px tall,
91+
8px padding, 11px text, no border), so six labelled ones measure 363px rather than 434px. The
92+
threshold sits above that; below it the row drops to icons, keeping title + aria-label, so only
93+
the eye loses anything and only when there is no room.
94+
ADDING A BUTTON? Re-measure and raise this; test/webviewCss.test.js pins the floor. */
9395
.sesscard { container-type: inline-size; }
94-
@container (max-width: 340px) { .sesscard .sesslbl { display: none; } }
96+
@container (max-width: 390px) { .sesscard .sesslbl { display: none; } }
9597
.sesscard .sessacts { display: none; align-items: center; gap: 4px; flex-wrap: nowrap; }
9698
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
9799
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -151,7 +153,8 @@
151153
check: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M13.6572 3.13573C13.8583 2.9465 14.175 2.95614 14.3643 3.15722C14.5535 3.35831 14.5438 3.675 14.3428 3.86425L5.84277 11.8642C5.64597 12.0494 5.33756 12.0446 5.14648 11.8535L1.64648 8.35351C1.45121 8.15824 1.45121 7.84174 1.64648 7.64647C1.84174 7.45121 2.15825 7.45121 2.35351 7.64647L5.50976 10.8027L13.6572 3.13573Z"/></svg>',
152154
trash: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg>',
153155
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>',
154-
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>'
156+
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>',
157+
fork: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.5 2a2 2 0 0 0-.5 3.93v1.32c0 1.1.9 2 2 2h1.5V11a2 2 0 1 0 1 0V9.25H10c1.1 0 2-.9 2-2V5.93A2 2 0 1 0 11 5.93v1.32a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5.93A2 2 0 0 0 4.5 2zm0 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM8 12a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>'
155158
};
156159
function sessActBtn(act, label, svg, cls){
157160
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
@@ -177,6 +180,7 @@
177180
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
178181
const acts = '<span class="sessacts">'
179182
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
183+
+ sessActBtn('fork', 'Fork', SESS_IC.fork)
180184
+ sessActBtn('export', 'Copy', SESS_IC.copy)
181185
+ sessActBtn('done', 'Done', SESS_IC.check)
182186
+ sessActBtn('delete', 'Delete', SESS_IC.trash)

extensions/levelcode-ai/sessionStore.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,15 @@ function indexFile(root, slug) { return path.join(root, slug, INDEX_NAME); }
7474
// ── event encoding & parsing (pure) ──────────────────────────────────────────────────────────────
7575

7676
/** The birth line — written once, so even a one-message crash leaves a listable session (design §4). */
77-
function metaLine(id, projectPath, createdAtIso, title) {
78-
return { kind: 'meta', v: SCHEMA_V, id: String(id), project: String(projectPath == null ? '' : projectPath),
77+
function metaLine(id, projectPath, createdAtIso, title, forkedFrom) {
78+
const m = { kind: 'meta', v: SCHEMA_V, id: String(id), project: String(projectPath == null ? '' : projectPath),
7979
createdAt: String(createdAtIso), title: title == null ? null : String(title) };
80+
// Provenance for a forked session, and ONLY for one — the key is absent on an ordinary session so
81+
// every existing file and expectation stays byte-identical. Unknown meta keys are ignored by
82+
// parseSession and deriveEntry, so this is additive in both directions: an older build reading a
83+
// forked session simply does not know it was forked, rather than failing to read it.
84+
if (forkedFrom != null && String(forkedFrom)) { m.forkedFrom = String(forkedFrom); }
85+
return m;
8086
}
8187

8288
function encodeEvent(event) { return JSON.stringify(event) + '\n'; }
@@ -151,9 +157,9 @@ function deriveEntry(meta, events) {
151157
// ── writing (append-only) ────────────────────────────────────────────────────────────────────────
152158

153159
/** Create a session: its directory + the meta birth line. Idempotent-ish (a re-create just rewrites meta). */
154-
function createSession(root, slug, id, projectPath, createdAtIso, title) {
160+
function createSession(root, slug, id, projectPath, createdAtIso, title, forkedFrom) {
155161
fs.mkdirSync(projectDir(root, slug), { recursive: true });
156-
fs.writeFileSync(sessionFile(root, slug, id), encodeEvent(metaLine(id, projectPath, createdAtIso, title)));
162+
fs.writeFileSync(sessionFile(root, slug, id), encodeEvent(metaLine(id, projectPath, createdAtIso, title, forkedFrom)));
157163
return sessionFile(root, slug, id);
158164
}
159165

extensions/levelcode-ai/sessions.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,56 @@ function createSessions(opts) {
120120
note: planner.describeResume(plan, turnsSummarized) };
121121
}
122122

123+
/**
124+
* FORK a session (experience doc §6): a NEW session seeded with a copy of this one's conversation,
125+
* leaving the original completely untouched — "the what-if-I'd-told-it-to-do-X-instead branch".
126+
*
127+
* Fork-from-END, deliberately. §411 settles the scoping question: "fork-from-end first; per-turn
128+
* fork rides the transcript picker later." Forking from an arbitrary turn needs a UI for choosing
129+
* the turn, and that is a different piece of work from the copy itself.
130+
*
131+
* WHICH EVENTS TRAVEL is the whole design here, and the answer is not "all of them":
132+
* • `user` / `agent` / `assistant` — YES. This is the conversation; it is the thing being forked.
133+
* • `title` — YES, then overridden below, so the fork is recognisable in a
134+
* list where it would otherwise be a second row with the
135+
* identical name.
136+
* • `end` — NO. That is the original's terminal state. Copying it would
137+
* make a live fork claim it had already finished, and
138+
* deriveEntry would render it `done` while you typed into it.
139+
* • `label` — NO. Lifecycle and pinning belong to the ORIGINAL. A fork of
140+
* an archived session must arrive active, or it is born
141+
* invisible in the default Active scope; a fork of a pinned
142+
* one must not silently take up a second pin.
143+
*
144+
* Returns the new id, or null if the source is gone/unreadable. The caller resumes it — a fork IS a
145+
* resume, just into a copy — so nothing here duplicates the replay logic.
146+
*/
147+
function fork(id) {
148+
let s;
149+
try { s = store.readSession(store.sessionFile(root, slug, id)); }
150+
catch (e) { return null; }
151+
152+
const src = store.deriveEntry(s.meta, s.events);
153+
const newId = store.newSessionId(clock());
154+
try {
155+
// The meta records what it came from — provenance is the honesty guarantee everywhere else
156+
// in this system (§4), and it is also what a later branch-graph would draw from.
157+
store.createSession(root, slug, newId, projectPath, iso(), null, id);
158+
const file = store.sessionFile(root, slug, newId);
159+
for (const e of (Array.isArray(s.events) ? s.events : [])) {
160+
if (!e || e.kind === 'end' || e.kind === 'label') { continue; }
161+
store.appendEvent(file, e);
162+
}
163+
// Last, so it wins: deriveEntry takes the LATEST title event.
164+
const base = src.title || 'Untitled';
165+
store.appendEvent(file, events.titleEvent(/\(fork\)\s*$/.test(base) ? base : base + ' (fork)', iso()));
166+
live = { id: newId, file };
167+
if (state) { try { state.set('liveSessionId', newId); } catch (e2) { /* convenience */ } }
168+
reindexId(newId);
169+
return newId;
170+
} catch (e) { return null; }
171+
}
172+
123173
// Append-only lifecycle edits (§4.9): each writes one event, then refreshes that id's index row. All
124174
// best-effort (return false rather than throw) — a History edit must never disrupt anything.
125175
function appendTo(id, event) {
@@ -282,7 +332,7 @@ function createSessions(opts) {
282332

283333
function liveId() { return live ? live.id : null; }
284334

285-
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 };
335+
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 };
286336
}
287337

288338
module.exports = { createSessions };

0 commit comments

Comments
 (0)