mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge branch 'main' into agreeable-marlin
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {}
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+3
-6
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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": "الإصدار",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "バージョン",
|
||||
|
||||
@@ -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": "버전",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Версия",
|
||||
|
||||
@@ -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": "เวอร์ชัน",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Версія",
|
||||
|
||||
@@ -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": "版本",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user