|
1058 | 1058 | .acctcard.lc-modal { animation: none; } |
1059 | 1059 | .lc-btn:hover, .lc-btn-primary:hover, .lc-btn-primary:hover .lc-arrow { transform: none; } |
1060 | 1060 | } |
| 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; } } |
1061 | 1098 | </style> |
1062 | 1099 | </head> |
1063 | 1100 | <body> |
|
1183 | 1220 | </div> |
1184 | 1221 | </div> |
1185 | 1222 |
|
| 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">✕</button> |
| 1226 | + <div class="sessbar"><span class="sesstitle">Sessions</span></div> |
| 1227 | + <div class="sesssearch"><span aria-hidden="true">⌕</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 | + |
1186 | 1232 | <script nonce="__NONCE__"> |
1187 | 1233 | const vscode = acquireVsCodeApi(); |
1188 | 1234 | window.onerror = function(msg){ |
|
2830 | 2876 | // Slash commands — handled locally (deterministic, no LLM call). |
2831 | 2877 | if (/^\/skills\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listSkills' }); return; } |
2832 | 2878 | 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; } |
2833 | 2880 | if (/^\/rme\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); addReminder(); return; } |
2834 | 2881 | add('user', render(t)); forceStick(); input.value = ''; auto(); |
2835 | 2882 | vscode.postMessage({ type: 'send', text: t }); |
|
3230 | 3277 | d.innerHTML = inner + '</div>'; |
3231 | 3278 | log.appendChild(d); scrollIfStuck(); |
3232 | 3279 | } |
| 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 ⏎</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 | + |
3233 | 3385 | window.addEventListener('message', ev => { |
3234 | 3386 | const m = ev.data; |
3235 | 3387 | if (m.type === 'config'){ |
|
3283 | 3435 | else if (m.type === 'checkpointBusy'){ checkpointBusyNotice(m); } |
3284 | 3436 | else if (m.type === 'skillsList'){ renderSkillsList(m); } |
3285 | 3437 | 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(); } } |
3286 | 3439 | else if (m.type === 'verifyRun'){ addVerifyRun(m); } |
3287 | 3440 | else if (m.type === 'verifyOutput'){ verifyOutputAppend(m); } |
3288 | 3441 | else if (m.type === 'verifyDone'){ verifyDoneFinish(m); } |
|
0 commit comments