Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/levelcode-sessions-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
16 changes: 11 additions & 5 deletions extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -3351,7 +3355,8 @@
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>',
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>',
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>',
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>'
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>',
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>'
};
function sessActBtn(act, label, svg, cls){
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
Expand All @@ -3377,6 +3382,7 @@
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
const acts = '<span class="sessacts">'
+ 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)
Expand Down
14 changes: 9 additions & 5 deletions extensions/levelcode-ai/media/sessionsView.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -151,7 +153,8 @@
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>',
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>',
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>',
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>'
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>',
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>'
};
function sessActBtn(act, label, svg, cls){
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
Expand All @@ -177,6 +180,7 @@
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
const acts = '<span class="sessacts">'
+ 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)
Expand Down
14 changes: 10 additions & 4 deletions extensions/levelcode-ai/sessionStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'; }
Expand Down Expand Up @@ -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);
}

Expand Down
52 changes: 51 additions & 1 deletion extensions/levelcode-ai/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
Loading