Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 77 additions & 14 deletions extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -2257,11 +2257,17 @@ class ChatViewProvider {
* It is a MOVE. The sidebar hands over its slot and shows a card; the conversation continues in the
* tab with one live surface throughout.
*/
async function openChatInEditor() {
if (chatEditorPanel) { chatEditorPanel.reveal(); return; }
async function openChatInEditor(opts) {
// `preserveFocus` exists for the STARTUP path only. Opening the chat centred is what the user asked
// for; stealing the caret from a file VS Code just restored is not, and at startup those happen in
// the same instant. The panel still opens and is still the visible tab — only keyboard focus stays
// put. Invoking the command by hand passes nothing and keeps today's take-focus behaviour.
const preserveFocus = !!(opts && opts.preserveFocus === true);
if (chatEditorPanel) { chatEditorPanel.reveal(undefined, preserveFocus); return; }

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

/**
* The other direction of the move: put the chat back in the right-hand bar.
*
* Disposing the panel IS the move — `onDidDispose` above already hands the slot back to the sidebar
* and replays the transcript. Going through it rather than duplicating that path is what makes this
* button and ⌘W behave identically; a second implementation would drift from it the first time the
* hand-over changed.
*/
function moveChatToSidebar() {
if (chatEditorPanel) { chatEditorPanel.dispose(); return undefined; }
// Already there (or never moved) — just reveal it, so the command is never a silent no-op.
//
// RETURNED, not fired and forgotten. `registerCommand` awaits whatever the handler returns, so a
// failure here reaches the user as a failed command instead of an unhandled rejection. That is the
// opposite of the startup path on purpose: this is an explicit click, and silence would leave the
// user pressing a button that does nothing.
return vscode.commands.executeCommand('levelcodeAi.chat.focus');
}

/**
* Where the chat opens when the window does.
*
* The default is the EDITOR: the chat is the thing most sessions are actually about, and a centred
* column is where the reference puts it. `secondarySidebar` is the old behaviour, kept because the
* sidebar is the right answer when you want the chat beside code rather than instead of it, and
* `none` is the honest opt-out for anyone who would rather open it themselves.
*
* Unknown values fall back to the default rather than throwing: this is read at startup, and a typo
* in settings.json should not be able to leave a window with no chat and no explanation.
*/
function chatStartLocation() {
const raw = String(aiConfig().get('chat.startLocation', 'editor') || 'editor');
return ['editor', 'secondarySidebar', 'none'].includes(raw) ? raw : 'editor';
}

/** Open the chat where `chat.startLocation` says, once, as the window finishes starting. */
async function revealChatAtStartup() {
const where = chatStartLocation();
dbg('chat.startLocation', { where });
if (where === 'none') { return; }
if (where === 'secondarySidebar') { await vscode.commands.executeCommand('levelcodeAi.chat.focus'); return; }
await openChatInEditor({ preserveFocus: true });
}

/**
* Replay the live session's visible turns into whichever surface just took over.
*
Expand Down Expand Up @@ -2662,7 +2712,11 @@ function activate(context) {
vscode.commands.registerCommand('levelcode.ai.newChat', newChat),
vscode.commands.registerCommand('levelcode.ai.pickModel', pickModel),
vscode.commands.registerCommand('levelcode.ai.manageMcp', manageMcpServers),
vscode.commands.registerCommand('levelcode.ai.openChatInEditor', openChatInEditor),
// Wrapped, NOT passed by reference: a menu invocation hands the command its context as the first
// argument, and openChatInEditor now reads an options object there. Bound directly, a title-bar
// click would pass whatever VS Code supplies and could set preserveFocus by accident.
vscode.commands.registerCommand('levelcode.ai.openChatInEditor', () => openChatInEditor()),
vscode.commands.registerCommand('levelcode.ai.moveChatToSidebar', () => moveChatToSidebar()),
vscode.commands.registerCommand('levelcode.ai.addSelection', addSelection),
vscode.commands.registerCommand('levelcode.ai.addFileContext', addContext),
vscode.commands.registerCommand('levelcode.ai.setApiKey', () => promptForKey()),
Expand Down Expand Up @@ -2729,16 +2783,25 @@ function activate(context) {
// engaged (sent their first message). This makes sure new users always see it, instead of it only
// showing once. Once they've sent a message (handleSend sets the flag) we stop forcing it and defer
// to VS Code's own per-workspace layout persistence, so closing it stays closed.
// Fallback guard: if the webview never renders (provider error, missing resource) hasSentMessage
// is never set, which would otherwise force the panel open forever. Stop after a few launches.
const AUTO_REVEAL_MAX_LAUNCHES = 5;
if (!context.globalState.get('levelcode.ai.hasSentMessage')) {
const launches = (Number(context.globalState.get('levelcode.ai.autoRevealLaunches')) || 0) + 1;
context.globalState.update('levelcode.ai.autoRevealLaunches', launches);
if (launches <= AUTO_REVEAL_MAX_LAUNCHES) {
setTimeout(() => { vscode.commands.executeCommand('levelcodeAi.chat.focus'); }, 600);
}
}
// Open the chat where `chat.startLocation` says — every launch, not just the first few.
//
// This replaces an onboarding-only auto-reveal that opened the SIDEBAR for at most five launches
// and then stopped. Two reasons it goes:
// • It was the wrong surface. The default is now a centred editor tab, and leaving the old block
// in place would open both — a sidebar reveal AND a tab — on every fresh install.
// • Its launch cap was standing in for a setting that did not exist. The cap guarded against a
// broken webview forcing the panel open forever; `chat.startLocation: none` is a better answer
// to that, and a chat that silently stops appearing after five launches is worse to diagnose
// than one that keeps showing you it is broken.
//
// `.catch` because this is fire-and-forget: nothing awaits the timer, so a rejection from
// `createWebviewPanel` or the focus command would surface as an unhandled rejection in the
// extension host — noisy, and attributed to nothing in particular. Logged rather than swallowed:
// a chat that never appears, with no trace of why, is the one failure mode this whole setting is
// supposed to make explicable. The window still starts, and both surfaces remain openable by hand.
setTimeout(() => {
revealChatAtStartup().catch((e) => dbg('chat.startLocation.failed', { msg: String((e && e.message) || e) }));
}, 600);

// First-launch onboarding: open the "Welcome to LevelCode" walkthrough once. Only mark it shown
// AFTER it actually opens (previously the flag was set up-front, so a first-launch race that failed
Expand Down
26 changes: 26 additions & 0 deletions extensions/levelcode-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@
"category": "LevelCode",
"icon": "$(link-external)"
},
{
"command": "levelcode.ai.moveChatToSidebar",
"title": "AI: Move Chat to Sidebar",
"category": "LevelCode",
"icon": "$(layout-sidebar-right)"
},
{
"command": "levelcode.ai.sessions",
"title": "AI: Sessions",
Expand Down Expand Up @@ -234,6 +240,11 @@
}
],
"editor/title": [
{
"command": "levelcode.ai.moveChatToSidebar",
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
"group": "navigation@0"
},
{
"command": "levelcode.ai.review.keepActive",
"when": "levelcode.ai.reviewActive",
Expand Down Expand Up @@ -375,6 +386,21 @@
"default": false,
"description": "Include a list of all project file paths with each chat message, so the AI knows the repo structure. Uses more tokens."
},
"levelcode.ai.chat.startLocation": {
"type": "string",
"enum": [
"editor",
"secondarySidebar",
"none"
],
"enumDescriptions": [
"Open the chat as a centred editor tab, like any other file.",
"Reveal the chat in the right-hand sidebar.",
"Do not open the chat automatically."
],
"default": "editor",
"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."
},
"levelcode.ai.chat.fontSize": {
"type": "number",
"default": 0,
Expand Down
98 changes: 95 additions & 3 deletions extensions/levelcode-ai/test/chatSurface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,14 @@ test('SURFACE: only makeLive() ever moves the conversation, so two surfaces cann

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

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

test('START: the chat opens centred by default, and the setting is the only place that decides', () => {
const prop = pkg.contributes.configuration.properties['levelcode.ai.chat.startLocation'];
assert.ok(prop, 'chat.startLocation is not declared — the default would be unchangeable');
assert.strictEqual(prop.default, 'editor', 'the chat must open in the centre by default');
assert.deepStrictEqual(prop.enum, ['editor', 'secondarySidebar', 'none'],
'`none` is the opt-out that replaced the old launch cap — dropping it leaves no way to turn this off');
assert.strictEqual(prop.enumDescriptions.length, prop.enum.length,
'every value needs a description, or the settings UI shows bare identifiers');

// One reader, so a second caller cannot quietly disagree about what an unknown value means.
const body = fnBody(ext, 'chatStartLocation');
assert.match(body, /'editor', 'secondarySidebar', 'none'/, 'the reader no longer validates against the enum');
assert.match(body, /: 'editor'/, 'an unknown value must fall back to the default, not leave the window with no chat');
});

test('START: exactly one thing opens the chat at startup', () => {
// The bug this pins: the old onboarding block revealed the SIDEBAR on launch. Left in place next to
// the new centred default it would open both surfaces at once on a fresh install — and because the
// old one was capped at five launches, it would have "fixed itself" later, which is the worst kind.
assert.ok(!/AUTO_REVEAL_MAX_LAUNCHES/.test(ext),
'the old capped auto-reveal is still here — it opens the sidebar alongside the new editor tab');
const startupCalls = (ext.match(/revealChatAtStartup\(\)/g) || []).length;
assert.strictEqual(startupCalls, 2, 'expected one definition and one call site, found ' + startupCalls);

const body = fnBody(ext, 'revealChatAtStartup');
assert.match(body, /where === 'none'/, 'none must return before opening anything');
assert.match(body, /levelcodeAi\.chat\.focus/, 'secondarySidebar must still reveal the contributed view');
assert.match(body, /openChatInEditor\(\{ preserveFocus: true \}\)/,
'the startup open must preserve focus — otherwise it steals the caret from a restored file');
});

test('START: the startup open cannot be triggered by a menu click', () => {
// openChatInEditor now reads an options object from its first argument, and VS Code hands a command
// its menu context in exactly that position. Bound by reference, a title-bar click would pass
// whatever VS Code supplies — so the command is wrapped, and this is why.
assert.match(ext, /registerCommand\('levelcode\.ai\.openChatInEditor', \(\) => openChatInEditor\(\)\)/,
'bind the command through a wrapper, or a menu argument can reach the options parameter');
assert.match(fnBody(ext, 'openChatInEditor'), /opts && opts\.preserveFocus === true/,
'preserveFocus must be read strictly, so a stray truthy argument cannot enable it');
});

test('START: the fire-and-forget startup call cannot become an unhandled rejection', () => {
// Nothing awaits the startup timer, so a rejection from createWebviewPanel or from the focus
// command would land in the extension host attributed to nothing. Caught — but LOGGED, not
// swallowed: a chat that never appears with no trace of why is the exact failure this setting is
// supposed to make explicable.
const call = /revealChatAtStartup\(\)([\s\S]{0,160}?)\}, 600\)/.exec(ext);
assert.ok(call, 'the startup call site moved — this guard no longer covers it');
assert.match(call[1], /\.catch\(/, 'the fire-and-forget startup call has no .catch — unhandled rejection');
assert.match(call[1], /dbg\(/, 'the failure is swallowed silently; log the reason so it can be diagnosed');
});

test('MOVE BACK: a failed move reaches the user instead of vanishing', () => {
// Deliberately the OPPOSITE of the startup path. This is an explicit click, and registerCommand
// awaits what the handler returns — so returning the thenable turns a failure into a reported
// command error, where swallowing it would leave the user pressing a button that does nothing.
const body = fnBody(ext, 'moveChatToSidebar');
assert.match(body, /return vscode\.commands\.executeCommand\('levelcodeAi\.chat\.focus'\)/,
'the reveal must be RETURNED, or a failure is an unhandled rejection and the click looks inert');
assert.match(ext, /registerCommand\('levelcode\.ai\.moveChatToSidebar', \(\) => moveChatToSidebar\(\)\)/,
'the registration must return the handler result, or returning it inside buys nothing');
});

test('MOVE BACK: there is a button on the tab, and it reuses the dispose hand-over', () => {
// The chat now opens centred for everyone, so the way BACK has to be visible from the centre.
// Before this it existed only on the sidebar card — which you cannot see while the chat is a tab.
const cmd = pkg.contributes.commands.find((c) => c.command === 'levelcode.ai.moveChatToSidebar');
assert.ok(cmd, 'no move-back command — the only way right would be closing the tab');
assert.ok(cmd.icon, 'no icon — an editor/title action with no icon renders as nothing');

const entry = (pkg.contributes.menus['editor/title'] || [])
.find((m) => m.command === 'levelcode.ai.moveChatToSidebar');
assert.ok(entry, 'not contributed to editor/title — reachable only from the Command Palette');
assert.strictEqual(entry.when, "activeWebviewPanelId == 'levelcode.ai.chat'",
'scope it to the chat panel, or the button appears on every editor tab in the window');

// Disposing IS the move: onDidDispose already hands the slot back and replays the transcript, so
// this must not grow a second copy of that path.
const body = fnBody(ext, 'moveChatToSidebar');
assert.match(body, /chatEditorPanel\.dispose\(\)/, 'the move must go through dispose, not a parallel hand-over');
assert.ok(!/makeLive|replayLiveTranscript/.test(body),
'this is duplicating the hand-over instead of reusing onDidDispose — the two will drift');
assert.match(body, /levelcodeAi\.chat\.focus/, 'with no panel open the command must still reveal the chat, not do nothing');
});

console.log('\nchatSurface: ' + n + ' tests passed.');