Skip to content

Commit be7b028

Browse files
ndemiancclaude
andcommitted
feat(levelcode): session actions as row icon buttons on hover (no drawer)
Replaces the hover drawer with compact icon buttons on the right of the title row — Resume · Rename · Archive · Delete, plus a Pin toggle that stays lit at rest when pinned. The row reserves the icon height (sesstop min-height), so the icons fade in on hover without the card ever growing, reflowing, or floating over its neighbours: the "jump" is gone and nothing overlaps. Matches VS Code's own list affordances and the minimal direction of the panel. - media/chat.html + media/sessionsView.html (shared SESSIONS-PURE block, kept byte-identical): sessCardHtml now renders inline codicon buttons (edit/check/ trash are the editor's own glyphs; play + star hand-drawn) via a small sessActBtn helper; the resting card is just title + file·time. The old drawer/chips/meta/sparkline are gone — model · turns · files fold into the card's native tooltip so nothing is lost. CSS: actions display:none→flex on hover; pin shown at rest only when pinned; reverted the drawer positioning + list runway. - test/sessionsUi.test.js: rewrote the CARD tests for the new structure (icons + data-act + a11y labels + tooltip), dropped the sparkline test, and the NO-JUMP invariant now pins the row-icon approach (no .sessrich, actions hidden at rest → flex on hover, sesstop reserves the height). 30 files green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e48ec36 commit be7b028

3 files changed

Lines changed: 106 additions & 111 deletions

File tree

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

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,36 +1078,29 @@
10781078
--cc-warn: var(--vscode-editorWarning-foreground, var(--vscode-foreground)); --cc-danger: var(--vscode-errorForeground); }
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; }
1081-
.sesspanel .sesslist { flex: 1 1 auto; overflow-y: auto; padding: 2px 12px 60px; } /* runway for a down-opening drawer near the end */
1081+
.sesspanel .sesslist { flex: 1 1 auto; overflow-y: auto; padding: 2px 12px 14px; }
10821082
.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; }
10831083
.sesspanel .sessempty { padding: 44px 18px; text-align: center; color: var(--cc-text3); font-size: 13px; line-height: 1.6; }
10841084
.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; }
1085-
.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; z-index: 20; }
1085+
.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; }
10861086
.sesscard.warn { border-left: 2px solid var(--cc-warn); }
10871087
.sesscard.err { border-left: 2px solid var(--cc-danger); }
10881088
.sesscard.res { border-left: 2px solid var(--cc-accent); }
1089-
.sesscard .sesstop { display: flex; align-items: center; gap: 8px; }
1090-
.sesscard .sessname { font-weight: 600; font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
1091-
.sesscard .sessstar { color: var(--cc-accent); font-size: 13px; }
1089+
.sesscard .sesstop { display: flex; align-items: center; gap: 8px; min-height: 22px; }
1090+
.sesscard .sessname { font-weight: 600; font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
10921091
.sesscard .sesssub { margin-top: 4px; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
1093-
/* Actions/detail float as a hover DRAWER anchored to the card, so the card height never changes and the
1094-
list no longer jumps. Opens downward; the last card opens upward so the scroll container never clips it. */
1095-
.sesscard .sessrich { position: absolute; left: -1px; right: -1px; top: calc(100% - 3px); z-index: 30;
1096-
margin: 0; padding: 9px 11px 10px; background: var(--cc-bg); border: 1px solid var(--cc-accent);
1097-
border-radius: 8px; box-shadow: 0 12px 28px rgba(0,0,0,.34);
1098-
opacity: 0; visibility: hidden; transform: translateY(-4px); pointer-events: none;
1099-
transition: opacity .12s ease, transform .12s ease, visibility 0s linear .13s; }
1100-
.sesscard:hover .sessrich, .sesscard:focus-within .sessrich { opacity: 1; visibility: visible; transform: none; pointer-events: auto; transition: opacity .12s ease, transform .12s ease; }
1101-
.sesscard:last-child .sessrich { top: auto; bottom: calc(100% - 3px); transform: translateY(4px); }
1102-
.sesscard:last-child:hover .sessrich, .sesscard:last-child:focus-within .sessrich { transform: none; }
1103-
.sesscard .sessfiles { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
1104-
.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; }
1105-
.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); }
1106-
.sesscard .sessacts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; }
1107-
.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; }
1108-
.sesscard .sessbtn.pri { color: #fff; background: var(--cc-accent); border-color: transparent; }
1109-
.sesscard .sessbtn:hover { border-color: var(--cc-accent); }
1110-
@media (prefers-reduced-motion: reduce) { .sesscard, .sesscard .sessrich { transition: none; } .sesscard:hover, .sesscard:focus-visible, .sesscard:focus-within, .sesscard .sessrich, .sesscard:last-child .sessrich { transform: none; } }
1092+
/* Row actions: compact icon buttons on the right, revealed on hover — no drawer, so the card never
1093+
reflows or floats over its neighbours. sesstop reserves the icon height so appearing icons don't
1094+
nudge the line. The pin stays visible at rest when pinned. */
1095+
.sesscard .sessacts { display: none; align-items: center; gap: 1px; flex: 0 0 auto; }
1096+
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
1097+
.sesscard .sessact { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 22px; padding: 0; border: none; background: transparent; color: var(--cc-text2); border-radius: 5px; cursor: pointer; }
1098+
.sesscard .sessact:hover { background: var(--cc-surface); color: var(--cc-text); }
1099+
.sesscard .sessact.sesspin { display: none; }
1100+
.sesscard.pinned .sesspin, .sesscard:hover .sesspin, .sesscard:focus-within .sesspin { display: inline-flex; }
1101+
.sesscard.pinned .sesspin { color: var(--cc-accent); }
1102+
.sesscard .ci { width: 14px; height: 14px; }
1103+
@media (prefers-reduced-motion: reduce) { .sesscard { transition: none; } .sesscard:hover, .sesscard:focus-visible, .sesscard:focus-within { transform: none; } }
11111104
</style>
11121105
</head>
11131106
<body>
@@ -3311,14 +3304,17 @@
33113304
return 'Earlier';
33123305
}
33133306
function sessModelShort(m){ const s = String(m || ''); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(i + 1) : s; }
3314-
function sessSparkSvg(spark){
3315-
const a = (Array.isArray(spark) ? spark : []).map((x) => Math.max(0, Number(x) || 0));
3316-
if (!a.length) { return ''; }
3317-
const max = Math.max(1, Math.max.apply(null, a)), bw = 4, gap = 2, h = 15;
3318-
let bars = '';
3319-
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) + '"/>'; }
3320-
const w = a.length * (bw + gap);
3321-
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" aria-hidden="true"><g fill="var(--cc-accent)">' + bars + '</g></svg>';
3307+
// Inline codicons (self-contained — the shared block can't reach the chat's codicon() helper). play is a
3308+
// plain triangle; edit/check/trash are the editor's own glyphs; star doubles as the pin toggle.
3309+
var SESS_IC = {
3310+
play: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 3.3 L12.6 8 L5 12.7 Z"/></svg>',
3311+
edit: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M14.236 1.76386C13.2123 0.740172 11.5525 0.740171 10.5289 1.76386L2.65722 9.63549C2.28304 10.0097 2.01623 10.4775 1.88467 10.99L1.01571 14.3755C0.971767 14.5467 1.02148 14.7284 1.14646 14.8534C1.27144 14.9783 1.45312 15.028 1.62432 14.9841L5.00978 14.1151C5.52234 13.9836 5.99015 13.7168 6.36433 13.3426L14.236 5.47097C15.2596 4.44728 15.2596 2.78755 14.236 1.76386ZM11.236 2.47097C11.8691 1.8378 12.8957 1.8378 13.5288 2.47097C14.162 3.10413 14.162 4.1307 13.5288 4.76386L12.75 5.54269L10.4571 3.24979L11.236 2.47097ZM9.75002 3.9569L12.0429 6.24979L5.65722 12.6355C5.40969 12.883 5.10023 13.0595 4.76117 13.1465L2.19447 13.8053L2.85327 11.2386C2.9403 10.8996 3.1168 10.5901 3.36433 10.3426L9.75002 3.9569Z"/></svg>',
3312+
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>',
3313+
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>',
3314+
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>'
3315+
};
3316+
function sessActBtn(act, label, svg, cls){
3317+
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '</button>';
33223318
}
33233319
var SESS_BUCKETS = ['Pinned', 'Today', 'Yesterday', 'This week', 'Earlier'];
33243320
function sessGroup(entries, nowMs){
@@ -3327,20 +3323,26 @@
33273323
return SESS_BUCKETS.filter((b) => by[b].length).map((b) => ({ bucket: b, entries: by[b] }));
33283324
}
33293325
// esc/escAttr are INJECTED so this whole block stays pure (unit-testable without the webview globals).
3326+
// The card is two fixed-height lines: title + (primary file · time). Actions are compact icon buttons on
3327+
// the RIGHT of the title row — hidden at rest, revealed on hover — so nothing reflows or floats. The pin
3328+
// stays visible at rest when pinned. Secondary detail (model · turns · files) rides the native tooltip.
33303329
function sessCardHtml(e, nowMs, escFn, escAttrFn){
33313330
const st = e.state === 'interrupted' ? ' warn' : e.state === 'error' ? ' err' : e.state === 'resumed' ? ' res' : '';
33323331
const files = e.filesEdited || [];
33333332
const sub = (files.length ? escFn(files[0]) + ' · ' : '') + escFn(sessRelTime(e.updatedAt || e.createdAt, nowMs));
3334-
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>' : '');
33353333
const turns = e.turns || 0;
3336-
const rich = '<div class="sessrich">'
3337-
+ (files.length ? '<div class="sessfiles">' + chips + '</div>' : '')
3338-
+ '<div class="sessmeta">' + (e.model ? escFn(sessModelShort(e.model)) + ' · ' : '') + turns + ' turn' + (turns === 1 ? '' : 's') + ' ' + sessSparkSvg(e.spark) + '</div>'
3339-
+ '<div class="sessacts"><button class="sessbtn pri" data-act="resume">Resume &#9166;</button><button class="sessbtn" data-act="pin">' + (e.pinned ? 'Unpin' : 'Pin') + '</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>'
3340-
+ '</div>';
3341-
return '<div class="sesscard' + st + '" data-id="' + escAttrFn(e.id || '') + '" tabindex="0" role="button" aria-label="' + escAttrFn(e.title || 'Untitled') + '">'
3342-
+ '<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>'
3343-
+ '<div class="sesssub">' + sub + '</div>' + rich + '</div>';
3334+
const detail = (e.title || 'Untitled') + ' — ' + turns + ' turn' + (turns === 1 ? '' : 's')
3335+
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
3336+
const acts = '<span class="sessacts">'
3337+
+ sessActBtn('resume', 'Resume', SESS_IC.play)
3338+
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
3339+
+ sessActBtn('done', 'Archive', SESS_IC.check)
3340+
+ sessActBtn('delete', 'Delete', SESS_IC.trash)
3341+
+ '</span>'
3342+
+ sessActBtn('pin', e.pinned ? 'Unpin' : 'Pin', SESS_IC.star, 'sesspin');
3343+
return '<div class="sesscard' + st + (e.pinned ? ' pinned' : '') + '" data-id="' + escAttrFn(e.id || '') + '" tabindex="0" role="button" aria-label="' + escAttrFn(e.title || 'Untitled') + '" title="' + escAttrFn(detail) + '">'
3344+
+ '<div class="sesstop"><span class="sessname">' + escFn(e.title || 'Untitled') + '</span>' + acts + '</div>'
3345+
+ '<div class="sesssub">' + sub + '</div></div>';
33443346
}
33453347
// [SESSIONS-PURE-END]
33463348
// Placeholder data so /sessions renders the panel before the extension.js adapter posts real entries.

0 commit comments

Comments
 (0)