Skip to content

Commit e65286f

Browse files
authored
Merge pull request #76 from levelcodeai/feat/chat-center-by-default
feat(chat): the chat opens centred by default, with a button back to the sidebar
2 parents ef504f2 + b0a00d7 commit e65286f

3 files changed

Lines changed: 198 additions & 17 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2257,11 +2257,17 @@ class ChatViewProvider {
22572257
* It is a MOVE. The sidebar hands over its slot and shows a card; the conversation continues in the
22582258
* tab with one live surface throughout.
22592259
*/
2260-
async function openChatInEditor() {
2261-
if (chatEditorPanel) { chatEditorPanel.reveal(); return; }
2260+
async function openChatInEditor(opts) {
2261+
// `preserveFocus` exists for the STARTUP path only. Opening the chat centred is what the user asked
2262+
// for; stealing the caret from a file VS Code just restored is not, and at startup those happen in
2263+
// the same instant. The panel still opens and is still the visible tab — only keyboard focus stays
2264+
// put. Invoking the command by hand passes nothing and keeps today's take-focus behaviour.
2265+
const preserveFocus = !!(opts && opts.preserveFocus === true);
2266+
if (chatEditorPanel) { chatEditorPanel.reveal(undefined, preserveFocus); return; }
22622267

22632268
const panel = vscode.window.createWebviewPanel(
2264-
'levelcode.ai.chat', 'LevelCode AI', vscode.ViewColumn.Active,
2269+
'levelcode.ai.chat', 'LevelCode AI',
2270+
{ viewColumn: vscode.ViewColumn.Active, preserveFocus },
22652271
// retainContextWhenHidden: the transcript lives in this DOM, so switching to another tab and
22662272
// back must not wipe it — the same reason the contributed views set it.
22672273
{ enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [ctx.extensionUri] }
@@ -2295,6 +2301,50 @@ async function openChatInEditor() {
22952301
});
22962302
}
22972303

2304+
/**
2305+
* The other direction of the move: put the chat back in the right-hand bar.
2306+
*
2307+
* Disposing the panel IS the move — `onDidDispose` above already hands the slot back to the sidebar
2308+
* and replays the transcript. Going through it rather than duplicating that path is what makes this
2309+
* button and ⌘W behave identically; a second implementation would drift from it the first time the
2310+
* hand-over changed.
2311+
*/
2312+
function moveChatToSidebar() {
2313+
if (chatEditorPanel) { chatEditorPanel.dispose(); return undefined; }
2314+
// Already there (or never moved) — just reveal it, so the command is never a silent no-op.
2315+
//
2316+
// RETURNED, not fired and forgotten. `registerCommand` awaits whatever the handler returns, so a
2317+
// failure here reaches the user as a failed command instead of an unhandled rejection. That is the
2318+
// opposite of the startup path on purpose: this is an explicit click, and silence would leave the
2319+
// user pressing a button that does nothing.
2320+
return vscode.commands.executeCommand('levelcodeAi.chat.focus');
2321+
}
2322+
2323+
/**
2324+
* Where the chat opens when the window does.
2325+
*
2326+
* The default is the EDITOR: the chat is the thing most sessions are actually about, and a centred
2327+
* column is where the reference puts it. `secondarySidebar` is the old behaviour, kept because the
2328+
* sidebar is the right answer when you want the chat beside code rather than instead of it, and
2329+
* `none` is the honest opt-out for anyone who would rather open it themselves.
2330+
*
2331+
* Unknown values fall back to the default rather than throwing: this is read at startup, and a typo
2332+
* in settings.json should not be able to leave a window with no chat and no explanation.
2333+
*/
2334+
function chatStartLocation() {
2335+
const raw = String(aiConfig().get('chat.startLocation', 'editor') || 'editor');
2336+
return ['editor', 'secondarySidebar', 'none'].includes(raw) ? raw : 'editor';
2337+
}
2338+
2339+
/** Open the chat where `chat.startLocation` says, once, as the window finishes starting. */
2340+
async function revealChatAtStartup() {
2341+
const where = chatStartLocation();
2342+
dbg('chat.startLocation', { where });
2343+
if (where === 'none') { return; }
2344+
if (where === 'secondarySidebar') { await vscode.commands.executeCommand('levelcodeAi.chat.focus'); return; }
2345+
await openChatInEditor({ preserveFocus: true });
2346+
}
2347+
22982348
/**
22992349
* Replay the live session's visible turns into whichever surface just took over.
23002350
*
@@ -2662,7 +2712,11 @@ function activate(context) {
26622712
vscode.commands.registerCommand('levelcode.ai.newChat', newChat),
26632713
vscode.commands.registerCommand('levelcode.ai.pickModel', pickModel),
26642714
vscode.commands.registerCommand('levelcode.ai.manageMcp', manageMcpServers),
2665-
vscode.commands.registerCommand('levelcode.ai.openChatInEditor', openChatInEditor),
2715+
// Wrapped, NOT passed by reference: a menu invocation hands the command its context as the first
2716+
// argument, and openChatInEditor now reads an options object there. Bound directly, a title-bar
2717+
// click would pass whatever VS Code supplies and could set preserveFocus by accident.
2718+
vscode.commands.registerCommand('levelcode.ai.openChatInEditor', () => openChatInEditor()),
2719+
vscode.commands.registerCommand('levelcode.ai.moveChatToSidebar', () => moveChatToSidebar()),
26662720
vscode.commands.registerCommand('levelcode.ai.addSelection', addSelection),
26672721
vscode.commands.registerCommand('levelcode.ai.addFileContext', addContext),
26682722
vscode.commands.registerCommand('levelcode.ai.setApiKey', () => promptForKey()),
@@ -2729,16 +2783,25 @@ function activate(context) {
27292783
// engaged (sent their first message). This makes sure new users always see it, instead of it only
27302784
// showing once. Once they've sent a message (handleSend sets the flag) we stop forcing it and defer
27312785
// to VS Code's own per-workspace layout persistence, so closing it stays closed.
2732-
// Fallback guard: if the webview never renders (provider error, missing resource) hasSentMessage
2733-
// is never set, which would otherwise force the panel open forever. Stop after a few launches.
2734-
const AUTO_REVEAL_MAX_LAUNCHES = 5;
2735-
if (!context.globalState.get('levelcode.ai.hasSentMessage')) {
2736-
const launches = (Number(context.globalState.get('levelcode.ai.autoRevealLaunches')) || 0) + 1;
2737-
context.globalState.update('levelcode.ai.autoRevealLaunches', launches);
2738-
if (launches <= AUTO_REVEAL_MAX_LAUNCHES) {
2739-
setTimeout(() => { vscode.commands.executeCommand('levelcodeAi.chat.focus'); }, 600);
2740-
}
2741-
}
2786+
// Open the chat where `chat.startLocation` says — every launch, not just the first few.
2787+
//
2788+
// This replaces an onboarding-only auto-reveal that opened the SIDEBAR for at most five launches
2789+
// and then stopped. Two reasons it goes:
2790+
// • It was the wrong surface. The default is now a centred editor tab, and leaving the old block
2791+
// in place would open both — a sidebar reveal AND a tab — on every fresh install.
2792+
// • Its launch cap was standing in for a setting that did not exist. The cap guarded against a
2793+
// broken webview forcing the panel open forever; `chat.startLocation: none` is a better answer
2794+
// to that, and a chat that silently stops appearing after five launches is worse to diagnose
2795+
// than one that keeps showing you it is broken.
2796+
//
2797+
// `.catch` because this is fire-and-forget: nothing awaits the timer, so a rejection from
2798+
// `createWebviewPanel` or the focus command would surface as an unhandled rejection in the
2799+
// extension host — noisy, and attributed to nothing in particular. Logged rather than swallowed:
2800+
// a chat that never appears, with no trace of why, is the one failure mode this whole setting is
2801+
// supposed to make explicable. The window still starts, and both surfaces remain openable by hand.
2802+
setTimeout(() => {
2803+
revealChatAtStartup().catch((e) => dbg('chat.startLocation.failed', { msg: String((e && e.message) || e) }));
2804+
}, 600);
27422805

27432806
// First-launch onboarding: open the "Welcome to LevelCode" walkthrough once. Only mark it shown
27442807
// AFTER it actually opens (previously the flag was set up-front, so a first-launch race that failed

extensions/levelcode-ai/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@
135135
"category": "LevelCode",
136136
"icon": "$(link-external)"
137137
},
138+
{
139+
"command": "levelcode.ai.moveChatToSidebar",
140+
"title": "AI: Move Chat to Sidebar",
141+
"category": "LevelCode",
142+
"icon": "$(layout-sidebar-right)"
143+
},
138144
{
139145
"command": "levelcode.ai.sessions",
140146
"title": "AI: Sessions",
@@ -234,6 +240,11 @@
234240
}
235241
],
236242
"editor/title": [
243+
{
244+
"command": "levelcode.ai.moveChatToSidebar",
245+
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
246+
"group": "navigation@0"
247+
},
237248
{
238249
"command": "levelcode.ai.review.keepActive",
239250
"when": "levelcode.ai.reviewActive",
@@ -375,6 +386,21 @@
375386
"default": false,
376387
"description": "Include a list of all project file paths with each chat message, so the AI knows the repo structure. Uses more tokens."
377388
},
389+
"levelcode.ai.chat.startLocation": {
390+
"type": "string",
391+
"enum": [
392+
"editor",
393+
"secondarySidebar",
394+
"none"
395+
],
396+
"enumDescriptions": [
397+
"Open the chat as a centred editor tab, like any other file.",
398+
"Reveal the chat in the right-hand sidebar.",
399+
"Do not open the chat automatically."
400+
],
401+
"default": "editor",
402+
"markdownDescription": "Where the chat opens when a window opens.\n\nThe default puts it in the centre, where the transcript gets the full reading column. This is only the *starting* position \u2014 **AI: Move Chat to Sidebar** (a button on the chat tab) and **AI: Open Chat in Editor** (a button in the sidebar) move it either way at any time, without changing this setting."
403+
},
378404
"levelcode.ai.chat.fontSize": {
379405
"type": "number",
380406
"default": 0,

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

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,14 @@ test('SURFACE: only makeLive() ever moves the conversation, so two surfaces cann
7070

7171
test('SURFACE: an already-open tab is revealed, never opened twice', () => {
7272
// Two panels would mean two DOMs, two `ready` messages, and a race for activeWebview.
73-
assert.match(fnBody(ext, 'openChatInEditor'), /^\s*\{\s*if \(chatEditorPanel\) \{ chatEditorPanel\.reveal\(\); return; \}/,
74-
'the guard must be the first thing the command does');
73+
// The guard must still be the first STATEMENT, but it now reveals with the caller's focus
74+
// preference — a startup open that reveals an existing tab must not yank focus either.
75+
const open = fnBody(ext, 'openChatInEditor');
76+
assert.match(open, /if \(chatEditorPanel\) \{ chatEditorPanel\.reveal\(undefined, preserveFocus\); return; \}/,
77+
'the already-open guard is gone or no longer honours preserveFocus');
78+
const guardAt = open.indexOf('if (chatEditorPanel)');
79+
assert.ok(guardAt >= 0 && guardAt < open.indexOf('createWebviewPanel'),
80+
'the guard must run before anything can construct a second panel');
7581
assert.strictEqual((ext.match(/createWebviewPanel\(\s*\n?\s*'levelcode\.ai\.chat'/g) || []).length, 1,
7682
'more than one place constructs the chat panel');
7783
});
@@ -164,7 +170,8 @@ test('LABEL: a move is not rendered as "Resumed"', () => {
164170
});
165171

166172
test('COMMAND: it is registered and discoverable in the palette', () => {
167-
assert.match(ext, /registerCommand\('levelcode\.ai\.openChatInEditor', openChatInEditor\)/);
173+
// Wrapped rather than bound by reference — see the START test on menu arguments below.
174+
assert.match(ext, /registerCommand\('levelcode\.ai\.openChatInEditor', \(\) => openChatInEditor\(\)\)/);
168175
const cmd = pkg.contributes.commands.find((c) => c.command === 'levelcode.ai.openChatInEditor');
169176
assert.ok(cmd, 'not declared in package.json — it would not appear in the Command Palette');
170177
assert.match(cmd.title, /Chat in Editor/);
@@ -218,4 +225,89 @@ test('COMMAND: it has a BUTTON on the chat header, not only the palette', () =>
218225
'navigation keeps it inline and lets VS Code overflow it into … when the sidebar is narrow');
219226
});
220227

228+
test('START: the chat opens centred by default, and the setting is the only place that decides', () => {
229+
const prop = pkg.contributes.configuration.properties['levelcode.ai.chat.startLocation'];
230+
assert.ok(prop, 'chat.startLocation is not declared — the default would be unchangeable');
231+
assert.strictEqual(prop.default, 'editor', 'the chat must open in the centre by default');
232+
assert.deepStrictEqual(prop.enum, ['editor', 'secondarySidebar', 'none'],
233+
'`none` is the opt-out that replaced the old launch cap — dropping it leaves no way to turn this off');
234+
assert.strictEqual(prop.enumDescriptions.length, prop.enum.length,
235+
'every value needs a description, or the settings UI shows bare identifiers');
236+
237+
// One reader, so a second caller cannot quietly disagree about what an unknown value means.
238+
const body = fnBody(ext, 'chatStartLocation');
239+
assert.match(body, /'editor', 'secondarySidebar', 'none'/, 'the reader no longer validates against the enum');
240+
assert.match(body, /: 'editor'/, 'an unknown value must fall back to the default, not leave the window with no chat');
241+
});
242+
243+
test('START: exactly one thing opens the chat at startup', () => {
244+
// The bug this pins: the old onboarding block revealed the SIDEBAR on launch. Left in place next to
245+
// the new centred default it would open both surfaces at once on a fresh install — and because the
246+
// old one was capped at five launches, it would have "fixed itself" later, which is the worst kind.
247+
assert.ok(!/AUTO_REVEAL_MAX_LAUNCHES/.test(ext),
248+
'the old capped auto-reveal is still here — it opens the sidebar alongside the new editor tab');
249+
const startupCalls = (ext.match(/revealChatAtStartup\(\)/g) || []).length;
250+
assert.strictEqual(startupCalls, 2, 'expected one definition and one call site, found ' + startupCalls);
251+
252+
const body = fnBody(ext, 'revealChatAtStartup');
253+
assert.match(body, /where === 'none'/, 'none must return before opening anything');
254+
assert.match(body, /levelcodeAi\.chat\.focus/, 'secondarySidebar must still reveal the contributed view');
255+
assert.match(body, /openChatInEditor\(\{ preserveFocus: true \}\)/,
256+
'the startup open must preserve focus — otherwise it steals the caret from a restored file');
257+
});
258+
259+
test('START: the startup open cannot be triggered by a menu click', () => {
260+
// openChatInEditor now reads an options object from its first argument, and VS Code hands a command
261+
// its menu context in exactly that position. Bound by reference, a title-bar click would pass
262+
// whatever VS Code supplies — so the command is wrapped, and this is why.
263+
assert.match(ext, /registerCommand\('levelcode\.ai\.openChatInEditor', \(\) => openChatInEditor\(\)\)/,
264+
'bind the command through a wrapper, or a menu argument can reach the options parameter');
265+
assert.match(fnBody(ext, 'openChatInEditor'), /opts && opts\.preserveFocus === true/,
266+
'preserveFocus must be read strictly, so a stray truthy argument cannot enable it');
267+
});
268+
269+
test('START: the fire-and-forget startup call cannot become an unhandled rejection', () => {
270+
// Nothing awaits the startup timer, so a rejection from createWebviewPanel or from the focus
271+
// command would land in the extension host attributed to nothing. Caught — but LOGGED, not
272+
// swallowed: a chat that never appears with no trace of why is the exact failure this setting is
273+
// supposed to make explicable.
274+
const call = /revealChatAtStartup\(\)([\s\S]{0,160}?)\}, 600\)/.exec(ext);
275+
assert.ok(call, 'the startup call site moved — this guard no longer covers it');
276+
assert.match(call[1], /\.catch\(/, 'the fire-and-forget startup call has no .catch — unhandled rejection');
277+
assert.match(call[1], /dbg\(/, 'the failure is swallowed silently; log the reason so it can be diagnosed');
278+
});
279+
280+
test('MOVE BACK: a failed move reaches the user instead of vanishing', () => {
281+
// Deliberately the OPPOSITE of the startup path. This is an explicit click, and registerCommand
282+
// awaits what the handler returns — so returning the thenable turns a failure into a reported
283+
// command error, where swallowing it would leave the user pressing a button that does nothing.
284+
const body = fnBody(ext, 'moveChatToSidebar');
285+
assert.match(body, /return vscode\.commands\.executeCommand\('levelcodeAi\.chat\.focus'\)/,
286+
'the reveal must be RETURNED, or a failure is an unhandled rejection and the click looks inert');
287+
assert.match(ext, /registerCommand\('levelcode\.ai\.moveChatToSidebar', \(\) => moveChatToSidebar\(\)\)/,
288+
'the registration must return the handler result, or returning it inside buys nothing');
289+
});
290+
291+
test('MOVE BACK: there is a button on the tab, and it reuses the dispose hand-over', () => {
292+
// The chat now opens centred for everyone, so the way BACK has to be visible from the centre.
293+
// Before this it existed only on the sidebar card — which you cannot see while the chat is a tab.
294+
const cmd = pkg.contributes.commands.find((c) => c.command === 'levelcode.ai.moveChatToSidebar');
295+
assert.ok(cmd, 'no move-back command — the only way right would be closing the tab');
296+
assert.ok(cmd.icon, 'no icon — an editor/title action with no icon renders as nothing');
297+
298+
const entry = (pkg.contributes.menus['editor/title'] || [])
299+
.find((m) => m.command === 'levelcode.ai.moveChatToSidebar');
300+
assert.ok(entry, 'not contributed to editor/title — reachable only from the Command Palette');
301+
assert.strictEqual(entry.when, "activeWebviewPanelId == 'levelcode.ai.chat'",
302+
'scope it to the chat panel, or the button appears on every editor tab in the window');
303+
304+
// Disposing IS the move: onDidDispose already hands the slot back and replays the transcript, so
305+
// this must not grow a second copy of that path.
306+
const body = fnBody(ext, 'moveChatToSidebar');
307+
assert.match(body, /chatEditorPanel\.dispose\(\)/, 'the move must go through dispose, not a parallel hand-over');
308+
assert.ok(!/makeLive|replayLiveTranscript/.test(body),
309+
'this is duplicating the hand-over instead of reusing onDidDispose — the two will drift');
310+
assert.match(body, /levelcodeAi\.chat\.focus/, 'with no panel open the command must still reveal the chat, not do nothing');
311+
});
312+
221313
console.log('\nchatSurface: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)