diff --git a/.changeset/kiloclaw-kilo-chat-migration.md b/.changeset/kiloclaw-kilo-chat-migration.md new file mode 100644 index 0000000000..5945405333 --- /dev/null +++ b/.changeset/kiloclaw-kilo-chat-migration.md @@ -0,0 +1,8 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +"@kilocode/kilo-gateway": minor +"@kilocode/sdk": minor +--- + +Migrate KiloClaw chat to the new kilo-chat backend. Replaces the single-channel Stream Chat integration with a multi-conversation experience that matches the web UX at app.kilo.ai/claw/kilo-chat: conversation list, reactions, typing indicators, editing, and action approvals. The TUI continues to render a single chat view backed by the user's primary conversation. diff --git a/.changeset/remove-custom-providers.md b/.changeset/remove-custom-providers.md new file mode 100644 index 0000000000..b3ffa35773 --- /dev/null +++ b/.changeset/remove-custom-providers.md @@ -0,0 +1,7 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +"@kilocode/sdk": patch +--- + +Remove custom providers from settings when disconnecting them so they do not reappear after being disabled and re-enabled. diff --git a/.changeset/steady-agents-think.md b/.changeset/steady-agents-think.md new file mode 100644 index 0000000000..c3278a9d44 --- /dev/null +++ b/.changeset/steady-agents-think.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart. diff --git a/.changeset/unified-diff-panel.md b/.changeset/unified-diff-panel.md new file mode 100644 index 0000000000..c7c42d5ede --- /dev/null +++ b/.changeset/unified-diff-panel.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Unify the Changes panel into a single tab with a source dropdown to switch between workspace and current-session diffs. Show the session's additions and deletions on the sidebar changes badge, and warn in the panel when snapshots are disabled for the repository. diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index c7660a5d57..674235483a 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -96,6 +96,13 @@ jobs: echo "cli_url=$URL" >> "$GITHUB_OUTPUT" echo "::notice::Testing CLI v${VERSION} via asset API: $URL" + # Harbor's default agent-setup timeout is 360s. The hello-world container + # (FROM ubuntu:24.04) needs apt-get update + apt-get install + a NodeSource + # curl|bash + apt install nodejs before the CLI even downloads, and on + # the Blacksmith runners that occasionally pushes past 6 min when an apt + # mirror or NodeSource cdn is slow, killing the run with + # AgentSetupTimeoutError. 600s gives enough headroom for transient + # mirror/cdn slowness while still finishing well under timeout-minutes. - name: Run smoke test — hello-world env: KILO_CLI_URL: ${{ steps.cli.outputs.cli_url }} @@ -104,7 +111,8 @@ jobs: ./scripts/run_eval.sh \ -m kilo/anthropic/claude-sonnet-4.6 \ -d hello-world \ - --job-name smoke-test-hello-world + --job-name smoke-test-hello-world \ + --agent-setup-timeout 600 - name: Run smoke test — log-summary-date-ranges env: @@ -115,11 +123,18 @@ jobs: -m kilo/anthropic/claude-sonnet-4.6 \ -d terminal-bench-sample \ -t "log-summary-date-ranges" \ - --job-name smoke-test-log-summary + --job-name smoke-test-log-summary \ + --agent-setup-timeout 600 - name: Validate results run: python3 scripts/validate_smoke_test.py jobs/smoke-test-*/ + # Also upload the agent setup logs (stdout/stderr/return-code from the + # CLI install script) so we can see exactly which step stalls when the + # next AgentSetupTimeoutError happens. These come from the install + # script in kilo-bench, which uses `set -euo pipefail` (no `set -x`) + # and never echoes auth tokens or API keys, so the captured output is + # safe to upload. - name: Upload results if: always() uses: actions/upload-artifact@v7 # kilocode_change @@ -128,5 +143,6 @@ jobs: path: | jobs/smoke-test-*/**/result.json jobs/smoke-test-*/**/trajectory.json + jobs/smoke-test-*/**/agent/setup/*.txt retention-days: 30 if-no-files-found: warn diff --git a/bun.lock b/bun.lock index 66f76f529e..e7890dfe9b 100644 --- a/bun.lock +++ b/bun.lock @@ -251,7 +251,6 @@ "quick-lru": "^7.0.0", "simple-git": "3.35.2", "solid-js": "^1.9.11", - "stream-chat": "9.38.0", "uri-js": "^4.4.1", "virtua": "catalog:", "web-tree-sitter": "^0.24.7", @@ -392,7 +391,6 @@ "semver": "^7.6.3", "simple-git": "3.35.2", "solid-js": "catalog:", - "stream-chat": "9.38.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", @@ -580,7 +578,6 @@ "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", - "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.46", @@ -2134,8 +2131,6 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], - "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], "@types/linkify-it": ["@types/linkify-it@3.0.5", "", {}, "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="], @@ -3404,8 +3399,6 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], - "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], - "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -4156,8 +4149,6 @@ "storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="], - "stream-chat": ["stream-chat@9.38.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.5.14", "axios": "^1.12.2", "base64-js": "^1.5.1", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "jsonwebtoken": "^9.0.3", "linkifyjs": "^4.3.2", "ws": "^8.18.1" } }, "sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg=="], - "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index 04605df30f..46fa567f4c 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-BkFDOCEvivFrxKOvMjAIhj3QZwmb9vE/KvCZv5puw6E=", - "aarch64-linux": "sha256-sTkfQNjqCNCxmHbpQ8woA6azoiyD37e8Xy7UwB6eNsA=", - "aarch64-darwin": "sha256-lKuagsTHqij5C764DM3d+BOe4JKs2KV8XiwSlJItGMg=", - "x86_64-darwin": "sha256-g5gZMHLq8feSZHVaCOLGU6Tj2Qw56Zogif3pT9EIlz8=" + "x86_64-linux": "sha256-bTa0SN4bOmJTi8jrbksF9x7rHN7/4ih0R73liabbAgU=", + "aarch64-linux": "sha256-CLRpU85zXLKPdk0uHY77EDRZkH/L0DJiAkpg/agpvtE=", + "aarch64-darwin": "sha256-Vqt4NWbeDC44aWSchepIMN2p56Vq+Hlmj8xYMZNdc8w=", + "x86_64-darwin": "sha256-R5iv/ysbtm3nH8YzB84clPEFTjJydD/8Tt3Brxzn4RM=" } } diff --git a/package.json b/package.json index d4e3deb1e0..d1008676a0 100644 --- a/package.json +++ b/package.json @@ -143,8 +143,7 @@ "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" }, "version": "7.2.42", "peerDependencies": {} diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index 7e38c2c03c..ef52759b95 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -43,4 +43,6 @@ exclude = [ # Consistently times out in CI '^https?://opncd\.ai', '^https?://zod\.dev/v4/changelog', + # OpenAI docs return 404 to plain GET link checks but resolve in browsers + '^https?://platform\.openai\.com/docs/api-reference/responses/create', ] diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 6c39aa9a1b..c959cf3f9e 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -598,7 +598,7 @@ Options: --path directory path to generate the agent file [string] --description what the agent should do [string] --mode agent mode [string] [choices: "all", "primary", "subagent"] - --permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, codesearch, lsp, skill" [string] + --permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, lsp, skill" [string] -m, --model model to use in the format of provider/model [string] ``` diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index 75b518f1b2..0cc4931710 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -12,6 +12,24 @@ export const DEFAULT_KILO_API_URL = "https://api.kilo.ai" /** Base URL for Kilo API - can be overridden by KILO_API_URL env var */ export const KILO_API_BASE = process.env[ENV_KILO_API_URL] || DEFAULT_KILO_API_URL +/** Environment variable for custom Kilo Chat URL */ +export const KILO_CHAT_URL_ENV = "KILO_CHAT_URL" + +/** Default Kilo Chat URL (REST endpoint for messages, conversations, etc.) */ +export const KILO_DEFAULT_CHAT_URL = "https://chat.kiloapps.io" + +/** Base URL for Kilo Chat - can be overridden by KILO_CHAT_URL env var */ +export const KILO_CHAT_URL = process.env[KILO_CHAT_URL_ENV] || KILO_DEFAULT_CHAT_URL + +/** Environment variable for custom Event Service URL */ +export const KILO_EVENT_SERVICE_URL_ENV = "EVENT_SERVICE_URL" + +/** Default Event Service URL (WebSocket endpoint for kilo-chat events) */ +export const KILO_DEFAULT_EVENT_SERVICE_URL = "wss://events.kiloapps.io" + +/** Base URL for Event Service - can be overridden by EVENT_SERVICE_URL env var */ +export const KILO_EVENT_SERVICE_URL = process.env[KILO_EVENT_SERVICE_URL_ENV] || KILO_DEFAULT_EVENT_SERVICE_URL + /** Default base URL for OpenRouter-compatible endpoint */ export const KILO_OPENROUTER_BASE = `${KILO_API_BASE}/api/openrouter` diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 0282e2f94b..27b4c8b857 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -8,7 +8,13 @@ import { fetchProfile, fetchBalance } from "../api/profile.js" import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js" import { fetchOrganizationModes, clearModesCache } from "../api/modes.js" -import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js" +import { + KILO_API_BASE, + KILO_CHAT_URL, + KILO_EVENT_SERVICE_URL, + HEADER_FEATURE, + HEADER_ORGANIZATIONID, +} from "../api/constants.js" import { buildKiloHeaders } from "../headers.js" import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js" import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js" @@ -524,8 +530,24 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { "application/json": { schema: resolver( z.object({ + // `recovering` and `restoring` are transitional states the + // worker reports while it brings an instance back online + // after an unexpected stop or a snapshot restore — see + // cloud `services/kiloclaw/src/index.ts` and the + // `PlatformStatusResponse` type in + // cloud/apps/web/src/lib/kiloclaw/types.ts. Keeping them in + // the enum so the SDK types stay accurate. status: z - .enum(["provisioned", "starting", "restarting", "running", "stopped", "destroying"]) + .enum([ + "provisioned", + "starting", + "restarting", + "recovering", + "running", + "stopped", + "destroying", + "restoring", + ]) .nullable(), sandboxId: z.string().optional(), flyRegion: z.string().optional(), @@ -536,6 +558,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { channelCount: z.number().optional(), secretCount: z.number().optional(), userId: z.string().optional(), + botName: z.string().nullable().optional(), }), ), }, @@ -578,57 +601,52 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { "/claw/chat-credentials", describeRoute({ summary: "Get KiloClaw chat credentials", - description: "Fetch Stream Chat credentials for the user's KiloClaw instance", + description: + "Returns the bearer token and endpoint URLs the client uses to talk to the Kilo Chat worker " + + "and the Event Service. The bearer is the user's existing long-lived Kilo JWT — kilo-chat and " + + "event-service both verify it directly with NEXTAUTH_SECRET, so no separate token mint is needed.", operationId: "kilo.claw.chatCredentials", responses: { 200: { - description: "Stream Chat credentials or null", + description: "Kilo Chat credentials or null", content: { "application/json": { schema: resolver( z .object({ - apiKey: z.string(), - userId: z.string(), - userToken: z.string(), - channelId: z.string(), + token: z.string(), + expiresAt: z.string(), + kiloChatUrl: z.string(), + eventServiceUrl: z.string(), }) .nullable(), ), }, }, }, - ...errors(401, 502), + ...errors(401), }, }), async (c: any) => { - try { - const auth = await Auth.get("kilo") - if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) - const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined - if (!token) return c.json({ error: "No valid token found" }, 401) + const auth = await Auth.get("kilo") + if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined + if (!token) return c.json({ error: "No valid token found" }, 401) - const organizationId = auth.type === "oauth" ? auth.accountId : undefined - const headers: Record = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - } - if (organizationId) { - headers[HEADER_ORGANIZATIONID] = organizationId - } + // For OAuth, expires is a millisecond epoch we already track. For + // API tokens we don't have a verified expiry locally — the JWT is + // signed by the cloud and validated by kilo-chat/event-service on + // every request. Use a far-future placeholder so the client cache + // doesn't refetch unnecessarily; on 401 the client clears the + // cache and prompts re-auth. + const expiresAtMs = auth.type === "oauth" ? auth.expires : Date.now() + 365 * 24 * 60 * 60 * 1000 - const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, { headers }) - - if (!response.ok) { - const text = await response.text() - return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any) - } - - return c.json(await response.json()) - } catch (err: any) { - console.error("[Kilo Gateway] claw/chat-credentials: error", err?.message ?? err) - return c.json({ error: "Failed to reach KiloClaw" }, 502) - } + return c.json({ + token, + expiresAt: new Date(expiresAtMs).toISOString(), + kiloChatUrl: KILO_CHAT_URL, + eventServiceUrl: KILO_EVENT_SERVICE_URL, + }) }, ) .get( diff --git a/packages/kilo-ui/src/components/markdown.css b/packages/kilo-ui/src/components/markdown.css index c87f2f1383..ae502e8119 100644 --- a/packages/kilo-ui/src/components/markdown.css +++ b/packages/kilo-ui/src/components/markdown.css @@ -51,5 +51,4 @@ background: var(--background-stronger); margin-bottom: 1rem; } - } diff --git a/packages/kilo-ui/src/components/select.css b/packages/kilo-ui/src/components/select.css index 5d255dc4af..d7efba7bdf 100644 --- a/packages/kilo-ui/src/components/select.css +++ b/packages/kilo-ui/src/components/select.css @@ -81,6 +81,7 @@ /* Section headers */ [data-slot="select-section"] { + padding: 4px 12px; font-size: var(--kilo-font-size-11); font-weight: 500; color: var(--text-weak); diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index b11a766659..7f8125e5df 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -38,7 +38,7 @@ export default [ // New code must stay ≤ 20. Do not raise these caps; refactor instead. { files: ["src/KiloProvider.ts"], - rules: { complexity: ["error", 150], "max-lines": ["error", 3420] }, + rules: { complexity: ["error", 150], "max-lines": ["error", 3500] }, }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 25eb8875b7..53485a2738 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -949,7 +949,6 @@ "quick-lru": "^7.0.0", "simple-git": "3.35.2", "solid-js": "^1.9.11", - "stream-chat": "9.38.0", "uri-js": "^4.4.1", "virtua": "catalog:", "web-tree-sitter": "^0.24.7", diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts deleted file mode 100644 index a81b51650b..0000000000 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ /dev/null @@ -1,270 +0,0 @@ -import * as vscode from "vscode" -import type { KiloConnectionService } from "./services/cli-backend" -import { buildWebviewHtml, getWebviewFontSize } from "./utils" -import { GitOps } from "./agent-manager/GitOps" -import { watchFontSizeConfig } from "./kilo-provider/font-size" -import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client" -import { - appendOutput, - getWorkspaceRoot, - hashFileDiffs, - openWorkspaceRelativeFile, - resolveLocalDiffTarget, -} from "./review-utils" -import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings" - -/** - * DiffViewerProvider opens a full-screen diff viewer in an editor tab. - * It shows the local workspace diff and forwards review comments back to the sidebar chat. - */ -export class DiffViewerProvider implements vscode.Disposable { - public static readonly viewType = "kilo-code.new.DiffViewerPanel" - - private panel: vscode.WebviewPanel | undefined - private diffInterval: ReturnType | undefined - private lastDiffHash: string | undefined - private cachedDiffTarget: DiffTarget | undefined - private gitOps: GitOps - private outputChannel: vscode.OutputChannel - private fontConfigDisposable: vscode.Disposable | undefined - private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly connectionService: KiloConnectionService, - ) { - this.gitOps = new GitOps({ log: (...args) => this.log(...args) }) - this.outputChannel = vscode.window.createOutputChannel("Kilo Diff Viewer") - } - - private log(...args: unknown[]) { - appendOutput(this.outputChannel, "DiffViewer", ...args) - } - - public setCommentHandler(handler: (comments: unknown[], autoSend: boolean) => void): void { - this.onSendComments = handler - } - - public openPanel(): void { - if (this.panel) { - this.panel.reveal(vscode.ViewColumn.One) - return - } - - const panel = vscode.window.createWebviewPanel(DiffViewerProvider.viewType, "Changes", vscode.ViewColumn.One, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [this.extensionUri], - }) - - this.wirePanel(panel) - } - - /** Re-wire a deserialized panel after extension restart. */ - public deserializePanel(panel: vscode.WebviewPanel): void { - this.wirePanel(panel) - } - - private wirePanel(panel: vscode.WebviewPanel): void { - this.panel = panel - - panel.iconPath = { - light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"), - dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), - } - - panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg), undefined, []) - panel.webview.html = this.getHtml(panel.webview) - this.fontConfigDisposable?.dispose() - this.fontConfigDisposable = watchFontSizeConfig((msg) => this.post(msg)) - - panel.onDidDispose(() => { - this.log("Panel disposed") - this.stopDiffPolling() - this.fontConfigDisposable?.dispose() - this.fontConfigDisposable = undefined - this.panel = undefined - }) - } - - private onMessage(msg: Record): void { - const type = msg.type as string - - if (type === "webviewReady") { - this.post({ - type: "ready", - vscodeLanguage: vscode.env.language, - languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), - fontSize: getWebviewFontSize(), - workspaceDirectory: getWorkspaceRoot(), - }) - this.post({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() }) - this.startDiffPolling() - return - } - - if (type === "diffViewer.sendComments" && Array.isArray(msg.comments)) { - this.onSendComments?.(msg.comments, !!msg.autoSend) - return - } - - if (type === "diffViewer.close") { - this.panel?.dispose() - return - } - - if (type === "diffViewer.setDiffStyle" && (msg.style === "unified" || msg.style === "split")) { - return - } - - if (type === "diffViewer.setMarkdownRender" && typeof msg.render === "boolean") { - void setDiffMarkdownRender(msg.render) - return - } - - if (type === "diffViewer.revertFile" && typeof msg.file === "string") { - void this.revertFile(msg.file) - return - } - - if (type === "openFile" && typeof msg.filePath === "string") { - openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) - } - } - - private async revertFile(file: string): Promise { - const target = this.cachedDiffTarget ?? (await this.resolveLocalDiffTarget()) - if (!target) { - this.post({ - type: "diffViewer.revertFileResult", - file, - status: "error", - message: "Could not resolve diff target", - }) - return - } - - try { - const diff = new WorktreeDiffClient(this.connectionService.getClient(), this.gitOps, (...args) => - this.log(...args), - ) - const result = await diff.revertFile(target, file) - this.post({ - type: "diffViewer.revertFileResult", - file, - status: result.ok ? "success" : "error", - message: result.message, - }) - if (result.ok) void this.pollDiff() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - this.log("Failed to revert file:", message) - this.post({ type: "diffViewer.revertFileResult", file, status: "error", message }) - } - } - - private async resolveLocalDiffTarget(): Promise { - return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot()) - } - - private async initialFetch(): Promise { - this.post({ type: "diffViewer.loading", loading: true }) - - const target = await this.resolveLocalDiffTarget() - if (!target) { - this.post({ type: "diffViewer.diffs", diffs: [] }) - this.post({ type: "diffViewer.loading", loading: false }) - return - } - - this.cachedDiffTarget = target - - try { - await this.connectionService.connect(target.directory) - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diff( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - this.lastDiffHash = hashFileDiffs(diffs) - - this.log(`Initial diff: ${diffs.length} file(s)`) - this.post({ type: "diffViewer.diffs", diffs }) - } catch (err) { - this.log("Failed to fetch initial diff:", err) - } finally { - this.post({ type: "diffViewer.loading", loading: false }) - } - } - - private async pollDiff(): Promise { - const target = this.cachedDiffTarget - if (!target) { - await this.initialFetch() - return - } - - try { - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diff( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - const hash = hashFileDiffs(diffs) - - if (hash === this.lastDiffHash) return - this.lastDiffHash = hash - this.post({ type: "diffViewer.diffs", diffs }) - } catch (err) { - this.log("Failed to poll diff:", err) - } - } - - private startDiffPolling(): void { - this.stopDiffPolling() - this.lastDiffHash = undefined - this.cachedDiffTarget = undefined - - void this.initialFetch().then(() => { - if (!this.panel) return - this.diffInterval = setInterval(() => { - void this.pollDiff() - }, 2500) - }) - } - - private stopDiffPolling(): void { - if (this.diffInterval) { - clearInterval(this.diffInterval) - this.diffInterval = undefined - } - - this.lastDiffHash = undefined - this.cachedDiffTarget = undefined - } - - private post(message: Record): void { - if (this.panel?.webview) void this.panel.webview.postMessage(message) - } - - private getHtml(webview: vscode.Webview): string { - return buildWebviewHtml(webview, { - scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.js")), - styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.css")), - iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), - title: "Changes", - port: this.connectionService.getServerInfo()?.port, - extraStyles: "#root { display: flex; flex-direction: column; }", - }) - } - - public dispose(): void { - this.stopDiffPolling() - this.fontConfigDisposable?.dispose() - this.gitOps.dispose() - this.panel?.dispose() - this.outputChannel.dispose() - } -} diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 026e408023..e226602ecd 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -599,7 +599,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper post: (msg) => this.postMessage(msg), openAgentManager: () => vscode.commands.executeCommand("kilo-code.new.agentManagerOpen"), openAdvancedWorktree: () => vscode.commands.executeCommand("kilo-code.new.agentManager.advancedWorktree"), - openChanges: () => vscode.commands.executeCommand("kilo-code.new.showChanges"), + openChanges: (sessionId?: string) => + vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId }), + currentSessionId: this.currentSession?.id, createWorktree: async (baseBranch, branchName) => { await this.createWorktreeHandler?.(baseBranch, branchName) }, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index ffcf786856..309bc46626 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -3,7 +3,7 @@ import * as path from "path" import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" -import { resolveLocalDiffTarget } from "../review-utils" +import { resolveLocalDiffTarget } from "../diff/shared/target" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 478c2f71da..2bbe74ce35 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -600,8 +600,9 @@ export class WorktreeStateManager { let pruned = 0 for (const [id, s] of Object.entries(data.sessions ?? {})) { const ref = s.worktreeId + const session: ManagedSession = { id, worktreeId: s.worktreeId, createdAt: s.createdAt } if (ref === null) { - this.sessions.set(id, { id, ...s }) + this.sessions.set(id, session) continue } // Skip orphaned sessions referencing a deleted worktree. @@ -609,7 +610,7 @@ export class WorktreeStateManager { pruned++ continue } - this.sessions.set(id, { id, ...s }) + this.sessions.set(id, session) } for (const [id, sec] of Object.entries(data.sections ?? {})) { this.sections.set(id, { id, ...sec }) diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 26cef806ba..4939d703ac 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,6 +1,8 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" -import { WorktreeDiffClient } from "../worktree-diff-client" +import { hashFileDiffs } from "../diff/shared/hash" +import { resolveLocalDiffTarget } from "../diff/shared/target" +import { DIFF_POLL_INTERVAL_MS } from "../diff/polling" +import { WorktreeDiffClient } from "../diff/shared/client" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" @@ -200,7 +202,7 @@ export class WorktreeDiffController { if (this.session !== sessionId) return this.interval = setInterval(() => { void this.poll(sessionId) - }, 2500) + }, DIFF_POLL_INTERVAL_MS) }) } diff --git a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts new file mode 100644 index 0000000000..6bc6ba5470 --- /dev/null +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -0,0 +1,208 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../services/cli-backend" +import { appendOutput, getWorkspaceRoot, openWorkspaceRelativeFile } from "../review-utils" +import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" +import { buildWebviewHtml, getWebviewFontSize } from "../utils" +import { watchFontSizeConfig } from "../kilo-provider/font-size" +import type { DiffSourceCatalog } from "./sources/catalog" +import type { PanelContext } from "./types" +import { SourceController } from "./SourceController" + +type CommentHandler = (comments: unknown[], autoSend: boolean) => void + +export interface DiffViewerProviderOptions { + sessionIdProvider?: () => string | undefined +} + +/** + * Single global "Changes" panel. Owns the webview panel lifecycle and + * routes webview messages to a SourceController, which owns the active + * DiffSource. + */ +export class DiffViewerProvider implements vscode.Disposable { + public static readonly viewType = "kilo-code.new.DiffViewerPanel" + + private panel: vscode.WebviewPanel | undefined + private ctx: PanelContext | undefined + private controller: SourceController | undefined + private panelDisposables: vscode.Disposable[] = [] + private commentHandler: CommentHandler | undefined + private fontConfigDisposable: vscode.Disposable | undefined + private readonly sessionIdProvider: () => string | undefined + private readonly output: vscode.OutputChannel + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly connection: KiloConnectionService, + private readonly catalog: DiffSourceCatalog, + opts: DiffViewerProviderOptions = {}, + ) { + this.sessionIdProvider = opts.sessionIdProvider ?? (() => undefined) + this.output = vscode.window.createOutputChannel("Kilo Diff Panel") + } + + setCommentHandler(handler: CommentHandler): void { + this.commentHandler = handler + } + + openPanel(ctx: PanelContext): void { + this.ctx = ctx + + if (this.panel && this.controller) { + this.panel.reveal(this.panel.viewColumn ?? vscode.ViewColumn.One) + this.controller.setContext(ctx) + const nextId = this.catalog.defaultSourceId(ctx) + if (nextId && nextId !== this.controller.currentId) this.swap(nextId) + return + } + + this.createPanel() + } + + /** + * Entry point for the `kilo-code.new.showChanges` command. Composes the + * PanelContext from the arg + injected session/workspace lookups so + * callers don't have to know about it. + */ + openFromCommand(arg?: { sessionId?: string; initialSourceId?: string }): void { + this.openPanel({ + workspaceRoot: getWorkspaceRoot(), + sessionId: arg?.sessionId ?? this.sessionIdProvider(), + initialSourceId: arg?.initialSourceId, + }) + } + + /** + * Called when VS Code restores a serialized panel after restart. State + * is not persisted, so we discard the panel instead of rewiring it. + */ + deserializePanel(panel: vscode.WebviewPanel): void { + panel.dispose() + } + + dispose(): void { + this.controller?.dispose() + this.controller = undefined + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined + this.disposePanel() + this.output.dispose() + } + + private createPanel(): void { + const panel = vscode.window.createWebviewPanel(DiffViewerProvider.viewType, "Changes", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }) + panel.iconPath = { + light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"), + dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), + } + panel.webview.html = this.getHtml(panel.webview) + this.panel = panel + + this.controller = new SourceController( + (id, ctx) => this.catalog.build(id, ctx), + (ctx) => this.catalog.listAvailable(ctx), + (msg) => void panel.webview.postMessage(msg), + ) + if (this.ctx) this.controller.setContext(this.ctx) + + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = watchFontSizeConfig((msg) => void panel.webview.postMessage(msg)) + + this.panelDisposables.push( + panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg as Record)), + panel.onDidDispose(() => this.onPanelDisposed()), + ) + } + + private onPanelDisposed(): void { + this.log("Panel disposed") + this.controller?.dispose() + this.controller = undefined + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined + this.disposePanel() + } + + private disposePanel(): void { + for (const d of this.panelDisposables) d.dispose() + this.panelDisposables = [] + this.panel = undefined + } + + private onMessage(msg: Record): void { + const handler = this.messageHandlers[msg.type as string] + handler?.(msg) + } + + private readonly messageHandlers: Record) => void> = { + webviewReady: () => this.onWebviewReady(), + selectSource: (msg) => { + if (typeof msg.id === "string") this.swap(msg.id) + }, + "diffViewer.sendComments": (msg) => { + if (Array.isArray(msg.comments)) this.commentHandler?.(msg.comments, !!msg.autoSend) + }, + "diffViewer.close": () => this.panel?.dispose(), + "diffViewer.setDiffStyle": () => {}, + "diffViewer.setMarkdownRender": (msg) => { + if (typeof msg.render === "boolean") void setDiffMarkdownRender(msg.render) + }, + "diffViewer.revertFile": (msg) => { + if (typeof msg.file === "string") void this.controller?.revertFile(msg.file) + }, + "diffViewer.requestFile": (msg) => { + if (typeof msg.file === "string") void this.controller?.requestFile(msg.file) + }, + openFile: (msg) => { + if (typeof msg.filePath !== "string") return + openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) + }, + } + + private onWebviewReady(): void { + if (!this.panel) return + void this.panel.webview.postMessage({ + type: "ready", + vscodeLanguage: vscode.env.language, + languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), + fontSize: getWebviewFontSize(), + workspaceDirectory: getWorkspaceRoot(), + }) + void this.panel.webview.postMessage({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() }) + const initial = this.ctx ? this.catalog.defaultSourceId(this.ctx) : undefined + if (initial) this.swap(initial) + } + + private swap(id: string): void { + if (!this.panel || !this.controller) return + if (this.controller.currentId === id) return + + void this.panel.webview.postMessage({ type: "diffViewer.loading", loading: true }) + void this.panel.webview.postMessage({ type: "diffViewer.diffs", diffs: [] }) + void this.panel.webview.postMessage({ type: "diffViewer.notice", notice: undefined }) + + void this.controller.activate(id).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to activate source:", message) + }) + } + + private getHtml(webview: vscode.Webview): string { + return buildWebviewHtml(webview, { + scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.js")), + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.css")), + iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + title: "Changes", + port: this.connection.getServerInfo()?.port, + extraStyles: "#root { display: flex; flex-direction: column; }", + }) + } + + private log(...args: unknown[]): void { + appendOutput(this.output, "DiffViewerProvider", ...args) + } +} diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts new file mode 100644 index 0000000000..4433cab1dd --- /dev/null +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -0,0 +1,142 @@ +import type * as vscode from "vscode" +import type { DiffSource, DiffSourceDescriptor, DiffSourceMessage, DiffSourcePost } from "./sources/types" +import type { PanelContext } from "./types" + +/** + * Owns the active DiffSource for a panel: builds it via the injected + * `build` function, runs initialFetch + start, and disposes it on swap + * or teardown. + * + * Decoupled from the webview panel — receives neutral callbacks. Stale + * messages are filtered via an internal epoch counter that bumps on + * every stop/activate, so async posts from a disposed source are dropped. + */ +export class SourceController { + private ctx: PanelContext | undefined + private activeId: string | undefined + private active: DiffSource | undefined + private startDisposable: vscode.Disposable | undefined + private epoch = 0 + + constructor( + private readonly build: (id: string, ctx: PanelContext) => DiffSource, + private readonly listAvailable: (ctx: PanelContext) => DiffSourceDescriptor[], + private readonly post: (msg: unknown) => void, + ) {} + + setContext(ctx: PanelContext): void { + this.ctx = ctx + } + + get currentId(): string | undefined { + return this.activeId + } + + /** Dispose the active source and bump the epoch so in-flight posts are dropped. */ + stop(): void { + this.epoch++ + this.startDisposable?.dispose() + this.startDisposable = undefined + this.active?.dispose() + this.active = undefined + this.activeId = undefined + } + + /** + * Build, initial-fetch, and start source `id` in the current context. + * Internally disposes any previously active source. Throws if the + * catalog can't build the id — callers should catch and log. + */ + async activate(id: string): Promise { + const ctx = this.ctx + if (!ctx) return + this.stop() + const epoch = this.epoch + this.activeId = id + + const source = this.build(id, ctx) + this.active = source + + this.post({ + type: "setAvailableSources", + descriptors: this.listAvailable(ctx), + currentId: id, + }) + this.post({ + type: "diffViewer.capabilities", + capabilities: source.descriptor.capabilities, + }) + + const sourcePost = this.guardedPost(epoch) + await source.initialFetch(sourcePost) + // Prevents source polling from starting after teardown or source swap. + if (this.epoch !== epoch || this.activeId !== id) { + if (this.active === source) source.dispose() + return + } + this.startDisposable = source.start?.(sourcePost) + } + + async revertFile(file: string): Promise { + const source = this.active + if (!source?.revertFile) { + this.post({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Revert is not supported for the current source", + }) + return + } + + const result = await source.revertFile(file).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message } + }) + this.post({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + } + + /** + * Lazy detail load for a single file. Forwards to the active source's + * `requestFile`. Posts `diff: null` when the source can't resolve the file + * or doesn't support per-file detail, so the webview can clear its + * pending-loading indicator either way. + */ + async requestFile(file: string): Promise { + const source = this.active + const epoch = this.epoch + if (!source?.requestFile) { + this.post({ type: "diffViewer.diffFile", file, diff: null }) + return + } + const diff = await source.requestFile(file).catch(() => null) + // Drop the response if the source has been disposed/swapped while we waited. + if (this.epoch !== epoch) return + this.post({ type: "diffViewer.diffFile", file, diff }) + } + + dispose(): void { + this.stop() + } + + private guardedPost(epoch: number): DiffSourcePost { + return (msg: DiffSourceMessage) => { + // Drops stale messages from sources whose lifecycle epoch has ended. + if (this.epoch !== epoch) return + if (msg.type === "diffs") { + this.post({ type: "diffViewer.diffs", diffs: msg.diffs }) + } else if (msg.type === "loading") { + this.post({ type: "diffViewer.loading", loading: msg.loading }) + } else if (msg.type === "error") { + this.post({ type: "diffViewer.loading", loading: false }) + } else if (msg.type === "notice") { + this.post({ type: "diffViewer.notice", notice: msg.notice }) + } + } + } +} diff --git a/packages/kilo-vscode/src/diff/polling.ts b/packages/kilo-vscode/src/diff/polling.ts new file mode 100644 index 0000000000..a054aebc67 --- /dev/null +++ b/packages/kilo-vscode/src/diff/polling.ts @@ -0,0 +1,5 @@ +/** + * Shared polling cadence for diff sources in + * `diff/sources/` and the Agent Manager's `WorktreeDiffController` + */ +export const DIFF_POLL_INTERVAL_MS = 2500 diff --git a/packages/kilo-vscode/src/worktree-diff-client.ts b/packages/kilo-vscode/src/diff/shared/client.ts similarity index 84% rename from packages/kilo-vscode/src/worktree-diff-client.ts rename to packages/kilo-vscode/src/diff/shared/client.ts index 0b8ecce7df..18922a77a2 100644 --- a/packages/kilo-vscode/src/worktree-diff-client.ts +++ b/packages/kilo-vscode/src/diff/shared/client.ts @@ -1,5 +1,5 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { GitOps } from "./agent-manager/GitOps" +import type { GitOps } from "../../agent-manager/GitOps" /** * A worktree diff target: the working directory and the base branch we diff @@ -11,11 +11,8 @@ type Status = "added" | "deleted" | "modified" /** * Thin coordinator that wraps (KiloClient, GitOps, DiffTarget) and exposes the - * small set of operations used by both the sidebar DiffViewerProvider and the - * agent manager's WorktreeDiffController. - * - * Keeping the helper off review-utils.ts: this deals in HTTP + git orchestration, - * not the small path/vscode helpers that file is scoped to. + * small set of operations used by the Changes panel's WorktreeDiffSource and + * the agent manager's WorktreeDiffController. */ export class WorktreeDiffClient { constructor( diff --git a/packages/kilo-vscode/src/diff/shared/hash.ts b/packages/kilo-vscode/src/diff/shared/hash.ts new file mode 100644 index 0000000000..53bd7116e6 --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/hash.ts @@ -0,0 +1,29 @@ +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" + +export function hashFileDiffs( + diffs: Array< + SnapshotFileDiff & { + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string + } + >, +): string { + return diffs + .map((diff) => { + const content = diff.summarized ? "" : diff.patch + return [ + diff.file, + diff.status, + diff.additions, + diff.deletions, + diff.tracked ? "tracked" : "untracked", + diff.generatedLike ? "generated" : "source", + diff.summarized ? "summary" : "detail", + diff.stamp ?? "", + content, + ].join(":") + }) + .join("|") +} diff --git a/packages/kilo-vscode/src/diff/shared/target.ts b/packages/kilo-vscode/src/diff/shared/target.ts new file mode 100644 index 0000000000..9f1bc49eab --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/target.ts @@ -0,0 +1,28 @@ +import type { GitOps } from "../../agent-manager/GitOps" +import { resolveBase } from "../../agent-manager/local-diff" + +export async function resolveLocalDiffTarget( + gitOps: GitOps, + log: (...args: unknown[]) => void, + root?: string, +): Promise<{ directory: string; baseBranch: string } | undefined> { + if (!root) { + log("Local diff: no workspace root") + return + } + + const branch = await gitOps.currentBranch(root) + if (!branch || branch === "HEAD") { + log("Local diff: detached HEAD or no branch") + return + } + + const tracking = await gitOps.resolveTrackingBranch(root, branch) + const fallback = tracking ? undefined : await gitOps.resolveDefaultBranch(root, branch) + const raw = tracking || fallback || "HEAD" + const base = await resolveBase(gitOps, root, raw) + + log(`Local diff: branch=${branch} tracking=${tracking ?? "none"} default=${fallback ?? "none"} base=${base}`) + + return { directory: root, baseBranch: base } +} diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts new file mode 100644 index 0000000000..d0d2976397 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -0,0 +1,58 @@ +import type { KiloConnectionService } from "../../services/cli-backend" +import type { PanelContext } from "../types" +import type { DiffSource, DiffSourceDescriptor } from "./types" +import { WorktreeDiffSource, WORKSPACE_DESCRIPTOR, WORKSPACE_SOURCE_ID } from "./worktree" +import { + SESSION_PREFIX, + SessionDiffSource, + sessionDescriptor, + sessionSourceId, + type SessionDiffFetch, + type SnapshotEnabledCheck, +} from "./session" + +/** + * Enumerates and constructs diff sources for a PanelContext. + */ +export class DiffSourceCatalog { + private readonly sessionFetch: SessionDiffFetch = async ({ sessionID, directory }) => { + const client = this.connection.getClient() + const { data } = await client.session.diff({ sessionID, directory }, { throwOnError: true }) + return data ?? [] + } + + private readonly checkSnapshotsEnabled: SnapshotEnabledCheck = async (directory) => { + const client = this.connection.getClient() + const { data } = await client.config.get({ directory }, { throwOnError: true }) + // Snapshot tracking defaults to true when omitted. + return data?.snapshot !== false + } + + constructor(private readonly connection: KiloConnectionService) {} + + listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { + const out: DiffSourceDescriptor[] = [] + if (ctx.workspaceRoot) out.push(WORKSPACE_DESCRIPTOR) + if (ctx.sessionId) out.push(sessionDescriptor(ctx.sessionId)) + return out + } + + defaultSourceId(ctx: PanelContext): string | undefined { + if (ctx.initialSourceId) return ctx.initialSourceId + if (ctx.workspaceRoot) return WORKSPACE_SOURCE_ID + if (ctx.sessionId) return sessionSourceId(ctx.sessionId) + return undefined + } + + build(id: string, ctx: PanelContext): DiffSource { + if (id === WORKSPACE_SOURCE_ID) return new WorktreeDiffSource(this.connection) + + if (id.startsWith(SESSION_PREFIX)) { + const sessionId = id.slice(SESSION_PREFIX.length) + if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) + return new SessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + } + + throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) + } +} diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts new file mode 100644 index 0000000000..b92125e0dd --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -0,0 +1,134 @@ +import * as vscode from "vscode" +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import type { DiffFile } from "../types" +import { hashFileDiffs } from "../shared/hash" +import { DIFF_POLL_INTERVAL_MS } from "../polling" +import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } from "./types" +import { normalize, text } from "@kilocode/kilo-ui/session-diff" + +export type SessionDiffFetch = (params: { sessionID: string; directory?: string }) => Promise + +export type SnapshotEnabledCheck = (directory?: string) => Promise + +export const SESSION_PREFIX = "session:" + +export function sessionSourceId(sessionId: string): string { + return `${SESSION_PREFIX}${sessionId}` +} + +export function sessionDescriptor(sessionId: string): DiffSourceDescriptor { + return { + id: sessionSourceId(sessionId), + type: "session", + group: "Session", + capabilities: { revert: false, comments: true }, + } +} + +/** + * Diff for the current session. Initial fetch + 2.5s polling with hash dedup + */ +export class SessionDiffSource implements DiffSource { + readonly descriptor: DiffSourceDescriptor + + private lastHash: string | undefined + private interval: ReturnType | undefined + private disposed = false + + private snapshotsDisabled = false + + constructor( + private readonly sessionId: string, + private readonly fetch: SessionDiffFetch, + private readonly workspaceRoot?: string, + private readonly checkSnapshotsEnabled?: SnapshotEnabledCheck, + ) { + this.descriptor = sessionDescriptor(sessionId) + } + + async initialFetch(post: DiffSourcePost): Promise { + post({ type: "loading", loading: true }) + + try { + if (this.checkSnapshotsEnabled) { + const enabled = await this.checkSnapshotsEnabled(this.workspaceRoot) + if (this.disposed) return + if (!enabled) { + this.snapshotsDisabled = true + post({ type: "notice", notice: "snapshots-disabled" }) + post({ type: "diffs", diffs: [] }) + return + } + } + + const diffs = await this.fetchDiffs() + if (this.disposed) return + this.lastHash = hashFileDiffs(diffs as never) + post({ type: "diffs", diffs }) + } catch (err) { + if (this.disposed) return + const message = err instanceof Error ? err.message : String(err) + post({ type: "error", message }) + } finally { + if (!this.disposed) post({ type: "loading", loading: false }) + } + } + + start(post: DiffSourcePost): vscode.Disposable { + this.stopPolling() + // Skip polling entirely when snapshots are disabled — nothing to fetch. + if (this.snapshotsDisabled) return new vscode.Disposable(() => {}) + this.interval = setInterval(() => { + void this.poll(post) + }, DIFF_POLL_INTERVAL_MS) + + return new vscode.Disposable(() => this.stopPolling()) + } + + dispose(): void { + this.disposed = true + this.stopPolling() + this.lastHash = undefined + } + + private async fetchDiffs(): Promise { + const raw = await this.fetch({ sessionID: this.sessionId, directory: this.workspaceRoot }) + return raw.map((r) => { + // Empty patch means binary or summarized (>256 KB) — normalize() can't + // parse it, so short-circuit to empty strings. + const view = r.patch === "" ? null : normalize(r) + return { + file: r.file, + before: view ? text(view, "deletions") : "", + after: view ? text(view, "additions") : "", + additions: r.additions, + deletions: r.deletions, + status: r.status, + tracked: true, + generatedLike: false, + summarized: r.patch === "", + } + }) + } + + private async poll(post: DiffSourcePost): Promise { + try { + const diffs = await this.fetchDiffs() + if (this.disposed) return + const hash = hashFileDiffs(diffs as never) + if (hash === this.lastHash) return + this.lastHash = hash + post({ type: "diffs", diffs }) + } catch (err) { + if (this.disposed) return + console.log("[Kilo New] SessionDiffSource.poll error", err) + } + } + + private stopPolling(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + } +} diff --git a/packages/kilo-vscode/src/diff/sources/types.ts b/packages/kilo-vscode/src/diff/sources/types.ts new file mode 100644 index 0000000000..2ac5d01caf --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/types.ts @@ -0,0 +1,62 @@ +import type * as vscode from "vscode" +import type { DiffFile } from "../types" + +export interface DiffSourceCapabilities { + revert: boolean + comments: boolean +} + +/** + * Closed enum of diff source kinds. Drives i18n key composition: + * `diffViewer.source..label` and `diffViewer.source..tooltip`. + */ +export type DiffSourceType = "workspace" | "session" + +export interface DiffSourceDescriptor { + /** Unique within a panel context. E.g. "workspace", "session:". */ + id: string + type: DiffSourceType + group: "Session" | "Git" + /** kilo-ui icon name. */ + icon?: string + capabilities: DiffSourceCapabilities +} + +/** + * Well-known notice kinds that a source can surface to the diff viewer. + * The webview maps these to translated messages. + */ +export type DiffSourceNotice = "snapshots-disabled" + +export type DiffSourceMessage = + | { type: "diffs"; diffs: DiffFile[] } + | { type: "loading"; loading: boolean } + | { type: "error"; message: string } + | { type: "notice"; notice: DiffSourceNotice | undefined } + +export type DiffSourcePost = (msg: DiffSourceMessage) => void + +/** + * A DiffSource produces file diffs for a given context (local workspace, + * session changes, a turn, a git ref...). The SourceController owns one + * active source at a time and swaps between them on user request. + */ +export interface DiffSource { + readonly descriptor: DiffSourceDescriptor + + initialFetch(post: DiffSourcePost): Promise + + /** Start change detection (polling, SSE, watcher...). Dispose to stop. */ + start?(post: DiffSourcePost): vscode.Disposable + + revertFile?(file: string): Promise<{ ok: boolean; message: string }> + + /** + * Lazy detail load for a single file, for sources that emit summarized entries + * (no `before`/`after` content) so the webview can fetch + * full content on demand. + */ + requestFile?(file: string): Promise + + dispose(): void +} diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts new file mode 100644 index 0000000000..c31ceaf924 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -0,0 +1,177 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../services/cli-backend" +import { GitOps } from "../../agent-manager/GitOps" +import { diffSummary, diffFile } from "../../agent-manager/local-diff" +import type { WorktreeDiffEntry } from "../../agent-manager/types" +import { WorktreeDiffClient, type DiffTarget } from "../shared/client" +import { hashFileDiffs } from "../shared/hash" +import { resolveLocalDiffTarget } from "../shared/target" +import { DIFF_POLL_INTERVAL_MS } from "../polling" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } from "./types" + +export const WORKSPACE_SOURCE_ID = "workspace" + +export const WORKSPACE_DESCRIPTOR: DiffSourceDescriptor = { + id: WORKSPACE_SOURCE_ID, + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, +} + +/** + * Diffs between the local working tree and the base branch. Polls a summary + * (one entry per changed file, no content) every {@link DIFF_POLL_INTERVAL_MS}, + * then loads `before`/`after`/`patch` per file on demand via {@link requestFile}. + * + * Mirrors the Agent Manager's `WorktreeDiffController` and runs entirely in + * the extension host (no `kilo serve` round-trip) + */ +export class WorktreeDiffSource implements DiffSource { + readonly descriptor = WORKSPACE_DESCRIPTOR + + private readonly git: GitOps + private readonly output: vscode.OutputChannel + private target: DiffTarget | undefined + private lastHash: string | undefined + private interval: ReturnType | undefined + private post: DiffSourcePost | undefined + + constructor(private readonly connection: KiloConnectionService) { + this.git = new GitOps({ log: (...args) => this.log(...args) }) + this.output = vscode.window.createOutputChannel("Kilo Diff: Workspace") + } + + async initialFetch(post: DiffSourcePost): Promise { + this.post = post + post({ type: "loading", loading: true }) + + const target = await this.resolveTarget() + if (!target) { + post({ type: "diffs", diffs: [] }) + post({ type: "loading", loading: false }) + return + } + + this.target = target + await this.fetchAndPost(target, post, true) + post({ type: "loading", loading: false }) + } + + start(post: DiffSourcePost): vscode.Disposable { + this.post = post + this.stopPolling() + this.interval = setInterval(() => { + void this.poll(post) + }, DIFF_POLL_INTERVAL_MS) + + return new vscode.Disposable(() => this.stopPolling()) + } + + async revertFile(file: string): Promise<{ ok: boolean; message: string }> { + const target = this.target ?? (await this.resolveTarget()) + if (!target) { + return { ok: false, message: "Could not resolve diff target" } + } + + try { + const client = this.connection.getClient() + const diff = new WorktreeDiffClient(client, this.git, (...args) => this.log(...args)) + const result = await diff.revertFile(target, file) + if (result.ok && this.post) void this.poll(this.post) + return result + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to revert file:", message) + return { ok: false, message } + } + } + + async requestFile(file: string): Promise { + if (!file) return null + const target = this.target ?? (await this.resolveTarget()) + if (!target) return null + this.target = target + + try { + const entry = await diffFile(this.git, target.directory, target.baseBranch, file, (...args) => this.log(...args)) + if (!entry) return null + return toDiffFile(entry) + } catch (err) { + this.log("Failed to fetch worktree diff file:", err) + return null + } + } + + dispose(): void { + this.stopPolling() + this.git.dispose() + this.output.dispose() + this.post = undefined + this.target = undefined + this.lastHash = undefined + } + + private async resolveTarget(): Promise { + return await resolveLocalDiffTarget(this.git, (...args) => this.log(...args), getWorkspaceRoot()) + } + + private async fetchAndPost(target: DiffTarget, post: DiffSourcePost, force: boolean): Promise { + try { + const entries = await diffSummary(this.git, target.directory, target.baseBranch, (...args) => this.log(...args)) + const diffs = entries.map(toDiffFile) + const hash = hashFileDiffs(diffs as never) + if (!force && hash === this.lastHash) return + this.lastHash = hash + + this.log(`Diff: ${diffs.length} file(s)`) + post({ type: "diffs", diffs }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to fetch diff:", message) + if (force) post({ type: "error", message }) + } + } + + private async poll(post: DiffSourcePost): Promise { + const target = this.target + if (!target) { + await this.initialFetch(post) + return + } + await this.fetchAndPost(target, post, false) + } + + private stopPolling(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + } + + private log(...args: unknown[]): void { + appendOutput(this.output, "WorktreeDiffSource", ...args) + } +} + +/** + * Project a `WorktreeDiffEntry` from `local-diff.ts` onto the `DiffFile` shape + * expected by the diff viewer. Drops `patch` (the webview rebuilds before/after + * for itself) and coerces optional `before`/`after` to empty strings when the + * entry is summarized. + */ +function toDiffFile(entry: WorktreeDiffEntry): DiffFile { + return { + file: entry.file, + before: entry.before ?? "", + after: entry.after ?? "", + additions: entry.additions, + deletions: entry.deletions, + status: entry.status, + tracked: entry.tracked, + generatedLike: entry.generatedLike, + summarized: entry.summarized, + stamp: entry.stamp, + } +} diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts new file mode 100644 index 0000000000..9850668212 --- /dev/null +++ b/packages/kilo-vscode/src/diff/types.ts @@ -0,0 +1,20 @@ +export interface PanelContext { + workspaceRoot: string | undefined + sessionId?: string + /** Overrides the computed default source on open. */ + initialSourceId?: string +} + +/** Mirrors `WorktreeFileDiff` in webview-ui/src/types/messages/agent-manager.ts. */ +export interface DiffFile { + file: string + before: string + after: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string +} diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 2e225b788c..b00c64ecf9 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -3,7 +3,8 @@ import { KiloProvider } from "./KiloProvider" import { AgentManagerProvider } from "./agent-manager/AgentManagerProvider" import { VscodeHost } from "./agent-manager/vscode-host" import { KiloClawProvider } from "./kiloclaw/KiloClawProvider" -import { DiffViewerProvider } from "./DiffViewerProvider" +import { DiffViewerProvider } from "./diff/DiffViewerProvider" +import { DiffSourceCatalog } from "./diff/sources/catalog" import { DiffVirtualProvider } from "./DiffVirtualProvider" import { SettingsEditorProvider } from "./SettingsEditorProvider" import { SubAgentViewerProvider } from "./SubAgentViewerProvider" @@ -206,8 +207,10 @@ export function activate(context: vscode.ExtensionContext) { }), ) - // Create standalone diff viewer provider for the sidebar "Show Changes" action - const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService) + const diffSourceCatalog = new DiffSourceCatalog(connectionService) + const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService, diffSourceCatalog, { + sessionIdProvider: () => provider.getCurrentSessionId(), + }) diffViewerProvider.setCommentHandler((comments, autoSend) => { void provider.appendReviewComments(comments, autoSend) }) @@ -332,9 +335,12 @@ export function activate(context: vscode.ExtensionContext) { autoApprove, ) }), - vscode.commands.registerCommand("kilo-code.new.showChanges", () => { - diffViewerProvider.openPanel() - }), + vscode.commands.registerCommand( + "kilo-code.new.showChanges", + (arg?: { sessionId?: string; initialSourceId?: string }) => { + diffViewerProvider.openFromCommand(arg) + }, + ), vscode.commands.registerCommand("kilo-code.new.openSubAgentViewer", (sessionID: string, title?: string) => { subAgentViewerProvider.openPanel(sessionID, title) }), diff --git a/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts b/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts index e469d2517e..6bbf602ca3 100644 --- a/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts +++ b/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts @@ -1,5 +1,5 @@ import { GitOps } from "../agent-manager/GitOps" -import { resolveLocalDiffTarget } from "../review-utils" +import { resolveLocalDiffTarget } from "../diff/shared/target" let shared: GitOps | undefined diff --git a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts index a478d9b13c..84cccf373b 100644 --- a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts +++ b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts @@ -13,7 +13,8 @@ interface Ctx { post: (msg: unknown) => void openAgentManager: () => Thenable openAdvancedWorktree: () => Thenable - openChanges: () => Thenable + openChanges: (sessionId?: string) => Thenable + currentSessionId?: string createWorktree?: (baseBranch?: string, branchName?: string) => Promise continueInWorktree?: ( sessionId: string, @@ -53,7 +54,7 @@ export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) { } if (message.type === "openChanges") { - await ctx.openChanges() + await ctx.openChanges(ctx.currentSessionId) return true } diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index 4def9b0fda..fb0c4beebf 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -1,8 +1,13 @@ /** * KiloClaw panel provider for the VS Code extension. * - * Owns the Stream Chat WebSocket connection (in the extension host Node.js runtime) - * and relays messages to/from the webview via postMessage. + * Owns the Kilo Chat HTTP client + event-service WebSocket connection + * (in the extension host Node.js runtime) and relays messages to/from + * the webview via postMessage. + * + * Architecture: extension host owns both clients and reactive state; + * the webview is a stateless renderer that issues commands via + * postMessage and receives state diffs back. */ import * as vscode from "vscode" @@ -11,35 +16,79 @@ import type { KiloConnectionService } from "../services/cli-backend" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildWebviewHtml } from "../utils" import { watchFontSizeConfig } from "../kilo-provider/font-size" -import { connect, history, presence, type ClawChatClient } from "./chat-client" +import { TokenManager } from "./token-manager" +import { KiloChatApiError, KiloChatClient } from "./kilo-chat-client" +import { EventServiceClient, WebSocketAuthError } from "./event-service-client" +import { ulid } from "./ulid" import type { + ActionDeliveryFailedEvent, + BotStatusEvent, + BotStatusRecord, + ChatToken, + ClawStatus, + ContentBlock, + ConversationActivityEvent, + ConversationCreatedEvent, + ConversationLeftEvent, + ConversationListItem, + ConversationRenamedEvent, + ConversationStatusEvent, + ConversationStatusRecord, + ExecApprovalDecision, KiloClawInMessage, KiloClawOutMessage, KiloClawState, - ClawStatus, - ChatCredentials, - ChatMessage, + Message, + MessageCreatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + MessageUpdatedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + TypingMember, + TypingEvent, } from "./types" -const MAX_MESSAGES = 500 const STATUS_POLL_MS = 10_000 +const BOT_STATUS_NUDGE_MS = 15_000 +const TYPING_TIMEOUT_MS = 5_000 +const MESSAGES_PAGE = 50 +const CONVERSATIONS_PAGE = 50 export class KiloClawProvider implements vscode.Disposable { static readonly viewType = "kilo-code.new.KiloClawPanel" private panel: vscode.WebviewPanel | null = null - private chat: ClawChatClient | null = null private timer: ReturnType | null = null + private botNudge: ReturnType | null = null private subs: Array<() => void> = [] private chatSubs: Array<() => void> = [] - private messages: ChatMessage[] = [] - private status: ClawStatus | null = null - private online = false - private connected = false private disposed = false private initializing = false private generation = 0 + // Clients (created lazily per init) + private events: EventServiceClient | null = null + private chat: KiloChatClient | null = null + private tokens: TokenManager | null = null + + // Reactive state mirrored to the webview + private status: ClawStatus | null = null + private currentUserId: string | null = null + private sandboxId: string | null = null + private conversations: ConversationListItem[] = [] + private conversationsCursor: string | null = null + private hasMoreConversations = false + private activeConversationId: string | null = null + private messages: Message[] = [] + private hasMoreMessages = false + private botStatus: BotStatusRecord | null = null + private conversationStatus: ConversationStatusRecord | null = null + private typingMembers: TypingMember[] = [] + private typingTimers = new Map>() + private subscribedSandboxContext: string | null = null + private subscribedConversationContext: string | null = null + constructor( private readonly uri: vscode.Uri, private readonly connection: KiloConnectionService, @@ -106,14 +155,18 @@ export class KiloClawProvider implements vscode.Disposable { }) this.subs.push(() => disposeSub.dispose()) - // Pause status polling when the panel is not visible to avoid unnecessary HTTP traffic + // Pause status polling + bot nudge when the panel is not visible const viewSub = panel.onDidChangeViewState(() => { - if (panel.visible) this.startPolling() - else this.stopPolling() + if (panel.visible) { + this.startPolling() + this.startBotNudge() + } else { + this.stopPolling() + this.stopBotNudge() + } }) this.subs.push(() => viewSub.dispose()) - // Subscribe to language changes broadcast by other KiloProvider instances const unsub = this.connection.onLanguageChanged((locale) => { this.post({ type: "kiloclaw.locale", locale }) }) @@ -130,17 +183,59 @@ export class KiloClawProvider implements vscode.Disposable { switch (msg.type) { case "kiloclaw.ready": await this.init() - break - case "kiloclaw.send": - await this.sendChat(msg.text) - break + return case "kiloclaw.openExternal": { const uri = vscode.Uri.parse(msg.url) if (uri.scheme === "https" || uri.scheme === "http") { void vscode.env.openExternal(uri) } - break + return } + case "kiloclaw.selectConversation": + await this.selectConversation(msg.conversationId) + return + case "kiloclaw.createConversation": + await this.createConversation(msg.title) + return + case "kiloclaw.renameConversation": + await this.renameConversation(msg.conversationId, msg.title) + return + case "kiloclaw.leaveConversation": + await this.leaveConversation(msg.conversationId) + return + case "kiloclaw.loadMoreConversations": + await this.loadMoreConversations() + return + case "kiloclaw.sendMessage": + await this.sendMessage(msg.conversationId, msg.content, msg.inReplyToMessageId) + return + case "kiloclaw.editMessage": + await this.editMessage(msg.conversationId, msg.messageId, msg.content) + return + case "kiloclaw.deleteMessage": + await this.deleteMessage(msg.conversationId, msg.messageId) + return + case "kiloclaw.loadMoreMessages": + await this.loadMoreMessages(msg.conversationId, msg.before) + return + case "kiloclaw.addReaction": + await this.addReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.removeReaction": + await this.removeReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.executeAction": + await this.executeAction(msg.conversationId, msg.messageId, msg.groupId, msg.value) + return + case "kiloclaw.sendTyping": + await this.sendTyping(msg.conversationId) + return + case "kiloclaw.sendTypingStop": + await this.sendTypingStop(msg.conversationId) + return + case "kiloclaw.markRead": + await this.markRead(msg.conversationId) + return } } @@ -153,14 +248,13 @@ export class KiloClawProvider implements vscode.Disposable { return gen !== this.generation || this.disposed } + // ── init / lifecycle ──────────────────────────────────────────────── + private async init(): Promise { if (this.initializing || this.disposed) return this.initializing = true const gen = this.generation - // Track whether we deferred to waitForConnection — if so, keep - // `initializing` true so duplicate kiloclaw.ready messages are - // harmlessly ignored until the connection arrives. let deferred = false try { @@ -174,89 +268,195 @@ export class KiloClawProvider implements vscode.Disposable { return } - const credentials = await this.fetchCreds(client, gen) - if (!credentials) return - - // Connect to Stream Chat - try { - await this.connectChat(credentials, gen) - } catch (err: unknown) { - if (this.stale(gen)) return - const msg = err instanceof Error ? err.message : String(err) - console.error("[Kilo New] KiloClaw chat connect failed:", msg) - this.post({ - type: "kiloclaw.state", - state: { - phase: "ready", - locale: this.locale, - status: this.status, - connected: false, - online: false, - messages: [], - }, - }) - this.post({ type: "kiloclaw.error", error: msg || "Failed to connect to chat" }) - this.startPolling() - return - } - + const ok = await this.bootstrap(client, gen) + if (!ok) return if (this.stale(gen)) return - // Push ready state const state: KiloClawState = { phase: "ready", locale: this.locale, status: this.status, - connected: this.connected, - online: this.online, + currentUserId: this.currentUserId ?? "", + sandboxId: this.sandboxId ?? "", + conversations: this.conversations, + hasMoreConversations: this.hasMoreConversations, + activeConversationId: this.activeConversationId, messages: this.messages, + hasMoreMessages: this.hasMoreMessages, + botStatus: this.botStatus, + conversationStatus: this.conversationStatus, + typingMembers: this.typingMembers, } this.post({ type: "kiloclaw.state", state }) this.startPolling() + this.startBotNudge() } finally { if (!deferred) this.initializing = false } } /** - * Fetch and validate instance status + chat credentials. - * Returns credentials on success, null when stale or after posting a state. - * - * Matches the TUI flow in packages/opencode/src/kilocode/kilo-commands.tsx:67,75 — - * any failure of status() (SDK error, non-2xx from the gateway, missing data, or - * missing userId) funnels to noInstance (SetupView). Any failure of - * chatCredentials() funnels to needsUpgrade (UpgradeView). The upstream Kilo API - * returns a non-2xx when no instance is provisioned, which the gateway mirrors - * and the SDK surfaces as res.error — not a thrown exception. + * Resolve instance status, fetch chat token, and wire up all clients. + * Returns `true` if everything is ready, `false` if a non-ready phase + * was already posted (loading / noInstance / needsUpgrade / error). */ - private async fetchCreds(client: KiloClient, gen: number): Promise { - const res = await client.kilo.claw.status().catch(() => null) - if (this.stale(gen)) return null + private async bootstrap(client: KiloClient, gen: number): Promise { + const ok = await this.resolveStatus(client, gen) + if (!ok) return false + if (this.stale(gen)) return false - const data = res?.data as (ClawStatus & { userId?: string }) | undefined - if (!res || (res as Record).error || !data || !data.userId) { + const envelope = await this.fetchChatToken(gen) + if (!envelope) return false + if (this.stale(gen)) return false + + if (!(await this.openChatStream(envelope, gen))) return false + if (this.stale(gen)) return false + + if (!this.sandboxId) { this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) - return null + return false } - this.status = data + await this.loadInitialSnapshots() + return true + } - const creds = await client.kilo.claw.chatCredentials().catch(() => null) - if (this.stale(gen)) return null + private async resolveStatus(client: KiloClient, gen: number): Promise { + const statusRes = await client.kilo.claw.status().catch(() => null) + if (this.stale(gen)) return false - if (!creds || (creds as Record).error || !creds.data) { + const statusData = statusRes?.data as (ClawStatus & { userId?: string }) | undefined + if (!statusRes || (statusRes as Record).error || !statusData || !statusData.userId) { + this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) + return false + } + this.status = statusData + this.currentUserId = statusData.userId + this.sandboxId = statusData.sandboxId ?? null + return true + } + + private async fetchChatToken(gen: number): Promise { + const tokens = new TokenManager(() => { + try { + return this.connection.getClient() + } catch { + return null + } + }) + try { + const envelope = await tokens.getOrFetch() + this.tokens = tokens + return envelope + } catch (err) { + if (this.stale(gen)) return null + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw chat token fetch failed:", message) + // Token fetch typically fails when the instance hasn't been upgraded + // to support kilo-chat — surface that as the upgrade prompt. this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) return null } - - return creds.data as ChatCredentials } - /** - * Ensure the CLI backend is running and return its SDK client. - * Returns `null` when the backend isn't available yet (caller should defer). - */ - private async resolveClient() { + private async openChatStream(envelope: ChatToken, gen: number): Promise { + const tokens = this.tokens! + const events = new EventServiceClient({ + url: envelope.eventServiceUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.events = events + + const chat = new KiloChatClient({ + baseUrl: envelope.kiloChatUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.chat = chat + + try { + await events.connect() + } catch (err) { + if (this.stale(gen)) return false + if (err instanceof WebSocketAuthError) { + this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) + return false + } + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw event-service connect failed:", message) + this.post({ + type: "kiloclaw.state", + state: { phase: "error", locale: this.locale, error: message || "Failed to connect to chat" }, + }) + return false + } + + this.attachEventHandlers(events, chat) + this.subscribeSandboxContext() + return true + } + + private async loadInitialSnapshots(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw listConversations failed:", message) + } + + try { + const res = await this.chat.getBotStatus(target) + if (this.sandboxId !== target) return + this.botStatus = res.status ?? null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getBotStatus failed:", message) + } + + // Auto-select the most recent conversation so the panel opens straight + // into the user's ongoing chat instead of the "select a conversation" + // empty state. Only runs on first init (or after the active one was + // explicitly cleared) — preserves the user's selection across reconnects. + if (!this.activeConversationId && this.conversations.length > 0) { + const latest = this.conversations.reduce((best, c) => { + const ax = best.lastActivityAt ?? best.joinedAt + const bx = c.lastActivityAt ?? c.joinedAt + return bx > ax ? c : best + }) + this.activeConversationId = latest.conversationId + this.subscribeConversationContext(latest.conversationId) + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(latest.conversationId) + if (this.activeConversationId === latest.conversationId) { + this.conversationStatus = res.status ?? null + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) + } + + void this.markRead(latest.conversationId) + } + } + + private async resolveClient(): Promise { if (this.connection.getConnectionState() !== "connected") { try { const dir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? homedir() @@ -274,95 +474,673 @@ export class KiloClawProvider implements vscode.Disposable { } } - private async connectChat(creds: ChatCredentials, gen: number): Promise { - // Disconnect previous client to avoid duplicate websockets/listeners - this.disconnectChat() - - const client = await connect(creds) - - // If the panel was disposed or reinitialized while connect() was in flight, - // tear down the freshly-created client immediately to avoid leaked websockets. - if (this.stale(gen)) { - client.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw stale disconnect failed:", err?.message ?? err) - }) - return - } - - this.chat = client - - // Load history - const bot = `bot-${creds.channelId.replace(/^default-/, "")}` - this.messages = history(this.chat.channel, bot) - this.online = presence(this.chat.channel, bot) - this.connected = true - - // Subscribe to events and relay to webview - const unsub = this.chat.onMessage((msg) => { - // Dedupe: if a message with this id already exists, treat as update - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx !== -1) { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - return + private waitForConnection(): void { + const unsub = this.connection.onStateChange((state) => { + if (state === "connected" && !this.disposed) { + unsub() + this.initializing = false + void this.init() } - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) - } - this.post({ type: "kiloclaw.message", message: msg }) }) - this.chatSubs.push(unsub) + this.subs.push(unsub) + } - const unsubUpdated = this.chat.onMessageUpdated((msg) => { - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx === -1) { - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) + // ── Subscriptions ─────────────────────────────────────────────────── + + private subscribeSandboxContext(): void { + if (!this.events || !this.sandboxId) return + const ctx = `/kiloclaw/${this.sandboxId}` + this.events.subscribe([ctx]) + this.subscribedSandboxContext = ctx + } + + private subscribeConversationContext(conversationId: string): void { + if (!this.events || !this.sandboxId) return + if (this.subscribedConversationContext) { + this.events.unsubscribe([this.subscribedConversationContext]) + } + const ctx = `/kiloclaw/${this.sandboxId}/${conversationId}` + this.events.subscribe([ctx]) + this.subscribedConversationContext = ctx + } + + private unsubscribeConversationContext(): void { + if (!this.events || !this.subscribedConversationContext) return + this.events.unsubscribe([this.subscribedConversationContext]) + this.subscribedConversationContext = null + } + + private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void { + // Reset on reconnect — the event stream may have missed events while + // disconnected, so refetch authoritative state. + const offReconnect = events.onReconnect(() => { + void this.refreshOnReconnect() + }) + this.chatSubs.push(offReconnect) + + // ── Sandbox-scoped events ───────────────────────────────────────── + + this.chatSubs.push( + events.on("conversation.created", (ctx, e: ConversationCreatedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + // Newer servers include the full conversation snapshot — splice it in + // immediately so the list updates without a roundtrip. Fall back to a + // refetch when the snapshot is absent (older servers / safety net). + if (e.conversation) { + this.conversations = mergeConversations([e.conversation], this.conversations) + this.broadcastConversations({ replace: true }) + return } - } else { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) - } - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - }) - this.chatSubs.push(unsubUpdated) + void this.refreshConversations() + }), + ) - const unsubPresence = this.chat.onPresence((val) => { - this.online = val - this.post({ type: "kiloclaw.presence", online: val }) - }) - this.chatSubs.push(unsubPresence) + this.chatSubs.push( + events.on("conversation.renamed", (ctx, e: ConversationRenamedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, title: e.title } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.left", (ctx, e: ConversationLeftEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.filter((c) => c.conversationId !== e.conversationId) + if (this.activeConversationId === e.conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.hasMoreMessages = false + this.conversationStatus = null + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ + type: "kiloclaw.messages", + conversationId: e.conversationId, + messages: [], + hasMore: false, + replace: true, + }) + this.post({ type: "kiloclaw.conversationStatus", status: null }) + } + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.activity", (ctx, e: ConversationActivityEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("bot.status", (ctx, e: BotStatusEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + if (e.sandboxId !== this.sandboxId) return + this.botStatus = { online: e.online, at: e.at, updatedAt: Date.now() } + this.post({ type: "kiloclaw.botStatus", status: this.botStatus }) + }), + ) + + // ── Conversation-scoped events ──────────────────────────────────── + + this.chatSubs.push( + events.on("message.created", (ctx, e: MessageCreatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + // Skip if already in cache (race with HTTP response) + if (this.messages.some((m) => m.id === e.messageId)) return + const server = this.toMessageFromCreated(e) + // Reconcile optimistic message via clientId — send the full server + // message so the webview replaces content, not just the id. Without + // this, the webview would display stale client-side content under + // the new id until the next full broadcast. + if (e.clientId) { + const pending = `pending-${e.clientId}` + const idx = this.messages.findIndex((m) => m.id === pending) + if (idx !== -1) { + this.messages = this.messages.map((m, i) => (i === idx ? server : m)) + this.post({ + type: "kiloclaw.messageReplaced", + conversationId: this.activeConversationId ?? "", + pendingId: pending, + message: server, + }) + return + } + } + this.messages = [...this.messages, server] + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.updated", (ctx, e: MessageUpdatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + const idx = this.messages.findIndex((m) => m.id === e.messageId) + if (idx === -1) return + this.messages = this.messages.map((m, i) => + i === idx ? { ...m, content: e.content, clientUpdatedAt: e.clientUpdatedAt } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.deleted", (ctx, e: MessageDeletedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.delivery_failed", (ctx, e: MessageDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deliveryFailed: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("action.delivery_failed", (ctx, e: ActionDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => { + if (m.id !== e.messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== e.groupId) return b + return { ...b, resolved: undefined } + }), + } + }) + this.broadcastMessages({ replace: true }) + this.post({ type: "kiloclaw.error", error: "Couldn't reach the bot — please try again" }) + }), + ) + + this.chatSubs.push( + events.on("reaction.added", (ctx, e: ReactionAddedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionAdded(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("reaction.removed", (ctx, e: ReactionRemovedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("typing", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (this.currentUserId && e.memberId === this.currentUserId) return + this.upsertTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("typing.stop", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.removeTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("conversation.status", (ctx, e: ConversationStatusEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (e.conversationId !== this.activeConversationId) return + this.conversationStatus = { + conversationId: e.conversationId, + contextTokens: e.contextTokens, + contextWindow: e.contextWindow, + model: e.model, + provider: e.provider, + at: e.at, + updatedAt: Date.now(), + } + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + }), + ) } - private disconnectChat(): void { - for (const unsub of this.chatSubs) unsub() - this.chatSubs = [] + private async refreshOnReconnect(): Promise { + if (!this.chat || !this.sandboxId) return + await this.refreshConversations() + if (this.activeConversationId) { + await this.refreshActiveMessages() + } + } - if (this.chat) { - this.chat.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw disconnect failed:", err?.message ?? err) - }) - this.chat = null + // ── Mutations ─────────────────────────────────────────────────────── + + private async selectConversation(conversationId: string): Promise { + if (!this.chat) return + this.activeConversationId = conversationId + this.subscribeConversationContext(conversationId) + this.post({ type: "kiloclaw.activeConversation", conversationId }) + this.typingMembers = [] + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() + + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(conversationId) + // The user may have switched conversations while we awaited the fetch; + // only apply the status if it still matches the active conversation. + if (this.activeConversationId !== conversationId) return + this.conversationStatus = res.status ?? null + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) + return } - this.connected = false - this.online = false + if (this.activeConversationId !== conversationId) return + void this.markRead(conversationId) } - private async sendChat(text: string): Promise { - if (!this.chat) { - this.post({ type: "kiloclaw.error", error: "Chat not connected" }) - return + private async createConversation(title?: string): Promise { + if (!this.chat || !this.sandboxId) return + try { + // The server now returns the full conversation snapshot alongside + // `conversationId`. We rely on `refreshConversations()` to pick up the + // canonical list-item shape rather than mapping the detail payload here. + const res = await this.chat.createConversation({ sandboxId: this.sandboxId, title }) + await this.refreshConversations() + await this.selectConversation(res.conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to create conversation") }) + } + } + + private async renameConversation(conversationId: string, title: string): Promise { + if (!this.chat) return + this.conversations = this.conversations.map((c) => (c.conversationId === conversationId ? { ...c, title } : c)) + this.broadcastConversations({ replace: true }) + try { + await this.chat.renameConversation(conversationId, title) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to rename conversation") }) + void this.refreshConversations() + } + } + + private async leaveConversation(conversationId: string): Promise { + if (!this.chat) return + try { + await this.chat.leaveConversation(conversationId) + // Optimistic removal — server will also fire conversation.left. + this.conversations = this.conversations.filter((c) => c.conversationId !== conversationId) + if (this.activeConversationId === conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ type: "kiloclaw.messages", conversationId, messages: [], hasMore: false, replace: true }) + } + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to leave conversation") }) + } + } + + private async loadMoreConversations(): Promise { + if (!this.chat || !this.sandboxId || !this.hasMoreConversations || !this.conversationsCursor) return + const target = this.sandboxId + const cursor = this.conversationsCursor + try { + const res = await this.chat.listConversations({ + sandboxId: target, + limit: CONVERSATIONS_PAGE, + cursor, + }) + // Sandbox could have changed (reauth / cleanup). Also a newer refresh + // may have already moved the cursor — skip merging stale results. + if (this.sandboxId !== target || this.conversationsCursor !== cursor) return + this.conversations = mergeConversations(this.conversations, res.conversations) + this.conversationsCursor = res.nextCursor + this.hasMoreConversations = res.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load conversations") }) + } + } + + private async sendMessage( + conversationId: string, + content: ContentBlock[], + inReplyToMessageId?: string, + ): Promise { + if (!this.chat) return + if (!this.currentUserId) return + + // kilo-chat validates clientId as a ULID (Crockford Base32); generate + // it here so the webview doesn't need to know the format. + const clientId = ulid() + const pendingId = `pending-${clientId}` + const optimistic: Message = { + id: pendingId, + senderId: this.currentUserId, + content, + inReplyToMessageId: inReplyToMessageId ?? null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + + if (conversationId === this.activeConversationId) { + this.messages = [...this.messages, optimistic] + this.post({ type: "kiloclaw.messageOptimistic", conversationId, message: optimistic }) + } + + try { + await this.chat.sendMessage({ conversationId, content, clientId, inReplyToMessageId }) + // Server will fire `message.created` — reconciliation happens there. + } catch (err) { + console.error("[Kilo New] KiloClaw sendMessage failed:", err instanceof Error ? err.message : err) + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to send message") }) + if (conversationId === this.activeConversationId) { + this.messages = this.messages.filter((m) => m.id !== pendingId) + this.post({ type: "kiloclaw.messageRemoved", conversationId, messageId: pendingId }) + } + } + } + + private async editMessage(conversationId: string, messageId: string, content: ContentBlock[]): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, content, clientUpdatedAt: Date.now() } : m, + ) + this.broadcastMessages({ replace: true }) } try { - await this.chat.send(text) + await this.chat.editMessage(messageId, { conversationId, content, timestamp: Date.now() }) } catch (err) { - console.error("[Kilo New] KiloClaw send failed:", err instanceof Error ? err.message : err) - this.post({ type: "kiloclaw.error", error: "Failed to send message" }) + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to edit message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } } } + private async deleteMessage(conversationId: string, messageId: string): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.deleteMessage(messageId, conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to delete message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async loadMoreMessages(conversationId: string, before: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + const res = await this.chat.listMessages(conversationId, { before, limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only merge if the active conversation is still the same. + if (this.activeConversationId !== conversationId) return + const sorted = sortMessagesAscending(res.messages) + this.messages = mergeMessages(sorted, this.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load messages") }) + } + } + + private async addReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionAdded(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.addReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to add reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async removeReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.removeReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to remove reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async executeAction( + conversationId: string, + messageId: string, + groupId: string, + value: ExecApprovalDecision, + ): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => { + if (m.id !== messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== groupId) return b + return { ...b, resolved: { value, resolvedBy: this.currentUserId!, resolvedAt: Date.now() } } + }), + } + }) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.executeAction(conversationId, messageId, { groupId, value }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to execute action") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async sendTyping(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTyping(conversationId) + } catch (err) { + // Typing is fire-and-forget; don't surface errors. + void err + } + } + + private async sendTypingStop(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTypingStop(conversationId) + } catch (err) { + void err + } + } + + private async markRead(conversationId: string): Promise { + if (!this.chat) return + if (conversationId !== this.activeConversationId) return + // The mark-read endpoint requires `lastSeenMessageId`. With no messages + // loaded there is nothing to mark — silently skip. + const last = lastNonPendingMessageId(this.messages) + if (!last) return + try { + await this.chat.markConversationRead(conversationId, { lastSeenMessageId: last }) + } catch (err) { + void err + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private async refreshConversations(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + // Defensive: sandbox could theoretically change during the fetch + // (cleanup or reauth). Skip the write if so. + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshConversations failed:", message) + } + } + + private async refreshActiveMessages(): Promise { + if (!this.chat) return + const target = this.activeConversationId + if (!target) return + try { + const res = await this.chat.listMessages(target, { limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only apply the messages if the active conversation is still `target`. + if (this.activeConversationId !== target) return + this.messages = sortMessagesAscending(res.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshActiveMessages failed:", message) + } + } + + private upsertTypingMember(memberId: string): void { + const now = Date.now() + const idx = this.typingMembers.findIndex((m) => m.memberId === memberId) + if (idx === -1) { + this.typingMembers = [...this.typingMembers, { memberId, at: now }] + } else { + this.typingMembers = this.typingMembers.map((m, i) => (i === idx ? { ...m, at: now } : m)) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typing", conversationId: this.activeConversationId, memberId }) + } + const existing = this.typingTimers.get(memberId) + if (existing) clearTimeout(existing) + this.typingTimers.set( + memberId, + setTimeout(() => this.removeTypingMember(memberId), TYPING_TIMEOUT_MS), + ) + } + + private removeTypingMember(memberId: string): void { + this.typingMembers = this.typingMembers.filter((m) => m.memberId !== memberId) + const t = this.typingTimers.get(memberId) + if (t) { + clearTimeout(t) + this.typingTimers.delete(memberId) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typingStop", conversationId: this.activeConversationId, memberId }) + } + } + + private toMessageFromCreated(e: MessageCreatedEvent): Message { + return { + id: e.messageId, + senderId: e.senderId, + content: e.content, + inReplyToMessageId: e.inReplyToMessageId, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + } + + private broadcastConversations(opts: { replace: boolean }): void { + this.post({ + type: "kiloclaw.conversations", + conversations: this.conversations, + hasMore: this.hasMoreConversations, + replace: opts.replace, + }) + } + + private broadcastMessages(opts: { replace: boolean }): void { + if (!this.activeConversationId) return + this.post({ + type: "kiloclaw.messages", + conversationId: this.activeConversationId, + messages: this.messages, + hasMore: this.hasMoreMessages, + replace: opts.replace, + }) + } + + private formatError(err: unknown, fallback: string): string { + if (err instanceof KiloChatApiError) { + const body = err.body as Record | null + if (body && typeof body.error === "string") return body.error + } + if (err instanceof Error) return err.message || fallback + return fallback + } + + // ── Polling / nudges / cleanup ────────────────────────────────────── + private startPolling(): void { if (this.timer) return this.timer = setInterval(() => void this.poll(), STATUS_POLL_MS) @@ -374,6 +1152,22 @@ export class KiloClawProvider implements vscode.Disposable { this.timer = null } + private startBotNudge(): void { + if (this.botNudge) return + this.botNudge = setInterval(() => { + if (!this.chat || !this.sandboxId) return + this.chat.requestBotStatus(this.sandboxId).catch((err) => { + console.debug("[Kilo New] KiloClaw requestBotStatus failed:", (err as Error)?.message ?? err) + }) + }, BOT_STATUS_NUDGE_MS) + } + + private stopBotNudge(): void { + if (!this.botNudge) return + clearInterval(this.botNudge) + this.botNudge = null + } + private async poll(): Promise { try { const client = this.connection.getClient() @@ -387,33 +1181,115 @@ export class KiloClawProvider implements vscode.Disposable { } } - /** Subscribe to connection state changes and re-run init() once connected. */ - private waitForConnection(): void { - const unsub = this.connection.onStateChange((state) => { - if (state === "connected" && !this.disposed) { - unsub() - this.initializing = false - void this.init() - } - }) - this.subs.push(unsub) - } - private cleanup(): void { this.generation++ for (const unsub of this.subs) unsub() this.subs = [] + for (const unsub of this.chatSubs) unsub() + this.chatSubs = [] - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } + this.stopPolling() + this.stopBotNudge() - this.disconnectChat() + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() + + this.events?.disconnect() + this.events = null + this.chat = null + this.tokens?.clear() + this.tokens = null + + this.subscribedSandboxContext = null + this.subscribedConversationContext = null this.messages = [] + this.conversations = [] + this.conversationsCursor = null + this.hasMoreConversations = false + this.activeConversationId = null + this.hasMoreMessages = false + this.botStatus = null + this.conversationStatus = null + this.typingMembers = [] this.initializing = false this.status = null + this.currentUserId = null + this.sandboxId = null } } + +// ── Pure helpers ────────────────────────────────────────────────────── + +function applyReactionAdded( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + const existing = reactions.find((r) => r.emoji === emoji) + if (existing) { + if (existing.memberIds.includes(memberId)) return reactions + return reactions.map((r) => + r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r, + ) + } + return [...reactions, { emoji, count: 1, memberIds: [memberId] }] +} + +function applyReactionRemoved( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + return reactions + .map((r) => { + if (r.emoji !== emoji) return r + const memberIds = r.memberIds.filter((id) => id !== memberId) + return { ...r, count: memberIds.length, memberIds } + }) + .filter((r) => r.count > 0) +} + +/** Merge two ascending-sorted message arrays by id, keeping the most recent updates. */ +function mergeMessages(older: Message[], newer: Message[]): Message[] { + const seen = new Map() + for (const m of older) seen.set(m.id, m) + for (const m of newer) seen.set(m.id, m) + return [...seen.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +function mergeConversations( + existing: ConversationListItem[], + incoming: ConversationListItem[], +): ConversationListItem[] { + const seen = new Map() + for (const c of existing) seen.set(c.conversationId, c) + for (const c of incoming) seen.set(c.conversationId, c) + return [...seen.values()].sort((a, b) => { + const ax = a.lastActivityAt ?? a.joinedAt + const bx = b.lastActivityAt ?? b.joinedAt + return bx - ax + }) +} + +/** listMessages returns newest-first; the UI renders oldest-first. */ +function sortMessagesAscending(messages: Message[]): Message[] { + return [...messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +/** + * Pick the latest server-confirmed message id from an ascending list. Pending + * (optimistic) messages use `pending-` ids that the server doesn't + * recognise, so they're skipped — the new mark-read contract requires a real + * message id. + */ +function lastNonPendingMessageId(messages: Message[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (!m) continue + if (m.id.startsWith("pending-")) continue + return m.id + } + return null +} diff --git a/packages/kilo-vscode/src/kiloclaw/chat-client.ts b/packages/kilo-vscode/src/kiloclaw/chat-client.ts deleted file mode 100644 index b1f15e4108..0000000000 --- a/packages/kilo-vscode/src/kiloclaw/chat-client.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * KiloClaw Stream Chat client wrapper for the VS Code extension host. - * - * Port of packages/opencode/src/kilocode/claw/client.ts adapted for Node.js. - * No Bun patches needed — the extension host runs in standard Node.js. - * stream-chat resolves to its Node.js CJS entry point automatically. - */ - -import type { Channel, Event } from "stream-chat" -import type { ChatCredentials, ChatMessage } from "./types" - -export type ClawChatClient = { - channel: Channel - disconnect: () => Promise - send: (text: string) => Promise - onMessage: (cb: (msg: ChatMessage) => void) => () => void - onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void - onPresence: (cb: (online: boolean) => void) => () => void -} - -function botId(creds: ChatCredentials): string { - return `bot-${creds.channelId.replace(/^default-/, "")}` -} - -function toMessage(raw: Record, bot: string): ChatMessage { - const user = raw.user as Record | undefined - const uid = (user?.id as string) ?? (raw.user_id as string) ?? "" - return { - id: (raw.id as string) ?? "", - text: (raw.text as string) ?? "", - user: uid, - created: raw.created_at ? new Date(raw.created_at as string).toISOString() : new Date().toISOString(), - bot: uid === bot, - } -} - -export async function connect(creds: ChatCredentials): Promise { - const { StreamChat } = await import("stream-chat") - // Use a fresh instance instead of the singleton to avoid stale state - // (cached channels, event listeners) when credentials rotate. - const client = new StreamChat(creds.apiKey) - - await client.connectUser({ id: creds.userId }, creds.userToken) - - const channel = client.channel("messaging", creds.channelId) - try { - await channel.watch({ presence: true }) - } catch (err) { - // Disconnect the user to avoid leaking a partial connection - await client.disconnectUser().catch(() => {}) - throw err - } - - const bot = botId(creds) - - return { - channel, - async disconnect() { - await client.disconnectUser() - }, - async send(text: string) { - await channel.sendMessage({ text }) - }, - onMessage(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.new", handler) - return () => channel.off("message.new", handler) - }, - onMessageUpdated(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.updated", handler) - return () => channel.off("message.updated", handler) - }, - onPresence(cb) { - const handler = (event: Event) => { - if (event.user?.id === bot) { - cb(event.user.online ?? false) - } - } - client.on("user.presence.changed", handler) - return () => client.off("user.presence.changed", handler) - }, - } -} - -export function history(channel: Channel, bot: string): ChatMessage[] { - const state = channel.state.messages - return state.map((raw) => toMessage(raw as unknown as Record, bot)) -} - -export function presence(channel: Channel, bot: string): boolean { - const member = channel.state.members?.[bot] - return !!member?.user?.online -} diff --git a/packages/kilo-vscode/src/kiloclaw/event-service-client.ts b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts new file mode 100644 index 0000000000..fea08acbc4 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts @@ -0,0 +1,384 @@ +/** + * Event Service WebSocket client for the VS Code extension host. + * + * Minimal inline port of `@kilocode/event-service` (cloud monorepo). Connects + * to the kilo events Cloudflare Worker using a two-step ticket flow: + * 1. POST `/connect-ticket` with `Authorization: Bearer ` to mint a + * single-use ticket (30 s TTL). + * 2. Open WebSocket to `/connect?ticket=` with subprotocol + * `kilo.events.v1`. + * + * Runs in Node.js (the VS Code extension host). `WebSocket` is available in + * Node 22+ without any import, matching the environment used elsewhere in + * this extension (see `src/services/cli-backend/sdk-sse-adapter.ts`). + */ + +import type { KiloChatEventMap, KiloChatEventName } from "./types" + +const WS_SUBPROTOCOL = "kilo.events.v1" +const HANDSHAKE_TIMEOUT_MS = 10_000 +const PING_INTERVAL_MS = 15_000 +const TICKET_FETCH_TIMEOUT_MS = 10_000 + +export class WebSocketAuthError extends Error { + constructor(message = "WebSocket authentication failed") { + super(message) + this.name = "WebSocketAuthError" + } +} + +export class WebSocketConnectError extends Error { + constructor( + message: string, + public readonly code: number, + ) { + super(message) + this.name = "WebSocketConnectError" + } +} + +export class HandshakeTimeoutError extends Error { + constructor() { + super("WebSocket handshake timed out") + this.name = "HandshakeTimeoutError" + } +} + +// Close codes that signal the server rejected us for auth/policy reasons +// and reconnecting with the same token is pointless. Everything else +// (including 1006 "abnormal closure" from flaky networks) is transient. +function isAuthCloseCode(code: number): boolean { + if (code === 1008) return true // Policy Violation + if (code === 4401 || code === 4403) return true // Custom auth rejection + return false +} + +export type EventHandler = (context: string, payload: unknown) => void + +export type EventServiceConfig = { + url: string + getToken: () => Promise + onUnauthorized?: () => void +} + +/** + * The event-service base URL is configured as a WebSocket URL (`wss://…` / + * `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip + * the trailing slash and swap the protocol so `fetch()` accepts the URL. + */ +function toHttpBase(wsBase: string): string { + const trimmed = wsBase.replace(/\/$/, "") + if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6) + if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5) + return trimmed +} + +export class EventServiceClient { + private readonly url: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + + private ws: WebSocket | null = null + private connected = false + private destroyed = false + private reconnectAttempts = 0 + private hasConnectedBefore = false + private reconnectTimer: ReturnType | null = null + private pingTimer: ReturnType | null = null + private handshakeTimer: ReturnType | null = null + private abortHandshake: ((err: Error) => void) | null = null + + private eventHandlers = new Map>() + private activeContexts = new Set() + private reconnectHandlers = new Set<() => void>() + + constructor(config: EventServiceConfig) { + this.url = config.url + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + async connect(): Promise { + this.destroyed = false + this.reconnectAttempts = 0 + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + try { + await this.connectOnce() + } catch (err) { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + } + } + + disconnect(): void { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.clearHandshakeTimer() + if (this.abortHandshake) { + this.abortHandshake(new Error("disconnected")) + } + if (this.ws) { + this.ws.close() + this.ws = null + } + this.stopPing() + this.connected = false + } + + isConnected(): boolean { + return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN + } + + subscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.add(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.subscribe", contexts }) + } + } + + unsubscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.delete(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.unsubscribe", contexts }) + } + } + + on(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void { + const set = this.eventHandlers.get(event) ?? new Set() + // The raw dispatcher receives `unknown` payloads; the caller supplied a + // typed handler. We trust server payloads here — they're validated at the + // kilo-chat worker edge before broadcast. + const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N]) + set.add(wrapped) + this.eventHandlers.set(event, set) + return () => { + set.delete(wrapped) + if (set.size === 0) this.eventHandlers.delete(event) + } + } + + onReconnect(handler: () => void): () => void { + this.reconnectHandlers.add(handler) + return () => this.reconnectHandlers.delete(handler) + } + + // ── private ──────────────────────────────────────────────────────── + + private handleAuthFailure(err: unknown): boolean { + if (err instanceof WebSocketAuthError) { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.onUnauthorized?.() + return true + } + return false + } + + private async connectOnce(): Promise { + if (this.ws) { + const old = this.ws + this.ws = null + old.close() + } + + const token = await this.getToken() + const ticket = await this.fetchTicket(token) + + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL]) + this.ws = ws + + let settled = false + const settleResolve = () => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + resolve() + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + reject(err) + } + this.abortHandshake = settleReject + + this.handshakeTimer = setTimeout(() => { + this.handshakeTimer = null + if (this.ws === ws) ws.close(1000, "handshake-timeout") + settleReject(new HandshakeTimeoutError()) + }, HANDSHAKE_TIMEOUT_MS) + + ws.addEventListener("open", () => { + const isReconnect = this.hasConnectedBefore + this.connected = true + this.hasConnectedBefore = true + this.reconnectAttempts = 0 + this.resubscribeContexts() + if (isReconnect) { + for (const h of this.reconnectHandlers) h() + } + settleResolve() + this.startPing() + }) + + ws.addEventListener("message", (event: MessageEvent) => { + this.handleMessage(String(event.data)) + }) + + ws.addEventListener("close", (event: CloseEvent) => { + if (this.ws !== ws) return + const wasConnected = this.connected + this.connected = false + this.stopPing() + this.clearHandshakeTimer() + // A handshake failure always fires `close` after `error`, so we + // settle here with a classification based on the close code: + // explicit auth/policy codes → fatal; anything else → transient + // and the caller (`connect`) will schedule a reconnect. + if (!wasConnected) { + if (isAuthCloseCode(event.code)) { + settleReject(new WebSocketAuthError()) + } else { + settleReject( + new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code), + ) + } + return + } + if (!this.destroyed) this.scheduleReconnect() + }) + + ws.addEventListener("error", () => { + // Swallowed: the `close` event fires right after and carries the + // close code we need to distinguish auth failures from network + // blips. Settling here loses that context. + }) + }) + } + + /** + * Mint a single-use connection ticket. The event-service issues a 30 s ticket + * scoped to the bearer JWT; the WebSocket upgrade then consumes it. We + * surface 401/403 as `WebSocketAuthError` so the caller can drop the cached + * token and prompt re-auth. + * + * `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only + * accepts `http(s)`, so we rewrite the protocol before the HTTP call. + */ + private async fetchTicket(token: string): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS) + try { + const res = await fetch(toHttpBase(this.url) + "/connect-ticket", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + signal: ctrl.signal, + }) + if (res.status === 401 || res.status === 403) { + throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`) + } + if (!res.ok) { + throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status) + } + const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null + if (!body || typeof body.ticket !== "string" || !body.ticket) { + throw new WebSocketConnectError("Malformed event-service ticket response", 0) + } + return body.ticket + } catch (err) { + if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err + if ((err as { name?: string })?.name === "AbortError") { + throw new HandshakeTimeoutError() + } + throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0) + } finally { + clearTimeout(timer) + } + } + + private clearHandshakeTimer(): void { + if (this.handshakeTimer !== null) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + } + + private sendJson(msg: unknown): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)) + } + } + + private handleMessage(data: string): void { + if (data === "pong") return + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return + } + if (!parsed || typeof parsed !== "object") return + const m = parsed as Record + if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") { + const handlers = this.eventHandlers.get(m.event) + if (handlers) { + for (const h of handlers) h(m.context, m.payload) + } + return + } + if (m.type === "error") { + console.warn("[Kilo New] event-service server error", m) + } + } + + private startPing(): void { + this.stopPing() + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send("ping") + } + }, PING_INTERVAL_MS) + } + + private stopPing(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + } + + private resubscribeContexts(): void { + if (this.activeContexts.size > 0) { + this.sendJson({ + type: "context.subscribe", + contexts: Array.from(this.activeContexts), + }) + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts) + const delay = base * (0.5 + Math.random() * 0.5) + this.reconnectAttempts++ + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connectOnce().catch((err) => { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + }) + }, delay) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts new file mode 100644 index 0000000000..5583b32af5 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -0,0 +1,270 @@ +/** + * HTTP client for the kilo-chat Cloudflare Worker. + * + * Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo) tailored + * to what the VS Code extension needs: conversation list + details, message + * CRUD, reactions, typing, and action execution. No zod runtime validation — + * the kilo-chat worker is the source of truth and validates at its edge. + */ + +import type { + BotStatusRecord, + ContentBlock, + ConversationDetail, + ConversationListItem, + ConversationStatusRecord, + ExecApprovalDecision, + Message, +} from "./types" + +export type KiloChatClientConfig = { + baseUrl: string + getToken: () => Promise + onUnauthorized?: () => void +} + +export class KiloChatApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + ) { + super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) + this.name = "KiloChatApiError" + } +} + +function formatBodyDetail(body: unknown): string { + if (body === null || body === undefined) return "" + if (typeof body === "string") return ` - ${body}` + if (typeof body === "object") { + const err = (body as Record).error + if (typeof err === "string") return ` - ${err}` + // Fall back to a compact JSON dump so validation errors (zod issues, etc.) + // show up in the extension's Output channel without a separate logging hop. + try { + return ` - ${JSON.stringify(body)}` + } catch { + return "" + } + } + return "" +} + +type HttpOpts = { + method?: string + body?: unknown + query?: Record +} + +// Per-conversation send queues. sendMessage chains onto the tail of its +// conversation's queue so concurrent callers can't race ahead and get a lower +// server-assigned ULID than a later send. +type SendQueue = Map> + +export class KiloChatClient { + private readonly baseUrl: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + private readonly sendQueues: SendQueue = new Map() + + constructor(config: KiloChatClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, "") + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + // ── Conversations ──────────────────────────────────────────────── + + listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{ + conversations: ConversationListItem[] + hasMore: boolean + nextCursor: string | null + }> { + return this.request("/v1/conversations", { + query: { + sandboxId: opts?.sandboxId, + limit: opts?.limit, + cursor: opts?.cursor ?? undefined, + }, + }) + } + + getConversation(conversationId: string): Promise { + return this.request(`/v1/conversations/${conversationId}`) + } + + createConversation(req: { + sandboxId: string + title?: string + }): Promise<{ conversationId: string; conversation?: ConversationDetail }> { + return this.request("/v1/conversations", { method: "POST", body: req }) + } + + renameConversation(conversationId: string, title: string): Promise<{ ok: true }> { + return this.request(`/v1/conversations/${conversationId}`, { + method: "PATCH", + body: { title }, + }) + } + + async leaveConversation(conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/conversations/${conversationId}/leave`, { method: "POST" }) + } + + /** + * Mark messages up to `lastSeenMessageId` as read for the current user. + * The server enforces monotonic `lastReadAt` and returns whether the read + * pointer advanced plus whether the badge bucket was cleared. + */ + markConversationRead( + conversationId: string, + req: { lastSeenMessageId: string }, + ): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> { + return this.request(`/v1/conversations/${conversationId}/mark-read`, { + method: "POST", + body: req, + }) + } + + // ── Messages ───────────────────────────────────────────────────── + + sendMessage(req: { + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + clientId?: string + }): Promise<{ messageId: string; clientId?: string; message?: Message }> { + const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve() + const send = () => + this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", { + method: "POST", + body: req, + }) + const next = prev.then(send, send) + this.sendQueues.set(req.conversationId, next) + const cleanup = () => { + if (this.sendQueues.get(req.conversationId) === next) { + this.sendQueues.delete(req.conversationId) + } + } + void next.then(cleanup, cleanup) + return next + } + + editMessage( + messageId: string, + req: { conversationId: string; content: ContentBlock[]; timestamp: number }, + ): Promise<{ messageId?: string; message?: Message }> { + return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req }) + } + + async deleteMessage(messageId: string, conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/messages/${messageId}`, { + method: "DELETE", + query: { conversationId }, + }) + } + + listMessages( + conversationId: string, + opts?: { before?: string; limit?: number }, + ): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> { + return this.request(`/v1/conversations/${conversationId}/messages`, { + query: { before: opts?.before, limit: opts?.limit }, + }) + } + + executeAction( + conversationId: string, + messageId: string, + req: { groupId: string; value: ExecApprovalDecision }, + ): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> { + return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, { + method: "POST", + body: req, + }) + } + + // ── Reactions ──────────────────────────────────────────────────── + + addReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ id: string; operationId?: string }> { + return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req }) + } + + async removeReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ removed: boolean; id: string | null; operationId?: string }> { + return this.request<{ removed: boolean; id: string | null; operationId?: string }>( + `/v1/messages/${messageId}/reactions`, + { + method: "DELETE", + query: req, + }, + ) + } + + // ── Typing ─────────────────────────────────────────────────────── + + async sendTyping(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing`, { method: "POST" }) + } + + async sendTypingStop(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" }) + } + + // ── Bot / conversation status ──────────────────────────────────── + + getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> { + return this.request(`/v1/sandboxes/${sandboxId}/bot-status`) + } + + async requestBotStatus(sandboxId: string): Promise { + await this.request(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" }) + } + + getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> { + return this.request(`/v1/conversations/${conversationId}/conversation-status`) + } + + // ── private ────────────────────────────────────────────────────── + + private async request(path: string, opts: HttpOpts = {}): Promise { + const token = await this.getToken() + let url = `${this.baseUrl}${path}` + + if (opts.query) { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined || v === null) continue + params.set(k, String(v)) + } + const qs = params.toString() + if (qs) url += `?${qs}` + } + + const headers: Record = { Authorization: `Bearer ${token}` } + if (opts.body !== undefined) headers["Content-Type"] = "application/json" + + const res = await fetch(url, { + method: opts.method ?? "GET", + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }) + + if (!res.ok) { + if (res.status === 401 || res.status === 403) this.onUnauthorized?.() + const body: unknown = await res.json().catch(() => null) + throw new KiloChatApiError(res.status, body) + } + + if (res.status === 204) return undefined as unknown as T + return (await res.json()) as T + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/token-manager.ts b/packages/kilo-vscode/src/kiloclaw/token-manager.ts new file mode 100644 index 0000000000..745b9c7c3f --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/token-manager.ts @@ -0,0 +1,112 @@ +/** + * KiloChat access-token cache. + * + * Mirrors the web client pattern (see `apps/web/src/app/(app)/claw/kilo-chat/token.ts` + * in the cloud monorepo). The token is minted by the Kilo gateway + * (`kilo.claw.chatCredentials`) and kept in memory with a 5-minute freshness + * buffer. The gateway operation is a historical name — it used to return + * Stream Chat credentials and was repurposed when Kilo migrated to its own + * kilo-chat service. + * + * Concurrent callers share the same inflight fetch so we never double-issue. + * A short retry cooldown prevents tight loops when the gateway is flaky. + */ + +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { ChatToken } from "./types" + +const FRESHNESS_BUFFER_MS = 5 * 60 * 1000 +const RETRY_BACKOFF_MS = 5_000 + +export class TokenManager { + private cached: ChatToken | null = null + private expiresAtMs = 0 + private inflight: Promise | null = null + private lastFailedAt = 0 + + constructor(private readonly getClient: () => KiloClient | null) {} + + /** Latest resolved token info (may be stale). Used for URL extraction. */ + peek(): ChatToken | null { + return this.cached + } + + /** Drop the cached token; next `get` will refetch. */ + clear(): void { + this.cached = null + this.expiresAtMs = 0 + this.lastFailedAt = 0 + this.inflight = null + } + + async get(): Promise { + const info = await this.getOrFetch() + return info.token + } + + /** Resolve the full token envelope (URLs + token + expiry). */ + async getOrFetch(): Promise { + if (this.cached && Date.now() < this.expiresAtMs - FRESHNESS_BUFFER_MS) { + return this.cached + } + if (this.lastFailedAt && Date.now() - this.lastFailedAt < RETRY_BACKOFF_MS) { + throw new Error("Kilo chat token fetch on cooldown after recent failure") + } + if (!this.inflight) { + this.inflight = this.fetch() + .then((info) => { + this.cached = info + this.expiresAtMs = new Date(info.expiresAt).getTime() + this.lastFailedAt = 0 + this.inflight = null + return info + }) + .catch((err) => { + this.lastFailedAt = Date.now() + this.inflight = null + throw err + }) + } + return this.inflight + } + + private async fetch(): Promise { + const client = this.getClient() + if (!client) throw new Error("Kilo backend not connected") + const res = await client.kilo.claw.chatCredentials() + const errResponse = (res as Record | null)?.error + if (!res || errResponse || !res.data) { + // Propagate the server's error detail when present so the extension's + // Output channel makes it obvious whether this is an auth problem, + // "no active instance" (404), or a transient 5xx. + const detail = this.formatErrorDetail(errResponse) + throw new Error(`kilo-chat credentials fetch failed${detail ? `: ${detail}` : ""}`) + } + const data = res.data as Partial + const missing: string[] = [] + if (!data.token) missing.push("token") + if (!data.expiresAt) missing.push("expiresAt") + if (!data.kiloChatUrl) missing.push("kiloChatUrl") + if (!data.eventServiceUrl) missing.push("eventServiceUrl") + if (missing.length > 0) { + throw new Error( + `Malformed kilo-chat credentials response: missing ${missing.join(", ")} (received keys: ${Object.keys(data).join(", ") || ""})`, + ) + } + return { + token: data.token!, + expiresAt: data.expiresAt!, + kiloChatUrl: data.kiloChatUrl!, + eventServiceUrl: data.eventServiceUrl!, + } + } + + private formatErrorDetail(err: unknown): string { + if (!err) return "" + if (typeof err === "string") return err + if (typeof err === "object" && err && "error" in err && typeof err.error === "string") { + return err.error + } + return JSON.stringify(err) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/types.ts b/packages/kilo-vscode/src/kiloclaw/types.ts index 51f4e28631..20b796a67d 100644 --- a/packages/kilo-vscode/src/kiloclaw/types.ts +++ b/packages/kilo-vscode/src/kiloclaw/types.ts @@ -3,14 +3,29 @@ * * Defines the postMessage protocol between the extension host (Node.js) * and the KiloClaw webview (SolidJS). The extension host owns all network - * connections (SDK + Stream Chat) and relays data to the webview. + * connections (Kilo Chat HTTP + event-service WebSocket) and relays data + * to the webview. * - * SYNC: Shared types (ClawStatus, ChatMessage, KiloClawState, KiloClawOutMessage) - * are mirrored in webview-ui/kiloclaw/lib/types.ts — keep both in sync. + * SYNC: Shared types are mirrored in webview-ui/kiloclaw/lib/types.ts — + * keep both in sync. */ +// ── Instance status (KiloClaw worker) ─────────────────────────────── + export type ClawStatus = { - status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null + // `recovering` and `restoring` are transitional states the worker reports + // while bringing an instance back from an unexpected stop or a snapshot + // restore (cloud: `services/kiloclaw/src/index.ts`). + status: + | "provisioned" + | "starting" + | "restarting" + | "recovering" + | "running" + | "stopped" + | "destroying" + | "restoring" + | null sandboxId?: string flyRegion?: string machineSize?: { cpus: number; memory_mb: number } @@ -19,23 +34,207 @@ export type ClawStatus = { lastStoppedAt?: string | null channelCount?: number secretCount?: number + userId?: string + botName?: string | null } -export type ChatCredentials = { - apiKey: string - userId: string - userToken: string - channelId: string +// ── Kilo Chat token envelope (gateway response) ───────────────────── + +export type ChatToken = { + token: string + expiresAt: string // ISO timestamp + kiloChatUrl: string + eventServiceUrl: string } -export type ChatMessage = { +// ── Kilo Chat content blocks ──────────────────────────────────────── +// Mirrors `@kilocode/kilo-chat` schemas. See cloud/packages/kilo-chat/src/schemas.ts. + +export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" + +export type TextBlock = { type: "text"; text: string } + +export type ActionItem = { + label: string + style: "primary" | "danger" | "secondary" + value: ExecApprovalDecision +} + +export type ActionsBlock = { + type: "actions" + groupId: string + actions: ActionItem[] + resolved?: { + value: ExecApprovalDecision + resolvedBy: string + resolvedAt: number + } +} + +export type ContentBlock = TextBlock | ActionsBlock + +// ── Kilo Chat reactions ───────────────────────────────────────────── + +export type ReactionSummary = { + emoji: string + count: number + memberIds: string[] +} + +// ── Kilo Chat message ─────────────────────────────────────────────── + +export type Message = { id: string - text: string - user: string - created: string // ISO string (serializable via postMessage) - bot: boolean + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + updatedAt: number | null + clientUpdatedAt: number | null + deleted: boolean + deliveryFailed: boolean + reactions: ReactionSummary[] } +// ── Conversations ─────────────────────────────────────────────────── + +export type ConversationListItem = { + conversationId: string + title: string | null + lastActivityAt: number | null + lastReadAt: number | null + joinedAt: number +} + +export type ConversationMember = { id: string; kind: "user" | "bot" } + +export type ConversationDetail = { + id: string + title: string | null + createdBy: string + createdAt: number + members: ConversationMember[] +} + +// ── Bot / conversation status (telemetry) ─────────────────────────── + +export type BotStatusRecord = { + online: boolean + at: number + updatedAt: number +} + +export type ConversationStatusRecord = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number + updatedAt: number +} + +// ── Typed Kilo Chat events (server → client) ─────────────────────── +// Event names mirror `@kilocode/kilo-chat/events`. + +/** + * Snapshot of the message that was replied to. Server includes this on + * `message.created` so clients can render a reply preview without a follow-up + * fetch. `deleted` mirrors the soft-deletion state at the time of replying. + */ +export type ReplyToSnapshot = { + messageId: string + senderId: string + content: ContentBlock[] + deleted?: boolean +} + +export type MessageCreatedEvent = { + messageId: string + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + clientId?: string + replyTo?: ReplyToSnapshot | null +} + +export type MessageUpdatedEvent = { + messageId: string + content: ContentBlock[] + clientUpdatedAt: number | null +} + +export type MessageDeletedEvent = { messageId: string } +export type MessageDeliveryFailedEvent = { messageId: string } + +export type TypingEvent = { memberId: string } +export type TypingStopEvent = { memberId: string } + +export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } +export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } + +/** + * Server fans out the full conversation snapshot on `conversation.created` so + * clients can append to their list without a follow-up fetch. Older servers may + * still send only the `conversationId`, so the snapshot is optional. + */ +export type ConversationCreatedEvent = { + conversationId: string + conversation?: ConversationListItem +} +export type ConversationRenamedEvent = { conversationId: string; title: string } +export type ConversationLeftEvent = { conversationId: string } +export type ConversationReadEvent = { conversationId: string; memberId: string; lastReadAt: number } +export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number } + +export type ActionExecutedEvent = { + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + executedBy: string +} +export type ActionDeliveryFailedEvent = { + conversationId: string + messageId: string + groupId: string +} + +export type BotStatusEvent = { sandboxId: string; online: boolean; at: number } +export type ConversationStatusEvent = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number +} + +export type KiloChatEventMap = { + "message.created": MessageCreatedEvent + "message.updated": MessageUpdatedEvent + "message.deleted": MessageDeletedEvent + "message.delivery_failed": MessageDeliveryFailedEvent + typing: TypingEvent + "typing.stop": TypingStopEvent + "reaction.added": ReactionAddedEvent + "reaction.removed": ReactionRemovedEvent + "conversation.created": ConversationCreatedEvent + "conversation.renamed": ConversationRenamedEvent + "conversation.left": ConversationLeftEvent + "conversation.read": ConversationReadEvent + "conversation.activity": ConversationActivityEvent + "action.executed": ActionExecutedEvent + "action.delivery_failed": ActionDeliveryFailedEvent + "bot.status": BotStatusEvent + "conversation.status": ConversationStatusEvent +} + +export type KiloChatEventName = keyof KiloChatEventMap + +// ── Webview ↔ extension state ─────────────────────────────────────── + +export type TypingMember = { memberId: string; at: number } + // Full state snapshot pushed to the webview // Every phase carries `locale` so the webview can resolve translations immediately. export type KiloClawState = @@ -47,24 +246,65 @@ export type KiloClawState = phase: "ready" locale: string status: ClawStatus | null - connected: boolean - online: boolean - messages: ChatMessage[] + currentUserId: string + sandboxId: string + conversations: ConversationListItem[] + hasMoreConversations: boolean + activeConversationId: string | null + messages: Message[] + hasMoreMessages: boolean + botStatus: BotStatusRecord | null + conversationStatus: ConversationStatusRecord | null + typingMembers: TypingMember[] } -// Messages: Webview → Extension Host +// ── Messages: Webview → Extension Host ────────────────────────────── + export type KiloClawInMessage = | { type: "kiloclaw.ready" } - | { type: "kiloclaw.send"; text: string } | { type: "kiloclaw.openExternal"; url: string } + | { type: "kiloclaw.selectConversation"; conversationId: string } + | { type: "kiloclaw.createConversation"; title?: string } + | { type: "kiloclaw.renameConversation"; conversationId: string; title: string } + | { type: "kiloclaw.leaveConversation"; conversationId: string } + | { type: "kiloclaw.loadMoreConversations" } + | { + type: "kiloclaw.sendMessage" + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + } + | { type: "kiloclaw.editMessage"; conversationId: string; messageId: string; content: ContentBlock[] } + | { type: "kiloclaw.deleteMessage"; conversationId: string; messageId: string } + | { type: "kiloclaw.loadMoreMessages"; conversationId: string; before: string } + | { type: "kiloclaw.addReaction"; conversationId: string; messageId: string; emoji: string } + | { type: "kiloclaw.removeReaction"; conversationId: string; messageId: string; emoji: string } + | { + type: "kiloclaw.executeAction" + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + } + | { type: "kiloclaw.sendTyping"; conversationId: string } + | { type: "kiloclaw.sendTypingStop"; conversationId: string } + | { type: "kiloclaw.markRead"; conversationId: string } + +// ── Messages: Extension Host → Webview ────────────────────────────── -// Messages: Extension Host → Webview export type KiloClawOutMessage = | { type: "kiloclaw.state"; state: KiloClawState } - | { type: "kiloclaw.message"; message: ChatMessage } - | { type: "kiloclaw.messageUpdated"; message: ChatMessage } - | { type: "kiloclaw.presence"; online: boolean } | { type: "kiloclaw.status"; data: ClawStatus | null } | { type: "kiloclaw.locale"; locale: string } | { type: "kiloclaw.error"; error: string } + | { type: "kiloclaw.conversations"; conversations: ConversationListItem[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.activeConversation"; conversationId: string | null } + | { type: "kiloclaw.messages"; conversationId: string; messages: Message[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.messageOptimistic"; conversationId: string; message: Message } + | { type: "kiloclaw.messageReplaced"; conversationId: string; pendingId: string; message: Message } + | { type: "kiloclaw.messageRemoved"; conversationId: string; messageId: string } + | { type: "kiloclaw.botStatus"; status: BotStatusRecord | null } + | { type: "kiloclaw.conversationStatus"; status: ConversationStatusRecord | null } + | { type: "kiloclaw.typing"; conversationId: string; memberId: string } + | { type: "kiloclaw.typingStop"; conversationId: string; memberId: string } | { type: "fontSizeChanged"; fontSize: number } diff --git a/packages/kilo-vscode/src/kiloclaw/ulid.ts b/packages/kilo-vscode/src/kiloclaw/ulid.ts new file mode 100644 index 0000000000..a55bda569d --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/ulid.ts @@ -0,0 +1,41 @@ +/** + * Minimal ULID generator for the extension host. + * + * Produces a 26-character Crockford Base32 identifier: 10 time chars + * followed by 16 random chars. The kilo-chat worker validates clientId + * as a ULID, so `generateClientId` must emit only Crockford-legal + * characters — `toString(36)` is NOT safe because base36 includes + * I, L, O, and U which are excluded from Crockford Base32. + */ + +// Crockford Base32 — no I, L, O, U (reduces transcription ambiguity). +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +const ENCODING_LEN = ENCODING.length +const TIME_LEN = 10 +const RANDOM_LEN = 16 + +function encodeTime(ts: number): string { + let out = "" + let n = ts + for (let i = 0; i < TIME_LEN; i++) { + const mod = n % ENCODING_LEN + out = ENCODING[mod] + out + n = (n - mod) / ENCODING_LEN + } + return out +} + +function encodeRandom(): string { + const bytes = new Uint8Array(RANDOM_LEN) + globalThis.crypto.getRandomValues(bytes) + let out = "" + for (let i = 0; i < RANDOM_LEN; i++) { + out += ENCODING[bytes[i]! % ENCODING_LEN] + } + return out +} + +/** Generate a ULID at the current epoch. */ +export function ulid(): string { + return encodeTime(Date.now()) + encodeRandom() +} diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index cf7cb1f908..3d8e408922 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -2,14 +2,14 @@ * Provider action handlers extracted from KiloProvider to stay under max-lines. * These are pure async functions that operate on the SDK client — no vscode dependency. */ -import type { KiloClient } from "@kilocode/sdk/v2" +import type { Config, KiloClient } from "@kilocode/sdk/v2" import { validateProviderID as validateProviderIDShared } from "./shared/custom-provider" import { resolveCustomProviderAuth, sanitizeCustomProviderConfig, withCustomProviderDeletions, } from "./shared/custom-provider" -import { KILO_AUTO, parseModelString } from "./shared/provider-model" +import { CUSTOM_PROVIDER_PACKAGE, KILO_AUTO, parseModelString } from "./shared/provider-model" import { configFeatures } from "./features" /** @@ -22,6 +22,28 @@ function disabledWithout(list: string[] | undefined, id: string) { return (list ?? []).filter((item) => item !== id) } +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function customProvider(config: unknown) { + return record(config) && config.npm === CUSTOM_PROVIDER_PACKAGE +} + +function same(a: unknown, b: unknown): boolean { + if (a === b) return true + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + return a.every((value, index) => same(value, b[index])) + } + if (!record(a) || !record(b)) return false + const akeys = Object.keys(a).sort() + const bkeys = Object.keys(b).sort() + if (akeys.length !== bkeys.length) return false + return akeys.every((key, index) => key === bkeys[index] && same(a[key], b[key])) +} + /** Fetch auth methods alongside the provider list. Auth states default to empty (endpoint not yet available). */ export async function fetchProviderData(client: KiloClient, dir: string) { const authRequest = @@ -123,6 +145,7 @@ export function computeDefaultSelection( type PostMessage = (message: unknown) => void type GetErrorMessage = (error: unknown) => string +type SetCachedConfig = (msg: unknown) => void interface ActionContext { client: KiloClient @@ -155,6 +178,69 @@ function validateID( return null } +async function configs(ctx: ActionContext) { + const [{ data: global }, { data: merged }] = await Promise.all([ + ctx.client.global.config.get({ throwOnError: true }), + ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }), + ]) + return { global: global ?? {}, merged: merged ?? {} } +} + +async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfig) { + const { data: config } = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) + if (!config) return + const features = configFeatures(config) + setCachedConfig({ type: "configLoaded", config, features }) + ctx.postMessage({ type: "configUpdated", config, features }) +} + +async function saveGlobal(ctx: ActionContext, config: Config) { + await ctx.client.global.config.update({ config }, { throwOnError: true }) +} + +async function saveProject(ctx: ActionContext, config: Config) { + await ctx.client.config.update({ config, directory: ctx.workspaceDir }, { throwOnError: true }) +} + +async function removeAuth(ctx: ActionContext, id: string, configured: boolean) { + try { + await ctx.client.auth.remove({ providerID: id }, { throwOnError: true }) + } catch (err) { + if (!configured) throw err + console.warn(`[Kilo New] auth.remove failed for configured provider ${id} (non-fatal):`, err) + } +} + +async function removeCustom(ctx: ActionContext, id: string, global: Config, merged: Config) { + const cfg = global.provider?.[id] + const effective = merged.provider?.[id] + const tasks = [] + if (customProvider(cfg)) { + tasks.push( + saveGlobal(ctx, { + provider: { [id]: null }, + disabled_providers: disabledWithout(global.disabled_providers, id), + }), + ) + } + if (customProvider(effective)) { + tasks.push(saveProject(ctx, { provider: { [id]: null } })) + } + await Promise.all(tasks) +} + +async function disableConfigured(ctx: ActionContext, id: string, config: Config) { + const disabled = config.disabled_providers ?? [] + if (disabled.includes(id)) return + await saveGlobal(ctx, { disabled_providers: [...disabled, id] }) +} + +async function enableConfigured(ctx: ActionContext, id: string, config: Config) { + const disabled = disabledWithout(config.disabled_providers, id) + if (disabled.length === (config.disabled_providers ?? []).length) return + await saveGlobal(ctx, { disabled_providers: disabled }) +} + export async function connectProvider(ctx: ActionContext, requestId: string, providerID: string, apiKey: string) { const id = validateID(ctx, requestId, providerID, "connect") if (!id) return @@ -230,64 +316,45 @@ export async function disconnectProvider( requestId: string, providerID: string, cachedConfigMessage: unknown, - setCachedConfig: (msg: unknown) => void, + setCachedConfig: SetCachedConfig, ) { const id = validateID(ctx, requestId, providerID, "disconnect") if (!id) return try { - const globalConfig = (await ctx.client.global.config.get({ throwOnError: true })).data ?? {} - const configured = !!globalConfig.provider?.[id] + const config = await configs(ctx) + const cfg = config.global.provider?.[id] + const effective = config.merged.provider?.[id] + const configured = !!cfg || !!effective + const custom = customProvider(cfg) || customProvider(effective) const { response } = await fetchProviderData(ctx.client, ctx.workspaceDir) const active = response.all.find((item) => item.id === id) - const oauth = active?.source === "custom" && configured + const oauth = active?.source === "custom" && configured && !custom - // Remove auth store entry. Config-sourced providers may not have an auth - // store entry (credentials come from config or env), so failure is non-fatal. - // For auth-only providers, failure means disconnect failed. - try { - await ctx.client.auth.remove({ providerID: id }, { throwOnError: true }) - } catch (err) { - if (!configured) throw err - console.warn(`[Kilo New] auth.remove failed for configured provider ${id} (non-fatal):`, err) - } + // Config-sourced providers may not have auth store entries because + // credentials can come from config or env, so auth removal is non-fatal. + await removeAuth(ctx, id, configured) if (id === "kilo") { ctx.postMessage({ type: "profileData", data: null }) } - // Config-sourced providers stay "connected" after auth.remove because the - // server rebuilds state from config. Add to disabled_providers so the server - // excludes them. The config entry is preserved (user may re-enable later). - // This mirrors the server's disabled provider config behavior. - if (configured && !oauth) { - const disabled = globalConfig.disabled_providers ?? [] - if (!disabled.includes(id)) { - const merged = ( - await ctx.client.global.config.update( - { config: { disabled_providers: [...disabled, id] } }, - { throwOnError: true }, - ) - ).data - if (merged) { - setCachedConfig({ type: "configLoaded", config: merged, features: configFeatures(merged) }) - ctx.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) - } - } + if (custom) { + await removeCustom(ctx, id, config.global, config.merged) + } + + // Config-sourced built-in providers stay "connected" after auth.remove + // because the server rebuilds state from config. Add to disabled_providers + // so the server excludes them while preserving config for re-enable. + if (configured && !oauth && !custom) { + await disableConfigured(ctx, id, config.global) } if (oauth) { - const disabled = disabledWithout(globalConfig.disabled_providers, id) - if (disabled.length !== (globalConfig.disabled_providers ?? []).length) { - const merged = ( - await ctx.client.global.config.update({ config: { disabled_providers: disabled } }, { throwOnError: true }) - ).data - if (merged) { - setCachedConfig({ type: "configLoaded", config: merged }) - ctx.postMessage({ type: "configUpdated", config: merged }) - } - } + await enableConfigured(ctx, id, config.global) } + if (configured) await refreshConfig(ctx, setCachedConfig) + await ctx.disposeGlobal(`provider disconnect (${id})`) await ctx.fetchAndSendProviders() ctx.postMessage({ type: "providerDisconnected", requestId, providerID: id }) diff --git a/packages/kilo-vscode/src/review-utils.ts b/packages/kilo-vscode/src/review-utils.ts index 1e2a7c7927..42dac76b28 100644 --- a/packages/kilo-vscode/src/review-utils.ts +++ b/packages/kilo-vscode/src/review-utils.ts @@ -1,9 +1,6 @@ import * as path from "path" import * as vscode from "vscode" import { inspect } from "util" -import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" -import { GitOps } from "./agent-manager/GitOps" -import { resolveBase } from "./agent-manager/local-diff" export function appendOutput(channel: vscode.OutputChannel, prefix: string, ...args: unknown[]): void { const msg = args @@ -18,60 +15,6 @@ export function getWorkspaceRoot(): string | undefined { return undefined } -export async function resolveLocalDiffTarget( - gitOps: GitOps, - log: (...args: unknown[]) => void, - root?: string, -): Promise<{ directory: string; baseBranch: string } | undefined> { - if (!root) { - log("Local diff: no workspace root") - return - } - - const branch = await gitOps.currentBranch(root) - if (!branch || branch === "HEAD") { - log("Local diff: detached HEAD or no branch") - return - } - - const tracking = await gitOps.resolveTrackingBranch(root, branch) - const fallback = tracking ? undefined : await gitOps.resolveDefaultBranch(root, branch) - const raw = tracking || fallback || "HEAD" - const base = await resolveBase(gitOps, root, raw) - - log(`Local diff: branch=${branch} tracking=${tracking ?? "none"} default=${fallback ?? "none"} base=${base}`) - - return { directory: root, baseBranch: base } -} - -export function hashFileDiffs( - diffs: Array< - SnapshotFileDiff & { - tracked?: boolean - generatedLike?: boolean - summarized?: boolean - stamp?: string - } - >, -): string { - return diffs - .map((diff) => { - const content = diff.summarized ? "" : diff.patch - return [ - diff.file, - diff.status, - diff.additions, - diff.deletions, - diff.tracked ? "tracked" : "untracked", - diff.generatedLike ? "generated" : "source", - diff.summarized ? "summary" : "detail", - diff.stamp ?? "", - content, - ].join(":") - }) - .join("|") -} - export function openFileInEditor( filePath: string, line?: number, @@ -96,5 +39,5 @@ export function openWorkspaceRelativeFile(relativePath: string, line?: number, c if (!root) return const resolved = path.resolve(root, relativePath) if (!resolved.startsWith(root + path.sep) && resolved !== root) return - openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffViewerProvider") + openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffPanel") } diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 17b4137d7f..18c176c389 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -104,6 +104,12 @@ export class ServerManager { ...(extraCaCerts && { NODE_EXTRA_CA_CERTS: extraCaCerts }), ...(!proxyStrictSSL && { NODE_TLS_REJECT_UNAUTHORIZED: "0" }), ...process.env, + // VS Code's http.proxy / http.noProxy settings are not reflected in + // process.env, so spawned children bypass the user's configured proxy + // and fail behind corporate firewalls. Forward them as the standard + // HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars that Bun's fetch and + // most HTTP clients already respect. + ...buildProxyEnv(), // Force mimalloc (the allocator Bun ships with) to return freed pages // to the OS immediately instead of retaining them in its arenas. // Without this, Bun.spawn's piped stdio accumulates ~2 MB of native @@ -257,6 +263,72 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, "") } +/** + * Translate VS Code's `http.proxy` / `http.noProxy` / `http.proxySupport` + * settings into the standard proxy env vars, so the spawned CLI honors the + * user's proxy configuration. Returns an empty object when no override is + * needed, so callers can spread unconditionally. + * + * `http.proxySupport: "off"` is VS Code's opt-in way to disable proxy support + * entirely; when set, we explicitly clear the env vars so ambient shell + * HTTP_PROXY/http_proxy doesn't leak into the spawned child. + */ +export function buildProxyEnv(): Record { + const httpConfig = vscode.workspace.getConfiguration("http") + const proxyInfo = httpConfig.inspect("proxy") + const noProxyInfo = httpConfig.inspect("noProxy") + const proxySupport = httpConfig.get("proxySupport") + + if (proxySupport === "off") { + return { HTTP_PROXY: "", HTTPS_PROXY: "", NO_PROXY: "", http_proxy: "", https_proxy: "", no_proxy: "" } + } + + const proxy = httpConfig.get("proxy") + const noProxy = httpConfig.get("noProxy") + const proxySet = + proxyInfo !== undefined && + [ + proxyInfo.globalValue, + proxyInfo.workspaceValue, + proxyInfo.workspaceFolderValue, + proxyInfo.globalLanguageValue, + proxyInfo.workspaceLanguageValue, + proxyInfo.workspaceFolderLanguageValue, + ].some((value) => value !== undefined) + const noProxySet = + noProxyInfo !== undefined && + [ + noProxyInfo.globalValue, + noProxyInfo.workspaceValue, + noProxyInfo.workspaceFolderValue, + noProxyInfo.globalLanguageValue, + noProxyInfo.workspaceLanguageValue, + noProxyInfo.workspaceFolderLanguageValue, + ].some((value) => value !== undefined) + const env: Record = {} + if (proxy && proxy.trim() !== "") { + env.HTTP_PROXY = proxy + env.HTTPS_PROXY = proxy + env.http_proxy = proxy + env.https_proxy = proxy + } + if (proxySet && proxy !== undefined && proxy.trim() === "") { + env.HTTP_PROXY = "" + env.HTTPS_PROXY = "" + env.http_proxy = "" + env.https_proxy = "" + } + if (Array.isArray(noProxy) && noProxy.length > 0) { + env.NO_PROXY = noProxy.join(",") + env.no_proxy = noProxy.join(",") + } + if (noProxySet && Array.isArray(noProxy) && noProxy.length === 0) { + env.NO_PROXY = "" + env.no_proxy = "" + } + return env +} + export function toErrorMessage( error: string, stderrLines: string[], diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index 9ab5ff4203..25a02adca2 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -71,6 +71,16 @@ const mockVscode = { showTextDocument: async () => {}, showWarningMessage: async () => undefined, createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }), + createOutputChannel: () => ({ + name: "", + append: noop, + appendLine: noop, + replace: noop, + clear: noop, + show: noop, + hide: noop, + dispose: noop, + }), createStatusBarItem: () => ({ text: "", tooltip: "", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts new file mode 100644 index 0000000000..cba8460789 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test" +import { initialMessage, initialVariant, seedInitialVariant } from "../../webview-ui/agent-manager/initial-message" + +describe("Agent Manager initial message", () => { + it("forwards the selected variant to sendMessage", () => { + const msg = initialMessage({ + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + text: "Fix it", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + variant: "high", + }) + + expect(msg).toEqual({ + type: "sendMessage", + text: "Fix it", + sessionID: "session-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + variant: "high", + files: undefined, + }) + }) + + it("does not create an empty sendMessage payload", () => { + expect( + initialMessage({ + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + }), + ).toBeUndefined() + }) + + it("builds the initial session variant state", () => { + const state = initialVariant( + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + variant: "medium", + }, + "code", + ) + + expect(state).toEqual({ + sessionID: "session-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + value: "medium", + }) + }) + + it("does not build variant state without a complete model variant", () => { + expect( + initialVariant( + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + }, + "code", + ), + ).toBeUndefined() + }) + + it("seeds initial variant state into the session store", () => { + const calls: unknown[] = [] + + seedInitialVariant( + { + getSessionAgent: () => "code", + setSessionVariant: (...args) => calls.push(args), + }, + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + variant: "medium", + }, + ) + + expect(calls).toEqual([["session-a", "anthropic", "claude-sonnet-4", "medium", "code"]]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-hash.test.ts b/packages/kilo-vscode/tests/unit/diff-hash.test.ts new file mode 100644 index 0000000000..a05416fadb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-hash.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test" +import { hashFileDiffs } from "../../src/diff/shared/hash" +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" + +type Diff = SnapshotFileDiff & { + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string +} + +function diff(overrides: Partial = {}): Diff { + return { + file: "src/app.ts", + patch: "patch", + additions: 1, + deletions: 0, + status: "modified", + tracked: true, + generatedLike: false, + summarized: false, + stamp: "1:1", + ...overrides, + } as Diff +} + +describe("hashFileDiffs", () => { + it("returns empty string for an empty array", () => { + expect(hashFileDiffs([])).toBe("") + }) + + it("is stable for identical input", () => { + expect(hashFileDiffs([diff()])).toBe(hashFileDiffs([diff()])) + }) + + it("changes when any metadata field changes", () => { + const base = hashFileDiffs([diff()]) + expect(hashFileDiffs([diff({ file: "other.ts" })])).not.toBe(base) + expect(hashFileDiffs([diff({ status: "added" })])).not.toBe(base) + expect(hashFileDiffs([diff({ additions: 2 })])).not.toBe(base) + expect(hashFileDiffs([diff({ deletions: 5 })])).not.toBe(base) + expect(hashFileDiffs([diff({ tracked: false })])).not.toBe(base) + expect(hashFileDiffs([diff({ generatedLike: true })])).not.toBe(base) + expect(hashFileDiffs([diff({ stamp: "2:2" })])).not.toBe(base) + }) + + it("ignores patch content when summarized=true", () => { + // Regression guard: summary entries have unstable `patch` values from git + // (can even be ""). If this hashed the patch, the poller would spam the + // webview with "new" diffs every tick. + const a = hashFileDiffs([diff({ summarized: true, patch: "one" })]) + const b = hashFileDiffs([diff({ summarized: true, patch: "two" })]) + expect(a).toBe(b) + }) + + it("includes patch content when summarized=false", () => { + const a = hashFileDiffs([diff({ summarized: false, patch: "one" })]) + const b = hashFileDiffs([diff({ summarized: false, patch: "two" })]) + expect(a).not.toBe(b) + }) + + it("distinguishes summarized vs detail for the same metadata", () => { + // Flipping `summarized` must change the hash even when stamp/counts match — + // otherwise a summary-only re-fetch would silently keep stale detail. + const summary = hashFileDiffs([diff({ summarized: true })]) + const detail = hashFileDiffs([diff({ summarized: false })]) + expect(summary).not.toBe(detail) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts new file mode 100644 index 0000000000..b1c8cacb4e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "bun:test" +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import { SessionDiffSource, type SessionDiffFetch, type SnapshotEnabledCheck } from "../../src/diff/sources/session" +import type { DiffSourceMessage } from "../../src/diff/sources/types" + +type FetchCall = { sessionID: string; directory?: string } + +function recording(result: SnapshotFileDiff[] | Error): { fetch: SessionDiffFetch; calls: FetchCall[] } { + const calls: FetchCall[] = [] + const fetch: SessionDiffFetch = async (params) => { + calls.push(params) + if (result instanceof Error) throw result + return result + } + return { fetch, calls } +} + +function collect(): { post: (msg: DiffSourceMessage) => void; messages: DiffSourceMessage[] } { + const messages: DiffSourceMessage[] = [] + return { post: (msg) => messages.push(msg), messages } +} + +const modifiedPatch = [ + "diff --git a/foo.ts b/foo.ts", + "--- a/foo.ts", + "+++ b/foo.ts", + "@@ -1,2 +1,2 @@", + " keep", + "-old", + "+new", +].join("\n") + +describe("SessionDiffSource.initialFetch", () => { + it("posts loading/diffs/loading for an empty session", async () => { + const { fetch, calls } = recording([]) + const source = new SessionDiffSource("s1", fetch, "/repo") + const { post, messages } = collect() + + await source.initialFetch(post) + + expect(calls).toEqual([{ sessionID: "s1", directory: "/repo" }]) + expect(messages).toEqual([ + { type: "loading", loading: true }, + { type: "diffs", diffs: [] }, + { type: "loading", loading: false }, + ]) + }) + + it("converts patches into before/after diffs", async () => { + const raw: SnapshotFileDiff[] = [ + { + file: "foo.ts", + patch: modifiedPatch, + additions: 1, + deletions: 1, + status: "modified", + }, + { + file: "big.bin", + patch: "", + additions: 0, + deletions: 0, + status: "modified", + }, + ] + const { fetch } = recording(raw) + const source = new SessionDiffSource("s2", fetch, "/repo") + const { post, messages } = collect() + + await source.initialFetch(post) + + const diffsMsg = messages.find((m) => m.type === "diffs") + if (diffsMsg?.type !== "diffs") throw new Error("expected diffs message") + expect(diffsMsg.diffs).toHaveLength(2) + + const foo = diffsMsg.diffs[0]! + expect(foo.file).toBe("foo.ts") + expect(foo.before).toBe("keep\nold") + expect(foo.after).toBe("keep\nnew") + expect(foo.additions).toBe(1) + expect(foo.deletions).toBe(1) + expect(foo.status).toBe("modified") + expect(foo.tracked).toBe(true) + expect(foo.generatedLike).toBe(false) + expect(foo.summarized).toBe(false) + + const big = diffsMsg.diffs[1]! + expect(big.summarized).toBe(true) + expect(big.before).toBe("") + expect(big.after).toBe("") + }) + + it("reports an error when the fetch throws", async () => { + const { fetch } = recording(new Error("network down")) + const source = new SessionDiffSource("s3", fetch) + const { post, messages } = collect() + + await source.initialFetch(post) + + expect(messages).toEqual([ + { type: "loading", loading: true }, + { type: "error", message: "network down" }, + { type: "loading", loading: false }, + ]) + }) + + it("calls fetch without directory when workspaceRoot is not given", async () => { + const { fetch, calls } = recording([]) + const source = new SessionDiffSource("s4", fetch) + const { post } = collect() + + await source.initialFetch(post) + + expect(calls).toEqual([{ sessionID: "s4", directory: undefined }]) + }) +}) + +describe("SessionDiffSource lifecycle", () => { + it("dispose does not throw", () => { + const { fetch } = recording([]) + const source = new SessionDiffSource("s6", fetch) + source.dispose() + }) + + it("descriptor id encodes the session id", () => { + const { fetch } = recording([]) + const source = new SessionDiffSource("abc", fetch) + expect(source.descriptor.id).toBe("session:abc") + expect(source.descriptor.group).toBe("Session") + expect(source.descriptor.capabilities).toEqual({ revert: false, comments: true }) + }) + + it("posts the snapshots-disabled notice and skips fetch when the check returns false", async () => { + const { fetch, calls } = recording([ + { file: "foo.ts", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" }, + ]) + const checkSnapshotsEnabled: SnapshotEnabledCheck = async () => false + const source = new SessionDiffSource("s-disabled", fetch, "/repo", checkSnapshotsEnabled) + const { post, messages } = collect() + + await source.initialFetch(post) + + expect(calls).toEqual([]) + expect(messages).toEqual([ + { type: "loading", loading: true }, + { type: "notice", notice: "snapshots-disabled" }, + { type: "diffs", diffs: [] }, + { type: "loading", loading: false }, + ]) + }) + + it("fetches normally when snapshots are enabled", async () => { + const { fetch, calls } = recording([]) + const checkSnapshotsEnabled: SnapshotEnabledCheck = async () => true + const source = new SessionDiffSource("s-enabled", fetch, "/repo", checkSnapshotsEnabled) + const { post, messages } = collect() + + await source.initialFetch(post) + + expect(calls).toEqual([{ sessionID: "s-enabled", directory: "/repo" }]) + expect(messages.some((m) => m.type === "notice")).toBe(false) + expect(messages.filter((m) => m.type === "diffs")).toHaveLength(1) + }) + + it("start() is a no-op when snapshots are disabled", async () => { + const { fetch } = recording([]) + const checkSnapshotsEnabled: SnapshotEnabledCheck = async () => false + const source = new SessionDiffSource("s-disabled-2", fetch, "/repo", checkSnapshotsEnabled) + const { post } = collect() + + await source.initialFetch(post) + const disposable = source.start(post) + expect(typeof disposable.dispose).toBe("function") + // Disposing must not throw even though no interval was scheduled. + disposable.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts b/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts new file mode 100644 index 0000000000..cd37bb906f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "bun:test" +import type { KiloConnectionService } from "../../src/services/cli-backend" +import { DiffSourceCatalog } from "../../src/diff/sources/catalog" +import { SessionDiffSource, sessionDescriptor } from "../../src/diff/sources/session" +import { WORKSPACE_DESCRIPTOR, WorktreeDiffSource } from "../../src/diff/sources/worktree" + +// Minimal stand-in for the connection service — the catalog only holds a +// reference and passes it to the source constructors, so we never exercise +// any of its methods in these tests. +const connection = {} as unknown as KiloConnectionService + +function makeCatalog(): DiffSourceCatalog { + return new DiffSourceCatalog(connection) +} + +describe("DiffSourceCatalog.listAvailable", () => { + it("returns workspace + session when both are available", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: "/repo", sessionId: "s1" }) + expect(out.map((d) => d.id)).toEqual(["workspace", "session:s1"]) + }) + + it("returns only workspace when sessionId is missing", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: "/repo" }) + expect(out.map((d) => d.id)).toEqual(["workspace"]) + }) + + it("returns only session when workspaceRoot is missing", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: undefined, sessionId: "s1" }) + expect(out.map((d) => d.id)).toEqual(["session:s1"]) + }) + + it("returns [] when the context is empty", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: undefined }) + expect(out).toEqual([]) + }) +}) + +describe("DiffSourceCatalog.defaultSourceId", () => { + it("prefers explicit initialSourceId", () => { + const id = makeCatalog().defaultSourceId({ + workspaceRoot: "/repo", + sessionId: "s1", + initialSourceId: "workspace", + }) + expect(id).toBe("workspace") + }) + + it("prefers workspace over session when both are present", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: "/repo", sessionId: "s1" }) + expect(id).toBe("workspace") + }) + + it("falls back to workspace when only workspaceRoot is present", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: "/repo" }) + expect(id).toBe("workspace") + }) + + it("falls back to session when only sessionId is present", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: undefined, sessionId: "s1" }) + expect(id).toBe("session:s1") + }) + + it("returns undefined when nothing can be inferred", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: undefined }) + expect(id).toBeUndefined() + }) +}) + +describe("DiffSourceCatalog.build", () => { + it("builds a WorktreeDiffSource for 'workspace'", () => { + const src = makeCatalog().build("workspace", { workspaceRoot: "/repo" }) + expect(src).toBeInstanceOf(WorktreeDiffSource) + expect(src.descriptor.id).toBe("workspace") + src.dispose() + }) + + it("builds a SessionDiffSource for 'session:'", () => { + const src = makeCatalog().build("session:s1", { workspaceRoot: "/repo", sessionId: "s1" }) + expect(src).toBeInstanceOf(SessionDiffSource) + expect(src.descriptor.id).toBe("session:s1") + src.dispose() + }) + + it("throws on an empty session id", () => { + expect(() => makeCatalog().build("session:", { workspaceRoot: "/repo" })).toThrow(/empty session id/) + }) + + it("throws on an unknown source id", () => { + expect(() => makeCatalog().build("bogus", { workspaceRoot: "/repo" })).toThrow(/unknown source id/) + }) +}) + +// The webview composes i18n keys from `type`. Keep the type values stable +// so a rename here doesn't silently break existing translation dicts. +describe("descriptor types", () => { + it("workspace descriptor has type 'workspace'", () => { + expect(WORKSPACE_DESCRIPTOR.type).toBe("workspace") + }) + + it("session descriptor has type 'session'", () => { + expect(sessionDescriptor("s1").type).toBe("session") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts index 1a0172d0ab..5a96ef7eb2 100644 --- a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts @@ -25,7 +25,7 @@ const TARGETS = [ const WATCHED_PROVIDERS = [ path.join(ROOT, "src/KiloProvider.ts"), - path.join(ROOT, "src/DiffViewerProvider.ts"), + path.join(ROOT, "src/diff/DiffViewerProvider.ts"), path.join(ROOT, "src/DiffVirtualProvider.ts"), path.join(ROOT, "src/kiloclaw/KiloClawProvider.ts"), ] diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 8e65718216..3d6c689270 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -4,7 +4,7 @@ import * as os from "os" import * as path from "path" import { diffSummary, diffFile, generatedLike, resolveBase, MAX_DETAIL_BYTES } from "../../src/agent-manager/local-diff" import { GitOps } from "../../src/agent-manager/GitOps" -import { resolveLocalDiffTarget } from "../../src/review-utils" +import { resolveLocalDiffTarget } from "../../src/diff/shared/target" function git(): GitOps { return new GitOps({ log: () => undefined }) diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index 2e04caa3ad..dc1d97f27c 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -3,12 +3,13 @@ import { disconnectProvider, fetchProviderData, saveCustomProvider } from "../.. type ExistingGlobal = { disabled_providers?: string[]; provider?: Record } -function createCtx(existing: ExistingGlobal = { disabled_providers: [] }) { +function createCtx(existing: ExistingGlobal = { disabled_providers: [] }, merged: ExistingGlobal = existing) { const calls = { set: [] as Array<{ providerID: string; auth: { type: string; key: string } }>, remove: [] as Array<{ providerID: string }>, posts: [] as unknown[], config: [] as Array<{ config: Record }>, + project: [] as Array<{ config: Record }>, cached: [] as unknown[], refresh: 0, dispose: 0, @@ -53,6 +54,13 @@ function createCtx(existing: ExistingGlobal = { disabled_providers: [] }) { }, }, }, + config: { + get: async () => ({ data: merged }), + update: async (input: { config: Record }) => { + calls.project.push(input) + return { data: input } + }, + }, }, postMessage: (message: unknown) => calls.posts.push(message), getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), @@ -82,6 +90,13 @@ function createProvider() { } } +function createSavedProvider() { + return { + npm: "@ai-sdk/openai-compatible", + ...createProvider(), + } +} + describe("disconnectProvider", () => { it("keeps configured provider enabled after disconnecting oauth override", async () => { const existing = { @@ -273,6 +288,76 @@ describe("disconnectProvider", () => { expect(calls.config).toHaveLength(0) expect(calls.refresh).toBe(1) }) + + it("deletes saved custom provider config when disconnecting", async () => { + const existing = { + disabled_providers: ["myprovider", "openai"], + provider: { + myprovider: createSavedProvider(), + }, + } + const { ctx, calls, setCachedConfig } = createCtx(existing) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toHaveLength(1) + expect(calls.config[0].config).toEqual({ + provider: { myprovider: null }, + disabled_providers: ["openai"], + }) + expect(calls.project).toEqual([{ config: { provider: { myprovider: null } }, directory: "/tmp" }]) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + expect(calls.refresh).toBe(1) + }) + + it("deletes project custom provider config when it is not in global config", async () => { + const merged = { + provider: { + myprovider: createSavedProvider(), + }, + } + const { ctx, calls, setCachedConfig } = createCtx({ disabled_providers: [] }, merged) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toHaveLength(0) + expect(calls.project).toEqual([{ config: { provider: { myprovider: null } }, directory: "/tmp" }]) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + expect(calls.refresh).toBe(1) + }) + + it("deletes both global and project custom provider config when project overrides global", async () => { + const global = { + disabled_providers: ["myprovider", "openai"], + provider: { + myprovider: createSavedProvider(), + }, + } + const merged = { + ...global, + provider: { + myprovider: { + ...createSavedProvider(), + name: "Project Provider", + }, + }, + } + const { ctx, calls, setCachedConfig } = createCtx(global, merged) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toEqual([ + { + config: { + provider: { myprovider: null }, + disabled_providers: ["openai"], + }, + }, + ]) + expect(calls.project).toEqual([{ config: { provider: { myprovider: null } }, directory: "/tmp" }]) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + expect(calls.refresh).toBe(1) + }) }) describe("fetchProviderData", () => { diff --git a/packages/kilo-vscode/tests/unit/server-manager-proxy-env.test.ts b/packages/kilo-vscode/tests/unit/server-manager-proxy-env.test.ts new file mode 100644 index 0000000000..6aac3ae2bc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/server-manager-proxy-env.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, afterEach } from "bun:test" +import * as vscode from "vscode" +import { buildProxyEnv } from "../../src/services/cli-backend/server-manager" + +type Info = { globalValue?: unknown; workspaceValue?: unknown; workspaceFolderValue?: unknown } +type WorkspaceStub = { + getConfiguration: (section?: string) => { get: (key: string) => unknown; inspect: (key: string) => Info } +} + +const workspace = vscode.workspace as unknown as WorkspaceStub +const originalGetConfiguration = workspace.getConfiguration + +function stubHttpConfig(values: { proxy?: unknown; noProxy?: unknown; proxySupport?: unknown }): void { + workspace.getConfiguration = (section?: string) => { + if (section === "http") { + return { + get: (key: string) => { + if (key === "proxy") return values.proxy + if (key === "noProxy") return values.noProxy + if (key === "proxySupport") return values.proxySupport + return undefined + }, + inspect: (key: string) => { + if (key === "proxy" && values.proxy !== undefined) return { workspaceValue: values.proxy } + if (key === "noProxy" && values.noProxy !== undefined) return { workspaceValue: values.noProxy } + if (key === "proxySupport" && values.proxySupport !== undefined) + return { workspaceValue: values.proxySupport } + return {} + }, + } + } + return { get: () => undefined, inspect: () => ({}) } + } +} + +afterEach(() => { + workspace.getConfiguration = originalGetConfiguration +}) + +describe("buildProxyEnv", () => { + it("returns an empty object when neither proxy nor noProxy is configured", () => { + stubHttpConfig({ proxy: undefined, noProxy: undefined }) + + expect(buildProxyEnv()).toEqual({}) + }) + + it("forwards http.proxy as HTTP_PROXY and HTTPS_PROXY", () => { + stubHttpConfig({ proxy: "http://proxy.corp.example:8080" }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "http://proxy.corp.example:8080", + HTTPS_PROXY: "http://proxy.corp.example:8080", + http_proxy: "http://proxy.corp.example:8080", + https_proxy: "http://proxy.corp.example:8080", + }) + }) + + it("joins http.noProxy into a comma-separated NO_PROXY value", () => { + stubHttpConfig({ noProxy: ["localhost", "127.0.0.1", "*.internal"] }) + + expect(buildProxyEnv()).toEqual({ + NO_PROXY: "localhost,127.0.0.1,*.internal", + no_proxy: "localhost,127.0.0.1,*.internal", + }) + }) + + it("forwards both proxy and noProxy when both are configured", () => { + stubHttpConfig({ + proxy: "http://proxy.corp.example:8080", + noProxy: ["localhost", "*.internal"], + }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "http://proxy.corp.example:8080", + HTTPS_PROXY: "http://proxy.corp.example:8080", + NO_PROXY: "localhost,*.internal", + http_proxy: "http://proxy.corp.example:8080", + https_proxy: "http://proxy.corp.example:8080", + no_proxy: "localhost,*.internal", + }) + }) + + it("clears env vars when http.proxy is only whitespace", () => { + stubHttpConfig({ proxy: " " }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "", + HTTPS_PROXY: "", + http_proxy: "", + https_proxy: "", + }) + }) + + it("clears env var when http.noProxy is an empty array", () => { + stubHttpConfig({ noProxy: [] }) + + expect(buildProxyEnv()).toEqual({ + NO_PROXY: "", + no_proxy: "", + }) + }) + + it("ignores a non-array http.noProxy value", () => { + stubHttpConfig({ noProxy: "localhost" }) + + expect(buildProxyEnv()).toEqual({}) + }) + + it("explicitly clears env vars when http.proxySupport is off", () => { + stubHttpConfig({ proxySupport: "off" }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "", + HTTPS_PROXY: "", + NO_PROXY: "", + http_proxy: "", + https_proxy: "", + no_proxy: "", + }) + }) + + it("http.proxySupport=off wins over a configured http.proxy/http.noProxy", () => { + stubHttpConfig({ + proxy: "http://proxy.corp.example:8080", + noProxy: ["localhost"], + proxySupport: "off", + }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "", + HTTPS_PROXY: "", + NO_PROXY: "", + http_proxy: "", + https_proxy: "", + no_proxy: "", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index 22fd2b4871..6640971906 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -47,7 +47,7 @@ const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonne const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } describe("per-session model selection", () => { - it("selecting a model in session A writes per-mode globally", () => { + it("selecting a model in session A does not write per-mode globally", () => { const store = emptyStore() const e = env() @@ -58,10 +58,9 @@ describe("per-session model selection", () => { // Session A should see claude (via session override) expect(getSessionModel(updated, e, "session-a", "code")).toEqual(claude) - // Session B (no override) inherits the per-mode global selection. - // This matches CLI behavior: per-mode model is global, not per-session. + // Session B (no override) keeps the default model. const sessionB = getSessionModel(updated, e, "session-b", "code") - expect(sessionB).toEqual(claude) + expect(sessionB).toEqual(KILO_AUTO) }) it("each session preserves its own model independently", () => { @@ -150,15 +149,15 @@ describe("per-session model selection", () => { }) describe("per-mode model memory", () => { - it("applyModel in a session writes to both sessionOverrides and modelSelections", () => { + it("applyModel in a session writes only to sessionOverrides", () => { const store = emptyStore() const result = applyModel(store, "code", claude, "session-a") expect(result.sessionOverrides["session-a"]).toEqual(claude) - expect(result.modelSelections["code"]).toEqual(claude) + expect(result.modelSelections["code"]).toBeUndefined() }) - it("switching modes restores per-mode model after session override is cleared", () => { + it("switching modes falls back to default after session override is cleared", () => { let store = emptyStore() const e = env() @@ -169,20 +168,19 @@ describe("per-mode model memory", () => { // Simulate mode switch: clear session override (like selectAgent does) const cleared = { ...store, sessionOverrides: {} } - // The global modelSelections["code"] still has claude - expect(getSelected(cleared, e, "session-a", "code")).toEqual(claude) + expect(getSelected(cleared, e, "session-a", "code")).toEqual(KILO_AUTO) }) it("different modes remember their own model independently", () => { let store = emptyStore() const e = env() - // User picks claude for "code" in session A - let result = applyModel(store, "code", claude, "session-a") + // User picks claude for "code" globally + let result = applyModel(store, "code", claude, undefined) store = { ...store, ...result } - // User switches to "ask" mode and picks gpt - result = applyModel(store, "ask", gpt, "session-a") + // User switches to "ask" mode and picks gpt globally + result = applyModel(store, "ask", gpt, undefined) store = { ...store, ...result } // Clear session overrides (simulating mode switch) @@ -207,8 +205,8 @@ describe("per-mode model memory", () => { // Session A sees gpt (its override), not the global claude expect(getSelected(store, e, "session-a", "code")).toEqual(gpt) - // Global modelSelections was updated to gpt (last write wins) - expect(store.modelSelections["code"]).toEqual(gpt) + // Global modelSelections stays at the sidebar/default choice. + expect(store.modelSelections["code"]).toEqual(claude) }) it("applyModel without session only writes to modelSelections, not sessionOverrides", () => { diff --git a/packages/kilo-vscode/tests/unit/session-preferences.test.ts b/packages/kilo-vscode/tests/unit/session-preferences.test.ts new file mode 100644 index 0000000000..9da1614838 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-preferences.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { resolveMessagePrefs } from "../../webview-ui/src/context/session-preferences" +import type { Message } from "../../webview-ui/src/types/messages" + +function msg(input: Partial): Message { + return { + id: input.id ?? "msg", + sessionID: input.sessionID ?? "session-a", + role: input.role ?? "user", + createdAt: input.createdAt ?? "2026-01-01T00:00:00.000Z", + ...input, + } +} + +const agents = new Set(["code", "ask"]) + +describe("session preference recovery", () => { + it("recovers model, variant, and agent from the latest user message", () => { + const prefs = resolveMessagePrefs( + [ + msg({ + id: "old", + agent: "ask", + model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "low" }, + }), + msg({ + id: "new", + agent: "code", + model: { providerID: "openai", modelID: "gpt-5.5", variant: "medium" }, + }), + ], + agents, + ) + + expect(prefs).toEqual({ + agent: "code", + model: { providerID: "openai", modelID: "gpt-5.5" }, + variant: "medium", + }) + }) + + it("ignores assistant-only model data and invalid agents", () => { + const prefs = resolveMessagePrefs( + [ + msg({ + role: "assistant", + agent: "task", + model: { providerID: "openai", modelID: "gpt-5.5", variant: "high" }, + }), + ], + agents, + ) + + expect(prefs).toEqual({}) + }) + + it("can recover the latest valid agent separately from the latest user model", () => { + const prefs = resolveMessagePrefs( + [ + msg({ agent: "ask", model: { providerID: "anthropic", modelID: "claude-sonnet-4" } }), + msg({ role: "assistant", agent: "code" }), + ], + agents, + ) + + expect(prefs).toEqual({ + agent: "code", + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: undefined, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts new file mode 100644 index 0000000000..11901449db --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test" +import { + getVariant, + sessionVariantKeys, + sessionVariants, + transferVariants, + variantKey, +} from "../../webview-ui/src/context/session-variant-store" +import type { ModelSelection } from "../../webview-ui/src/types/messages" + +const model: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } +const variants = ["low", "medium", "high"] + +describe("per-session variant selection", () => { + it("keeps reasoning effort independent for each Agent Manager session", () => { + const store: Record = {} + + store[variantKey(model, "code", "session-a")] = "low" + store[variantKey(model, "code", "session-b")] = "high" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("low") + expect(getVariant(store, model, variants, "code", "session-b")).toBe("high") + }) + + it("keeps reasoning effort independent for each pending local tab", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + store[variantKey(model, "code", "pending-local-2")] = "high" + + expect(getVariant(store, model, variants, "code", "pending-local-1")).toBe("medium") + expect(getVariant(store, model, variants, "code", "pending-local-2")).toBe("high") + }) + + it("keeps no-session reasoning effort independent per agent", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + store[variantKey(model, "ask")] = "high" + + expect(getVariant(store, model, variants, "code")).toBe("medium") + expect(getVariant(store, model, variants, "ask")).toBe("high") + }) + + it("carries the pre-submit agent variant into a newly created session", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("prefers a session variant over the pre-submit agent variant", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + store[variantKey(model, "code", "session-a")] = "high" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("high") + }) + + it("falls back to the legacy provider/model variant key", () => { + const store: Record = { "anthropic/claude-sonnet-4": "medium" } + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("transfers a pending local tab variant to the created session", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + Object.assign(store, transferVariants(store, "pending-local-1", "session-a")) + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("extracts persisted session variant preferences", () => { + const store: Record = {} + + store[variantKey(model, "code", "session-a")] = "medium" + store[variantKey(model, "code", "session-b")] = "high" + + expect(sessionVariants(store, "session-a")).toEqual({ "anthropic/claude-sonnet-4": "medium" }) + }) + + it("finds only variant keys for the requested session", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + store[variantKey(model, "code", "pending-local-2")] = "high" + + expect(sessionVariantKeys(store, "pending-local-1")).toEqual(["session/pending-local-1/anthropic/claude-sonnet-4"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts new file mode 100644 index 0000000000..509a7e5c19 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from "bun:test" +import * as vscode from "vscode" +import { SourceController } from "../../src/diff/SourceController" +import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } from "../../src/diff/sources/types" + +const WORKSPACE_DESC: DiffSourceDescriptor = { + id: "workspace", + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, +} + +const SESSION_DESC: DiffSourceDescriptor = { + id: "session:s1", + type: "session", + group: "Session", + capabilities: { revert: false, comments: true }, +} + +function disposable(onDispose: () => void = () => {}): vscode.Disposable { + return new vscode.Disposable(onDispose) +} + +function make(sources: Record, descriptors?: DiffSourceDescriptor[]) { + const posted: unknown[] = [] + const controller = new SourceController( + (id) => { + const src = sources[id] + if (!src) throw new Error(`no source: ${id}`) + return src + }, + () => descriptors ?? Object.values(sources).map((s) => s.descriptor), + (m) => posted.push(m), + ) + return { controller, posted } +} + +const byType = (posted: unknown[], type: string) => + posted.filter((m): m is Record => { + return typeof m === "object" && m !== null && (m as { type: string }).type === type + }) + +describe("SourceController.activate", () => { + it("builds, fetches, and starts the source", async () => { + let starts = 0 + const source: DiffSource = { + descriptor: SESSION_DESC, + async initialFetch(post) { + post({ type: "diffs", diffs: [] }) + }, + start() { + starts++ + return disposable() + }, + dispose() {}, + } + const { controller, posted } = make({ "session:s1": source }, [WORKSPACE_DESC, SESSION_DESC]) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + + expect(starts).toBe(1) + expect(controller.currentId).toBe("session:s1") + + const available = byType(posted, "setAvailableSources") + expect(available).toHaveLength(1) + expect(available[0]!.currentId).toBe("session:s1") + expect(available[0]!.descriptors).toEqual([WORKSPACE_DESC, SESSION_DESC]) + + const caps = byType(posted, "diffViewer.capabilities") + expect(caps).toHaveLength(1) + expect(caps[0]!.capabilities).toEqual({ revert: false, comments: true }) + }) + + it("disposes the previous source when activating a new one", async () => { + let workspaceDisposed = 0 + let workspaceSubscriptionDisposed = false + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async initialFetch() {}, + start() { + return disposable(() => { + workspaceSubscriptionDisposed = true + }) + }, + dispose() { + workspaceDisposed++ + }, + } + let sessionStarts = 0 + const session: DiffSource = { + descriptor: SESSION_DESC, + async initialFetch() {}, + start() { + sessionStarts++ + return disposable() + }, + dispose() {}, + } + const { controller } = make({ workspace, "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("workspace") + await controller.activate("session:s1") + + expect(workspaceDisposed).toBe(1) + expect(workspaceSubscriptionDisposed).toBe(true) + expect(sessionStarts).toBe(1) + expect(controller.currentId).toBe("session:s1") + }) + + it("does not start polling if the controller is stopped during initialFetch", async () => { + let release: () => void = () => {} + let fetched = 0 + let started = 0 + let disposed = 0 + const session: DiffSource = { + descriptor: SESSION_DESC, + async initialFetch() { + fetched++ + await new Promise((r) => (release = r)) + }, + start() { + started++ + return disposable() + }, + dispose() { + disposed++ + }, + } + const { controller } = make({ "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + const activation = controller.activate("session:s1") + controller.stop() + release() + await activation + + expect(fetched).toBe(1) + expect(started).toBe(0) + expect(disposed).toBe(1) + }) + + it("drops stale posts from a source that was swapped out", async () => { + let capturedPost: DiffSourcePost | undefined + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async initialFetch(post) { + capturedPost = post + }, + start() { + return disposable() + }, + dispose() {}, + } + const session: DiffSource = { + descriptor: SESSION_DESC, + async initialFetch(post) { + post({ type: "diffs", diffs: [] }) + }, + start() { + return disposable() + }, + dispose() {}, + } + const { controller, posted } = make({ workspace, "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("workspace") + await controller.activate("session:s1") + + posted.length = 0 + capturedPost?.({ type: "diffs", diffs: [{ file: "stale.ts" } as never] }) + + expect(byType(posted, "diffViewer.diffs")).toEqual([]) + }) +}) + +describe("SourceController.stop", () => { + it("disposes the active source and its start subscription", async () => { + let disposed = 0 + let subscriptionDisposed = false + const session: DiffSource = { + descriptor: SESSION_DESC, + async initialFetch() {}, + start() { + return disposable(() => { + subscriptionDisposed = true + }) + }, + dispose() { + disposed++ + }, + } + const { controller } = make({ "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + + controller.stop() + + expect(disposed).toBe(1) + expect(subscriptionDisposed).toBe(true) + expect(controller.currentId).toBeUndefined() + }) +}) + +describe("SourceController.revertFile", () => { + it("posts error when the active source does not support revert", async () => { + const session: DiffSource = { + descriptor: SESSION_DESC, + async initialFetch() {}, + start() { + return disposable() + }, + dispose() {}, + } + const { controller, posted } = make({ "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + posted.length = 0 + await controller.revertFile("foo.ts") + + const results = byType(posted, "diffViewer.revertFileResult") + expect(results).toHaveLength(1) + expect(results[0]!.status).toBe("error") + expect(results[0]!.file).toBe("foo.ts") + }) + + it("posts success from a successful revert", async () => { + const calls: string[] = [] + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async initialFetch() {}, + start() { + return disposable() + }, + async revertFile(file) { + calls.push(file) + return { ok: true, message: "Reverted" } + }, + dispose() {}, + } + const { controller, posted } = make({ workspace }) + + controller.setContext({ workspaceRoot: "/repo" }) + await controller.activate("workspace") + posted.length = 0 + await controller.revertFile("foo.ts") + + expect(calls).toEqual(["foo.ts"]) + const results = byType(posted, "diffViewer.revertFileResult") + expect(results[0]!.status).toBe("success") + expect(results[0]!.message).toBe("Reverted") + }) + + it("posts error when the revert implementation throws", async () => { + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async initialFetch() {}, + start() { + return disposable() + }, + async revertFile() { + throw new Error("boom") + }, + dispose() {}, + } + const { controller, posted } = make({ workspace }) + + controller.setContext({ workspaceRoot: "/repo" }) + await controller.activate("workspace") + posted.length = 0 + await controller.revertFile("foo.ts") + + const results = byType(posted, "diffViewer.revertFileResult") + expect(results[0]!.status).toBe("error") + expect(results[0]!.message).toBe("boom") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index a1c104582f..71c84b1a78 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -77,6 +77,27 @@ describe("WorktreeStateManager", () => { expect(s.worktreeId).toBeNull() }) + it("drops obsolete session prefs while loading state", async () => { + const file = path.join(root, ".kilo", "agent-manager.json") + fs.writeFileSync( + file, + JSON.stringify({ + worktrees: {}, + sessions: { + "local-1": { + worktreeId: null, + createdAt: new Date().toISOString(), + prefs: { agent: "code" }, + }, + }, + }), + ) + + await manager.load() + + expect(manager.getSession("local-1")).not.toHaveProperty("prefs") + }) + it("filters sessions by worktreeId", () => { const wt1 = manager.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) const wt2 = manager.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index df117cb477..974ea6962e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -114,6 +114,7 @@ import { import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" import { mergeWorktreeDiffs } from "./diff-state" +import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import "./agent-manager.css" import "./agent-manager-review.css" @@ -1407,18 +1408,12 @@ const AgentManagerContent: Component = () => { if (ev.providerID && ev.modelID) { session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) } + seedInitialVariant(session, ev) // Only send a message if there's text — otherwise just clear busy state - if (ev.text) { - vscode.postMessage({ - type: "sendMessage", - text: ev.text, - sessionID: ev.sessionId, - providerID: ev.providerID, - modelID: ev.modelID, - agent: ev.agent, - files: ev.files, - }) + const init = initialMessage(ev) + if (init) { + vscode.postMessage(init) } // Clear busy state — use worktreeId from the message directly // to avoid race condition where managedSessions() hasn't updated yet diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index c1d433b891..f722b7aa68 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -52,6 +52,10 @@ interface FullScreenDiffViewProps { onOpenFile?: (relativePath: string, line?: number) => void onRevertFile?: (file: string) => void revertingFiles?: Set + /** Defaults to true. Hides the per-file Revert action when false. */ + canRevert?: boolean + /** Defaults to true. Disables comment creation and "Send all" when false. */ + canComment?: boolean onClose: () => void } @@ -288,6 +292,7 @@ export const FullScreenDiffView: Component = (props) => } const handleGutterClick = (file: string, range: SelectedLineRange) => { + if (props.canComment === false) return if (draft()) return const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" preserveScroll(() => { @@ -310,6 +315,7 @@ export const FullScreenDiffView: Component = (props) => if (!(e.metaKey || e.ctrlKey)) return const target = e.target if (keepNativeFocus(target)) return + if (props.canComment === false) return if (comments().length === 0) return e.preventDefault() e.stopPropagation() @@ -445,7 +451,7 @@ export const FullScreenDiffView: Component = (props) => {open().length === props.diffs.length ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")} - 0}> + 0 && props.canComment !== false}> = (props) => activeFile={activeFile()} onFileSelect={handleFileSelect} comments={comments()} - onRevertFile={props.onRevertFile} + onRevertFile={props.canRevert !== false ? props.onRevertFile : undefined} revertingFiles={props.revertingFiles} /> @@ -567,7 +573,7 @@ export const FullScreenDiffView: Component = (props) => /> - + = (props) => diffStyle={props.diffStyle} annotations={annotationsForFile(diff.file)} renderAnnotation={buildAnnotation} - enableGutterUtility={true} + enableGutterUtility={props.canComment !== false} onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} onLineNumberClick={(event) => { if (event.annotationSide === "deletions") return diff --git a/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts new file mode 100644 index 0000000000..15049eec69 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts @@ -0,0 +1,37 @@ +import type { AgentManagerSendInitialMessage, SendMessageRequest } from "../src/types/messages" + +interface VariantSession { + getSessionAgent: (sessionID: string) => string + setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void +} + +export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageRequest | undefined { + if (!ev.text) return undefined + return { + type: "sendMessage", + text: ev.text, + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent, + variant: ev.variant, + files: ev.files, + } +} + +export function initialVariant(ev: AgentManagerSendInitialMessage, agent: string) { + if (!ev.providerID || !ev.modelID || !ev.variant) return undefined + return { + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent ?? agent, + value: ev.variant, + } +} + +export function seedInitialVariant(session: VariantSession, ev: AgentManagerSendInitialMessage) { + const state = initialVariant(ev, session.getSessionAgent(ev.sessionId)) + if (!state) return + session.setSessionVariant(state.sessionID, state.providerID, state.modelID, state.value, state.agent) +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx new file mode 100644 index 0000000000..82d438f7b1 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx @@ -0,0 +1,81 @@ +import type { Component } from "solid-js" +import { createSignal, Show } from "solid-js" +import { Select } from "@kilocode/kilo-ui/select" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { useLanguage } from "../src/context/language" +import type { DiffSourceDescriptor } from "../../src/diff/sources/types" + +interface DiffPickerHeaderProps { + descriptors: DiffSourceDescriptor[] + currentId: string | undefined + onSelect: (id: string) => void +} + +const GROUP_KEYS: Record = { + Session: "diffViewer.group.session", + Git: "diffViewer.group.git", +} + +const TOOLTIP_OPEN_DELAY_MS = 500 + +export const DiffPickerHeader: Component = (props) => { + const { t } = useLanguage() + const current = () => props.descriptors.find((d) => d.id === props.currentId) + const [highlight, setHighlight] = createSignal(undefined) + + const label = (desc: DiffSourceDescriptor): string => t(`diffViewer.source.${desc.type}.label`) + + const tooltip = (desc: DiffSourceDescriptor): string => t(`diffViewer.source.${desc.type}.tooltip`) + + const group = (desc: DiffSourceDescriptor): string => t(GROUP_KEYS[desc.group]) + + const onHighlight = (desc: DiffSourceDescriptor | undefined) => { + if (!desc) { + setHighlight(undefined) + return + } + const timer = setTimeout(() => setHighlight(desc.id), TOOLTIP_OPEN_DELAY_MS) + return () => { + clearTimeout(timer) + setHighlight(undefined) + } + } + + return ( +
+ 1} + fallback={ + + + {(d) => label(d())} + + + } + > + + options={props.descriptors} + current={current()} + value={(d) => d.id} + label={label} + groupBy={group} + variant="secondary" + size="small" + onSelect={(d) => { + if (d) props.onSelect(d.id) + }} + onHighlight={onHighlight} + > + {(desc) => { + if (!desc) return "" + return ( + + {label(desc)} + + ) + }} + + +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index 87093996ec..dedc564dbb 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -1,4 +1,4 @@ -import { createSignal, onCleanup } from "solid-js" +import { createEffect, createSignal, on, onCleanup, Show } from "solid-js" import type { Component } from "solid-js" import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" @@ -8,13 +8,22 @@ import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" import { Code } from "@kilocode/kilo-ui/code" import { Diff } from "@kilocode/kilo-ui/diff" import { File } from "@kilocode/kilo-ui/file" +import { Icon } from "@kilocode/kilo-ui/icon" import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { Toast } from "@kilocode/kilo-ui/toast" import { FullScreenDiffView } from "../agent-manager/FullScreenDiffView" -import { LanguageProvider } from "../src/context/language" +import { mergeWorktreeDiffs } from "../agent-manager/diff-state" +import { LanguageProvider, useLanguage } from "../src/context/language" import { ServerProvider, useServer } from "../src/context/server" import { getVSCodeAPI, VSCodeProvider, useVSCode } from "../src/context/vscode" import type { ReviewComment, WebviewMessage, WorktreeFileDiff } from "../src/types/messages" +import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/diff/sources/types" +import type { DiffViewerNotice } from "../src/types/messages/extension-messages" +import { DiffPickerHeader } from "./DiffPickerHeader" + +const NOTICE_KEYS: Record = { + "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", +} type DiffStyle = "unified" | "split" @@ -22,12 +31,24 @@ const post = (message: WebviewMessage) => getVSCodeAPI().postMessage(message) const DiffViewerContent: Component = () => { const vscode = useVSCode() + const { t } = useLanguage() const [diffs, setDiffs] = createSignal([]) const [loading, setLoading] = createSignal(true) const [comments, setComments] = createSignal([]) const [diffStyle, setDiffStyle] = createSignal("unified") const [markdown, setMarkdown] = createSignal(false) const [reverting, setReverting] = createSignal>(new Set()) + const [loadingFiles, setLoadingFiles] = createSignal>(new Set()) + const [availableSources, setAvailableSources] = createSignal([]) + const [currentSourceId, setCurrentSourceId] = createSignal(undefined) + const [capabilities, setCapabilities] = createSignal(undefined) + const [notice, setNotice] = createSignal(undefined) + + const noticeText = () => { + const n = notice() + if (!n) return "" + return t(NOTICE_KEYS[n]) + } const markReverting = (file: string, active: boolean) => { setReverting((prev) => { @@ -38,9 +59,47 @@ const DiffViewerContent: Component = () => { }) } + const markLoadingFile = (file: string, active: boolean) => { + setLoadingFiles((prev) => { + if (active && prev.has(file)) return prev + if (!active && !prev.has(file)) return prev + const next = new Set(prev) + if (active) next.add(file) + else next.delete(file) + return next + }) + } + + const requestDiffFile = (file: string) => { + if (loadingFiles().has(file)) return + markLoadingFile(file, true) + post({ type: "diffViewer.requestFile", file }) + } + + const refreshStaleDiffs = (files: Set) => { + for (const file of files) { + if (loadingFiles().has(file)) continue + markLoadingFile(file, true) + post({ type: "diffViewer.requestFile", file }) + } + } + const unsubscribe = vscode.onMessage((msg) => { if (msg.type === "diffViewer.diffs") { - setDiffs(msg.diffs) + // Preserve cached `before`/`after` across polls so summarized polling + // updates don't clobber loaded detail. Mirrors the agent manager's + // worktree diff merge — see worktree-diff-controller.ts. + const merged = mergeWorktreeDiffs(diffs(), msg.diffs) + setDiffs(merged.diffs) + if (merged.stale.size > 0) refreshStaleDiffs(merged.stale) + return + } + + if (msg.type === "diffViewer.diffFile") { + markLoadingFile(msg.file, false) + const fresh = msg.diff + if (!fresh) return + setDiffs((prev) => prev.map((entry) => (entry.file === fresh.file ? fresh : entry))) return } @@ -58,8 +117,43 @@ const DiffViewerContent: Component = () => { setMarkdown(msg.render) return } + if (msg.type === "setAvailableSources") { + setAvailableSources(msg.descriptors) + setCurrentSourceId(msg.currentId) + return + } + + if (msg.type === "diffViewer.capabilities") { + setCapabilities(msg.capabilities) + return + } + + if (msg.type === "diffViewer.notice") { + setNotice(msg.notice) + return + } }) + const selectSource = (id: string) => { + if (id === currentSourceId()) return + post({ type: "selectSource", id }) + } + + // Reset transient UI state when the active source changes. Comments are + // discarded without confirmation; diff style goes back to + // unified; in-flight revert indicators are cleared. The diffs list itself + // is reset by the extension sending `diffs: []` before the new fetch. + createEffect( + on(currentSourceId, (id, prev) => { + if (prev === undefined || id === prev) return + setComments([]) + setDiffStyle("unified") + setReverting(new Set()) + setLoadingFiles(new Set()) + setNotice(undefined) + }), + ) + const handler = (event: MessageEvent) => { const msg = event.data if (msg?.type !== "appendReviewComments" || !Array.isArray(msg.comments)) return @@ -73,35 +167,52 @@ const DiffViewerContent: Component = () => { }) return ( - {}} - diffStyle={diffStyle()} - onDiffStyleChange={(style) => { - setDiffStyle(style) - post({ type: "diffViewer.setDiffStyle", style }) - }} - markdownRender={markdown()} - onMarkdownRenderChange={(render) => { - setMarkdown(render) - post({ type: "diffViewer.setMarkdownRender", render }) - }} - onOpenFile={(relativePath) => { - post({ type: "openFile", filePath: relativePath }) - }} - onRevertFile={(file) => { - markReverting(file, true) - post({ type: "diffViewer.revertFile", file }) - }} - revertingFiles={reverting()} - onClose={() => { - post({ type: "diffViewer.close" }) - }} - /> + <> + 0}> + + + +
+ + + + {noticeText()} +
+
+ {}} + diffStyle={diffStyle()} + onDiffStyleChange={(style) => { + setDiffStyle(style) + post({ type: "diffViewer.setDiffStyle", style }) + }} + markdownRender={markdown()} + onMarkdownRenderChange={(render) => { + setMarkdown(render) + post({ type: "diffViewer.setMarkdownRender", render }) + }} + onOpenFile={(relativePath) => { + post({ type: "openFile", filePath: relativePath }) + }} + onRevertFile={(file) => { + markReverting(file, true) + post({ type: "diffViewer.revertFile", file }) + }} + revertingFiles={reverting()} + canRevert={capabilities()?.revert ?? true} + canComment={capabilities()?.comments ?? true} + onClose={() => { + post({ type: "diffViewer.close" }) + }} + /> + ) } diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx index 161ed4ebff..aa4cff2f12 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx +++ b/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx @@ -8,7 +8,8 @@ import { Spinner } from "@kilocode/kilo-ui/spinner" import { Toast } from "@kilocode/kilo-ui/toast" import { ClawProvider, useClaw } from "./context/claw" import { KiloClawLanguageProvider, useKiloClawLanguage } from "./context/language" -import { ChatPanel } from "./components/ChatPanel" +import { ConversationList } from "./components/ConversationList" +import { MessageArea } from "./components/MessageArea" import { StatusSidebar } from "./components/StatusSidebar" import { SetupView } from "./components/SetupView" import { UpgradeView } from "./components/UpgradeView" @@ -46,7 +47,8 @@ function Content() {
- + +
diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx deleted file mode 100644 index 42b52c085f..0000000000 --- a/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx +++ /dev/null @@ -1,107 +0,0 @@ -// KiloClaw chat panel — message list + input - -import { createSignal, createEffect, For, Show, createMemo, onMount } from "solid-js" -import { Button } from "@kilocode/kilo-ui/button" -import { useClaw } from "../context/claw" -import { useKiloClawLanguage } from "../context/language" -import { MessageBubble } from "./MessageBubble" - -export function ChatPanel() { - const claw = useClaw() - const { t } = useKiloClawLanguage() - const [text, setText] = createSignal("") - let list!: HTMLDivElement - let input!: HTMLTextAreaElement - - const disabled = createMemo(() => { - const s = claw.status() - return !s || s.status !== "running" || !claw.connected() - }) - - const placeholder = createMemo(() => { - if (!claw.connected()) return t("kiloClaw.chat.connecting") - const s = claw.status() - if (!s || s.status !== "running") return t("kiloClaw.chat.notRunning") - return t("kiloClaw.chat.placeholder") - }) - - // Auto-scroll to bottom when messages change - createEffect(() => { - claw.messages() - if (list) { - requestAnimationFrame(() => { - list.scrollTop = list.scrollHeight - }) - } - }) - - // Focus input on mount - onMount(() => { - if (input && !disabled()) input.focus() - }) - - const submit = () => { - const val = text().trim() - if (!val || disabled()) return - claw.send(val) - setText("") - if (input) { - input.style.height = "auto" - } - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - submit() - } - } - - const onInput = (e: InputEvent) => { - const target = e.target as HTMLTextAreaElement - setText(target.value) - // Auto-resize - target.style.height = "auto" - target.style.height = Math.min(target.scrollHeight, 120) + "px" - } - - return ( -
- {/* Header */} -
-
- - - KiloClaw {claw.online() ? t("kiloClaw.chat.online") : t("kiloClaw.chat.offline")} - -
-
- - {/* Messages */} -
- -
{t("kiloClaw.chat.empty")}
-
- {(msg) => } -
- - {/* Input */} -
-