From 119b40d7eec158ce1924d7fb709207e7c9bf4ea8 Mon Sep 17 00:00:00 2001 From: Slight_wind <758494478@qq.com> Date: Mon, 17 Aug 2026 20:09:54 +0800 Subject: [PATCH] fix(vscode): scope New Conversation to the sidebar view The kimi.newConversation command reveals and focuses the sidebar view before it broadcasts, so it is the sidebar's action, yet it delivered the event to every registered webview. With a Kimi view open as an editor tab, pressing + in the sidebar also cleared that tab's conversation and aborted its in-flight turn. Track the sidebar's webview id and deliver the event only there. broadcastInternal already takes a target and insertEditorMention already uses it, and the in-webview Cmd/Ctrl+N shortcut already scopes itself to the focused view, so the command now matches both. --- .changeset/vscode-new-conversation-scope.md | 5 + apps/vscode/src/KimiWebviewProvider.ts | 9 + apps/vscode/src/extension.ts | 2 +- apps/vscode/test/webview-provider.test.ts | 224 ++++++++++++++++++++ 4 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 .changeset/vscode-new-conversation-scope.md create mode 100644 apps/vscode/test/webview-provider.test.ts diff --git a/.changeset/vscode-new-conversation-scope.md b/.changeset/vscode-new-conversation-scope.md new file mode 100644 index 00000000000..179be362784 --- /dev/null +++ b/.changeset/vscode-new-conversation-scope.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +The sidebar's New Conversation action now resets only the sidebar view; Kimi views opened as editor tabs keep their conversation and their in-flight turn. diff --git a/apps/vscode/src/KimiWebviewProvider.ts b/apps/vscode/src/KimiWebviewProvider.ts index 2fed3fa09d0..d2630312b13 100644 --- a/apps/vscode/src/KimiWebviewProvider.ts +++ b/apps/vscode/src/KimiWebviewProvider.ts @@ -18,6 +18,7 @@ function getNonce(): string { */ export class KimiWebviewProvider implements vscode.WebviewViewProvider { private webviews = new Map(); + private sidebarWebviewId: string | undefined; private bridgeHandler: BridgeHandler; constructor( @@ -50,9 +51,11 @@ export class KimiWebviewProvider implements vscode.WebviewViewProvider { resolveWebviewView(webviewView: vscode.WebviewView): void { const webviewId = `sidebar_${crypto.randomUUID()}`; + this.sidebarWebviewId = webviewId; this.setupWebview(webviewId, webviewView.webview); webviewView.onDidDispose(() => { + if (this.sidebarWebviewId === webviewId) this.sidebarWebviewId = undefined; void this.bridgeHandler.disposeView(webviewId); this.webviews.delete(webviewId); }); @@ -81,6 +84,12 @@ export class KimiWebviewProvider implements vscode.WebviewViewProvider { this.broadcastInternal(event, data); } + /** Actions contributed to the sidebar's `view/title` act on that view alone; panels own their own state. */ + broadcastToSidebar(event: string, data: unknown): void { + if (this.sidebarWebviewId === undefined) return; + this.broadcastInternal(event, data, this.sidebarWebviewId); + } + async insertEditorMention(documentUri: vscode.Uri, selection: vscode.Selection): Promise { let inserted = false; await Promise.all( diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 4418526fbd9..090525cb8b1 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -115,7 +115,7 @@ export async function activate(context: vscode.ExtensionContext): Promise }, "kimi.newConversation": async () => { await vscode.commands.executeCommand("kimi.webview.focus"); - provider?.broadcast(Events.NewConversation, {}); + provider?.broadcastToSidebar(Events.NewConversation, {}); }, "kimi.showLogs": () => outputChannel?.show(), "kimi.resetKimi": () => provider?.resetAllWebviews(), diff --git a/apps/vscode/test/webview-provider.test.ts b/apps/vscode/test/webview-provider.test.ts new file mode 100644 index 00000000000..beb59dba563 --- /dev/null +++ b/apps/vscode/test/webview-provider.test.ts @@ -0,0 +1,224 @@ +/** + * Scenario: the Kimi sidebar and one or more editor-tab panels are open at the same time. + * Responsibilities: an action owned by the sidebar's view/title must reach the sidebar alone — + * panels hold independent sessions — while genuinely global notifications still reach every view. + * Wiring: the real KimiWebviewProvider and BridgeHandler; VS Code and the public Node SDK harness + * boundary are replaced. + * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/webview-provider.test.ts + */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type * as vscode from "vscode"; + +import { KimiWebviewProvider } from "../src/KimiWebviewProvider"; + +const host = vi.hoisted(() => { + const watcher = { + onDidChange: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }; + const harness = { + homeDir: "/tmp/kimi-code-test-home", + close: vi.fn(async () => undefined), + getConfig: vi.fn(async () => ({ models: {} })), + setConfig: vi.fn(async () => undefined), + listSessions: vi.fn(async () => []), + resumeSession: vi.fn(), + forkSession: vi.fn(), + deleteSession: vi.fn(async () => undefined), + }; + + class Uri { + readonly scheme = "file"; + readonly authority = ""; + readonly path: string; + + constructor(readonly fsPath: string) { + this.path = fsPath; + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri(join(base.fsPath, ...segments)); + } + + toString(): string { + return `file://${this.path}`; + } + } + + return { + Uri, + watcher, + harness, + createWebviewPanel: vi.fn(), + workspaceFolders: [] as Array<{ uri: Uri }>, + }; +}); + +vi.mock("vscode", () => ({ + Uri: host.Uri, + ViewColumn: { One: 1 }, + workspace: { + get workspaceFolders() { + return host.workspaceFolders; + }, + getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }), + createFileSystemWatcher: () => host.watcher, + textDocuments: [], + }, + window: { + showWarningMessage: vi.fn(async () => undefined), + createWebviewPanel: (...args: unknown[]) => host.createWebviewPanel(...args), + }, +})); + +vi.mock("@moonshot-ai/kimi-code-sdk", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createKimiHarness: () => host.harness, + createKimiHarnessV2: () => host.harness, + }; +}); + +interface FakeView { + readonly posted: Array<{ event?: string; data?: unknown }>; + readonly webview: vscode.Webview; + /** Captures the provider's onDidDispose callback so a test can close the view like VS Code does. */ + register: (handler: () => void) => void; + dispose: () => void; +} + +function createFakeView(): FakeView { + const posted: Array<{ event?: string; data?: unknown }> = []; + let disposeHandler: () => void = () => undefined; + + const webview = { + options: {}, + html: "", + cspSource: "vscode-webview:", + asWebviewUri: (uri: { toString: () => string }) => uri, + onDidReceiveMessage: () => ({ dispose: () => undefined }), + postMessage: async (message: { event?: string; data?: unknown }) => { + posted.push(message); + return true; + }, + } as unknown as vscode.Webview; + + return { + posted, + webview, + register: (handler) => { + disposeHandler = handler; + }, + dispose: () => { + disposeHandler(); + }, + }; +} + +function asWebviewView(view: FakeView): vscode.WebviewView { + return { + webview: view.webview, + onDidDispose: (handler: () => void) => { + view.register(handler); + return { dispose: () => undefined }; + }, + } as unknown as vscode.WebviewView; +} + +function asWebviewPanel(view: FakeView): vscode.WebviewPanel { + return { + webview: view.webview, + onDidDispose: (handler: () => void) => { + view.register(handler); + return { dispose: () => undefined }; + }, + } as unknown as vscode.WebviewPanel; +} + +let provider: KimiWebviewProvider; +let root: string; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "kimi-vscode-provider-")); + host.workspaceFolders.splice(0, host.workspaceFolders.length, { uri: new host.Uri(root) }); + const context = { + workspaceState: { get: vi.fn((_key: string, fallback: unknown) => fallback), update: vi.fn() }, + globalStorageUri: new host.Uri(join(root, "global-storage")), + } as unknown as vscode.ExtensionContext; + + provider = new KimiWebviewProvider( + new host.Uri(root) as unknown as vscode.Uri, + context, + () => undefined, + () => undefined, + ); +}); + +afterEach(async () => { + await provider.shutdown(); + vi.clearAllMocks(); + await rm(root, { recursive: true, force: true }); +}); + +describe("Webview broadcast scope (sidebar actions must not reset editor-tab panels)", () => { + it("delivers a sidebar-scoped event to the sidebar view only", () => { + const sidebar = createFakeView(); + const panel = createFakeView(); + host.createWebviewPanel.mockReturnValue(asWebviewPanel(panel)); + + provider.resolveWebviewView(asWebviewView(sidebar)); + provider.createPanel(); + + provider.broadcastToSidebar("newConversation", {}); + + expect(sidebar.posted).toEqual([{ event: "newConversation", data: {} }]); + expect(panel.posted).toEqual([]); + }); + + it("still delivers an unscoped broadcast to every view", () => { + const sidebar = createFakeView(); + const panel = createFakeView(); + host.createWebviewPanel.mockReturnValue(asWebviewPanel(panel)); + + provider.resolveWebviewView(asWebviewView(sidebar)); + provider.createPanel(); + + provider.broadcast("extensionConfigChanged", { config: {} }); + + expect(sidebar.posted).toHaveLength(1); + expect(panel.posted).toHaveLength(1); + }); + + it("drops a sidebar-scoped event when no sidebar view is registered", () => { + const panel = createFakeView(); + host.createWebviewPanel.mockReturnValue(asWebviewPanel(panel)); + + provider.createPanel(); + + provider.broadcastToSidebar("newConversation", {}); + + expect(panel.posted).toEqual([]); + }); + + it("stops targeting the sidebar view after it is disposed", () => { + const sidebar = createFakeView(); + const panel = createFakeView(); + host.createWebviewPanel.mockReturnValue(asWebviewPanel(panel)); + + provider.resolveWebviewView(asWebviewView(sidebar)); + provider.createPanel(); + sidebar.dispose(); + + provider.broadcastToSidebar("newConversation", {}); + + expect(sidebar.posted).toEqual([]); + expect(panel.posted).toEqual([]); + }); +});