Merge branch 'main' into agreeable-marlin

This commit is contained in:
Kirill Kalishev
2026-05-06 15:43:05 -04:00
committed by GitHub
167 changed files with 10721 additions and 1670 deletions
@@ -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.
+7
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart.
+5
View File
@@ -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.
+18 -2
View File
@@ -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
-9
View File
@@ -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=="],
+4 -4
View File
@@ -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="
}
}
+1 -2
View File
@@ -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": {}
+2
View File
@@ -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',
]
@@ -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]
```
@@ -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`
+52 -34
View File
@@ -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<string, string> = {
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(
@@ -51,5 +51,4 @@
background: var(--background-stronger);
margin-bottom: 1rem;
}
}
@@ -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);
+1 -1
View File
@@ -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"],
-1
View File
@@ -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",
@@ -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<typeof setInterval> | 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<string, unknown>): 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<string>("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<void> {
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<DiffTarget | undefined> {
return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot())
}
private async initialFetch(): Promise<void> {
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<void> {
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<string, unknown>): 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()
}
}
+3 -1
View File
@@ -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)
},
@@ -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"
@@ -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 })
@@ -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)
})
}
@@ -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<string, unknown>)),
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<string, unknown>): void {
const handler = this.messageHandlers[msg.type as string]
handler?.(msg)
}
private readonly messageHandlers: Record<string, (msg: Record<string, unknown>) => 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<string>("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)
}
}
@@ -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<void> {
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<void> {
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<void> {
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 })
}
}
}
}
+5
View File
@@ -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
@@ -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(
@@ -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("|")
}
@@ -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 }
}
@@ -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}"`)
}
}
@@ -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<SnapshotFileDiff[]>
export type SnapshotEnabledCheck = (directory?: string) => Promise<boolean>
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<typeof setInterval> | 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<void> {
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<DiffFile[]> {
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<void> {
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
}
}
}
@@ -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.<type>.label` and `diffViewer.source.<type>.tooltip`.
*/
export type DiffSourceType = "workspace" | "session"
export interface DiffSourceDescriptor {
/** Unique within a panel context. E.g. "workspace", "session:<sessionId>". */
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<void>
/** 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<DiffFile | null>
dispose(): void
}
@@ -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<typeof setInterval> | 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<void> {
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<DiffFile | null> {
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<DiffTarget | undefined> {
return await resolveLocalDiffTarget(this.git, (...args) => this.log(...args), getWorkspaceRoot())
}
private async fetchAndPost(target: DiffTarget, post: DiffSourcePost, force: boolean): Promise<void> {
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<void> {
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,
}
}
+20
View File
@@ -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
}
+12 -6
View File
@@ -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)
}),
@@ -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
@@ -13,7 +13,8 @@ interface Ctx {
post: (msg: unknown) => void
openAgentManager: () => Thenable<unknown>
openAdvancedWorktree: () => Thenable<unknown>
openChanges: () => Thenable<unknown>
openChanges: (sessionId?: string) => Thenable<unknown>
currentSessionId?: string
createWorktree?: (baseBranch?: string, branchName?: string) => Promise<void>
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
}
File diff suppressed because it is too large Load Diff
@@ -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<void>
send: (text: string) => Promise<void>
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<string, unknown>, bot: string): ChatMessage {
const user = raw.user as Record<string, unknown> | 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<ClawChatClient> {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, bot))
}
export function presence(channel: Channel, bot: string): boolean {
const member = channel.state.members?.[bot]
return !!member?.user?.online
}
@@ -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 <JWT>` to mint a
* single-use ticket (30 s TTL).
* 2. Open WebSocket to `/connect?ticket=<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<string>
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<string>
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<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
private abortHandshake: ((err: Error) => void) | null = null
private eventHandlers = new Map<string, Set<EventHandler>>()
private activeContexts = new Set<string>()
private reconnectHandlers = new Set<() => void>()
constructor(config: EventServiceConfig) {
this.url = config.url
this.getToken = config.getToken
this.onUnauthorized = config.onUnauthorized
}
async connect(): Promise<void> {
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<N extends KiloChatEventName>(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void {
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
// 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<void> {
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<void>((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<string> {
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<string, unknown>
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)
}
}
@@ -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<string>
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<string, unknown>).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<string, string | number | boolean | undefined | null>
}
// 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<string, Promise<unknown>>
export class KiloChatClient {
private readonly baseUrl: string
private readonly getToken: () => Promise<string>
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<ConversationDetail> {
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<void> {
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
await this.request<unknown>(`/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<void> {
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
await this.request<unknown>(`/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<void> {
await this.request<unknown>(`/v1/conversations/${conversationId}/typing`, { method: "POST" })
}
async sendTypingStop(conversationId: string): Promise<void> {
await this.request<unknown>(`/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<void> {
await this.request<unknown>(`/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<T>(path: string, opts: HttpOpts = {}): Promise<T> {
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<string, string> = { 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
}
}
@@ -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<ChatToken> | 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<string> {
const info = await this.getOrFetch()
return info.token
}
/** Resolve the full token envelope (URLs + token + expiry). */
async getOrFetch(): Promise<ChatToken> {
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<ChatToken> {
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<string, unknown> | 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<ChatToken>
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(", ") || "<empty>"})`,
)
}
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)
}
}
+263 -23
View File
@@ -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 }
+41
View File
@@ -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()
}
+110 -43
View File
@@ -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<string, unknown> {
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 })
+1 -58
View File
@@ -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")
}
@@ -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<string, string> {
const httpConfig = vscode.workspace.getConfiguration("http")
const proxyInfo = httpConfig.inspect<string>("proxy")
const noProxyInfo = httpConfig.inspect<string[]>("noProxy")
const proxySupport = httpConfig.get<string>("proxySupport")
if (proxySupport === "off") {
return { HTTP_PROXY: "", HTTPS_PROXY: "", NO_PROXY: "", http_proxy: "", https_proxy: "", no_proxy: "" }
}
const proxy = httpConfig.get<string>("proxy")
const noProxy = httpConfig.get<string[]>("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<string, string> = {}
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[],
@@ -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: "",
@@ -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"]])
})
})
@@ -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> = {}): 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)
})
})
@@ -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()
})
})
@@ -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:<id>'", () => {
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")
})
})
@@ -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"),
]
@@ -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 })
@@ -3,12 +3,13 @@ import { disconnectProvider, fetchProviderData, saveCustomProvider } from "../..
type ExistingGlobal = { disabled_providers?: string[]; provider?: Record<string, unknown> }
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<string, unknown> }>,
project: [] as Array<{ config: Record<string, unknown> }>,
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<string, unknown> }) => {
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", () => {
@@ -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: "",
})
})
})
@@ -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", () => {
@@ -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>): 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,
})
})
})
@@ -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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<string, string> = { "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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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"])
})
})
@@ -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<string, DiffSource>, 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<string, unknown> => {
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<void>((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")
})
})
@@ -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" })
@@ -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
@@ -52,6 +52,10 @@ interface FullScreenDiffViewProps {
onOpenFile?: (relativePath: string, line?: number) => void
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
/** 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<FullScreenDiffViewProps> = (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<FullScreenDiffViewProps> = (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<FullScreenDiffViewProps> = (props) =>
<Icon name="chevron-grabber-vertical" size="small" />
{open().length === props.diffs.length ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")}
</Button>
<Show when={comments().length > 0}>
<Show when={comments().length > 0 && props.canComment !== false}>
<TooltipKeybind
title={t("agentManager.review.sendAllToChat")}
keybind={sendAllKeybind()}
@@ -469,7 +475,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
activeFile={activeFile()}
onFileSelect={handleFileSelect}
comments={comments()}
onRevertFile={props.onRevertFile}
onRevertFile={props.canRevert !== false ? props.onRevertFile : undefined}
revertingFiles={props.revertingFiles}
/>
</div>
@@ -567,7 +573,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
/>
</Tooltip>
</Show>
<Show when={props.onRevertFile}>
<Show when={props.onRevertFile && props.canRevert !== false}>
<Tooltip value={t("agentManager.diff.revertFile")} placement="top">
<IconButton
icon="discard"
@@ -631,7 +637,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (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
@@ -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)
}
@@ -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<DiffSourceDescriptor["group"], string> = {
Session: "diffViewer.group.session",
Git: "diffViewer.group.git",
}
const TOOLTIP_OPEN_DELAY_MS = 500
export const DiffPickerHeader: Component<DiffPickerHeaderProps> = (props) => {
const { t } = useLanguage()
const current = () => props.descriptors.find((d) => d.id === props.currentId)
const [highlight, setHighlight] = createSignal<string | undefined>(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 (
<div data-component="diff-picker-header">
<Show
when={props.descriptors.length > 1}
fallback={
<span data-slot="diff-picker-single-label">
<Show when={current()} fallback="">
{(d) => label(d())}
</Show>
</span>
}
>
<Select<DiffSourceDescriptor>
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 (
<Tooltip value={tooltip(desc)} placement="right" forceOpen={highlight() === desc.id}>
<span data-slot="diff-picker-option-label">{label(desc)}</span>
</Tooltip>
)
}}
</Select>
</Show>
</div>
)
}
@@ -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<DiffViewerNotice, string> = {
"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<WorktreeFileDiff[]>([])
const [loading, setLoading] = createSignal(true)
const [comments, setComments] = createSignal<ReviewComment[]>([])
const [diffStyle, setDiffStyle] = createSignal<DiffStyle>("unified")
const [markdown, setMarkdown] = createSignal(false)
const [reverting, setReverting] = createSignal<Set<string>>(new Set())
const [loadingFiles, setLoadingFiles] = createSignal<Set<string>>(new Set())
const [availableSources, setAvailableSources] = createSignal<DiffSourceDescriptor[]>([])
const [currentSourceId, setCurrentSourceId] = createSignal<string | undefined>(undefined)
const [capabilities, setCapabilities] = createSignal<DiffSourceCapabilities | undefined>(undefined)
const [notice, setNotice] = createSignal<DiffViewerNotice | undefined>(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<string>) => {
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<string>())
setLoadingFiles(new Set<string>())
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 (
<FullScreenDiffView
diffs={diffs()}
loading={loading()}
sessionKey="local"
comments={comments()}
onCommentsChange={setComments}
onSendAll={() => {}}
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" })
}}
/>
<>
<Show when={availableSources().length > 0}>
<DiffPickerHeader descriptors={availableSources()} currentId={currentSourceId()} onSelect={selectSource} />
</Show>
<Show when={noticeText()}>
<div class="diff-viewer-notice" role="status">
<span class="diff-viewer-notice-icon">
<Icon name="warning" size="small" />
</span>
<span class="diff-viewer-notice-text">{noticeText()}</span>
</div>
</Show>
<FullScreenDiffView
diffs={diffs()}
loading={loading()}
loadingFiles={loadingFiles()}
onRequestDiff={requestDiffFile}
sessionKey={currentSourceId() ?? "local"}
comments={comments()}
onCommentsChange={setComments}
onSendAll={() => {}}
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" })
}}
/>
</>
)
}
@@ -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() {
</Match>
<Match when={claw.phase() === "ready"}>
<div class="kiloclaw-layout">
<ChatPanel />
<ConversationList />
<MessageArea />
<StatusSidebar />
</div>
</Match>
@@ -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 (
<div class="kiloclaw-chat">
{/* Header */}
<div class="kiloclaw-chat-header">
<div class="kiloclaw-chat-header-left">
<span class={`kiloclaw-dot ${claw.online() ? "kiloclaw-dot-online" : "kiloclaw-dot-offline"}`} />
<span class="kiloclaw-chat-header-title">
KiloClaw {claw.online() ? t("kiloClaw.chat.online") : t("kiloClaw.chat.offline")}
</span>
</div>
</div>
{/* Messages */}
<div class="kiloclaw-messages" ref={list} role="log" aria-live="polite">
<Show when={claw.messages().length === 0 && claw.connected()}>
<div class="kiloclaw-empty">{t("kiloClaw.chat.empty")}</div>
</Show>
<For each={claw.messages()}>{(msg) => <MessageBubble message={msg} />}</For>
</div>
{/* Input */}
<div class="kiloclaw-input-wrap">
<textarea
ref={input}
class="kiloclaw-input"
placeholder={placeholder()}
disabled={disabled()}
value={text()}
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
aria-label={t("kiloClaw.chat.placeholder")}
/>
<Button variant="primary" disabled={disabled() || !text().trim()} onClick={submit}>
{t("kiloClaw.chat.send")}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,198 @@
// KiloClaw conversation sidebar — mirrors the web UI in
// cloud/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx
import { For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { useClaw } from "../context/claw"
import { useKiloClawLanguage } from "../context/language"
import type { ConversationListItem } from "../lib/types"
type Group = { label: string; items: ConversationListItem[] }
function groupConversations(convs: ConversationListItem[], labels: Record<string, string>): Group[] {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const yesterdayStart = todayStart - 86_400_000
const weekStart = todayStart - 6 * 86_400_000
const buckets: Record<string, ConversationListItem[]> = {
today: [],
yesterday: [],
week: [],
older: [],
}
for (const c of convs) {
const ts = c.lastActivityAt ?? c.joinedAt
if (ts >= todayStart) buckets.today.push(c)
else if (ts >= yesterdayStart) buckets.yesterday.push(c)
else if (ts >= weekStart) buckets.week.push(c)
else buckets.older.push(c)
}
const order: Array<[keyof typeof buckets, string]> = [
["today", labels.today],
["yesterday", labels.yesterday],
["week", labels.week],
["older", labels.older],
]
return order.filter(([key]) => buckets[key].length > 0).map(([key, label]) => ({ label, items: buckets[key] }))
}
export function ConversationList() {
const claw = useClaw()
const { t } = useKiloClawLanguage()
const groups = createMemo(() =>
groupConversations(claw.conversations(), {
today: t("kiloClaw.conversations.groupToday"),
yesterday: t("kiloClaw.conversations.groupYesterday"),
week: t("kiloClaw.conversations.groupThisWeek"),
older: t("kiloClaw.conversations.groupOlder"),
}),
)
let scrollEl!: HTMLDivElement
const onScroll = () => {
if (!scrollEl) return
if (!claw.hasMoreConversations()) return
if (scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 80) {
claw.loadMoreConversations()
}
}
onMount(() => scrollEl?.addEventListener("scroll", onScroll))
onCleanup(() => scrollEl?.removeEventListener("scroll", onScroll))
return (
<div class="kiloclaw-convlist">
<div class="kiloclaw-convlist-header">
<span class="kiloclaw-convlist-title">{t("kiloClaw.conversations.title")}</span>
<button
type="button"
class="kiloclaw-iconbtn"
onClick={() => claw.createConversation()}
aria-label={t("kiloClaw.conversations.new")}
title={t("kiloClaw.conversations.new")}
>
+
</button>
</div>
<div class="kiloclaw-convlist-scroll" ref={scrollEl}>
<Show
when={claw.conversations().length > 0}
fallback={<div class="kiloclaw-convlist-empty">{t("kiloClaw.conversations.empty")}</div>}
>
<For each={groups()}>
{(group) => (
<div class="kiloclaw-convlist-group">
<div class="kiloclaw-convlist-grouplabel">{group.label}</div>
<For each={group.items}>{(conv) => <ConversationItem conversation={conv} />}</For>
</div>
)}
</For>
</Show>
</div>
</div>
)
}
function ConversationItem(props: { conversation: ConversationListItem }) {
const claw = useClaw()
const { t } = useKiloClawLanguage()
const [isRenaming, setIsRenaming] = createSignal(false)
const [renameText, setRenameText] = createSignal("")
let inputEl: HTMLInputElement | undefined
const isActive = createMemo(() => claw.activeConversationId() === props.conversation.conversationId)
const isUnread = createMemo(() => {
const { lastActivityAt, lastReadAt } = props.conversation
if (!lastActivityAt) return false
return lastReadAt === null || lastReadAt < lastActivityAt
})
const startRename = (e: MouseEvent) => {
e.stopPropagation()
setRenameText(props.conversation.title ?? "")
setIsRenaming(true)
queueMicrotask(() => inputEl?.focus())
}
const commitRename = () => {
const title = renameText().trim()
if (title && title !== (props.conversation.title ?? "")) {
claw.renameConversation(props.conversation.conversationId, title)
}
setIsRenaming(false)
}
const cancelRename = () => {
setRenameText("")
setIsRenaming(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
commitRename()
} else if (e.key === "Escape") {
e.preventDefault()
cancelRename()
}
}
return (
<div
class={`kiloclaw-convitem ${isActive() ? "kiloclaw-convitem-active" : ""}`}
onClick={() => {
if (isRenaming()) return
claw.selectConversation(props.conversation.conversationId)
}}
role="button"
tabindex={0}
>
<Show
when={!isRenaming()}
fallback={
<input
ref={inputEl}
class="kiloclaw-convitem-renameinput"
value={renameText()}
onInput={(e) => setRenameText(e.currentTarget.value)}
onKeyDown={onKey}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
maxLength={200}
/>
}
>
<span class="kiloclaw-convitem-title">
<Show when={isUnread()}>
<span class="kiloclaw-convitem-unread" aria-hidden="true" />
</Show>
{props.conversation.title ?? t("kiloClaw.conversations.untitled")}
</span>
</Show>
<div class="kiloclaw-convitem-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={startRename}
title={t("kiloClaw.conversations.rename")}
aria-label={t("kiloClaw.conversations.rename")}
>
</button>
<button
type="button"
class="kiloclaw-iconbtn-sm kiloclaw-iconbtn-danger"
onClick={() => claw.leaveConversation(props.conversation.conversationId)}
title={t("kiloClaw.conversations.leave")}
aria-label={t("kiloClaw.conversations.leave")}
>
×
</button>
</div>
</div>
)
}
@@ -0,0 +1,342 @@
// KiloClaw active-conversation view — message list + composer.
// Mirrors cloud/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useClaw } from "../context/claw"
import { useKiloClawLanguage } from "../context/language"
import { MessageBubble } from "./MessageBubble"
import { computeBotDisplay, useNowTicker } from "./botStatus"
import type { Message } from "../lib/types"
export function MessageArea() {
const claw = useClaw()
const { t } = useKiloClawLanguage()
let scrollEl: HTMLDivElement | undefined
let input!: HTMLTextAreaElement
// Sticky-to-bottom scroll mirrored from the session route: follows new
// content while near the bottom, pauses when the user scrolls up, and
// reactivates on `forceScrollToBottom()`. Using the shared hook fixes
// the <Show>-lifecycle issue with the previous onMount/ResizeObserver
// setup (refs weren't bound until the user selected a conversation, by
// which time onMount had already returned early).
//
// Uses @kilocode/kilo-ui's createAutoScroll (the same one MessageList.tsx
// uses) because it tracks real user-input events — wheel/pointer/key/touch —
// via `markUser`. The older @opencode-ai/ui hook only listens to wheel
// events, so touchpad/scrollbar/keyboard scrolls never set `userScrolled`
// and the ResizeObserver kept yanking the view back to the bottom.
const auto = createAutoScroll({
working: () => true,
})
const [text, setText] = createSignal("")
const [replyingTo, setReplyingTo] = createSignal<Message | null>(null)
const [pendingDeleteId, setPendingDeleteId] = createSignal<string | null>(null)
const showScrollButton = createMemo(() => auto.userScrolled())
// Staleness ticker (10s) for send-gate & bot status display.
const now = useNowTicker(10_000)
const presence = createMemo(() => {
const s = claw.botStatus()
return s ? { online: s.online, lastAt: s.at } : undefined
})
const botDisplay = createMemo(() =>
computeBotDisplay({
instanceStatus: claw.status()?.status ?? null,
presence: presence(),
now: now(),
}),
)
const canSend = createMemo(() => {
const st = botDisplay().state
return st === "online" || st === "idle"
})
const sendDisabledReason = createMemo(() => {
if (canSend()) return null
const st = botDisplay().state
if (st === "unknown") return t("kiloClaw.chat.waitingBotStatus")
return t("kiloClaw.chat.botOffline")
})
// Render the typing banner with friendly names. Bot members come in as
// `bot:kiloclaw:{sandboxId}` — the bot is the only non-self member in
// 1:1 chats, so we resolve bot ids to the user-configured `botName`
// (falls back to the default "KiloClaw" label) and render any human
// collaborators by their raw memberId.
const typingNames = createMemo(() => {
const activeId = claw.activeConversationId()
if (!activeId) return []
const assistant = claw.assistantName() ?? t("kiloClaw.message.bot")
return claw.typingMembers(activeId).map((m) => (m.memberId.startsWith("bot:") ? assistant : m.memberId))
})
// Reset auto-scroll when active conversation changes — force to bottom
// so the freshly-loaded history lands pinned at the latest message.
//
// Wrapped in `on()` so the effect only fires when the conversation id
// actually changes. A plain `createEffect` would subscribe to every
// reactive read in its body, including `store.userScrolled` (read
// internally by `auto.forceScrollToBottom` at create-auto-scroll.tsx:77
// to reset the flag). Without `on`, the effect would re-run the instant
// the user scrolls up — re-calling `forceScrollToBottom` and snapping
// the view right back to the bottom, making upward scrolling impossible.
createEffect(
on(
() => claw.activeConversationId(),
() => {
auto.forceScrollToBottom()
setReplyingTo(null)
setText("")
if (input) {
input.style.height = "auto"
input.focus()
}
},
),
)
// Keep the bottom pinned when the composer region first grows.
// The typing indicator and reply preview sit OUTSIDE the scroll
// container, so their appearance shrinks the messages viewport without
// firing `createAutoScroll`'s content-side ResizeObserver — leaving
// the last message clipped behind the new row. A boolean memo gates
// this to the rising edge only: typing-heartbeat events that keep
// `typingNames` "non-empty" don't rerun the effect (memoized identity
// stays `true`), so we don't fight the user's scroll attempts with
// a `forceScrollToBottom` every few seconds.
//
// Wrapped in `on()` for the same reason as the reset effect above:
// `forceScrollToBottom` internally reads `userScrolled`, so a plain
// `createEffect` would subscribe to that read and re-fire whenever the
// user scrolls up, immediately pulling the view back to the bottom.
const composerExpanded = createMemo(() => typingNames().length > 0 || replyingTo() !== null)
createEffect(
on(composerExpanded, (expanded) => {
if (!expanded) return
if (auto.userScrolled()) return
auto.forceScrollToBottom()
}),
)
const onScroll = () => {
if (!scrollEl) return
// Load older messages on scroll to top.
if (scrollEl.scrollTop < 50) {
const activeId = claw.activeConversationId()
const oldest = claw.messages()[0]
if (activeId && oldest && !oldest.id.startsWith("pending-")) {
claw.loadMoreMessages(activeId, oldest.id)
}
}
auto.handleScroll()
}
const scrollToBottom = () => auto.forceScrollToBottom()
const submit = () => {
const val = text().trim()
const activeId = claw.activeConversationId()
if (!val || !activeId || !canSend()) return
auto.forceScrollToBottom()
claw.sendMessage(activeId, [{ type: "text", text: val }], replyingTo()?.id)
setText("")
setReplyingTo(null)
if (input) input.style.height = "auto"
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}
let typingNudgeAt = 0
let typingStopTimer: ReturnType<typeof setTimeout> | null = null
const scheduleTypingStop = () => {
const activeId = claw.activeConversationId()
if (!activeId) return
if (typingStopTimer !== null) clearTimeout(typingStopTimer)
typingStopTimer = setTimeout(() => {
claw.sendTypingStop(activeId)
typingStopTimer = null
}, 4000)
}
const onInput = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement
setText(target.value)
target.style.height = "auto"
target.style.height = Math.min(target.scrollHeight, 160) + "px"
const activeId = claw.activeConversationId()
if (!activeId) return
const now = Date.now()
if (now - typingNudgeAt > 3000 && target.value.trim().length > 0) {
claw.sendTyping(activeId)
typingNudgeAt = now
}
if (target.value.trim().length === 0) {
claw.sendTypingStop(activeId)
if (typingStopTimer !== null) {
clearTimeout(typingStopTimer)
typingStopTimer = null
}
} else {
scheduleTypingStop()
}
}
onCleanup(() => {
if (typingStopTimer !== null) clearTimeout(typingStopTimer)
})
const messageMap = createMemo(() => {
const map = new Map<string, Message>()
for (const m of claw.messages()) map.set(m.id, m)
return map
})
return (
<Show
when={claw.activeConversationId()}
fallback={
<div class="kiloclaw-empty-area">
<p>{t("kiloClaw.conversations.selectOne")}</p>
</div>
}
>
<div class="kiloclaw-area">
{/* Messages */}
<div class="kiloclaw-area-messages-wrap">
<div
class="kiloclaw-area-messages"
ref={(el) => {
scrollEl = el
auto.scrollRef(el)
}}
onScroll={onScroll}
role="log"
aria-live="polite"
>
<div ref={auto.contentRef}>
<Show when={claw.messages().length === 0}>
<div class="kiloclaw-empty">
{claw.assistantName()
? t("kiloClaw.chat.emptyWithBot").replace("{bot}", claw.assistantName()!)
: t("kiloClaw.chat.empty")}
</div>
</Show>
<For each={claw.messages()}>
{(msg) => (
<MessageBubble
message={msg}
isOwn={msg.senderId === claw.currentUserId()}
assistantName={claw.assistantName()}
replyToMessage={msg.inReplyToMessageId ? (messageMap().get(msg.inReplyToMessageId) ?? null) : null}
pendingDeleteId={pendingDeleteId()}
onReply={setReplyingTo}
onRequestDelete={(id) => setPendingDeleteId(id)}
onCancelDelete={() => setPendingDeleteId(null)}
onConfirmDelete={(id) => {
const activeId = claw.activeConversationId()
if (activeId) claw.deleteMessage(activeId, id)
setPendingDeleteId(null)
}}
onEdit={(id, content) => {
const activeId = claw.activeConversationId()
if (activeId) claw.editMessage(activeId, id, content)
}}
onAddReaction={(id, emoji) => {
const activeId = claw.activeConversationId()
if (activeId) claw.addReaction(activeId, id, emoji)
}}
onRemoveReaction={(id, emoji) => {
const activeId = claw.activeConversationId()
if (activeId) claw.removeReaction(activeId, id, emoji)
}}
onExecuteAction={(id, groupId, value) => {
const activeId = claw.activeConversationId()
if (activeId) claw.executeAction(activeId, id, groupId, value)
}}
/>
)}
</For>
</div>
</div>
<Show when={showScrollButton()}>
<button
type="button"
class="kiloclaw-scrollbtn"
onClick={scrollToBottom}
aria-label="Scroll to latest message"
title="Scroll to bottom"
>
</button>
</Show>
</div>
{/* Typing indicator — matches CLI: "{botName} is typing" with a
spinner to mirror the session-route feel. */}
<Show when={typingNames().length > 0}>
<div class="kiloclaw-typing">
<Spinner />
<span>
{typingNames().length === 1
? t("kiloClaw.typing.one").replace("{name}", typingNames()[0])
: t("kiloClaw.typing.many").replace("{count}", String(typingNames().length))}
</span>
</div>
</Show>
{/* Composer */}
<Show when={replyingTo()}>
{(r) => (
<div class="kiloclaw-reply-preview">
<span class="kiloclaw-reply-preview-label">{t("kiloClaw.message.replyTo")}</span>
<span class="kiloclaw-reply-preview-text">{replyText(r())}</span>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={() => setReplyingTo(null)}
aria-label={t("kiloClaw.message.cancelReply")}
>
×
</button>
</div>
)}
</Show>
<div class="kiloclaw-input-wrap">
<textarea
ref={input}
class="kiloclaw-input"
placeholder={sendDisabledReason() ?? t("kiloClaw.chat.placeholder")}
disabled={!canSend()}
value={text()}
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
aria-label={t("kiloClaw.chat.placeholder")}
/>
<Button variant="primary" disabled={!canSend() || !text().trim()} onClick={submit}>
{t("kiloClaw.chat.send")}
</Button>
</div>
</div>
</Show>
)
}
function replyText(msg: Message): string {
for (const block of msg.content) {
if (block.type === "text") return block.text.slice(0, 120)
}
return ""
}
@@ -1,34 +1,395 @@
// Individual chat message bubble
// Individual chat message bubble — renders Kilo Chat ContentBlock[] content
// with reactions, action approvals, edit/delete/reply controls.
//
// Layout mirrors the web client (cloud/apps/web/src/app/(app)/claw/kilo-chat/
// components/MessageBubble.tsx): own messages right-justified with a primary
// bubble, bot/other messages left-justified with a muted bubble, author name
// and reply preview stacked above the bubble, timestamp/edited markers
// rendered inside the bubble's footer, reactions stacked below, and the
// per-message toolbar positioned to the side and revealed on hover.
import { Show, createMemo } from "solid-js"
import { Show, For, createMemo, createSignal } from "solid-js"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { TextShimmer } from "@kilocode/kilo-ui/text-shimmer"
import type { ChatMessage } from "../lib/types"
import { showToast } from "@kilocode/kilo-ui/toast"
import type { ContentBlock, ExecApprovalDecision, Message } from "../lib/types"
import { useKiloClawLanguage } from "../context/language"
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
const ULID_TIME_LEN = 10
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
const ENCODING_LEN = ENCODING.length
/** Decode the time portion of a ULID into a millisecond epoch. */
function ulidToTimestamp(id: string): number {
if (!id || id.length < ULID_TIME_LEN) return Date.now()
const time = id.slice(0, ULID_TIME_LEN).toUpperCase()
let ts = 0
for (const ch of time) {
const idx = ENCODING.indexOf(ch)
if (idx === -1) return Date.now()
ts = ts * ENCODING_LEN + idx
}
return ts
}
export function MessageBubble(props: { message: ChatMessage }) {
function contentBlocksToText(content: ContentBlock[]): string {
let out = ""
for (const block of content) {
if (block.type === "text") out += block.text
}
return out
}
function formatTime(epoch: number): string {
const d = new Date(epoch)
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
}
type MessageBubbleProps = {
message: Message
isOwn: boolean
assistantName: string | null
replyToMessage: Message | null
pendingDeleteId: string | null
onReply: (msg: Message) => void
onRequestDelete: (id: string) => void
onCancelDelete: () => void
onConfirmDelete: (id: string) => void
onEdit: (id: string, content: ContentBlock[]) => void
onAddReaction: (id: string, emoji: string) => void
onRemoveReaction: (id: string, emoji: string) => void
onExecuteAction: (id: string, groupId: string, value: ExecApprovalDecision) => void
}
export function MessageBubble(props: MessageBubbleProps) {
const { t } = useKiloClawLanguage()
const empty = createMemo(() => !props.message.text || !props.message.text.trim())
const [isEditing, setIsEditing] = createSignal(false)
const [editText, setEditText] = createSignal("")
const [showReactionPick, setShowReactionPick] = createSignal(false)
const isBot = createMemo(() => props.message.senderId.startsWith("bot:"))
const isOptimistic = createMemo(() => props.message.id.startsWith("pending-"))
const timestamp = createMemo(() => (isOptimistic() ? Date.now() : ulidToTimestamp(props.message.id)))
const textContent = createMemo(() => (props.message.deleted ? "" : contentBlocksToText(props.message.content)))
const empty = createMemo(() => !textContent() || !textContent().trim())
const isDeleting = createMemo(() => props.pendingDeleteId === props.message.id)
const variant = createMemo(() => (props.isOwn ? "own" : isBot() ? "bot" : "other"))
// Reactions are rendered inline below the bubble; author name sits above
// bot bubbles only (we never label our own messages).
const showAuthor = createMemo(() => isBot() && !props.isOwn)
const actionBlocks = createMemo(() => props.message.content.filter((b) => b.type === "actions"))
const startEdit = () => {
setEditText(textContent())
setIsEditing(true)
}
const saveEdit = () => {
const trimmed = editText().trim()
if (!trimmed) {
setIsEditing(false)
return
}
if (trimmed === textContent().trim()) {
setIsEditing(false)
return
}
props.onEdit(props.message.id, [{ type: "text", text: trimmed }])
setIsEditing(false)
}
const cancelEdit = () => {
setIsEditing(false)
setEditText("")
}
const copyText = async () => {
const text = textContent()
if (!text) return
try {
await navigator.clipboard.writeText(text)
showToast({ title: t("kiloClaw.message.copied"), variant: "success", duration: 2000 })
} catch {
showToast({ title: t("kiloClaw.message.copyFailed"), variant: "error", duration: 3000 })
}
}
return (
<div class={`kiloclaw-msg ${props.message.bot ? "kiloclaw-msg-bot" : "kiloclaw-msg-user"}`}>
<div class="kiloclaw-msg-header">
<span class="kiloclaw-msg-author">
{props.message.bot ? t("kiloClaw.message.bot") : t("kiloClaw.message.you")}
</span>
<span class="kiloclaw-msg-time">{formatTime(props.message.created)}</span>
</div>
<div class="kiloclaw-msg-body">
<Show when={!empty()} fallback={<TextShimmer text={t("kiloClaw.message.thinking")} />}>
<Show when={props.message.bot} fallback={<span>{props.message.text}</span>}>
<Markdown text={props.message.text} />
</Show>
<div class={`kiloclaw-msg kiloclaw-msg-${variant()}`}>
<div class="kiloclaw-msg-column">
{/* Author label — above the bubble for bot messages only */}
<Show when={showAuthor()}>
<span class="kiloclaw-msg-author">{props.assistantName ?? t("kiloClaw.message.bot")}</span>
</Show>
{/* Reply preview — above the bubble, aligned with the author label */}
<Show when={props.replyToMessage}>
{(reply) => (
<div class="kiloclaw-msg-reply">
<span class="kiloclaw-msg-reply-arrow" aria-hidden="true">
</span>
<Show
when={!reply().deleted}
fallback={<span class="kiloclaw-msg-reply-deleted">{t("kiloClaw.message.replyDeleted")}</span>}
>
<span class="kiloclaw-msg-reply-text">
{(() => {
const txt = contentBlocksToText(reply().content)
return txt.length > 60 ? `${txt.slice(0, 60)}` : txt
})()}
</span>
</Show>
</div>
)}
</Show>
{/* Bubble + side-positioned toolbar */}
<div class="kiloclaw-msg-bubble-wrap">
<Show when={!props.message.deleted && !isEditing() && !isDeleting() && !isOptimistic()}>
<div class="kiloclaw-msg-toolbar">
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={() => setShowReactionPick((v) => !v)}
title={t("kiloClaw.message.react")}
aria-label={t("kiloClaw.message.react")}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
<Show when={!empty()}>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={copyText}
title={t("kiloClaw.message.copy")}
aria-label={t("kiloClaw.message.copy")}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</Show>
<Show when={!props.message.deliveryFailed}>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={() => props.onReply(props.message)}
title={t("kiloClaw.message.reply")}
aria-label={t("kiloClaw.message.reply")}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 17 4 12 9 7" />
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
</svg>
</button>
</Show>
<Show when={props.isOwn && !props.message.deliveryFailed}>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={startEdit}
title={t("kiloClaw.message.edit")}
aria-label={t("kiloClaw.message.edit")}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</Show>
<Show when={props.isOwn}>
<button
type="button"
class="kiloclaw-iconbtn-sm kiloclaw-iconbtn-danger"
onClick={() => props.onRequestDelete(props.message.id)}
title={t("kiloClaw.message.delete")}
aria-label={t("kiloClaw.message.delete")}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" />
</svg>
</button>
</Show>
</div>
</Show>
{/* Quick reaction picker (popover, toggled from the toolbar) */}
<Show when={showReactionPick()}>
<div class="kiloclaw-msg-reactionpick">
<For each={["👍", "❤️", "😂", "🎉", "🚀", "👀"]}>
{(emoji) => (
<button
type="button"
class="kiloclaw-msg-reactionpick-btn"
onClick={() => {
setShowReactionPick(false)
props.onAddReaction(props.message.id, emoji)
}}
>
{emoji}
</button>
)}
</For>
</div>
</Show>
<div class="kiloclaw-msg-bubble">
<Show
when={!props.message.deleted}
fallback={<span class="kiloclaw-msg-deleted">{t("kiloClaw.message.deleted")}</span>}
>
<Show when={isEditing()}>
<div class="kiloclaw-msg-edit">
<textarea
class="kiloclaw-msg-edit-input"
value={editText()}
onInput={(e) => setEditText(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
saveEdit()
} else if (e.key === "Escape") {
cancelEdit()
}
}}
autofocus
/>
<div class="kiloclaw-msg-edit-actions">
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={saveEdit}
title={t("kiloClaw.message.save")}
>
</button>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={cancelEdit}
title={t("kiloClaw.message.cancel")}
>
×
</button>
</div>
</div>
</Show>
<Show when={isDeleting()}>
<div class="kiloclaw-msg-confirm-delete">
<span>{t("kiloClaw.message.confirmDelete")}</span>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={() => props.onConfirmDelete(props.message.id)}
title={t("kiloClaw.message.confirmDelete")}
>
</button>
<button
type="button"
class="kiloclaw-iconbtn-sm"
onClick={() => props.onCancelDelete()}
title={t("kiloClaw.message.cancel")}
>
×
</button>
</div>
</Show>
<Show when={!isEditing() && !isDeleting()}>
<div class="kiloclaw-msg-body">
<Show
when={!empty()}
fallback={<span class="kiloclaw-msg-thinking">{t("kiloClaw.message.thinking")}</span>}
>
<Show when={isBot()} fallback={<span class="kiloclaw-msg-text">{textContent()}</span>}>
<Markdown text={textContent()} />
</Show>
</Show>
</div>
</Show>
{/* Action approval blocks rendered inside the bubble */}
<For each={actionBlocks()}>
{(block) => {
if (block.type !== "actions") return null
return (
<Show
when={!block.resolved}
fallback={
<div class="kiloclaw-msg-actions-resolved">
<span class="kiloclaw-msg-actions-resolved-icon">
{block.resolved!.value === "deny" ? "✗" : "✓"}
</span>
<span>
{block.actions.find((a) => a.value === block.resolved!.value)?.label ??
block.resolved!.value}
</span>
</div>
}
>
<div class="kiloclaw-msg-actions">
<For each={block.actions}>
{(action) => (
<button
type="button"
class={`kiloclaw-msg-action kiloclaw-msg-action-${action.style}`}
onClick={() => props.onExecuteAction(props.message.id, block.groupId, action.value)}
>
{action.label}
</button>
)}
</For>
</div>
</Show>
)
}}
</For>
</Show>
{/* Footer: delivery status + edited marker + timestamp (inside bubble, right-aligned) */}
<Show when={!isEditing() && !isDeleting()}>
<div class="kiloclaw-msg-footer">
<Show when={props.message.deliveryFailed}>
<span class="kiloclaw-msg-failed">{t("kiloClaw.message.notDelivered")}</span>
</Show>
<Show when={props.message.clientUpdatedAt && !props.message.deleted}>
<span>{t("kiloClaw.message.edited")}</span>
</Show>
<span>{formatTime(timestamp())}</span>
</div>
</Show>
</div>
{/* Reaction summaries — below the bubble, aligned with the bubble edge */}
<Show when={!props.message.deleted && props.message.reactions.length > 0}>
<div class="kiloclaw-msg-reactions">
<For each={props.message.reactions}>
{(r) => (
<button
type="button"
class="kiloclaw-msg-reaction-pill"
onClick={() => props.onRemoveReaction(props.message.id, r.emoji)}
title={t("kiloClaw.message.removeReaction")}
>
<span>{r.emoji}</span>
<span>{r.count}</span>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
</div>
)
@@ -1,6 +1,9 @@
// KiloClaw status sidebar — instance info, bot presence, details
// KiloClaw status sidebar — mirrors the CLI sidebar structure:
// conversation title at top, then Bot Status, Context, Instance, Details.
//
// Ref: packages/opencode/src/kilocode/claw/sidebar.tsx
import { Show, createMemo } from "solid-js"
import { Show, createMemo, createSignal } from "solid-js"
import { useClaw } from "../context/claw"
import { useKiloClawLanguage } from "../context/language"
@@ -31,15 +34,134 @@ function capitalize(s: string | null | undefined, fallback: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}
function formatTokens(n: number): string {
if (n < 1000) return String(n)
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
return `${(n / 1_000_000).toFixed(1)}M`
}
export function StatusSidebar() {
const claw = useClaw()
const { t } = useKiloClawLanguage()
const status = createMemo(() => claw.status())
const ctx = createMemo(() => claw.conversationStatus())
const [isRenamingTitle, setIsRenamingTitle] = createSignal(false)
const [renameTitleText, setRenameTitleText] = createSignal("")
const activeConversation = createMemo(() => {
const id = claw.activeConversationId()
if (!id) return null
return claw.conversations().find((c) => c.conversationId === id) ?? null
})
const conversationTitle = createMemo(() => {
const conv = activeConversation()
if (!conv) return t("kiloClaw.conversations.new")
return conv.title ?? t("kiloClaw.conversations.untitled")
})
const handleTitleClick = () => {
if (!activeConversation()) return
setRenameTitleText(conversationTitle())
setIsRenamingTitle(true)
}
const commitTitleRename = () => {
const next = renameTitleText().trim()
const conv = activeConversation()
if (conv && next && next !== conversationTitle()) {
claw.renameConversation(conv.conversationId, next)
}
setIsRenamingTitle(false)
}
const onTitleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
commitTitleRename()
} else if (e.key === "Escape") {
setRenameTitleText("")
setIsRenamingTitle(false)
}
}
return (
<div class="kiloclaw-sidebar">
<h3 class="kiloclaw-sidebar-title">{t("kiloClaw.sidebar.title")}</h3>
{/* Conversation title (top, like the session route title) */}
<Show when={claw.activeConversationId()}>
<div class="kiloclaw-sidebar-section">
<Show
when={!isRenamingTitle()}
fallback={
<input
autofocus
class="kiloclaw-sidebar-titleinput"
value={renameTitleText()}
onInput={(e) => setRenameTitleText(e.currentTarget.value)}
onKeyDown={onTitleKeyDown}
onBlur={commitTitleRename}
maxLength={200}
/>
}
>
<button
type="button"
class="kiloclaw-sidebar-titlebtn"
onClick={handleTitleClick}
title={t("kiloClaw.conversations.rename")}
>
{conversationTitle()}
</button>
</Show>
</div>
</Show>
{/* Bot Status */}
<Show when={claw.activeConversationId()}>
<div class="kiloclaw-sidebar-section">
<div class="kiloclaw-sidebar-label">{t("kiloClaw.sidebar.botStatus")}</div>
<div class="kiloclaw-sidebar-row">
<span class={`kiloclaw-dot ${claw.botStatus()?.online ? "kiloclaw-dot-online" : "kiloclaw-dot-offline"}`} />
<span>{claw.botStatus()?.online ? t("kiloClaw.chat.online") : t("kiloClaw.chat.offline")}</span>
</div>
</div>
</Show>
{/* Context window usage */}
<Show when={ctx()}>
{(c) => (
<div class="kiloclaw-sidebar-section">
<div class="kiloclaw-sidebar-label">{t("kiloClaw.sidebar.context")}</div>
<Show when={c().contextWindow > 0}>
<div class="kiloclaw-sidebar-detail">
<span class="kiloclaw-sidebar-muted">{t("kiloClaw.sidebar.used")}</span>
<span>{Math.min(100, Math.round((c().contextTokens / c().contextWindow) * 100))}%</span>
</div>
</Show>
<div class="kiloclaw-sidebar-detail">
<span class="kiloclaw-sidebar-muted">{t("kiloClaw.sidebar.tokens")}</span>
<span>
{formatTokens(c().contextTokens)} / {formatTokens(c().contextWindow)}
</span>
</div>
<Show when={c().model}>
<div class="kiloclaw-sidebar-detail">
<span class="kiloclaw-sidebar-muted">{t("kiloClaw.sidebar.model")}</span>
<span class="kiloclaw-sidebar-value-truncate">{c().model}</span>
</div>
</Show>
<Show when={c().provider}>
<div class="kiloclaw-sidebar-detail">
<span class="kiloclaw-sidebar-muted">{t("kiloClaw.sidebar.provider")}</span>
<span class="kiloclaw-sidebar-value-truncate">{c().provider}</span>
</div>
</Show>
</div>
)}
</Show>
{/* Instance status */}
<Show when={status()}>
<div class="kiloclaw-sidebar-section">
<div class="kiloclaw-sidebar-label">{t("kiloClaw.sidebar.instance")}</div>
@@ -54,14 +176,7 @@ export function StatusSidebar() {
</div>
</div>
<div class="kiloclaw-sidebar-section">
<div class="kiloclaw-sidebar-label">{t("kiloClaw.sidebar.bot")}</div>
<div class="kiloclaw-sidebar-row">
<span class={`kiloclaw-dot ${claw.online() ? "kiloclaw-dot-online" : "kiloclaw-dot-offline"}`} />
<span>{claw.online() ? t("kiloClaw.chat.online") : t("kiloClaw.chat.offline")}</span>
</div>
</div>
{/* Details */}
<div class="kiloclaw-sidebar-section">
<div class="kiloclaw-sidebar-label">{t("kiloClaw.sidebar.details")}</div>
<div class="kiloclaw-sidebar-detail">
@@ -0,0 +1,44 @@
// Bot presence display helpers — adapted from the web client so the
// VS Code extension reaches the same conclusions for
// "online / idle / offline / unknown".
//
// Ref: cloud/apps/web/src/app/(app)/claw/kilo-chat/components/BotStatus.tsx
import { createSignal, onCleanup } from "solid-js"
export type BotPresence = { online: boolean; lastAt: number }
export type BotDisplayState = "online" | "idle" | "offline" | "unknown"
export type BotDisplay = { state: BotDisplayState }
/**
* Compute the bot's display state.
*
* The bot heartbeat (delivered via the event-service WebSocket every few
* seconds) is the authoritative signal for "can I reach the bot right now?".
* The gateway's `instanceStatus` is a 10 s poll over a slower control plane
* and lags transitional states (`recovering`, `restoring`, `restarting`) where
* the bot is still — or already — reachable. Trusting the heartbeat here
* avoids disabling the composer just because the gateway hasn't repolled yet.
*/
export function computeBotDisplay(params: {
instanceStatus: string | null
presence: BotPresence | undefined
now: number
}): BotDisplay {
if (!params.presence) {
if (params.instanceStatus === "running") return { state: "unknown" }
return { state: "offline" }
}
if (!params.presence.online) return { state: "offline" }
const elapsed = params.now - params.presence.lastAt
if (elapsed > 90_000) return { state: "offline" }
if (elapsed > 30_000) return { state: "idle" }
return { state: "online" }
}
export function useNowTicker(intervalMs: number) {
const [now, setNow] = createSignal(Date.now())
const id = setInterval(() => setNow(Date.now()), intervalMs)
onCleanup(() => clearInterval(id))
return now
}
@@ -1,12 +1,24 @@
// KiloClaw state context
//
// All data arrives from the extension host via postMessage.
// The webview has no direct network access.
// The webview has no direct network access — it dispatches commands via
// vscode.postMessage() and receives state diffs back.
import { createContext, createSignal, onMount, onCleanup, useContext, type JSX } from "solid-js"
import { createContext, createMemo, createSignal, onMount, onCleanup, useContext, type JSX } from "solid-js"
import { showToast } from "@kilocode/kilo-ui/toast"
import type { ClawStatus, ChatMessage, KiloClawOutMessage } from "../lib/types"
import { applyFontSize } from "../../src/font-size"
import type {
BotStatusRecord,
ClawStatus,
ContentBlock,
ConversationListItem,
ConversationStatusRecord,
ExecApprovalDecision,
KiloClawOutMessage,
KiloClawState,
Message,
TypingMember,
} from "../lib/types"
type VSCodeAPI = {
postMessage(msg: unknown): void
@@ -18,21 +30,58 @@ declare function acquireVsCodeApi(): VSCodeAPI
const vscode = acquireVsCodeApi()
const MAX_MESSAGES = 500
type Phase = "loading" | "noInstance" | "needsUpgrade" | "error" | "ready"
type ClawCtx = {
export type ClawCtx = {
// Lifecycle
phase: () => Phase
locale: () => string | undefined
status: () => ClawStatus | null
messages: () => ChatMessage[]
online: () => boolean
connected: () => boolean
error: () => string | null
send: (text: string) => void
openExternal: (url: string) => void
retry: () => void
// Identity / instance
status: () => ClawStatus | null
currentUserId: () => string
sandboxId: () => string
assistantName: () => string | null
// Conversations
conversations: () => ConversationListItem[]
hasMoreConversations: () => boolean
activeConversationId: () => string | null
selectConversation: (conversationId: string) => void
createConversation: (title?: string) => void
renameConversation: (conversationId: string, title: string) => void
leaveConversation: (conversationId: string) => void
loadMoreConversations: () => void
// Messages
messages: () => Message[]
hasMoreMessages: () => boolean
sendMessage: (conversationId: string, content: ContentBlock[], inReplyToMessageId?: string) => void
editMessage: (conversationId: string, messageId: string, content: ContentBlock[]) => void
deleteMessage: (conversationId: string, messageId: string) => void
loadMoreMessages: (conversationId: string, before: string) => void
// Reactions
addReaction: (conversationId: string, messageId: string, emoji: string) => void
removeReaction: (conversationId: string, messageId: string, emoji: string) => void
// Approvals
executeAction: (conversationId: string, messageId: string, groupId: string, value: ExecApprovalDecision) => void
// Typing
sendTyping: (conversationId: string) => void
sendTypingStop: (conversationId: string) => void
typingMembers: (conversationId: string) => TypingMember[]
// Status
botStatus: () => BotStatusRecord | null
conversationStatus: () => ConversationStatusRecord | null
markConversationRead: (conversationId: string) => void
// External links
openExternal: (url: string) => void
}
const ClawContext = createContext<ClawCtx>()
@@ -41,11 +90,91 @@ export function ClawProvider(props: { children: JSX.Element }) {
const [phase, setPhase] = createSignal<Phase>("loading")
const [locale, setLocale] = createSignal<string | undefined>(undefined)
const [status, setStatus] = createSignal<ClawStatus | null>(null)
const [messages, setMessages] = createSignal<ChatMessage[]>([])
const [online, setOnline] = createSignal(false)
const [connected, setConnected] = createSignal(false)
const [currentUserId, setCurrentUserId] = createSignal<string>("")
const [sandboxId, setSandboxId] = createSignal<string>("")
// `assistantName` is derived from `status.botName` so it tracks updates
// from both the initial state snapshot and subsequent status polls
// (the gateway's onboarding mutation can set/change the bot name at
// any time).
const assistantName = createMemo<string | null>(() => status()?.botName ?? null)
const [conversations, setConversations] = createSignal<ConversationListItem[]>([])
const [hasMoreConversations, setHasMoreConversations] = createSignal(false)
const [activeConversationId, setActiveConversationId] = createSignal<string | null>(null)
const [messages, setMessages] = createSignal<Message[]>([])
const [hasMoreMessages, setHasMoreMessages] = createSignal(false)
const [botStatus, setBotStatus] = createSignal<BotStatusRecord | null>(null)
const [conversationStatus, setConversationStatus] = createSignal<ConversationStatusRecord | null>(null)
const [typingMembers, setTypingMembers] = createSignal<TypingMember[]>([])
const [error, setError] = createSignal<string | null>(null)
const applyState = (s: KiloClawState) => {
setPhase(s.phase)
setLocale(s.locale)
if (s.phase === "ready") {
setStatus(s.status)
setCurrentUserId(s.currentUserId)
setSandboxId(s.sandboxId)
setConversations(s.conversations)
setHasMoreConversations(s.hasMoreConversations)
setActiveConversationId(s.activeConversationId)
setMessages(s.messages)
setHasMoreMessages(s.hasMoreMessages)
setBotStatus(s.botStatus)
setConversationStatus(s.conversationStatus)
setTypingMembers(s.typingMembers)
setError(null)
} else if (s.phase === "error") {
setError(s.error)
}
}
const upsertTyping = (memberId: string) => {
setTypingMembers((prev) => {
const idx = prev.findIndex((m) => m.memberId === memberId)
const now = Date.now()
if (idx === -1) return [...prev, { memberId, at: now }]
return prev.map((m, i) => (i === idx ? { ...m, at: now } : m))
})
}
const handleConversationMessage = (msg: KiloClawOutMessage, active: string | null): boolean => {
switch (msg.type) {
case "kiloclaw.messages":
if (msg.conversationId === active) {
setMessages(msg.messages)
setHasMoreMessages(msg.hasMore)
}
return true
case "kiloclaw.messageOptimistic":
if (msg.conversationId === active) {
setMessages((prev) => (prev.some((m) => m.id === msg.message.id) ? prev : [...prev, msg.message]))
}
return true
case "kiloclaw.messageReplaced":
if (msg.conversationId === active) {
// Replace the whole optimistic entry with the server's canonical
// message so content, timestamps, and reactions stay in sync.
setMessages((prev) => prev.map((m) => (m.id === msg.pendingId ? msg.message : m)))
}
return true
case "kiloclaw.messageRemoved":
if (msg.conversationId === active) {
setMessages((prev) => prev.filter((m) => m.id !== msg.messageId))
}
return true
case "kiloclaw.typing":
if (msg.conversationId === active) upsertTyping(msg.memberId)
return true
case "kiloclaw.typingStop":
if (msg.conversationId === active) {
setTypingMembers((prev) => prev.filter((m) => m.memberId !== msg.memberId))
}
return true
default:
return false
}
}
const handler = (event: MessageEvent) => {
const msg = event.data as KiloClawOutMessage
if (msg?.type === "fontSizeChanged") {
@@ -53,55 +182,36 @@ export function ClawProvider(props: { children: JSX.Element }) {
return
}
if (!msg?.type?.startsWith("kiloclaw.")) return
const active = activeConversationId()
if (handleConversationMessage(msg, active)) return
switch (msg.type) {
case "kiloclaw.state": {
const s = msg.state
setPhase(s.phase)
setLocale(s.locale)
if (s.phase === "ready") {
setStatus(s.status)
setConnected(s.connected)
setOnline(s.online)
setMessages(s.messages)
setError(null)
} else if (s.phase === "error") {
setError(s.error)
}
break
}
case "kiloclaw.message":
setMessages((prev) => {
// Dedupe: if a message with this id already exists, update it
const idx = prev.findIndex((m) => m.id === msg.message.id)
if (idx !== -1) return prev.map((m, i) => (i === idx ? msg.message : m))
const next = [...prev, msg.message]
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next
})
break
case "kiloclaw.messageUpdated": {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === msg.message.id)
if (idx === -1) {
const next = [...prev, msg.message]
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next
}
return prev.map((m, i) => (i === idx ? msg.message : m))
})
break
}
case "kiloclaw.presence":
setOnline(msg.online)
break
case "kiloclaw.state":
applyState(msg.state)
return
case "kiloclaw.status":
setStatus(msg.data)
break
return
case "kiloclaw.locale":
setLocale(msg.locale)
break
return
case "kiloclaw.error":
showToast({ title: msg.error, variant: "error", duration: 5000 })
break
return
case "kiloclaw.conversations":
setConversations(msg.conversations)
setHasMoreConversations(msg.hasMore)
return
case "kiloclaw.activeConversation":
setActiveConversationId(msg.conversationId)
setTypingMembers([])
return
case "kiloclaw.botStatus":
setBotStatus(msg.status)
return
case "kiloclaw.conversationStatus":
setConversationStatus(msg.status)
return
}
}
@@ -116,14 +226,61 @@ export function ClawProvider(props: { children: JSX.Element }) {
const ctx: ClawCtx = {
phase,
locale,
status,
messages,
online,
connected,
error,
send: (text: string) => vscode.postMessage({ type: "kiloclaw.send", text }),
openExternal: (url: string) => vscode.postMessage({ type: "kiloclaw.openExternal", url }),
retry: () => vscode.postMessage({ type: "kiloclaw.ready" }),
status,
currentUserId,
sandboxId,
assistantName,
conversations,
hasMoreConversations,
activeConversationId,
selectConversation: (conversationId) => vscode.postMessage({ type: "kiloclaw.selectConversation", conversationId }),
createConversation: (title) => vscode.postMessage({ type: "kiloclaw.createConversation", title }),
renameConversation: (conversationId, title) =>
vscode.postMessage({ type: "kiloclaw.renameConversation", conversationId, title }),
leaveConversation: (conversationId) => vscode.postMessage({ type: "kiloclaw.leaveConversation", conversationId }),
loadMoreConversations: () => vscode.postMessage({ type: "kiloclaw.loadMoreConversations" }),
messages,
hasMoreMessages,
sendMessage: (conversationId, content, inReplyToMessageId) =>
vscode.postMessage({
type: "kiloclaw.sendMessage",
conversationId,
content,
inReplyToMessageId,
}),
editMessage: (conversationId, messageId, content) =>
vscode.postMessage({ type: "kiloclaw.editMessage", conversationId, messageId, content }),
deleteMessage: (conversationId, messageId) =>
vscode.postMessage({ type: "kiloclaw.deleteMessage", conversationId, messageId }),
loadMoreMessages: (conversationId, before) =>
vscode.postMessage({ type: "kiloclaw.loadMoreMessages", conversationId, before }),
addReaction: (conversationId, messageId, emoji) =>
vscode.postMessage({ type: "kiloclaw.addReaction", conversationId, messageId, emoji }),
removeReaction: (conversationId, messageId, emoji) =>
vscode.postMessage({ type: "kiloclaw.removeReaction", conversationId, messageId, emoji }),
executeAction: (conversationId, messageId, groupId, value) =>
vscode.postMessage({ type: "kiloclaw.executeAction", conversationId, messageId, groupId, value }),
sendTyping: (conversationId) => vscode.postMessage({ type: "kiloclaw.sendTyping", conversationId }),
sendTypingStop: (conversationId) => vscode.postMessage({ type: "kiloclaw.sendTypingStop", conversationId }),
typingMembers: (conversationId) => {
// Typing members are tracked for the active conversation only.
if (conversationId !== activeConversationId()) return []
return typingMembers()
},
botStatus,
conversationStatus,
markConversationRead: (conversationId) => vscode.postMessage({ type: "kiloclaw.markRead", conversationId }),
openExternal: (url) => vscode.postMessage({ type: "kiloclaw.openExternal", url }),
}
return <ClawContext.Provider value={ctx}>{props.children}</ClawContext.Provider>
+6
View File
@@ -27,6 +27,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "المثيل",
"kiloClaw.sidebar.unknown": "غير معروف",
"kiloClaw.sidebar.bot": "الروبوت",
"kiloClaw.sidebar.botStatus": "حالة الروبوت",
"kiloClaw.sidebar.context": "السياق",
"kiloClaw.sidebar.used": "مستخدم",
"kiloClaw.sidebar.tokens": "الرموز",
"kiloClaw.sidebar.model": "النموذج",
"kiloClaw.sidebar.provider": "المزود",
"kiloClaw.sidebar.details": "التفاصيل",
"kiloClaw.sidebar.region": "المنطقة",
"kiloClaw.sidebar.version": "الإصدار",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instância",
"kiloClaw.sidebar.unknown": "Desconhecido",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Status do Bot",
"kiloClaw.sidebar.context": "Contexto",
"kiloClaw.sidebar.used": "Usado",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Modelo",
"kiloClaw.sidebar.provider": "Provedor",
"kiloClaw.sidebar.details": "Detalhes",
"kiloClaw.sidebar.region": "Região",
"kiloClaw.sidebar.version": "Versão",
+6
View File
@@ -29,6 +29,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instanca",
"kiloClaw.sidebar.unknown": "Nepoznato",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Status bota",
"kiloClaw.sidebar.context": "Kontekst",
"kiloClaw.sidebar.used": "Korišteno",
"kiloClaw.sidebar.tokens": "Tokeni",
"kiloClaw.sidebar.model": "Model",
"kiloClaw.sidebar.provider": "Provajder",
"kiloClaw.sidebar.details": "Detalji",
"kiloClaw.sidebar.region": "Regija",
"kiloClaw.sidebar.version": "Verzija",
+6
View File
@@ -29,6 +29,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instans",
"kiloClaw.sidebar.unknown": "Ukendt",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Bot-status",
"kiloClaw.sidebar.context": "Kontekst",
"kiloClaw.sidebar.used": "Brugt",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Model",
"kiloClaw.sidebar.provider": "Udbyder",
"kiloClaw.sidebar.details": "Detaljer",
"kiloClaw.sidebar.region": "Region",
"kiloClaw.sidebar.version": "Version",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instanz",
"kiloClaw.sidebar.unknown": "Unbekannt",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Bot-Status",
"kiloClaw.sidebar.context": "Kontext",
"kiloClaw.sidebar.used": "Verwendet",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Modell",
"kiloClaw.sidebar.provider": "Anbieter",
"kiloClaw.sidebar.details": "Details",
"kiloClaw.sidebar.region": "Region",
"kiloClaw.sidebar.version": "Version",
@@ -8,13 +8,52 @@ export const dict = {
"kiloClaw.chat.placeholder": "Type a message... (Enter to send)",
"kiloClaw.chat.online": "Online",
"kiloClaw.chat.offline": "Offline",
"kiloClaw.chat.idle": "Idle",
"kiloClaw.chat.unknown": "Unknown",
"kiloClaw.chat.empty": "No messages yet. Say hello!",
"kiloClaw.chat.emptyWithBot": "No messages yet. Say hello to {bot}!",
"kiloClaw.chat.send": "Send",
"kiloClaw.chat.waitingBotStatus": "Waiting for bot status...",
"kiloClaw.chat.botOffline": "The bot is offline",
// Conversations
"kiloClaw.conversations.title": "Conversations",
"kiloClaw.conversations.new": "New conversation",
"kiloClaw.conversations.empty": "No conversations yet",
"kiloClaw.conversations.untitled": "Untitled",
"kiloClaw.conversations.rename": "Rename",
"kiloClaw.conversations.leave": "Leave",
"kiloClaw.conversations.selectOne": "Select a conversation to start chatting",
"kiloClaw.conversations.groupToday": "Today",
"kiloClaw.conversations.groupYesterday": "Yesterday",
"kiloClaw.conversations.groupThisWeek": "This week",
"kiloClaw.conversations.groupOlder": "Older",
// Typing
"kiloClaw.typing.one": "{name} is typing...",
"kiloClaw.typing.many": "{count} people are typing...",
// Messages
"kiloClaw.message.bot": "KiloClaw",
"kiloClaw.message.you": "You",
"kiloClaw.message.thinking": "Thinking...",
"kiloClaw.message.deleted": "[deleted message]",
"kiloClaw.message.replyDeleted": "original message deleted",
"kiloClaw.message.notDelivered": "Not delivered",
"kiloClaw.message.edited": "(edited)",
"kiloClaw.message.reply": "Reply",
"kiloClaw.message.replyTo": "Replying to",
"kiloClaw.message.cancelReply": "Cancel reply",
"kiloClaw.message.edit": "Edit",
"kiloClaw.message.delete": "Delete",
"kiloClaw.message.confirmDelete": "Delete this message?",
"kiloClaw.message.cancel": "Cancel",
"kiloClaw.message.save": "Save",
"kiloClaw.message.react": "React",
"kiloClaw.message.removeReaction": "Remove reaction",
"kiloClaw.message.copy": "Copy",
"kiloClaw.message.copied": "Copied to clipboard",
"kiloClaw.message.copyFailed": "Failed to copy",
// Error
"kiloClaw.error.retry": "Retry",
@@ -41,6 +80,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instance",
"kiloClaw.sidebar.unknown": "Unknown",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Bot Status",
"kiloClaw.sidebar.context": "Context",
"kiloClaw.sidebar.used": "Used",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Model",
"kiloClaw.sidebar.provider": "Provider",
"kiloClaw.sidebar.details": "Details",
"kiloClaw.sidebar.region": "Region",
"kiloClaw.sidebar.version": "Version",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instancia",
"kiloClaw.sidebar.unknown": "Desconocido",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Estado del bot",
"kiloClaw.sidebar.context": "Contexto",
"kiloClaw.sidebar.used": "Usado",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Modelo",
"kiloClaw.sidebar.provider": "Proveedor",
"kiloClaw.sidebar.details": "Detalles",
"kiloClaw.sidebar.region": "Región",
"kiloClaw.sidebar.version": "Versión",
+6
View File
@@ -29,6 +29,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instance",
"kiloClaw.sidebar.unknown": "Inconnu",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Statut du bot",
"kiloClaw.sidebar.context": "Contexte",
"kiloClaw.sidebar.used": "Utilisé",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Modèle",
"kiloClaw.sidebar.provider": "Fournisseur",
"kiloClaw.sidebar.details": "Détails",
"kiloClaw.sidebar.region": "Région",
"kiloClaw.sidebar.version": "Version",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "インスタンス",
"kiloClaw.sidebar.unknown": "不明",
"kiloClaw.sidebar.bot": "ボット",
"kiloClaw.sidebar.botStatus": "ボットステータス",
"kiloClaw.sidebar.context": "コンテキスト",
"kiloClaw.sidebar.used": "使用中",
"kiloClaw.sidebar.tokens": "トークン",
"kiloClaw.sidebar.model": "モデル",
"kiloClaw.sidebar.provider": "プロバイダー",
"kiloClaw.sidebar.details": "詳細",
"kiloClaw.sidebar.region": "リージョン",
"kiloClaw.sidebar.version": "バージョン",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "인스턴스",
"kiloClaw.sidebar.unknown": "알 수 없음",
"kiloClaw.sidebar.bot": "봇",
"kiloClaw.sidebar.botStatus": "봇 상태",
"kiloClaw.sidebar.context": "컨텍스트",
"kiloClaw.sidebar.used": "사용됨",
"kiloClaw.sidebar.tokens": "토큰",
"kiloClaw.sidebar.model": "모델",
"kiloClaw.sidebar.provider": "공급자",
"kiloClaw.sidebar.details": "상세 정보",
"kiloClaw.sidebar.region": "리전",
"kiloClaw.sidebar.version": "버전",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instantie",
"kiloClaw.sidebar.unknown": "Onbekend",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Botstatus",
"kiloClaw.sidebar.context": "Context",
"kiloClaw.sidebar.used": "Gebruikt",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Model",
"kiloClaw.sidebar.provider": "Provider",
"kiloClaw.sidebar.details": "Details",
"kiloClaw.sidebar.region": "Regio",
"kiloClaw.sidebar.version": "Versie",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instans",
"kiloClaw.sidebar.unknown": "Ukjent",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Bot-status",
"kiloClaw.sidebar.context": "Kontekst",
"kiloClaw.sidebar.used": "Brukt",
"kiloClaw.sidebar.tokens": "Tokens",
"kiloClaw.sidebar.model": "Modell",
"kiloClaw.sidebar.provider": "Leverandør",
"kiloClaw.sidebar.details": "Detaljer",
"kiloClaw.sidebar.region": "Region",
"kiloClaw.sidebar.version": "Versjon",
+6
View File
@@ -28,6 +28,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Instancja",
"kiloClaw.sidebar.unknown": "Nieznany",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Status bota",
"kiloClaw.sidebar.context": "Kontekst",
"kiloClaw.sidebar.used": "Użyte",
"kiloClaw.sidebar.tokens": "Tokeny",
"kiloClaw.sidebar.model": "Model",
"kiloClaw.sidebar.provider": "Dostawca",
"kiloClaw.sidebar.details": "Szczegóły",
"kiloClaw.sidebar.region": "Region",
"kiloClaw.sidebar.version": "Wersja",
+6
View File
@@ -29,6 +29,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Экземпляр",
"kiloClaw.sidebar.unknown": "Неизвестно",
"kiloClaw.sidebar.bot": "Бот",
"kiloClaw.sidebar.botStatus": "Статус бота",
"kiloClaw.sidebar.context": "Контекст",
"kiloClaw.sidebar.used": "Использовано",
"kiloClaw.sidebar.tokens": "Токены",
"kiloClaw.sidebar.model": "Модель",
"kiloClaw.sidebar.provider": "Провайдер",
"kiloClaw.sidebar.details": "Детали",
"kiloClaw.sidebar.region": "Регион",
"kiloClaw.sidebar.version": "Версия",
+6
View File
@@ -27,6 +27,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "อินสแตนซ์",
"kiloClaw.sidebar.unknown": "ไม่ทราบ",
"kiloClaw.sidebar.bot": "บอท",
"kiloClaw.sidebar.botStatus": "สถานะบอท",
"kiloClaw.sidebar.context": "บริบท",
"kiloClaw.sidebar.used": "ใช้แล้ว",
"kiloClaw.sidebar.tokens": "โทเค็น",
"kiloClaw.sidebar.model": "โมเดล",
"kiloClaw.sidebar.provider": "ผู้ให้บริการ",
"kiloClaw.sidebar.details": "รายละเอียด",
"kiloClaw.sidebar.region": "ภูมิภาค",
"kiloClaw.sidebar.version": "เวอร์ชัน",
+6
View File
@@ -29,6 +29,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Örnek",
"kiloClaw.sidebar.unknown": "Bilinmiyor",
"kiloClaw.sidebar.bot": "Bot",
"kiloClaw.sidebar.botStatus": "Bot Durumu",
"kiloClaw.sidebar.context": "Bağlam",
"kiloClaw.sidebar.used": "Kullanılan",
"kiloClaw.sidebar.tokens": "Token",
"kiloClaw.sidebar.model": "Model",
"kiloClaw.sidebar.provider": "Sağlayıcı",
"kiloClaw.sidebar.details": "Ayrıntılar",
"kiloClaw.sidebar.region": "Bölge",
"kiloClaw.sidebar.version": "Sürüm",
+6
View File
@@ -29,6 +29,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "Екземпляр",
"kiloClaw.sidebar.unknown": "Невідомо",
"kiloClaw.sidebar.bot": "Бот",
"kiloClaw.sidebar.botStatus": "Статус бота",
"kiloClaw.sidebar.context": "Контекст",
"kiloClaw.sidebar.used": "Використано",
"kiloClaw.sidebar.tokens": "Токени",
"kiloClaw.sidebar.model": "Модель",
"kiloClaw.sidebar.provider": "Провайдер",
"kiloClaw.sidebar.details": "Деталі",
"kiloClaw.sidebar.region": "Регіон",
"kiloClaw.sidebar.version": "Версія",
+6
View File
@@ -27,6 +27,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "实例",
"kiloClaw.sidebar.unknown": "未知",
"kiloClaw.sidebar.bot": "机器人",
"kiloClaw.sidebar.botStatus": "机器人状态",
"kiloClaw.sidebar.context": "上下文",
"kiloClaw.sidebar.used": "已使用",
"kiloClaw.sidebar.tokens": "令牌",
"kiloClaw.sidebar.model": "模型",
"kiloClaw.sidebar.provider": "提供商",
"kiloClaw.sidebar.details": "详情",
"kiloClaw.sidebar.region": "区域",
"kiloClaw.sidebar.version": "版本",
+6
View File
@@ -27,6 +27,12 @@ export const dict = {
"kiloClaw.sidebar.instance": "執行個體",
"kiloClaw.sidebar.unknown": "未知",
"kiloClaw.sidebar.bot": "機器人",
"kiloClaw.sidebar.botStatus": "機器人狀態",
"kiloClaw.sidebar.context": "上下文",
"kiloClaw.sidebar.used": "已使用",
"kiloClaw.sidebar.tokens": "權杖",
"kiloClaw.sidebar.model": "模型",
"kiloClaw.sidebar.provider": "供應商",
"kiloClaw.sidebar.details": "詳細資訊",
"kiloClaw.sidebar.region": "地區",
"kiloClaw.sidebar.version": "版本",
@@ -134,67 +134,6 @@
padding: 40px 0;
}
/* Message bubble */
.kiloclaw-msg {
padding: 8px 12px;
border-radius: 6px;
max-width: 85%;
font-size: var(--kilo-font-size-13);
line-height: 1.5;
}
.kiloclaw-msg-user {
align-self: flex-end;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border-bottom-right-radius: 2px;
}
.kiloclaw-msg-bot {
align-self: flex-start;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-foreground);
border: 1px solid var(--vscode-panel-border);
border-bottom-left-radius: 2px;
}
.kiloclaw-msg-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
gap: 12px;
}
.kiloclaw-msg-author {
font-weight: 600;
font-size: var(--kilo-font-size-11);
opacity: 0.8;
}
.kiloclaw-msg-time {
font-size: var(--kilo-font-size-10);
opacity: 0.6;
}
.kiloclaw-msg-body {
white-space: pre-wrap;
word-break: break-word;
}
/* Markdown inside bot messages — reset spacing for inline rendering */
.kiloclaw-msg-bot .kiloclaw-msg-body [data-component="markdown"] {
white-space: normal;
}
.kiloclaw-msg-bot .kiloclaw-msg-body [data-component="markdown"] p:first-child {
margin-top: 0;
}
.kiloclaw-msg-bot .kiloclaw-msg-body [data-component="markdown"] p:last-child {
margin-bottom: 0;
}
/* Input area */
.kiloclaw-input-wrap {
display: flex;
@@ -244,13 +183,6 @@
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
}
.kiloclaw-sidebar-title {
margin: 0 0 16px;
font-size: var(--kilo-font-size-14);
font-weight: 600;
color: var(--vscode-foreground);
}
.kiloclaw-sidebar-section {
margin-bottom: 16px;
}
@@ -264,6 +196,39 @@
margin-bottom: 6px;
}
/* Conversation title at the top of the sidebar (mirrors the session
route layout). Clicking enters rename mode. */
.kiloclaw-sidebar-titlebtn {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: var(--kilo-font-size-14);
font-weight: 600;
color: var(--vscode-foreground);
cursor: pointer;
text-align: left;
font-family: inherit;
width: 100%;
word-break: break-word;
}
.kiloclaw-sidebar-titlebtn:hover {
text-decoration: underline;
}
.kiloclaw-sidebar-titleinput {
width: 100%;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 3px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 4px 8px;
font-size: var(--kilo-font-size-14);
font-weight: 600;
font-family: inherit;
}
.kiloclaw-sidebar-row {
display: flex;
align-items: center;
@@ -276,6 +241,7 @@
display: flex;
justify-content: space-between;
font-size: var(--kilo-font-size-13);
gap: 8px;
color: var(--vscode-foreground);
padding: 2px 0;
}
@@ -283,3 +249,634 @@
.kiloclaw-sidebar-muted {
color: var(--vscode-descriptionForeground);
}
.kiloclaw-sidebar-value-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* ── Conversation list ─────────────────────────────────────────────── */
.kiloclaw-convlist {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
display: flex;
flex-direction: column;
overflow: hidden;
}
.kiloclaw-convlist-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
}
.kiloclaw-convlist-title {
font-size: var(--kilo-font-size-13);
font-weight: 600;
color: var(--vscode-foreground);
}
.kiloclaw-convlist-scroll {
flex: 1;
overflow-y: auto;
}
.kiloclaw-convlist-empty {
padding: 24px 16px;
text-align: center;
font-size: var(--kilo-font-size-12);
color: var(--vscode-descriptionForeground);
}
.kiloclaw-convlist-group {
margin-bottom: 8px;
}
.kiloclaw-convlist-grouplabel {
padding: 8px 16px 4px;
font-size: var(--kilo-font-size-10);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
}
.kiloclaw-convitem {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
cursor: pointer;
font-size: var(--kilo-font-size-13);
color: var(--vscode-foreground);
border-left: 2px solid transparent;
}
.kiloclaw-convitem:hover {
background: var(--vscode-list-hoverBackground);
}
.kiloclaw-convitem-active {
background: var(--vscode-list-activeSelectionBackground);
border-left-color: var(--vscode-focusBorder);
}
.kiloclaw-convitem-title {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kiloclaw-convitem-unread {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--vscode-focusBorder);
flex-shrink: 0;
}
.kiloclaw-convitem-renameinput {
flex: 1;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 3px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 2px 6px;
font-size: var(--kilo-font-size-12);
font-family: inherit;
}
/* Actions reserve their layout space on every row and only toggle
visibility on hover. Using `display: none` here instead would let the
row's height collapse to the title text, then expand to fit the icon
buttons on hover producing a visible layout jump as the mouse moves
between items. `visibility: hidden` keeps the row's height fixed. */
.kiloclaw-convitem-actions {
display: flex;
gap: 2px;
align-items: center;
visibility: hidden;
}
.kiloclaw-convitem:hover .kiloclaw-convitem-actions {
visibility: visible;
}
/* ── Active message area ───────────────────────────────────────────── */
.kiloclaw-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.kiloclaw-empty-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-descriptionForeground);
font-size: var(--kilo-font-size-13);
padding: 24px;
}
.kiloclaw-area-messages-wrap {
position: relative;
flex: 1;
overflow: hidden;
}
.kiloclaw-area-messages {
height: 100%;
overflow-y: auto;
padding: 12px 0;
display: flex;
flex-direction: column;
}
.kiloclaw-scrollbtn {
position: absolute;
right: 16px;
bottom: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font-size: var(--kilo-font-size-16);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.kiloclaw-typing {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-size: var(--kilo-font-size-11);
color: var(--vscode-descriptionForeground);
font-style: italic;
flex-shrink: 0;
}
.kiloclaw-reply-preview {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--vscode-editorWidget-background);
border-top: 1px solid var(--vscode-panel-border);
font-size: var(--kilo-font-size-12);
color: var(--vscode-descriptionForeground);
}
.kiloclaw-reply-preview-label {
font-weight: 600;
}
.kiloclaw-reply-preview-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Dot states ────────────────────────────────────────────────────── */
.kiloclaw-dot-idle {
background: var(--vscode-editorWarning-foreground, #cca700);
}
.kiloclaw-dot-unknown {
background: var(--vscode-descriptionForeground, #888);
opacity: 0.5;
}
/* Message bubble
*
* Layout mirrors the web UI (cloud/apps/web/src/app/(app)/claw/kilo-chat/
* components/MessageBubble.tsx):
*
* [own] [bot/other]
* column column
* (author) (none) author
* (reply) (reply)
* [bubble] [bubble]
* reacts reacts
*
*
* Each message is a flex row that justifies its column to either end or
* start. Author/reply labels stack above the bubble; timestamp + edited
* markers render inside the bubble's footer (right-aligned); reactions
* sit below. The per-message toolbar is absolutely positioned beside the
* bubble and revealed on hover of the whole row.
*/
.kiloclaw-msg {
position: relative;
display: flex;
padding: 2px 16px;
}
.kiloclaw-msg-own {
justify-content: flex-end;
}
.kiloclaw-msg-bot,
.kiloclaw-msg-other {
justify-content: flex-start;
}
.kiloclaw-msg-column {
display: flex;
flex-direction: column;
max-width: 75%;
min-width: 0;
}
.kiloclaw-msg-own .kiloclaw-msg-column {
align-items: flex-end;
}
.kiloclaw-msg-bot .kiloclaw-msg-column,
.kiloclaw-msg-other .kiloclaw-msg-column {
align-items: flex-start;
}
.kiloclaw-msg-author {
font-size: var(--kilo-font-size-12);
font-weight: 500;
color: var(--vscode-descriptionForeground);
margin-bottom: 2px;
padding: 0 4px;
}
.kiloclaw-msg-reply {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 2px;
padding: 0 4px;
font-size: var(--kilo-font-size-11);
color: var(--vscode-descriptionForeground);
max-width: 100%;
min-width: 0;
}
.kiloclaw-msg-reply-arrow {
flex-shrink: 0;
}
.kiloclaw-msg-reply-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.kiloclaw-msg-reply-deleted {
font-style: italic;
opacity: 0.7;
}
/* Bubble wrapper relative so we can absolutely position the toolbar
and reaction popup to the side. */
.kiloclaw-msg-bubble-wrap {
position: relative;
min-width: 0;
max-width: 100%;
}
.kiloclaw-msg-bubble {
padding: 6px 12px;
border-radius: 16px;
font-size: var(--kilo-font-size-13);
line-height: 1.5;
overflow-wrap: break-word;
word-wrap: break-word;
overflow: hidden;
}
/* Own messages — primary (VS Code button) palette. */
.kiloclaw-msg-own .kiloclaw-msg-bubble {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
/* Bot/other messages — muted editor-widget palette. */
.kiloclaw-msg-bot .kiloclaw-msg-bubble,
.kiloclaw-msg-other .kiloclaw-msg-bubble {
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-foreground);
}
.kiloclaw-msg-body {
white-space: pre-wrap;
word-break: break-word;
}
.kiloclaw-msg-text {
white-space: pre-wrap;
word-break: break-word;
}
/* Markdown inside bot bubbles strip default paragraph margins so the
first/last block sit flush with the bubble padding, and let markdown
render as normal flow rather than pre-wrap. */
.kiloclaw-msg-bot .kiloclaw-msg-body [data-component="markdown"],
.kiloclaw-msg-other .kiloclaw-msg-body [data-component="markdown"] {
white-space: normal;
}
.kiloclaw-msg-bot .kiloclaw-msg-body [data-component="markdown"] p:first-child,
.kiloclaw-msg-other .kiloclaw-msg-body [data-component="markdown"] p:first-child {
margin-top: 0;
}
.kiloclaw-msg-bot .kiloclaw-msg-body [data-component="markdown"] p:last-child,
.kiloclaw-msg-other .kiloclaw-msg-body [data-component="markdown"] p:last-child {
margin-bottom: 0;
}
/* Footer inside the bubble (timestamp + edited + delivery) */
.kiloclaw-msg-footer {
margin-top: 2px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
font-size: var(--kilo-font-size-10);
}
.kiloclaw-msg-own .kiloclaw-msg-footer {
color: var(--vscode-button-foreground);
opacity: 0.7;
}
.kiloclaw-msg-bot .kiloclaw-msg-footer,
.kiloclaw-msg-other .kiloclaw-msg-footer {
color: var(--vscode-descriptionForeground);
}
.kiloclaw-msg-deleted {
font-style: italic;
opacity: 0.5;
}
.kiloclaw-msg-thinking {
font-style: italic;
opacity: 0.6;
}
.kiloclaw-msg-failed {
color: var(--vscode-errorForeground);
}
/* Action approval buttons */
.kiloclaw-msg-actions {
margin-top: 8px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.kiloclaw-msg-action {
padding: 4px 10px;
font-size: var(--kilo-font-size-12);
border-radius: 4px;
border: none;
cursor: pointer;
font-family: inherit;
}
.kiloclaw-msg-action-primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.kiloclaw-msg-action-danger {
background: var(--vscode-errorForeground, #f14c4c);
color: var(--vscode-button-foreground);
}
.kiloclaw-msg-action-secondary {
background: var(--vscode-button-secondaryBackground, var(--vscode-editorWidget-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.kiloclaw-msg-actions-resolved {
margin-top: 6px;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--kilo-font-size-11);
opacity: 0.7;
}
.kiloclaw-msg-actions-resolved-icon {
font-weight: 700;
}
/* Reactions — sit below the bubble, aligned with the bubble edge. */
.kiloclaw-msg-reactions {
margin-top: 4px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.kiloclaw-msg-own .kiloclaw-msg-reactions {
justify-content: flex-end;
}
.kiloclaw-msg-reaction-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
font-size: var(--kilo-font-size-11);
border-radius: 10px;
background: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-panel-border);
cursor: pointer;
font-family: inherit;
color: var(--vscode-foreground);
}
.kiloclaw-msg-reaction-pill:hover {
background: var(--vscode-list-hoverBackground);
}
/* Per-message toolbar absolutely positioned to the side; shown on
hover of the whole row (so hovering the toolbar itself also keeps it
visible, since it is a descendant of `.kiloclaw-msg`). */
.kiloclaw-msg-toolbar {
position: absolute;
top: 0;
z-index: 10;
display: none;
align-items: center;
gap: 0;
padding: 2px;
background: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.kiloclaw-msg-own .kiloclaw-msg-toolbar {
right: 100%;
margin-right: 4px;
}
.kiloclaw-msg-bot .kiloclaw-msg-toolbar,
.kiloclaw-msg-other .kiloclaw-msg-toolbar {
left: 100%;
margin-left: 4px;
}
.kiloclaw-msg:hover .kiloclaw-msg-toolbar {
display: flex;
}
.kiloclaw-msg-toolbar .kiloclaw-iconbtn-sm {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 3px;
}
/* Quick reaction picker popover (anchored to the toolbar side) */
.kiloclaw-msg-reactionpick {
position: absolute;
top: 30px;
z-index: 20;
display: flex;
gap: 2px;
padding: 2px;
background: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
width: max-content;
}
.kiloclaw-msg-own .kiloclaw-msg-reactionpick {
right: 100%;
margin-right: 4px;
}
.kiloclaw-msg-bot .kiloclaw-msg-reactionpick,
.kiloclaw-msg-other .kiloclaw-msg-reactionpick {
left: 100%;
margin-left: 4px;
}
.kiloclaw-msg-reactionpick-btn {
background: none;
border: none;
padding: 4px 6px;
cursor: pointer;
font-size: var(--kilo-font-size-14);
border-radius: 2px;
}
.kiloclaw-msg-reactionpick-btn:hover {
background: var(--vscode-list-hoverBackground);
}
.kiloclaw-msg-confirm-delete {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--kilo-font-size-13);
}
.kiloclaw-msg-edit {
display: flex;
flex-direction: column;
gap: 4px;
}
.kiloclaw-msg-edit-input {
width: 100%;
resize: vertical;
border: 1px solid currentColor;
border-color: rgba(128, 128, 128, 0.3);
border-radius: 3px;
background: transparent;
color: inherit;
padding: 4px 6px;
font-size: var(--kilo-font-size-13);
font-family: inherit;
min-height: 40px;
}
.kiloclaw-msg-edit-input:focus {
outline: none;
border-color: currentColor;
opacity: 0.8;
}
.kiloclaw-msg-edit-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
margin-top: 2px;
}
/* ── Icon buttons ──────────────────────────────────────────────────── */
.kiloclaw-iconbtn {
background: none;
border: 1px solid var(--vscode-panel-border);
color: var(--vscode-foreground);
border-radius: 3px;
padding: 2px 6px;
cursor: pointer;
font-size: var(--kilo-font-size-14);
font-family: inherit;
}
.kiloclaw-iconbtn:hover {
background: var(--vscode-list-hoverBackground);
}
.kiloclaw-iconbtn-sm {
background: none;
border: none;
color: var(--vscode-foreground);
border-radius: 2px;
padding: 2px 4px;
cursor: pointer;
font-size: var(--kilo-font-size-12);
font-family: inherit;
opacity: 0.7;
}
.kiloclaw-iconbtn-sm:hover {
background: var(--vscode-list-hoverBackground);
opacity: 1;
}
.kiloclaw-iconbtn-danger {
color: var(--vscode-errorForeground);
}
@@ -7,8 +7,21 @@
* SYNC: These types are mirrored from src/kiloclaw/types.ts keep both in sync.
*/
// ── Instance status ─────────────────────────────────────────────────
export type ClawStatus = {
status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null
// Mirrors src/kiloclaw/types.ts. `recovering` / `restoring` are transitional
// states the cloud worker reports when bringing an instance back online.
status:
| "provisioned"
| "starting"
| "restarting"
| "recovering"
| "running"
| "stopped"
| "destroying"
| "restoring"
| null
sandboxId?: string
flyRegion?: string
machineSize?: { cpus: number; memory_mb: number }
@@ -17,16 +30,91 @@ export type ClawStatus = {
lastStoppedAt?: string | null
channelCount?: number
secretCount?: number
userId?: string
botName?: string | null
}
export type ChatMessage = {
id: string
text: string
user: string
created: string
bot: boolean
// ── Kilo Chat content blocks ────────────────────────────────────────
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
// ── Reactions ───────────────────────────────────────────────────────
export type ReactionSummary = {
emoji: string
count: number
memberIds: string[]
}
// ── Messages ────────────────────────────────────────────────────────
export type Message = {
id: string
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
}
// ── Bot / conversation status ───────────────────────────────────────
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
}
// ── Typing ──────────────────────────────────────────────────────────
export type TypingMember = { memberId: string; at: number }
// ── Webview state ──────────────────────────────────────────────────
export type KiloClawState =
| { phase: "loading"; locale: string }
| { phase: "noInstance"; locale: string }
@@ -36,18 +124,37 @@ 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: 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 }
// Note: messages sent from the webview to the extension host are typed in
// src/kiloclaw/types.ts (KiloClawInMessage). The webview dispatches them
// inline via vscode.postMessage and does not import the type.
@@ -82,7 +82,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const mention = useFileMention(vscode, sid, hasGit)
const terminal = useTerminalContext(vscode)
const git = useGitChangesContext(vscode, ctx, hasGit)
const slash = useSlashCommand(vscode, () => (session.variantList().length > 0 ? new Set() : new Set(["variant"])))
const slash = useSlashCommand(vscode, () =>
session.variantList(sid()).length > 0 ? new Set() : new Set(["variant"]),
)
const imageAttach = useImageAttachments()
imageAttach.setFilePathDropHandler((paths) => {
const cwd = server.workspaceDirectory()
@@ -315,7 +317,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const onCompact = () => {
if (session.status() === "busy") return
if (session.messages().length === 0) return
if (!session.selected()) return
if (!session.selected(sid())) return
session.compact()
}
window.addEventListener("compactSession", onCompact)
@@ -387,7 +389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (message.type === "triggerTask") {
if (isDisabled()) return
const sel = session.selected()
const sel = session.selected(sid())
session.sendMessage(message.text, sel?.providerID, sel?.modelID, undefined, undefined, ctx())
}
@@ -679,9 +681,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const mentionFiles = mention.parseFileAttachments(draft)
const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl, filename: img.filename }))
const sel = session.selected()
const pendingId = props.pendingSessionID ?? session.draftSessionID()
const id = sid()
const sel = session.selected(id)
const terminalFile = await terminal.resolveAttachment(message, id).catch((err: Error) => {
showToast({ variant: "error", title: "Terminal context unavailable", description: err.message })
@@ -955,15 +957,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<ModeSwitcher />
<ModelSelector />
<ThinkingSelector />
<Show when={session.hasModelOverride()}>
<ModeSwitcher sessionID={sid} />
<ModelSelector sessionID={sid} />
<ThinkingSelector sessionID={sid} />
<Show when={session.hasModelOverride(sid())}>
<Tooltip value={language.t("prompt.action.resetModel")} placement="top">
<Button
variant="ghost"
size="small"
onClick={() => session.clearModelOverride()}
onClick={() => session.clearModelOverride(sid())}
aria-label={language.t("prompt.action.resetModel")}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
@@ -271,7 +271,7 @@ const ProvidersTab: Component = () => {
</Button>
</Show>
<Button size="large" variant="ghost" onClick={() => disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
{language.t(isCustom(item) ? "common.delete" : "common.disconnect")}
</Button>
</Show>
</div>
@@ -7,7 +7,7 @@
* ModeSwitcher thin wrapper wired to session context for chat usage.
*/
import { Component, createSignal, onCleanup, For, Show } from "solid-js"
import { type Accessor, Component, createSignal, onCleanup, For, Show } from "solid-js"
import { PopupSelector } from "./PopupSelector"
import { Button } from "@kilocode/kilo-ui/button"
import { useSession } from "../../context/session"
@@ -167,15 +167,20 @@ export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (props) => {
// Chat-specific wrapper (backwards-compatible)
// ---------------------------------------------------------------------------
export const ModeSwitcher: Component = () => {
interface ModeSwitcherProps {
sessionID?: Accessor<string | undefined>
}
export const ModeSwitcher: Component<ModeSwitcherProps> = (props) => {
const session = useSession()
const id = () => props.sessionID?.()
return (
<ModeSwitcherBase
agents={session.agents()}
value={session.selectedAgent()}
value={session.selectedAgent(id())}
onSelect={(name) => {
session.selectAgent(name)
session.selectAgent(name, id())
requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt")))
}}
/>
@@ -8,7 +8,7 @@
*/
import { createSignal, createMemo, createEffect, onCleanup, For, Show, createSelector, useContext } from "solid-js"
import type { Component } from "solid-js"
import type { Accessor, Component } from "solid-js"
import { PopupSelector } from "./PopupSelector"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
@@ -730,14 +730,19 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// Chat-specific wrapper (backwards-compatible default export)
// ---------------------------------------------------------------------------
export const ModelSelector: Component = () => {
interface ModelSelectorProps {
sessionID?: Accessor<string | undefined>
}
export const ModelSelector: Component<ModelSelectorProps> = (props) => {
const session = useSession()
const id = () => props.sessionID?.()
return (
<ModelSelectorBase
value={session.selected()}
value={session.selected(id())}
onSelect={(providerID, modelID) => {
session.selectModel(providerID, modelID)
session.selectModel(providerID, modelID, id())
}}
onPick={() => {
requestAnimationFrame(() => window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } })))
@@ -7,7 +7,7 @@
* ThinkingSelector thin wrapper wired to session context for chat usage.
*/
import { Component, createSignal, For, onCleanup, Show } from "solid-js"
import { type Accessor, Component, createSignal, For, onCleanup, Show } from "solid-js"
import { PopupSelector } from "./PopupSelector"
import { Button } from "@kilocode/kilo-ui/button"
import { useSession } from "../../context/session"
@@ -157,14 +157,19 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props
// Chat-specific wrapper (backwards-compatible)
// ---------------------------------------------------------------------------
export const ThinkingSelector: Component = () => {
interface ThinkingSelectorProps {
sessionID?: Accessor<string | undefined>
}
export const ThinkingSelector: Component<ThinkingSelectorProps> = (props) => {
const session = useSession()
const id = () => props.sessionID?.()
return (
<ThinkingSelectorBase
variants={session.variantList()}
value={session.currentVariant()}
onSelect={(value) => session.selectVariant(value)}
variants={session.variantList(id())}
value={session.currentVariant(id())}
onSelect={(value) => session.selectVariant(value, id())}
/>
)
}
@@ -86,10 +86,9 @@ export interface ApplyResult {
/**
* Apply a user-initiated model selection.
*
* Always writes to the global modelSelections map so switching modes
* restores the last-used model (mirrors CLI TUI's model.json behavior).
* When a session is active, also writes to the per-session override so
* the active session uses the chosen model immediately.
* Session-scoped selections write only to the per-session override.
* No-session selections write to the global modelSelections map so sidebar
* default picks still mirror CLI TUI's model.json behavior.
*/
export function applyModel(
store: ModelStore,
@@ -97,7 +96,9 @@ export function applyModel(
selection: ModelSelection,
sessionID: string | undefined,
): ApplyResult {
const modelSelections = { ...store.modelSelections, [agentName]: selection }
const modelSelections = sessionID
? { ...store.modelSelections }
: { ...store.modelSelections, [agentName]: selection }
const sessionOverrides = { ...store.sessionOverrides }
if (sessionID) {
@@ -0,0 +1,25 @@
import type { Message, ModelSelection } from "../types/messages"
export interface MessagePrefs {
agent?: string
model?: ModelSelection
variant?: string
}
export function resolveMessagePrefs(messages: Message[], names: Set<string>): MessagePrefs {
const prefs: MessagePrefs = {}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (!msg) continue
if (!prefs.agent) {
const agent = msg.agent?.trim()
if (agent && names.has(agent)) prefs.agent = agent
}
if (!prefs.model && msg.role === "user" && msg.model?.providerID && msg.model.modelID) {
prefs.model = { providerID: msg.model.providerID, modelID: msg.model.modelID }
prefs.variant = msg.model.variant
}
if (prefs.agent && prefs.model) break
}
return prefs
}

Some files were not shown because too many files have changed in this diff Show More