Skip to content

Commit 2198bd6

Browse files
ndemiancclaude
andcommitted
feat(sessions): the History panel in the webview (minimal cards)
The visible surface. An in-webview overlay (`/sessions`, or a 'sessions' message from the host) rendering the session index in the classic look: - minimal card at rest (title + one quiet line: primary file · relative time), state SILENT when done, a 2px left-edge only when noteworthy (interrupted/ error/resumed), pin only when pinned; hover/focus expands to files, model, turns, sparkline, and inline actions; - time buckets (Pinned/Today/Yesterday/This week/Earlier), instant local search over title+files+preview, Esc/backdrop close. Theme-true across One Dark / One Light / high-contrast (reuses the account modal's --cc-* tokens); reduced-motion-safe. Renders against MOCK_SESSIONS for now (TODO: the extension.js adapter posts real entries via 'sessions'). The pure render helpers sit in a [SESSIONS-PURE] block, extracted + unit-tested (test/sessionsUi.test.js): time/bucket/match/group/sparkline + the card HTML (esc injected), plus static invariants for the 3 theme token blocks, the reduced-motion guard, the state->--cc-token mapping, and the wiring. sessionsUi: 13 tests. Full suite: 31 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8009266 commit 2198bd6

2 files changed

Lines changed: 289 additions & 0 deletions

File tree

extensions/levelcode-ai/media/chat.html

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,43 @@
10581058
.acctcard.lc-modal { animation: none; }
10591059
.lc-btn:hover, .lc-btn-primary:hover, .lc-btn-primary:hover .lc-arrow { transform: none; }
10601060
}
1061+
/* ── Sessions panel (History) — the classic look, minimal cards. docs/levelcode-sessions-experience.md ── */
1062+
.sesspanel { --cc-bg:#282c34; --cc-surface:#21252b; --cc-text:#dcdfe4; --cc-text2:#abb2bf; --cc-text3:#828997; --cc-line:#3e4451; --cc-accent:#7d6bff; --cc-warn:#d19a66; --cc-danger:#e06c75;
1063+
position: relative; width: 100%; max-width: 440px; max-height: 82vh; display: flex; flex-direction: column;
1064+
background: var(--cc-surface); color: var(--cc-text); border: 1px solid var(--cc-line); border-radius: 12px; overflow: hidden;
1065+
box-shadow: 0 18px 50px rgba(0,0,0,.34); font-variant-numeric: tabular-nums; }
1066+
body.vscode-light .sesspanel { --cc-bg:#ffffff; --cc-surface:#fafaf9; --cc-text:#333; --cc-text2:#555; --cc-text3:#777; --cc-line:#e0e0e0; --cc-accent:#5b3fd6; --cc-warn:#b9761f; --cc-danger:#d9534f; }
1067+
body.vscode-high-contrast .sesspanel, body.vscode-high-contrast-light .sesspanel {
1068+
--cc-bg: var(--vscode-editor-background); --cc-surface: var(--vscode-editorWidget-background, var(--vscode-editor-background));
1069+
--cc-text: var(--vscode-foreground); --cc-text2: var(--vscode-foreground); --cc-text3: var(--vscode-descriptionForeground, var(--vscode-foreground));
1070+
--cc-line: var(--vscode-contrastBorder, var(--vscode-widget-border)); --cc-accent: var(--vscode-textLink-foreground, var(--vscode-focusBorder));
1071+
--cc-warn: var(--vscode-editorWarning-foreground, var(--vscode-foreground)); --cc-danger: var(--vscode-errorForeground); }
1072+
.sesspanel .sessbar { display: flex; align-items: center; gap: 9px; padding: 12px 14px 10px; border-bottom: 1px solid var(--cc-line); }
1073+
.sesspanel .sesstitle { font-weight: 600; font-size: 14px; }
1074+
.sesspanel .sesssearch { margin: 10px 12px 6px; display: flex; align-items: center; gap: 7px; padding: 7px 10px; background: var(--cc-bg); border: 1px solid var(--cc-line); border-radius: 6px; color: var(--cc-text3); }
1075+
.sesspanel .sesssearch input { flex: 1; background: transparent; border: none; outline: none; color: var(--cc-text); font: inherit; font-size: 12.5px; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); }
1076+
.sesspanel .sesslist { flex: 1 1 auto; overflow-y: auto; padding: 2px 12px 14px; }
1077+
.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; }
1078+
.sesspanel .sessempty { padding: 44px 18px; text-align: center; color: var(--cc-text3); font-size: 13px; line-height: 1.6; }
1079+
.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; }
1080+
.sesscard:hover, .sesscard:focus-visible, .sesscard:focus-within { border-color: var(--cc-accent); transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0,0,0,.22); outline: none; }
1081+
.sesscard.warn { border-left: 2px solid var(--cc-warn); }
1082+
.sesscard.err { border-left: 2px solid var(--cc-danger); }
1083+
.sesscard.res { border-left: 2px solid var(--cc-accent); }
1084+
.sesscard .sesstop { display: flex; align-items: center; gap: 8px; }
1085+
.sesscard .sessname { font-weight: 600; font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
1086+
.sesscard .sessstar { color: var(--cc-accent); font-size: 13px; }
1087+
.sesscard .sesssub { margin-top: 4px; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
1088+
.sesscard .sessrich { display: none; margin-top: 8px; }
1089+
.sesscard:hover .sessrich, .sesscard:focus-within .sessrich { display: block; }
1090+
.sesscard .sessfiles { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
1091+
.sesscard .sesschip { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11px; color: var(--cc-text2); background: var(--cc-surface); border: 1px solid var(--cc-line); border-radius: 4px; padding: 1px 6px; }
1092+
.sesscard .sessmeta { display: flex; align-items: center; gap: 7px; margin-top: 6px; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
1093+
.sesscard .sessacts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; }
1094+
.sesscard .sessbtn { font: inherit; font-size: 11.5px; color: var(--cc-text); background: var(--cc-surface); border: 1px solid var(--cc-line); border-radius: 5px; padding: 3px 9px; cursor: pointer; }
1095+
.sesscard .sessbtn.pri { color: #fff; background: var(--cc-accent); border-color: transparent; }
1096+
.sesscard .sessbtn:hover { border-color: var(--cc-accent); }
1097+
@media (prefers-reduced-motion: reduce) { .sesscard { transition: none; } .sesscard:hover, .sesscard:focus-visible, .sesscard:focus-within { transform: none; } }
10611098
</style>
10621099
</head>
10631100
<body>
@@ -1183,6 +1220,15 @@
11831220
</div>
11841221
</div>
11851222

1223+
<div id="sessOverlay" class="overlay" style="display:none">
1224+
<div class="sesspanel" role="dialog" aria-modal="true" aria-label="Sessions">
1225+
<button class="ovclose" id="sessClose" title="Close" aria-label="Close">&#10005;</button>
1226+
<div class="sessbar"><span class="sesstitle">Sessions</span></div>
1227+
<div class="sesssearch"><span aria-hidden="true">&#8981;</span><input id="sessSearch" type="text" placeholder="Search sessions, files…" aria-label="Search sessions" /></div>
1228+
<div class="sesslist" id="sessList"></div>
1229+
</div>
1230+
</div>
1231+
11861232
<script nonce="__NONCE__">
11871233
const vscode = acquireVsCodeApi();
11881234
window.onerror = function(msg){
@@ -2830,6 +2876,7 @@
28302876
// Slash commands — handled locally (deterministic, no LLM call).
28312877
if (/^\/skills\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listSkills' }); return; }
28322878
if (/^\/mcp\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listMcp' }); return; }
2879+
if (/^\/sessions\b/i.test(t)){ input.value = ''; auto(); openSessions(); return; }
28332880
if (/^\/rme\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); addReminder(); return; }
28342881
add('user', render(t)); forceStick(); input.value = ''; auto();
28352882
vscode.postMessage({ type: 'send', text: t });
@@ -3230,6 +3277,111 @@
32303277
d.innerHTML = inner + '</div>';
32313278
log.appendChild(d); scrollIfStuck();
32323279
}
3280+
// ── Sessions panel (History) — renders the session index. docs/levelcode-sessions-experience.md ──
3281+
// [SESSIONS-PURE-START] — dependency-free helpers; extracted + unit-tested in test/sessionsUi.test.js.
3282+
function sessRelTime(iso, nowMs){
3283+
const t = Date.parse(iso); if (!isFinite(t)) { return ''; }
3284+
const s = Math.max(0, Math.round((nowMs - t) / 1000));
3285+
if (s < 45) { return 'just now'; }
3286+
const m = Math.round(s / 60); if (m < 60) { return m + 'm'; }
3287+
const h = Math.round(m / 60); if (h < 24) { return h + 'h'; }
3288+
const d = Math.round(h / 24); if (d < 7) { return d + 'd'; }
3289+
const w = Math.round(d / 7); if (w < 5) { return w + 'w'; }
3290+
try { return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch (e) { return d + 'd'; }
3291+
}
3292+
function sessBucket(iso, nowMs){
3293+
const t = Date.parse(iso); if (!isFinite(t)) { return 'Earlier'; }
3294+
const startOfDay = (ms) => { const dt = new Date(ms); dt.setHours(0, 0, 0, 0); return dt.getTime(); };
3295+
const days = Math.round((startOfDay(nowMs) - startOfDay(t)) / 86400000);
3296+
if (days <= 0) { return 'Today'; }
3297+
if (days === 1) { return 'Yesterday'; }
3298+
if (days <= 7) { return 'This week'; }
3299+
return 'Earlier';
3300+
}
3301+
function sessMatch(entry, q){
3302+
if (!q) { return true; }
3303+
const s = String(q).toLowerCase();
3304+
if (String(entry.title || '').toLowerCase().indexOf(s) >= 0) { return true; }
3305+
if (String(entry.preview || '').toLowerCase().indexOf(s) >= 0) { return true; }
3306+
return (entry.filesEdited || []).some((f) => String(f).toLowerCase().indexOf(s) >= 0);
3307+
}
3308+
function sessModelShort(m){ const s = String(m || ''); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(i + 1) : s; }
3309+
function sessSparkSvg(spark){
3310+
const a = (Array.isArray(spark) ? spark : []).map((x) => Math.max(0, Number(x) || 0));
3311+
if (!a.length) { return ''; }
3312+
const max = Math.max(1, Math.max.apply(null, a)), bw = 4, gap = 2, h = 15;
3313+
let bars = '';
3314+
for (let i = 0; i < a.length; i++){ const bh = Math.max(1, Math.round((a[i] / max) * h)); bars += '<rect x="' + (i * (bw + gap)) + '" y="' + (h - bh) + '" width="' + bw + '" height="' + bh + '" opacity="' + (0.4 + 0.5 * (a[i] / max)).toFixed(2) + '"/>'; }
3315+
const w = a.length * (bw + gap);
3316+
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" aria-hidden="true"><g fill="var(--cc-accent)">' + bars + '</g></svg>';
3317+
}
3318+
var SESS_BUCKETS = ['Pinned', 'Today', 'Yesterday', 'This week', 'Earlier'];
3319+
function sessGroup(entries, nowMs){
3320+
const by = {}; SESS_BUCKETS.forEach((b) => { by[b] = []; });
3321+
for (const e of entries){ const b = e.pinned ? 'Pinned' : sessBucket(e.updatedAt || e.createdAt, nowMs); (by[b] || by.Earlier).push(e); }
3322+
return SESS_BUCKETS.filter((b) => by[b].length).map((b) => ({ bucket: b, entries: by[b] }));
3323+
}
3324+
// esc/escAttr are INJECTED so this whole block stays pure (unit-testable without the webview globals).
3325+
function sessCardHtml(e, nowMs, escFn, escAttrFn){
3326+
const st = e.state === 'interrupted' ? ' warn' : e.state === 'error' ? ' err' : e.state === 'resumed' ? ' res' : '';
3327+
const files = e.filesEdited || [];
3328+
const sub = (files.length ? escFn(files[0]) + ' · ' : '') + escFn(sessRelTime(e.updatedAt || e.createdAt, nowMs));
3329+
const chips = files.slice(0, 3).map((f) => '<span class="sesschip">' + escFn(f) + '</span>').join('') + (files.length > 3 ? '<span class="sesschip">+' + (files.length - 3) + '</span>' : '');
3330+
const turns = e.turns || 0;
3331+
const rich = '<div class="sessrich">'
3332+
+ (files.length ? '<div class="sessfiles">' + chips + '</div>' : '')
3333+
+ '<div class="sessmeta">' + (e.model ? escFn(sessModelShort(e.model)) + ' · ' : '') + turns + ' turn' + (turns === 1 ? '' : 's') + ' ' + sessSparkSvg(e.spark) + '</div>'
3334+
+ '<div class="sessacts"><button class="sessbtn pri" data-act="resume">Resume &#9166;</button><button class="sessbtn" data-act="done">Done</button><button class="sessbtn" data-act="rename">Rename</button><button class="sessbtn" data-act="delete">Delete</button></div>'
3335+
+ '</div>';
3336+
return '<div class="sesscard' + st + '" data-id="' + escAttrFn(e.id || '') + '" tabindex="0" role="button" aria-label="' + escAttrFn(e.title || 'Untitled') + '">'
3337+
+ '<div class="sesstop"><span class="sessname" title="' + escAttrFn(e.title || '') + '">' + escFn(e.title || 'Untitled') + '</span>' + (e.pinned ? '<span class="sessstar" aria-label="pinned">★</span>' : '') + '</div>'
3338+
+ '<div class="sesssub">' + sub + '</div>' + rich + '</div>';
3339+
}
3340+
// [SESSIONS-PURE-END]
3341+
// Placeholder data so /sessions renders the panel before the extension.js adapter posts real entries.
3342+
const MOCK_SESSIONS = [
3343+
{ id: 's1', title: 'Idempotent refunds via Redis keys', createdAt: '2026-07-28T09:00:00Z', updatedAt: '2026-07-28T10:00:00Z', turns: 41, model: 'anthropic/claude-opus-5', state: 'done', lifecycle: 'active', pinned: true, filesEdited: ['refund.rb', 'redis_lock.rb', 'ledger.rb', 'spec.rb'], spark: [1, 3, 2, 5, 8, 6, 4, 2, 1] },
3344+
{ id: 's2', title: 'Debug the flaky payment webhook test', createdAt: '2026-07-28T05:00:00Z', updatedAt: '2026-07-28T05:30:00Z', turns: 12, model: 'moonshotai/kimi-k2.7-code', state: 'interrupted', lifecycle: 'active', pinned: false, filesEdited: ['webhook_spec.rb'], spark: [2, 6, 3, 1] },
3345+
{ id: 's3', title: 'Tidy the CHANGELOG for v1.0.4', createdAt: '2026-07-27T12:00:00Z', updatedAt: '2026-07-27T12:20:00Z', turns: 6, model: 'anthropic/claude-opus-5', state: 'done', lifecycle: 'active', pinned: false, filesEdited: ['RELEASE-NOTES.md'], spark: [1, 2, 1] }
3346+
];
3347+
let sessData = null, sessQuery = '';
3348+
function renderSessions(){
3349+
const list = document.getElementById('sessList'); if (!list) { return; }
3350+
const entries = (sessData || []).filter((e) => (e.lifecycle || 'active') === 'active' && sessMatch(e, sessQuery));
3351+
if (!entries.length){
3352+
list.innerHTML = '<div class="sessempty">' + (sessQuery ? 'Nothing matches “' + esc(sessQuery) + '”.' : 'Every chat you have is saved here — automatically, on your machine.') + '</div>';
3353+
return;
3354+
}
3355+
const now = Date.now(); let html = '';
3356+
for (const g of sessGroup(entries, now)){
3357+
html += '<div class="sessbucket">' + (g.bucket === 'Pinned' ? '★ ' : '') + esc(g.bucket) + '</div>';
3358+
for (const e of g.entries){ html += sessCardHtml(e, now, esc, escAttr); }
3359+
}
3360+
list.innerHTML = html;
3361+
}
3362+
function openSessions(){
3363+
const ov = document.getElementById('sessOverlay'); if (!ov) { return; }
3364+
if (sessData == null) { vscode.postMessage({ type: 'listSessions' }); sessData = MOCK_SESSIONS.slice(); } // TODO(adapter): drop the mock once extension.js posts 'sessions'
3365+
sessQuery = ''; const inp = document.getElementById('sessSearch'); if (inp) { inp.value = ''; }
3366+
renderSessions(); ov.style.display = 'flex';
3367+
if (inp && inp.focus) { try { inp.focus(); } catch (e) {} }
3368+
}
3369+
function closeSessions(){ const ov = document.getElementById('sessOverlay'); if (ov) { ov.style.display = 'none'; } }
3370+
(function wireSessions(){
3371+
const close = document.getElementById('sessClose'); if (close) { close.onclick = closeSessions; }
3372+
const ov = document.getElementById('sessOverlay'); if (ov) { ov.addEventListener('click', (e) => { if (e.target === ov) { closeSessions(); } }); }
3373+
const inp = document.getElementById('sessSearch'); if (inp) { inp.addEventListener('input', () => { sessQuery = inp.value; renderSessions(); }); }
3374+
const list = document.getElementById('sessList');
3375+
if (list) { list.addEventListener('click', (e) => {
3376+
const card = e.target.closest && e.target.closest('.sesscard'); if (!card) { return; }
3377+
const id = card.getAttribute('data-id'); const btn = e.target.closest('[data-act]');
3378+
const action = btn ? btn.getAttribute('data-act') : 'resume';
3379+
vscode.postMessage({ type: 'sessionAction', action: action, id: id });
3380+
if (action === 'resume') { closeSessions(); }
3381+
}); }
3382+
document.addEventListener('keydown', (e) => { const o = document.getElementById('sessOverlay'); if (e.key === 'Escape' && o && o.style.display !== 'none'){ e.preventDefault(); closeSessions(); } });
3383+
})();
3384+
32333385
window.addEventListener('message', ev => {
32343386
const m = ev.data;
32353387
if (m.type === 'config'){
@@ -3283,6 +3435,7 @@
32833435
else if (m.type === 'checkpointBusy'){ checkpointBusyNotice(m); }
32843436
else if (m.type === 'skillsList'){ renderSkillsList(m); }
32853437
else if (m.type === 'mcpList'){ renderMcpList(m.mcp || {}); }
3438+
else if (m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; if (m.open === false) { renderSessions(); } else { openSessions(); } }
32863439
else if (m.type === 'verifyRun'){ addVerifyRun(m); }
32873440
else if (m.type === 'verifyOutput'){ verifyOutputAppend(m); }
32883441
else if (m.type === 'verifyDone'){ verifyDoneFinish(m); }

0 commit comments

Comments
 (0)