Skip to content

Commit c392ae5

Browse files
ndemiancclaude
andcommitted
feat(levelcode): Undo for Done/Delete — accidental clicks are recoverable
Done and Delete removed a session from the list with no way back in the UI (the JSONL stayed on disk, but a user couldn't recover it). Now each offers an Undo: after the card vanishes, a toast at the panel foot says "Archived/Deleted "<title>" · Undo" for a few seconds; one click restores it. - sessions.js: restore(id) — an append-only label event that sets lifecycle back to active, reversing archive/trash. Exported + tested. - extension.js: handleSessionAction captures the title, applies Done/Delete, then posts a sessionUndo to both surfaces; a new 'restore' action calls m.restore. postSessions() factored out (posts to the modal + the sidebar). - media/chat.html + media/sessionsView.html: a #sessUndo/#svUndo toast pinned at the foot, shown on sessionUndo (auto-hides after 7s), its Undo button posting a restore. Themed, guarded. - tests: +1 sessions (restore reverses archive and trash), +1 sessionsUi (undo wiring on both surfaces). 30 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 919fb72 commit c392ae5

6 files changed

Lines changed: 92 additions & 13 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -687,23 +687,32 @@ function sessionList() {
687687

688688
// Push the current list to whichever surfaces are open — the /sessions modal (in the chat webview) and the
689689
// 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.
690+
// Post a message to whichever session surfaces are open — the /sessions modal (chat webview) and the
691+
// Sessions sidebar. Both guarded (either may be closed).
692+
function postSessions(msg) {
693+
try { post(msg); } catch (e) { /* chat webview may be closed */ }
694+
try { if (sessionsWebview) { sessionsWebview.postMessage(msg); } } catch (e) { /* sidebar may be closed */ }
695+
}
696+
function refreshSessions() { postSessions({ type: 'sessions', entries: sessionList(), open: false }); }
697+
698+
// A card action from either surface. resume reopens the session; done/delete/rename/pin are append-only edits
699+
// (§4.9): Done archives (reversible, never deletes), Delete soft-trashes, Rename retitles, Pin toggles.
700+
// Done/Delete offer an Undo (restore) so an accidental click is recoverable. Best-effort — a History action
701+
// must never throw into the UI.
699702
async function handleSessionAction(action, id) {
700703
const m = sessionsManager();
701704
if (!m || !id) { return; }
702705
dbg('sessions.action', { action, id });
703706
try {
704707
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; }
708+
if (action === 'done' || action === 'delete') {
709+
const title = (m.list().find((e) => e.id === id) || {}).title || 'this session';
710+
if (action === 'done') { m.archive(id); } else { m.trash(id); }
711+
refreshSessions();
712+
postSessions({ type: 'sessionUndo', action, id, title }); // offer to undo — the card just vanished
713+
return;
714+
}
715+
if (action === 'restore') { m.restore(id); refreshSessions(); return; }
707716
if (action === 'pin') { const cur = (m.list().find((e) => e.id === id) || {}).pinned; m.setPinned(id, !cur); refreshSessions(); return; }
708717
if (action === 'rename') {
709718
const cur = (m.list().find((e) => e.id === id) || {}).title || '';

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,12 @@
10791079
.sesspanel .sessbar { display: flex; align-items: center; gap: 9px; padding: 12px 14px 10px; border-bottom: 1px solid var(--cc-line); }
10801080
.sesspanel .sesstitle { font-weight: 600; font-size: 14px; }
10811081
.sesspanel .sesslist { flex: 1 1 auto; overflow-y: auto; padding: 2px 12px 14px; }
1082+
/* Undo toast pinned at the panel foot after Done/Delete (an accidental click is recoverable). */
1083+
.sesspanel .sessundo { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 12px 12px; padding: 8px 12px; background: var(--cc-surface); border: 1px solid var(--cc-line); border-radius: 8px; font-size: 12.5px; color: var(--cc-text2); }
1084+
.sesspanel .sessundo[hidden] { display: none; }
1085+
.sesspanel .sessundo .ut { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1086+
.sesspanel .sessundo .ub { flex: 0 0 auto; background: transparent; border: none; color: var(--cc-accent); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
1087+
.sesspanel .sessundo .ub:hover { text-decoration: underline; }
10821088
.sesspanel .sessbucket { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--cc-text3); margin: 12px 4px 7px; }
10831089
.sesspanel .sessempty { padding: 44px 18px; text-align: center; color: var(--cc-text3); font-size: 13px; line-height: 1.6; }
10841090
.sesscard { position: relative; background: var(--cc-bg); border: 1px solid var(--cc-line); border-radius: 7px; padding: 9px 11px; margin-bottom: 7px; cursor: pointer; transition: border-color .12s, transform .12s, box-shadow .12s; }
@@ -1231,6 +1237,7 @@
12311237
<button class="ovclose" id="sessClose" title="Close" aria-label="Close">&#10005;</button>
12321238
<div class="sessbar"><span class="sesstitle">Sessions</span></div>
12331239
<div class="sesslist" id="sessList"></div>
1240+
<div class="sessundo" id="sessUndo" hidden></div>
12341241
</div>
12351242
</div>
12361243

@@ -3372,6 +3379,15 @@
33723379
renderSessions(); ov.style.display = 'flex';
33733380
}
33743381
function closeSessions(){ const ov = document.getElementById('sessOverlay'); if (ov) { ov.style.display = 'none'; } }
3382+
// The Undo toast for Done/Delete — a quick, recoverable safety net. Auto-hides after a few seconds.
3383+
let sessUndoTimer = null;
3384+
function showUndo(m){
3385+
const el = document.getElementById('sessUndo'); if (!el) { return; }
3386+
const verb = m.action === 'delete' ? 'Deleted' : 'Archived';
3387+
el.innerHTML = '<span class="ut">' + verb + ' “' + esc(m.title || 'session') + '”</span><button class="ub" data-id="' + escAttr(m.id || '') + '">Undo</button>';
3388+
el.hidden = false;
3389+
clearTimeout(sessUndoTimer); sessUndoTimer = setTimeout(function(){ el.hidden = true; }, 7000);
3390+
}
33753391
// Replay a resumed session into the chat: a compact "Resumed" banner (+ the honest summary note, if the
33763392
// transcript was too big to carry verbatim) followed by the readable conversation — user prompts and the
33773393
// assistant's prose, tool plumbing dropped. The host has already reset the log and restored the model's
@@ -3402,6 +3418,12 @@
34023418
vscode.postMessage({ type: 'sessionAction', action: action, id: id });
34033419
if (action === 'resume') { closeSessions(); }
34043420
}); }
3421+
const undo = document.getElementById('sessUndo');
3422+
if (undo) { undo.addEventListener('click', (e) => {
3423+
const b = e.target.closest && e.target.closest('.ub'); if (!b) { return; }
3424+
vscode.postMessage({ type: 'sessionAction', action: 'restore', id: b.getAttribute('data-id') });
3425+
undo.hidden = true; clearTimeout(sessUndoTimer);
3426+
}); }
34053427
document.addEventListener('keydown', (e) => { const o = document.getElementById('sessOverlay'); if (e.key === 'Escape' && o && o.style.display !== 'none'){ e.preventDefault(); closeSessions(); } });
34063428
})();
34073429

@@ -3460,6 +3482,7 @@
34603482
else if (m.type === 'mcpList'){ renderMcpList(m.mcp || {}); }
34613483
else if (m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; if (m.open === false) { renderSessions(); } else { openSessions(); } }
34623484
else if (m.type === 'sessionResumed'){ resumeTranscript(m); }
3485+
else if (m.type === 'sessionUndo'){ showUndo(m); }
34633486
else if (m.type === 'verifyRun'){ addVerifyRun(m); }
34643487
else if (m.type === 'verifyOutput'){ verifyOutputAppend(m); }
34653488
else if (m.type === 'verifyDone'){ verifyDoneFinish(m); }

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
.sessview { display: flex; flex-direction: column; height: 100vh; }
1919
.sessnew { margin: 2px 10px 8px; padding: 7px 10px; background: var(--cc-bg); border: 1px solid var(--cc-line); border-radius: 6px; color: var(--cc-text); font: inherit; font-size: 12.5px; cursor: pointer; text-align: center; }
2020
.sessnew:hover { border-color: var(--cc-accent); }
21+
/* Undo toast pinned at the foot after Done/Delete (an accidental click is recoverable). */
22+
.sessundo { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 10px 10px; padding: 8px 11px; background: var(--cc-bg); border: 1px solid var(--cc-line); border-radius: 8px; font-size: 12.5px; color: var(--cc-text2); }
23+
.sessundo[hidden] { display: none; }
24+
.sessundo .ut { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
25+
.sessundo .ub { flex: 0 0 auto; background: transparent; border: none; color: var(--cc-accent); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
26+
.sessundo .ub:hover { text-decoration: underline; }
2127
.sesslist { flex: 1 1 auto; overflow-y: auto; padding: 0 10px 12px; }
2228
.sessbucket { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--cc-text3); margin: 12px 4px 7px; }
2329
.sessbucket .cnt { letter-spacing: 0; }
@@ -52,6 +58,7 @@
5258
<div class="sessview">
5359
<button id="svNew" class="sessnew" type="button">New Session</button>
5460
<div class="sesslist" id="svList"></div>
61+
<div class="sessundo" id="svUndo" hidden></div>
5562
</div>
5663
<script nonce="__NONCE__">
5764
const vscode = acquireVsCodeApi();
@@ -147,7 +154,21 @@
147154
const id = card.getAttribute('data-id'); const btn = e.target.closest('[data-act]');
148155
vscode.postMessage({ type: 'sessionAction', action: btn ? btn.getAttribute('data-act') : 'resume', id: id });
149156
});
150-
window.addEventListener('message', (ev) => { const m = ev.data; if (m && m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; renderSessions(); } });
157+
// The Undo toast for Done/Delete — a quick, recoverable safety net. Auto-hides after a few seconds.
158+
let sessUndoTimer = null;
159+
function showUndo(m){
160+
const el = document.getElementById('svUndo'); if (!el) { return; }
161+
const verb = m.action === 'delete' ? 'Deleted' : 'Archived';
162+
el.innerHTML = '<span class="ut">' + verb + ' “' + esc(m.title || 'session') + '”</span><button class="ub" data-id="' + escAttr(m.id || '') + '">Undo</button>';
163+
el.hidden = false;
164+
clearTimeout(sessUndoTimer); sessUndoTimer = setTimeout(function(){ el.hidden = true; }, 7000);
165+
}
166+
document.getElementById('svUndo').addEventListener('click', (e) => {
167+
const b = e.target.closest && e.target.closest('.ub'); if (!b) { return; }
168+
vscode.postMessage({ type: 'sessionAction', action: 'restore', id: b.getAttribute('data-id') });
169+
document.getElementById('svUndo').hidden = true; clearTimeout(sessUndoTimer);
170+
});
171+
window.addEventListener('message', (ev) => { const m = ev.data; if (m && m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; renderSessions(); } else if (m && m.type === 'sessionUndo'){ showUndo(m); } });
151172
renderSessions();
152173
vscode.postMessage({ type: 'listSessions' });
153174
</script>

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ function createSessions(opts) {
119119
function archive(id) { return appendTo(id, events.labelEvent({ lifecycle: 'archived' }, iso())); }
120120
/** Soft delete — moves to the `trashed` lifecycle (reversible, append-only; the file is left on disk). */
121121
function trash(id) { return appendTo(id, events.labelEvent({ lifecycle: 'trashed' }, iso())); }
122+
/** Bring an archived/trashed session back into the active list — the Undo behind Done/Delete. */
123+
function restore(id) { return appendTo(id, events.labelEvent({ lifecycle: 'active' }, iso())); }
122124
function setPinned(id, on) { return appendTo(id, events.labelEvent({ pinned: !!on }, iso())); }
123125
function rename(id, title) {
124126
if (title == null || !String(title).trim()) { return false; }
@@ -152,7 +154,7 @@ function createSessions(opts) {
152154

153155
function liveId() { return live ? live.id : null; }
154156

155-
return { ensure, recordTurn, seal, resume, archive, trash, setPinned, rename, autoArchiveStale, list, liveId };
157+
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, list, liveId };
156158
}
157159

158160
module.exports = { createSessions };

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ test('MANAGER: archive / rename / trash are append-only edits that update the in
176176
assert.strictEqual(m.list().find((e) => e.id === id).lifecycle, 'trashed', 'the latest label event wins');
177177
});
178178

179+
test('MANAGER: restore reverses Done/Delete (the Undo behind them)', () => {
180+
const root = freshRoot(), slug = store.projectSlug('/pu');
181+
const m = createSessions({ root, slug, projectPath: '/pu' });
182+
m.recordTurn([{ role: 'user', content: 'x' }], 'm');
183+
const id = m.liveId();
184+
m.archive(id);
185+
assert.strictEqual(m.list().find((e) => e.id === id).lifecycle, 'archived');
186+
m.restore(id);
187+
assert.strictEqual(m.list().find((e) => e.id === id).lifecycle, 'active', 'restore brings an archived session back');
188+
m.trash(id);
189+
m.restore(id);
190+
assert.strictEqual(m.list().find((e) => e.id === id).lifecycle, 'active', 'and a trashed one too');
191+
});
192+
179193
test('MANAGER: autoArchiveStale fades stale, non-pinned sessions and exempts pinned + recent', () => {
180194
const root = freshRoot(), slug = store.projectSlug('/aa');
181195
const DAY = 86400000;

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ test('HOUSEKEEPING: a Pin toggle on the card, and a 30-day auto-archive sweep on
145145
assert.ok(/sessActBtn\('pin'/.test(html) && /sessActBtn\('pin'/.test(view), 'both surfaces render a Pin action');
146146
});
147147

148+
test('UNDO: Done/Delete offer a recoverable Undo (restore) on both surfaces', () => {
149+
assert.match(ext, /type: 'sessionUndo'/, 'the host offers an undo after Done/Delete');
150+
assert.match(ext, /action === 'restore'\) \{ m\.restore\(id\)/, 'and handles restore (reverses the lifecycle change)');
151+
for (const [name, src, uid] of [['modal', html, 'sessUndo'], ['sidebar', view, 'svUndo']]) {
152+
assert.match(src, new RegExp('id="' + uid + '"'), name + ': has an undo toast element');
153+
assert.match(src, /m\.type === 'sessionUndo'/, name + ': handles the sessionUndo message');
154+
assert.match(src, /action: 'restore'/, name + ': the Undo button posts a restore');
155+
}
156+
});
157+
148158
test('VIEW: the sidebar view and the modal share the EXACT same render block (no drift)', () => {
149159
const grab = (s) => s.slice(s.indexOf('// [SESSIONS-PURE-START]'), s.indexOf('// [SESSIONS-PURE-END]'));
150160
assert.ok(grab(view).length > 500, 'the view carries the pure block');

0 commit comments

Comments
 (0)