Skip to content

Commit a6c66d1

Browse files
ndemiancclaude
andcommitted
feat(levelcode): resume sessions + Done/Rename/Delete from History
The read side of Sessions. Reopen a past session and it walks back in — the model's context is restored (budget-fitted to the window, §4.5) and the readable conversation is replayed into the chat; the card actions archive, rename, and soft-delete. All append-only, so nothing rewrites history. - sessionEvents.js: toDisplayTurns(messages) — the pure fold from a raw transcript to the readable prompts + prose to replay (tool_result user messages and tool_use-only assistant turns dropped). - sessions.js: resume(id) rebuilds the transcript, plans the load (three-tier planResume), re-attaches so further turns append to the SAME session (no fork), and returns { full, messages, turns, plan, note }. rename/archive/ trash/setPinned are append-only label/title events; reindex now keys off any id so a lifecycle edit on a past session updates its list row too. - extension.js: handleSessionAction routes resume/done/rename/delete from both surfaces; resumeSession restores agentMessages + replays via a sessionResumed post + focuses the chat; refreshSessions pushes the list to the modal and the sidebar after an edit. Done archives (reversible), Delete soft-trashes. - media/chat.html: a "Resumed" banner (+ the honest summary note when the transcript was too big to carry verbatim) and resumeTranscript() replaying the conversation; log-token themed, no drift to the SESSIONS-PURE block. - package.json: sessions.resumeBudgetPct (default 40). - tests: +2 sessionEvents (toDisplayTurns), +4 sessions (resume verbatim / compacted / missing, and archive+rename+trash), +1 sessionsUi (resume wiring). Full suite 30 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 77d4f13 commit a6c66d1

8 files changed

Lines changed: 268 additions & 11 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const SYSTEM_PROMPT =
4343
let ctx;
4444
/** @type {vscode.Webview | undefined} */
4545
let activeWebview;
46+
let sessionsWebview; // the Sessions sidebar webview (for pushing list refreshes after a History action)
4647
/** @type {{role:string,content:string}[]} */
4748
let conversation = [];
4849
/** @type {string | null} */
@@ -684,6 +685,68 @@ function sessionList() {
684685
return shown;
685686
}
686687

688+
// Push the current list to whichever surfaces are open — the /sessions modal (in the chat webview) and the
689+
// Sessions sidebar. Called after any lifecycle edit so both stay live.
690+
function refreshSessions() {
691+
const entries = sessionList();
692+
try { post({ type: 'sessions', entries, open: false }); } catch (e) { /* chat webview may be closed */ }
693+
try { if (sessionsWebview) { sessionsWebview.postMessage({ type: 'sessions', entries }); } } catch (e) { /* sidebar may be closed */ }
694+
}
695+
696+
// A card action from either surface. resume reopens the session; done/rename/delete are append-only edits
697+
// (§4.9): Done archives (reversible, never deletes), Delete soft-trashes, Rename retitles. All refresh both
698+
// surfaces. Best-effort — a History action must never throw into the UI.
699+
async function handleSessionAction(action, id) {
700+
const m = sessionsManager();
701+
if (!m || !id) { return; }
702+
dbg('sessions.action', { action, id });
703+
try {
704+
if (action === 'resume') { await resumeSession(id); return; }
705+
if (action === 'done') { m.archive(id); refreshSessions(); return; }
706+
if (action === 'delete') { m.trash(id); refreshSessions(); return; }
707+
if (action === 'rename') {
708+
const cur = (m.list().find((e) => e.id === id) || {}).title || '';
709+
const title = await vscode.window.showInputBox({ prompt: 'Rename this session', value: cur, validateInput: (v) => (v && v.trim() ? null : 'Enter a name') });
710+
if (title != null && title.trim()) { m.rename(id, title); refreshSessions(); }
711+
return;
712+
}
713+
} catch (e) { dbg('sessions.action.error', { action, id, msg: String((e && e.message) || e) }); }
714+
}
715+
716+
// Reopen a past session: restore its transcript as the live agent context (budget-fitted, §4.5), reset the
717+
// per-turn UI, and replay the readable conversation into the chat so it feels like walking back in.
718+
async function resumeSession(id) {
719+
const m = sessionsManager();
720+
if (!m) { return; }
721+
if (abort) { abort.abort(); } // stop any running turn before we swap the context out
722+
let r = null;
723+
try { r = m.resume(id, { contextWindow: currentContextLimit(), budgetPct: aiConfig().get('sessions.resumeBudgetPct', 40) }); }
724+
catch (e) { dbg('sessions.resume.error', { id, msg: String((e && e.message) || e) }); }
725+
if (!r) { post({ type: 'agentError', message: 'That session could not be opened — it may have been deleted.' }); return; }
726+
727+
agentMessages = Array.isArray(r.messages) ? r.messages.slice() : []; // what the model resumes with
728+
// Tier 2/3: the head didn't fit the window, so only the recent tail is in context. Prepend a factual
729+
// note so the model KNOWS earlier turns exist (a real head-summary is a later refinement) — the user
730+
// still sees the whole transcript in the replay. Ephemeral: never persisted (recordTurn appends only
731+
// new turns from the goal onward).
732+
if (r.plan && r.plan.tier > 1) {
733+
const omitted = Math.max(0, (Array.isArray(r.full) ? r.full.length : 0) - agentMessages.length);
734+
agentMessages.unshift({ role: 'user', content: '[Resumed session — the earlier part of this conversation (' + omitted + ' message' + (omitted === 1 ? '' : 's') + ') was omitted to fit the context window. It is shown above in the transcript but not included here; ask if you need details from it.]' });
735+
}
736+
conversation = [];
737+
checkpoints.length = 0; currentCheckpoint = null; // old file-snapshots can't be restored
738+
if (!agentMode) { agentMode = true; post({ type: 'mode', agent: true }); }
739+
const turns = Array.isArray(r.turns) ? r.turns : [];
740+
const lastUser = turns.filter((t) => t.role === 'user').pop();
741+
if (lastUser) { lastAgentGoal = lastUser.text; } // keep Continue/Retry meaningful
742+
post({ type: 'reset' });
743+
post({ type: 'sessionResumed', id, title: (r.entry && r.entry.title) || 'Session', note: r.note || '', tier: r.plan && r.plan.tier, turns });
744+
postContextFiles();
745+
refreshSessions(); // the resumed session bumps to the top — keep both surfaces current
746+
vscode.commands.executeCommand('levelcodeAi.chat.focus');
747+
dbg('sessions.resumed', { id, tier: r.plan && r.plan.tier, restored: agentMessages.length, shown: turns.length });
748+
}
749+
687750
function newChat() {
688751
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
689752
// so it lands in History as a finished session and the next turn opens a fresh one.
@@ -1788,7 +1851,7 @@ class ChatViewProvider {
17881851
case 'listSkills': { const en = aiConfig().get('skills.enabled', true); const idx = en ? loadSkills(ctx.extensionPath, dbg) : new Map(); dbg('listSkills', { enabled: en, count: idx.size }); post({ type: 'skillsList', enabled: en, skills: skillsMenu(idx) }); break; }
17891852
case 'listMcp': post({ type: 'mcpList', mcp: mcpOverview() }); break;
17901853
case 'listSessions': post({ type: 'sessions', entries: sessionList(), open: false }); break; // reply into the already-open modal
1791-
case 'sessionAction': dbg('sessions.action', { action: msg.action, id: msg.id }); break; // TODO(next): resume/done/rename/delete
1854+
case 'sessionAction': await handleSessionAction(msg.action, msg.id); break;
17921855
case 'feedback': dbg('feedback', { value: msg.value, model: msg.model }); await recordFeedback(msg.value, msg.model); break;
17931856
case 'openFile': await openWorkspaceFile(msg.path); break;
17941857
case 'reviewKeepFile': dbg('review.keep', { id: msg.id }); review.keepFile(msg.id, 'kept'); break;
@@ -1834,16 +1897,16 @@ function getHtml() {
18341897
class SessionsViewProvider {
18351898
/** @param {vscode.WebviewView} view */
18361899
resolveWebviewView(view) {
1900+
sessionsWebview = view.webview;
18371901
view.webview.options = { enableScripts: true, localResourceRoots: [ctx.extensionUri] };
18381902
view.webview.html = getSessionsHtml();
1839-
view.webview.onDidReceiveMessage((msg) => {
1903+
view.webview.onDidReceiveMessage(async (msg) => {
18401904
switch (msg.type) {
18411905
// The real session index for this workspace (empty on a fresh install — the view shows its
18421906
// own empty state). Posted to THIS view's webview, not the chat's.
18431907
case 'listSessions': view.webview.postMessage({ type: 'sessions', entries: sessionList() }); break;
18441908
case 'newSession': newChat(); vscode.commands.executeCommand('levelcodeAi.chat.focus'); break;
1845-
// TODO(next): resume/done/rename/delete once the read side (planResume + replay) lands.
1846-
case 'sessionAction': dbg('sessions.action', { action: msg.action, id: msg.id }); break;
1909+
case 'sessionAction': await handleSessionAction(msg.action, msg.id); break;
18471910
}
18481911
});
18491912
}

‎extensions/levelcode-ai/media/chat.html‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,13 @@
622622
.diff .dl.del .s { color: var(--vscode-gitDecoration-deletedResourceForeground, #e06c75); }
623623
.diff .dl.ctx { opacity: .55; }
624624
.autoctx { font-size: 11px; opacity: .55; margin: -4px 2px 2px; }
625+
/* The "Resumed" banner at the top of a replayed session (message-list notice, so it uses log tokens). */
626+
.sessresumed { margin: 2px 2px 12px; padding: 8px 11px; border: 1px solid var(--border); border-left: 2px solid var(--vscode-textLink-foreground, #6a9955); border-radius: 8px; background: var(--field-bg); }
627+
.sessresumed .rrow { display: flex; align-items: center; gap: 6px; min-width: 0; }
628+
.sessresumed .ci { width: 13px; height: 13px; opacity: .8; flex: 0 0 auto; }
629+
.sessresumed .rtag { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--vscode-descriptionForeground); flex: 0 0 auto; }
630+
.sessresumed .rtitle { font-size: 12.5px; font-weight: 600; color: var(--vscode-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
631+
.sessresumed .rnote { margin-top: 5px; font-size: 11px; color: var(--vscode-descriptionForeground); }
625632
.autoctx .files { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); }
626633
#empty { margin: auto; max-width: min(560px, 92vw); text-align: center; opacity: .65; line-height: 1.55; padding: 20px; }
627634
#empty .empty-copy { max-width: 280px; margin: 0 auto; }
@@ -3355,6 +3362,25 @@
33553362
renderSessions(); ov.style.display = 'flex';
33563363
}
33573364
function closeSessions(){ const ov = document.getElementById('sessOverlay'); if (ov) { ov.style.display = 'none'; } }
3365+
// Replay a resumed session into the chat: a compact "Resumed" banner (+ the honest summary note, if the
3366+
// transcript was too big to carry verbatim) followed by the readable conversation — user prompts and the
3367+
// assistant's prose, tool plumbing dropped. The host has already reset the log and restored the model's
3368+
// context; this is the visible half, so it feels like stepping back into the session.
3369+
function resumeTranscript(m){
3370+
closeSessions();
3371+
clearEmpty();
3372+
const banner = document.createElement('div');
3373+
banner.className = 'sessresumed';
3374+
banner.innerHTML = '<div class="rrow">' + codicon('sync') + '<span class="rtag">Resumed</span>'
3375+
+ '<span class="rtitle" title="' + escAttr(m.title || '') + '">' + esc(m.title || 'Session') + '</span></div>'
3376+
+ (m.note ? '<div class="rnote">' + esc(m.note) + '</div>' : '');
3377+
log.appendChild(banner);
3378+
turnLabeled = false;
3379+
for (const t of (Array.isArray(m.turns) ? m.turns : [])){
3380+
add(t.role, t.role === 'user' ? esc(t.text) : render(t.text));
3381+
}
3382+
forceStick();
3383+
}
33583384
(function wireSessions(){
33593385
const close = document.getElementById('sessClose'); if (close) { close.onclick = closeSessions; }
33603386
const ov = document.getElementById('sessOverlay'); if (ov) { ov.addEventListener('click', (e) => { if (e.target === ov) { closeSessions(); } }); }
@@ -3423,6 +3449,7 @@
34233449
else if (m.type === 'skillsList'){ renderSkillsList(m); }
34243450
else if (m.type === 'mcpList'){ renderMcpList(m.mcp || {}); }
34253451
else if (m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; if (m.open === false) { renderSessions(); } else { openSessions(); } }
3452+
else if (m.type === 'sessionResumed'){ resumeTranscript(m); }
34263453
else if (m.type === 'verifyRun'){ addVerifyRun(m); }
34273454
else if (m.type === 'verifyOutput'){ verifyOutputAppend(m); }
34283455
else if (m.type === 'verifyDone'){ verifyDoneFinish(m); }

‎extensions/levelcode-ai/package.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,13 @@
441441
"description": "Folder to store session History in. Empty = ~/.levelcode/sessions. Each project gets its own subfolder; the files are append-only JSONL you can back up, diff, or delete by hand.",
442442
"scope": "machine"
443443
},
444+
"levelcode.ai.sessions.resumeBudgetPct": {
445+
"type": "number",
446+
"default": 40,
447+
"minimum": 5,
448+
"maximum": 100,
449+
"description": "When you resume a session, the share of the model's context window its restored transcript may fill before it is summarized to fit. Lower = resume with less history (cheaper, faster); higher = carry more of the past conversation verbatim. The full transcript is always kept on disk and shown in the replay regardless."
450+
},
444451
"levelcode.ai.verify.enabled": {
445452
"type": "boolean",
446453
"default": true,

‎extensions/levelcode-ai/sessionEvents.js‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,37 @@ function eventsToMessages(events) {
8484
return out;
8585
}
8686

87+
// ── the readable transcript for a resume replay (pure) ───────────────────────────────────────────
88+
89+
/** The human-visible text of a provider message: a string as-is, or the joined `text` blocks of an array. */
90+
function messageText(content) {
91+
if (content == null) { return ''; }
92+
if (typeof content === 'string') { return content; }
93+
if (Array.isArray(content)) {
94+
return content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n\n');
95+
}
96+
return '';
97+
}
98+
99+
/**
100+
* Fold a rebuilt messages array into the readable conversation to REPLAY on resume: one entry per user
101+
* prompt and per assistant answer, in order. Tool plumbing is dropped — a user message that is only a
102+
* `tool_result`, and an assistant turn that is only `tool_use` (no prose) — so the replay reads like the
103+
* chat did, not like the raw transcript. Pure, so the webview never has to know provider message shapes.
104+
*/
105+
function toDisplayTurns(messages) {
106+
const out = [];
107+
for (const m of (Array.isArray(messages) ? messages : [])) {
108+
if (!m || (m.role !== 'user' && m.role !== 'assistant')) { continue; }
109+
// a user message whose content is purely tool_result blocks is plumbing, not something the user typed
110+
if (m.role === 'user' && Array.isArray(m.content) && m.content.length && m.content.every((b) => b && b.type === 'tool_result')) { continue; }
111+
const text = messageText(m.content);
112+
if (!text.trim()) { continue; } // e.g. an assistant turn that was only tool calls
113+
out.push({ role: m.role, text });
114+
}
115+
return out;
116+
}
117+
87118
/** How many messages are already persisted — so a per-turn append stores only the new tail, not the lot. */
88119
function tailFrom(messages, storedCount) {
89120
const msgs = Array.isArray(messages) ? messages : [];
@@ -95,5 +126,5 @@ module.exports = {
95126
EDIT_TOOLS,
96127
toolStatsFromMessages,
97128
userTurnEvent, agentTurnEvent, endEvent, titleEvent, labelEvent,
98-
eventsToMessages, tailFrom
129+
eventsToMessages, messageText, toDisplayTurns, tailFrom
99130
};

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
const store = require('./sessionStore');
1919
const events = require('./sessionEvents');
20+
const planner = require('./sessionResume');
2021

2122
/**
2223
* @param {{ root: string, slug: string, projectPath: string,
@@ -32,16 +33,18 @@ function createSessions(opts) {
3233

3334
const iso = () => clock().toISOString();
3435

35-
// Refresh this session's index row from its file. Best-effort: the index is a cache (scan rebuilds it),
36-
// so a failure here must never surface into a chat turn.
37-
function reindex() {
38-
if (!live) { return; }
36+
// Refresh a session's index row from its file. Best-effort: the index is a cache (scan rebuilds it), so
37+
// a failure here must never surface into a chat turn. Works for any id, so lifecycle actions on a past
38+
// (non-live) session update the list too.
39+
function reindexId(id) {
40+
if (!id) { return; }
3941
try {
40-
const s = store.readSession(live.file);
42+
const s = store.readSession(store.sessionFile(root, slug, id));
4143
const idx = store.loadIndex(root, slug);
4244
store.writeIndex(root, slug, store.upsertEntry(idx.entries, store.deriveEntry(s.meta, s.events)));
4345
} catch (e) { /* index is a rebuildable cache */ }
4446
}
47+
function reindex() { if (live) { reindexId(live.id); } }
4548

4649
/** Open a fresh session (its dir + meta birth line) if none is live. Idempotent while one is live. */
4750
function ensure() {
@@ -81,12 +84,53 @@ function createSessions(opts) {
8184
if (state) { try { state.set('liveSessionId', null); } catch (e) { /* convenience */ } }
8285
}
8386

87+
/**
88+
* Reopen a past session as the LIVE one. Rebuilds its full transcript, plans how much fits the model's
89+
* window (three-tier, §4.5), and re-attaches so further turns append to THIS session. Returns:
90+
* full — the whole conversation (for the visible replay),
91+
* messages — the budget-fitted subset the model resumes with (== full for a verbatim/tier-1 resume),
92+
* plan — the resume plan (tier + cut), note — the honest "resumed from a summary" line, entry.
93+
* null if the session is gone/unreadable. Append-only: the prior `end` event is harmless (rebuild skips
94+
* it, and the next turn's events simply extend the history).
95+
*/
96+
function resume(id, opts) {
97+
let s;
98+
try { s = store.readSession(store.sessionFile(root, slug, id)); } catch (e) { return null; }
99+
const full = events.eventsToMessages(s.events);
100+
const plan = planner.planResume(full, opts || {});
101+
const messages = plan.tier === 1 ? full : (Array.isArray(plan.tail) ? plan.tail : full);
102+
const head = full.slice(0, plan.cutIndex || 0);
103+
const turnsSummarized = head.filter((m) => m && m.role === 'user').length;
104+
live = { id, file: store.sessionFile(root, slug, id) };
105+
if (state) { try { state.set('liveSessionId', id); } catch (e) { /* convenience */ } }
106+
reindexId(id);
107+
return { id, meta: s.meta, entry: store.deriveEntry(s.meta, s.events), full, messages, plan,
108+
turns: events.toDisplayTurns(full), // the readable transcript to replay in the webview
109+
note: planner.describeResume(plan, turnsSummarized) };
110+
}
111+
112+
// Append-only lifecycle edits (§4.9): each writes one event, then refreshes that id's index row. All
113+
// best-effort (return false rather than throw) — a History edit must never disrupt anything.
114+
function appendTo(id, event) {
115+
try { store.appendEvent(store.sessionFile(root, slug, id), event); reindexId(id); return true; }
116+
catch (e) { return false; }
117+
}
118+
/** Completion = ARCHIVE (reversible, keeps the work), never delete. Drops out of the active list. */
119+
function archive(id) { return appendTo(id, events.labelEvent({ lifecycle: 'archived' }, iso())); }
120+
/** Soft delete — moves to the `trashed` lifecycle (reversible, append-only; the file is left on disk). */
121+
function trash(id) { return appendTo(id, events.labelEvent({ lifecycle: 'trashed' }, iso())); }
122+
function setPinned(id, on) { return appendTo(id, events.labelEvent({ pinned: !!on }, iso())); }
123+
function rename(id, title) {
124+
if (title == null || !String(title).trim()) { return false; }
125+
return appendTo(id, events.titleEvent(String(title).trim(), iso()));
126+
}
127+
84128
/** The session index for this project (what the panel/view list). Empty (never throws) if unreadable. */
85129
function list() { try { return store.loadIndex(root, slug).entries; } catch (e) { return []; } }
86130

87131
function liveId() { return live ? live.id : null; }
88132

89-
return { ensure, recordTurn, seal, list, liveId };
133+
return { ensure, recordTurn, seal, resume, archive, trash, setPinned, rename, list, liveId };
90134
}
91135

92136
module.exports = { createSessions };

‎extensions/levelcode-ai/test/sessionEvents.test.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,20 @@ test('END-TO-END: events built here derive the right card in sessionStore (one s
9393
assert.deepStrictEqual(card.filesEdited, ['refund.rb', 'redis_lock.rb'], 'and so did the files-edited chips');
9494
});
9595

96+
test('DISPLAY: toDisplayTurns folds a raw transcript into the readable prompts + prose to replay', () => {
97+
const turns = E.toDisplayTurns(conversation());
98+
assert.deepStrictEqual(turns, [
99+
{ role: 'user', text: 'add idempotency to refunds' }, // the user's typed prompt
100+
{ role: 'assistant', text: 'reading' }, // the text block, not the read_file tool_use
101+
{ role: 'assistant', text: 'Done: idempotent refunds via Redis keys' }
102+
], 'tool_result user messages and tool_use-only assistant turns are dropped');
103+
});
104+
105+
test('DISPLAY: toDisplayTurns is tolerant of junk and joins multiple text blocks', () => {
106+
assert.deepStrictEqual(E.toDisplayTurns(null), []);
107+
assert.deepStrictEqual(E.toDisplayTurns([null, { role: 'system', content: 'x' }, { role: 'user', content: '' }]), [], 'empty/other roles skipped');
108+
const joined = E.toDisplayTurns([{ role: 'assistant', content: [{ type: 'text', text: 'one' }, { type: 'tool_use', id: 'a', name: 'x' }, { type: 'text', text: 'two' }] }]);
109+
assert.deepStrictEqual(joined, [{ role: 'assistant', text: 'one\n\ntwo' }]);
110+
});
111+
96112
console.log('sessionEvents: ' + n + ' tests passed');

0 commit comments

Comments
 (0)