Skip to content

Commit 64c7073

Browse files
authored
Merge pull request #87 from levelcodeai/feat/sessions-button-on-chat-tab
feat(chat): reach Sessions from the chat tab
2 parents 8bc9af5 + 165268e commit 64c7073

6 files changed

Lines changed: 163 additions & 23 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ let chatProvider; // the single provider instance; both surfaces wire thro
6161
// receive anything at all.
6262
let pendingTranscriptReplay = '';
6363
let sessionsWebview; // the Sessions sidebar webview (for pushing list refreshes after a History action)
64+
// Which tab a pending reveal wants. The container is hidden by default, so the FIRST reveal usually
65+
// resolves the view from scratch and there is no webview to post to yet — the request has to wait for
66+
// the view to announce itself. Flushed on `listSessions`, the earliest thing a loaded view sends.
67+
let pendingSessionsTab = '';
6468
/** @type {{role:string,content:string}[]} */
6569
let conversation = [];
6670
// Bumped by every teardown. A turn captures it when it starts and checks it before writing anything
@@ -2414,6 +2418,26 @@ function chatStartLocation() {
24142418
return ['editor', 'none'].includes(raw) ? raw : 'editor';
24152419
}
24162420

2421+
/**
2422+
* Reveal the Sessions panel on a specific tab.
2423+
*
2424+
* Two steps that cannot be collapsed into one. Revealing is a command; switching tabs is a message to
2425+
* a webview that may not exist yet — the container is hidden by default, so the first click of the
2426+
* session usually has to CREATE the view. So the tab is recorded first and posted twice over: once
2427+
* immediately for a view that is already up, and once from the view's own `listSessions` for one that
2428+
* is still loading. showTab is idempotent, so the overlap costs nothing and the cold path is covered.
2429+
*/
2430+
function revealSessions(tab) {
2431+
pendingSessionsTab = tab;
2432+
const done = Promise.resolve(vscode.commands.executeCommand('levelcodeAi.sessions.focus'))
2433+
.then(undefined, (e) => dbg('sessions.reveal.failed', { tab, msg: String((e && e.message) || e) }));
2434+
if (sessionsWebview) {
2435+
pendingSessionsTab = '';
2436+
try { sessionsWebview.postMessage({ type: 'showTab', tab }); } catch (e) { /* view closed mid-flight */ }
2437+
}
2438+
return done;
2439+
}
2440+
24172441
/** Open the chat where `chat.startLocation` says, once, as the window finishes starting. */
24182442
async function revealChatAtStartup() {
24192443
const where = chatStartLocation();
@@ -2480,7 +2504,12 @@ class SessionsViewProvider {
24802504
switch (msg.type) {
24812505
// The real session index for this workspace (empty on a fresh install — the view shows its
24822506
// own empty state). Posted to THIS view's webview, not the chat's.
2483-
case 'listSessions': view.webview.postMessage({ type: 'sessions', entries: sessionList() }); break;
2507+
case 'listSessions': {
2508+
view.webview.postMessage({ type: 'sessions', entries: sessionList() });
2509+
// The view is alive now, so a tab requested before it existed can finally be applied.
2510+
if (pendingSessionsTab) { const t = pendingSessionsTab; pendingSessionsTab = ''; view.webview.postMessage({ type: 'showTab', tab: t }); }
2511+
break;
2512+
}
24842513
case 'newSession': newChat(); focusChatView('sessions.newSession'); break;
24852514
case 'sessionAction': await handleSessionAction(msg.action, msg.id); break;
24862515
// Memory tab (§M3): list the recorded outcomes + facts; act on them; open the file.
@@ -2745,7 +2774,9 @@ function activate(context) {
27452774
vscode.window.registerWebviewViewProvider('levelcodeAi.sessions', new SessionsViewProvider(), {
27462775
webviewOptions: { retainContextWhenHidden: true }
27472776
}),
2748-
vscode.commands.registerCommand('levelcode.ai.sessions', () => vscode.commands.executeCommand('levelcodeAi.sessions.focus')),
2777+
// Both land on the same panel, each on its own tab, so a button says exactly where it goes.
2778+
vscode.commands.registerCommand('levelcode.ai.sessions', () => revealSessions('history')),
2779+
vscode.commands.registerCommand('levelcode.ai.memory', () => revealSessions('memory')),
27492780
vscode.window.onDidChangeActiveTextEditor(() => postActiveFile()),
27502781
// ⇧⌘I. Opens the chat where the chat lives — the editor tab. This pointed at the contributed
27512782
// view, which is why the shortcut kept pulling a panel out on the right after the conversation

extensions/levelcode-ai/media/chat.html

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,7 +1233,7 @@
12331233
/* Line 2 swaps file·time ⇄ the labeled action buttons on hover — same fixed-height row, full width, so
12341234
nothing wraps, reflows, or floats and the title above is left intact. The modal keeps icons + labels. */
12351235
.sesscard .sessline2 { margin-top: 4px; min-height: 26px; display: flex; align-items: center; }
1236-
.sesscard .sesssub { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
1236+
.sesscard .sesssub { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
12371237
/* The action row is nowrap by design (the card is two fixed-height lines — nothing may reflow), so
12381238
a button that does not fit OVERFLOWS the card rather than wrapping. Six labelled buttons
12391239
(Rename · Fork · Copy · Done · Delete · Pin) measure 434px at this styling; icon-only they are
@@ -1244,10 +1244,15 @@
12441244
reader — only to the eye, and only when there is no room for it.
12451245
ADDING A BUTTON? Re-measure and raise this; test/webviewCss.test.js pins the floor. */
12461246
.sesscard { container-type: inline-size; }
1247-
@container (max-width: 460px) { .sesscard .sesslbl { display: none; } }
1248-
.sesscard .sessacts { display: none; align-items: center; gap: 6px; flex-wrap: nowrap; }
1249-
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
1250-
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
1247+
/* Same contract as the Sessions panel (media/sessionsView.html): the actions are always present,
1248+
quiet until hover, and the icon is the label's fallback rather than a second hidden thing. */
1249+
.sesscard .sessact .ci { display: none; width: 13px; height: 13px; }
1250+
@container (max-width: 460px) {
1251+
.sesscard .sesslbl { display: none; }
1252+
.sesscard .sessact .ci { display: inline-flex; }
1253+
}
1254+
.sesscard .sessacts { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; flex: 0 0 auto; margin-left: auto; opacity: .5; transition: opacity .1s; }
1255+
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { opacity: 1; }
12511256
.sesscard .sessact { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 9px; border: 1px solid var(--cc-line); background: var(--cc-surface); color: var(--cc-text); border-radius: 5px; cursor: pointer; font: inherit; font-size: 11.5px; white-space: nowrap; }
12521257
.sesscard .sessact:hover { border-color: var(--cc-accent); }
12531258
.sesscard .sessact[data-act="delete"]:hover { border-color: var(--cc-danger); color: var(--cc-danger); }

extensions/levelcode-ai/media/sessionsView.html

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,20 +85,32 @@
8585
/* Line 2 shares its slot: at rest the file·time line; on hover the labeled buttons take the same
8686
fixed-height row (min-height) at full width — nothing wraps, reflows, or floats, and the title above is
8787
left intact. Sidebar buttons are label-only (icon hidden) to stay compact in a narrow panel. */
88-
.sesscard .sessline2 { margin: 2px 0 0 16px; min-height: 22px; display: flex; align-items: center; }
89-
.sesscard .sesssub { font-size: 12px; color: var(--cc-text3); }
88+
.sesscard .sessline2 { margin: 2px 0 0 16px; min-height: 22px; display: flex; align-items: center; gap: 8px; }
89+
/* The metadata truncates so the actions beside it always have room; it is the expendable half. */
90+
.sesscard .sesssub { font-size: 12px; color: var(--cc-text3); flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9091
/* Same reasoning as chat.html, different numbers — this pane's buttons are smaller (22px tall,
9192
8px padding, 11px text, no border), so six labelled ones measure 363px rather than 434px. The
9293
threshold sits above that; below it the row drops to icons, keeping title + aria-label, so only
9394
the eye loses anything and only when there is no room.
9495
ADDING A BUTTON? Re-measure and raise this; test/webviewCss.test.js pins the floor. */
9596
.sesscard { container-type: inline-size; }
96-
@container (max-width: 390px) { .sesscard .sesslbl { display: none; } }
97-
.sesscard .sessacts { display: none; align-items: center; gap: 4px; flex-wrap: nowrap; }
98-
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
99-
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
97+
/* ALWAYS PRESENT, not hover-revealed. A control that exists only while the pointer is on it is a
98+
control most people never find — and it cannot be found at all on a touch device. Quiet by
99+
default so a list of sessions still reads as a list; full strength on hover or keyboard focus. */
100+
.sesscard .sessacts { display: flex; align-items: center; gap: 2px; flex-wrap: nowrap; flex: 0 0 auto; margin-left: auto; opacity: .5; transition: opacity .1s; }
101+
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { opacity: 1; }
102+
@media (prefers-reduced-motion: reduce) { .sesscard .sessacts { transition: none; } }
100103
.sesscard .sessact { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border: none; background: transparent; color: var(--cc-text2); border-radius: 4px; cursor: pointer; font: inherit; font-size: 11px; white-space: nowrap; }
101-
.sesscard .sessact .ci { display: none; }
104+
/* The icon is the FALLBACK for the label, so it is hidden only while the label is showing — and it
105+
needs a size, because an inline <svg> with just a viewBox has no intrinsic one.
106+
This is the bug that made the buttons invisible: `.ci` was display:none unconditionally, so below
107+
the label threshold each button fell back to nothing and rendered as an empty box. */
108+
.sesscard .sessact .ci { display: none; width: 13px; height: 13px; }
109+
@container (max-width: 390px) {
110+
.sesscard .sesslbl { display: none; }
111+
.sesscard .sessact .ci { display: inline-flex; }
112+
.sesscard .sessact { padding: 0 5px; }
113+
}
102114
.sesscard .sessact:hover { background: color-mix(in srgb, var(--cc-text3) 20%, transparent); color: var(--cc-text); }
103115
.sesscard .sessact[data-act="delete"]:hover { background: color-mix(in srgb, var(--cc-danger) 18%, transparent); color: var(--cc-danger); }
104116
.sesscard.pinned [data-act="pin"] { color: var(--cc-accent); }
@@ -282,7 +294,7 @@
282294
vscode.postMessage({ type: 'sessionAction', action: 'restore', id: b.getAttribute('data-id') });
283295
document.getElementById('svUndo').hidden = true; clearTimeout(sessUndoTimer);
284296
});
285-
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 === 'memoryList'){ renderMemory(m); } else if (m && m.type === 'sessionUndo'){ showUndo(m); } });
297+
window.addEventListener('message', (ev) => { const m = ev.data; if (m && m.type === 'showTab'){ showTab(m.tab === 'memory' ? 'memory' : 'history'); } else if (m && m.type === 'sessions'){ sessData = Array.isArray(m.entries) ? m.entries : []; renderSessions(); } else if (m && m.type === 'memoryList'){ renderMemory(m); } else if (m && m.type === 'sessionUndo'){ showUndo(m); } });
286298
renderSessions();
287299
vscode.postMessage({ type: 'listSessions' });
288300
</script>

extensions/levelcode-ai/package.json

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,14 @@
133133
{
134134
"command": "levelcode.ai.sessions",
135135
"title": "AI: Sessions",
136-
"category": "LevelCode"
136+
"category": "LevelCode",
137+
"icon": "$(history)"
138+
},
139+
{
140+
"command": "levelcode.ai.memory",
141+
"title": "AI: Project Memory",
142+
"category": "LevelCode",
143+
"icon": "$(book)"
137144
},
138145
{
139146
"command": "levelcode.ai.sketch",
@@ -218,10 +225,20 @@
218225
"group": "navigation@1"
219226
},
220227
{
221-
"command": "levelcode.ai.setApiKey",
228+
"command": "levelcode.ai.sessions",
222229
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
223230
"group": "navigation@2"
224231
},
232+
{
233+
"command": "levelcode.ai.memory",
234+
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
235+
"group": "navigation@3"
236+
},
237+
{
238+
"command": "levelcode.ai.setApiKey",
239+
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
240+
"group": "navigation@4"
241+
},
225242
{
226243
"command": "levelcode.ai.review.keepActive",
227244
"when": "levelcode.ai.reviewActive",

extensions/levelcode-ai/test/chatSurface.test.js

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const path = require('path');
2727

2828
const ext = fs.readFileSync(path.join(__dirname, '..', 'extension.js'), 'utf8');
2929
const chatHtml = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8');
30+
const chatSessionsHtml = fs.readFileSync(path.join(__dirname, '..', 'media', 'sessionsView.html'), 'utf8');
3031
const pkg = require('../package.json');
3132

3233
let n = 0;
@@ -189,7 +190,11 @@ test('COMMAND: the chat TAB carries the actions the view title used to', () => {
189190
const onTab = (pkg.contributes.menus['editor/title'] || [])
190191
.filter((m) => m.when === "activeWebviewPanelId == 'levelcode.ai.chat'");
191192
const ids = onTab.map((m) => m.command);
192-
for (const id of ['levelcode.ai.newChat', 'levelcode.ai.addFileContext', 'levelcode.ai.setApiKey']) {
193+
// Sessions joined them: with the chat out of the right-hand bar, its container is one VS Code
194+
// hides by default, and `AI: Sessions` had no icon, no keybinding and no button anywhere — so the
195+
// only route to your own past conversations was knowing the palette entry existed.
196+
for (const id of ['levelcode.ai.newChat', 'levelcode.ai.addFileContext', 'levelcode.ai.setApiKey',
197+
'levelcode.ai.sessions', 'levelcode.ai.memory']) {
193198
assert.ok(ids.includes(id), id + ' lost its button when the sidebar view was removed');
194199
const cmd = pkg.contributes.commands.find((c) => c.command === id);
195200
assert.ok(cmd && cmd.icon, id + ' has no icon — an editor/title action with no icon renders as nothing');
@@ -199,6 +204,13 @@ test('COMMAND: the chat TAB carries the actions the view title used to', () => {
199204
m.command + ' must be in navigation, so VS Code can overflow it into … on a narrow tab');
200205
}
201206

207+
// The one thing still living in the right-hand container must stay reachable FROM the chat, or it
208+
// is reachable only by knowing it is there.
209+
assert.ok(ids.includes('levelcode.ai.sessions'),
210+
'no way from the conversation to the list of past conversations except the command palette');
211+
assert.ok(ids.includes('levelcode.ai.memory'),
212+
'no way from the conversation to what the project remembers except the command palette');
213+
202214
// And nothing may be scoped to the view that no longer exists — a stale `when` is a button that
203215
// never appears anywhere.
204216
const all = Object.values(pkg.contributes.menus).flat();
@@ -454,4 +466,41 @@ test('START: the docstring describes the values that actually exist', () => {
454466
assert.match(doc, /`editor`[\s\S]*`none`/, 'the docstring should name the two values that are actually supported');
455467
});
456468

469+
test('SESSIONS: each button lands on its own tab, and survives the view not existing yet', () => {
470+
// The panel's container is hidden by default, so the FIRST click of a session usually has to CREATE
471+
// the view — there is no webview to post `showTab` to at the moment the command runs. Posting once
472+
// and hoping is the bug this shape avoids: the tab is recorded first, posted immediately for a view
473+
// that is already up, and posted again from the view's own `listSessions` for one still loading.
474+
const body = fnBody(ext, 'revealSessions');
475+
// Presence FIRST. An ordering assertion on indexOf alone passes when the thing is missing, because
476+
// -1 is less than every real index — so deleting the line entirely would have satisfied it.
477+
const recorded = body.indexOf('pendingSessionsTab = tab');
478+
const revealed = body.indexOf('levelcodeAi.sessions.focus');
479+
assert.ok(recorded >= 0, 'the requested tab is never recorded — the cold path has nothing to flush');
480+
assert.ok(revealed >= 0, 'nothing reveals the Sessions container');
481+
assert.ok(recorded < revealed,
482+
'the tab must be recorded BEFORE the reveal — the view can resolve before the next statement runs');
483+
assert.match(body, /if \(sessionsWebview\)/, 'an already-open panel never gets told which tab to show');
484+
assert.match(body, /postMessage\(\{ type: 'showTab', tab \}\)/, 'the warm path does not post the tab');
485+
486+
// The cold path: the view announces itself with `listSessions`, which is where a tab requested
487+
// before it existed has to be flushed — and cleared, so a later plain reveal is not hijacked.
488+
// Anchored on the PROVIDER, not on the first `case 'listSessions'` in the file: the chat's own
489+
// /sessions modal handles a message by the same name, and an indexOf would read that one instead —
490+
// passing or failing on which handler happens to come first.
491+
const flush = fnBody(ext, 'resolveWebviewView');
492+
assert.match(flush, /pendingSessionsTab/, 'a tab requested before the view loaded is never applied');
493+
assert.match(flush, /pendingSessionsTab = ''/, 'the pending tab is not cleared — the next reveal inherits it');
494+
495+
// The webview end of the contract, and the two commands that use it.
496+
assert.match(chatSessionsHtml, /m\.type === 'showTab'/, 'the sessions view ignores showTab');
497+
assert.match(ext, /registerCommand\('levelcode\.ai\.sessions', \(\) => revealSessions\('history'\)\)/,
498+
'Sessions must name its tab, or the two buttons land in the same place');
499+
assert.match(ext, /registerCommand\('levelcode\.ai\.memory', \(\) => revealSessions\('memory'\)\)/,
500+
'Memory must name its tab');
501+
502+
// Fire-and-forget reveal: a rejection here must not become an unhandled rejection in the host.
503+
assert.match(body, /\.then\(undefined,|\.catch\(/, 'the reveal can reject unhandled');
504+
});
505+
457506
console.log('\nchatSurface: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)