diff --git a/.changeset/preserve-question-typing-focus.md b/.changeset/preserve-question-typing-focus.md new file mode 100644 index 0000000000..656023986d --- /dev/null +++ b/.changeset/preserve-question-typing-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep focus in active text fields when questions appear or refresh. diff --git a/.changeset/quiet-compaction-model.md b/.changeset/quiet-compaction-model.md new file mode 100644 index 0000000000..56efcb1eab --- /dev/null +++ b/.changeset/quiet-compaction-model.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Choose a separate model for conversation compaction in Context settings. diff --git a/.changeset/quiet-parallel-agents.md b/.changeset/quiet-parallel-agents.md new file mode 100644 index 0000000000..09665a265c --- /dev/null +++ b/.changeset/quiet-parallel-agents.md @@ -0,0 +1,7 @@ +--- +"kilo-code": patch +"@kilocode/kilo-ui": patch +"@opencode-ai/ui": patch +--- + +Stop offscreen loading animations while preserving their original appearance, and release obsolete transcript pages from memory. diff --git a/.changeset/restore-past-session-mentions.md b/.changeset/restore-past-session-mentions.md new file mode 100644 index 0000000000..7b86c50132 --- /dev/null +++ b/.changeset/restore-past-session-mentions.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Search the latest 5,000 chats across the worktree family and display the best 50 matches in the sidebar and Agent Manager. Skip inaccessible folders from unrelated projects when finding past chats. diff --git a/.changeset/session-tab-attention.md b/.changeset/session-tab-attention.md new file mode 100644 index 0000000000..2a0d3b3225 --- /dev/null +++ b/.changeset/session-tab-attention.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show consistent running, completion, and input-required indicators across session tabs and Agent Manager worktrees. diff --git a/.changeset/steady-compaction-turns.md b/.changeset/steady-compaction-turns.md new file mode 100644 index 0000000000..ecf12b246e --- /dev/null +++ b/.changeset/steady-compaction-turns.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Resume pending requests after automatic compaction without replaying requests that already completed. diff --git a/AGENTS.md b/AGENTS.md index 45963d7b0a..3e2954cb77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi - Prefer single word variable names where possible - Use Bun APIs when possible, like `Bun.file()` - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity +- Prefer `Promise.withResolvers()` for deferreds when runtime/types support it; allow callback/event executors, not async executors or redundant Promise wrapping. ### Avoid let statements diff --git a/packages/core/src/kilocode/zero-id.ts b/packages/core/src/kilocode/zero-id.ts new file mode 100644 index 0000000000..b182d40724 --- /dev/null +++ b/packages/core/src/kilocode/zero-id.ts @@ -0,0 +1,5 @@ +export function zeroID(...parts: (string | number | boolean)[]) { + if (parts.length === 2) return `${parts[0]}\0${parts[1]}` + if (parts.length === 3) return `${parts[0]}\0${parts[1]}\0${parts[2]}` + return parts.join("\0") +} diff --git a/packages/core/test/kilocode/zero-id.test.ts b/packages/core/test/kilocode/zero-id.test.ts new file mode 100644 index 0000000000..9790f1e66e --- /dev/null +++ b/packages/core/test/kilocode/zero-id.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, expectTypeOf, test } from "bun:test" +import { zeroID } from "@opencode-ai/core/kilocode/zero-id" + +describe("zeroID", () => { + test("accepts only string, number, and boolean parts", () => { + expectTypeOf>().toEqualTypeOf<(string | number | boolean)[]>() + expectTypeOf>().toEqualTypeOf() + }) + + test("matches template literals for short composite keys", () => { + const values = ["", "a", "a\0b", "路径", "null", "undefined", 0, -0, -1, 0.5, NaN, Infinity, -Infinity, true, false] + for (const first of values) { + for (const second of values) { + expect(zeroID(first, second)).toBe(`${first}\0${second}`) + for (const third of values) { + expect(zeroID(first, second, third)).toBe(`${first}\0${second}\0${third}`) + } + } + } + }) + + test("matches array joins across arities and preserves empty parts", () => { + const cases: Parameters[] = [ + [], + [""], + [false], + [0], + ["", ""], + ["", "", ""], + ["", "", "", ""], + ["prefix", "", 0, false, "suffix"], + ["/repo", "", "ancestor", "file.ts", true, "modified", 3, 0, false, ""], + ["\0", "a\0b", "", "路径", NaN, -0, -Infinity, true, false], + ] + for (const parts of cases) { + const expected = parts.join("\0") + expect(zeroID(...parts)).toBe(expected) + expect(Buffer.from(zeroID(...parts))).toEqual(Buffer.from(expected)) + } + }) + + test("preserves namespace prefixes, suffixes, and nested keys", () => { + expect(zeroID("project", "")).toBe("project\0") + expect(zeroID("", "file.ts")).toBe("\0file.ts") + expect(zeroID("error", "message")).toBe("error\0message") + expect(zeroID(zeroID("project", "session"), "file.ts")).toBe("project\0session\0file.ts") + expect(zeroID("ab", "c")).not.toBe(zeroID("a", "bc")) + expect(zeroID("project", "session").startsWith(zeroID("project", ""))).toBe(true) + expect(zeroID("project-other", "session").startsWith(zeroID("project", ""))).toBe(false) + }) + + test("supports explicit caller-specific nullish coercion", () => { + for (const value of [null, undefined]) { + expect(zeroID("scope", String(value))).toBe(`scope\0${value}`) + expect(zeroID("scope", value ?? "")).toBe(["scope", value].join("\0")) + } + }) +}) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/session-tab-activity-states-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/session-tab-activity-states-chromium-linux.png new file mode 100644 index 0000000000..501f015c53 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/session-tab-activity-states-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d55567442d6b3d627e64b583e7d4f49a522685efd8455013b17c4c4f0a84d84 +size 8753 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-active-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-active-chromium-linux.png new file mode 100644 index 0000000000..fcee3b4a0b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-active-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d4601147436962bb2fb3db3eede146e2f83a6d88d50f5d7dce016c34817062d +size 18551 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-chromium-linux.png new file mode 100644 index 0000000000..60a76c6029 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f86a360b9c84e63f8614898edf4fd64e0ce41a570e306326f80868ce99483720 +size 18265 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-1280-chromium-linux.png new file mode 100644 index 0000000000..1596eedfdd --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-1280-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c071338a92316478623abbe23cdb262c366917266511a599f405ccdd7e399225 +size 5710 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-chromium-linux.png new file mode 100644 index 0000000000..cf734a5f66 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e275e0ec4ce304fc29c71eaac017872a6828d52fa73a504acce4b779fa389228 +size 7407 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-200-chromium-linux.png new file mode 100644 index 0000000000..937d23edec --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f924b317c2eafaf5112229cc31404591d7526a7e0fba8c1d46cd97597c3a4392 +size 1778 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-chromium-linux.png new file mode 100644 index 0000000000..484a1eb1d7 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:268efbc7a6121b9fa3c2c843308b70f41a2235d2b9447e61ef6ac5c38b23c5b5 +size 3729 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png index 4bb775859c..c41e9aa40d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:273c76976df0e83fa404ae2af2d561f8c38458e3ef1b32f4f5b1990a5906a195 -size 14239 +oid sha256:f62edc4cf5786ca40fececbec0ae08e20096f0bc2bc08258de42c8a524a2366e +size 20730 diff --git a/packages/kilo-ui/src/components/spinner.css b/packages/kilo-ui/src/components/spinner.css new file mode 100644 index 0000000000..d1bd402f55 --- /dev/null +++ b/packages/kilo-ui/src/components/spinner.css @@ -0,0 +1,5 @@ +@media (prefers-reduced-motion: reduce) { + [data-component="spinner"] > rect { + animation: none !important; + } +} diff --git a/packages/kilo-ui/src/stories/spinner.stories.tsx b/packages/kilo-ui/src/stories/spinner.stories.tsx index 11b8ba7f2b..edec1d094c 100644 --- a/packages/kilo-ui/src/stories/spinner.stories.tsx +++ b/packages/kilo-ui/src/stories/spinner.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" -import { Spinner } from "@opencode-ai/ui/spinner" +import { Spinner } from "@kilocode/kilo-ui/spinner" const meta: Meta = { title: "Components/Spinner", @@ -20,6 +20,16 @@ export const Large: Story = { render: () => , } +export const Parallel: Story = { + render: () => ( +
+ {Array.from({ length: 24 }, () => ( + + ))} +
+ ), +} + export const Colored: Story = { render: () => , } diff --git a/packages/kilo-ui/src/styles/index.css b/packages/kilo-ui/src/styles/index.css index adc0af0f53..37b1f47029 100644 --- a/packages/kilo-ui/src/styles/index.css +++ b/packages/kilo-ui/src/styles/index.css @@ -35,6 +35,7 @@ @import "../components/select.css"; @import "../components/session.css"; @import "../components/settings-sidebar.css"; +@import "../components/spinner.css"; @import "../components/status-indicator.css"; @import "../components/switch.css"; @import "../components/tabs.css"; diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 520c484b70..18a1572753 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -142,6 +142,7 @@ import { } from "./kilo-provider/handlers/question" import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" +import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity" import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments" import { completesWithoutStatus } from "./kilo-provider/command-completion" import { KiloProviderMemory } from "./kilo-provider/memory" @@ -392,6 +393,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly refreshes = new Map() private readonly anacondaDesktop = new AnacondaDesktopBridge() private sessionStatusMap = new Map() // Latest status used for destructive config warnings. + private activity: Activity = "idle" + private caption: string | undefined private readonly epochs = new Map>() private readonly requests = new Map() private epoch = 0 @@ -509,7 +512,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (id) this.refreshes.set(id, (this.refreshes.get(id) ?? 0) + 1) } this.currentSession = session - this.opts.tabTitle?.(nativeTitle(session)) + this.updateTitle() + } + + private updateTitle(): void { + if (!this.opts.tabTitle) return + const title = nativeTitle(this.currentSession, this.activity, this.opts.tabLabel) + if (this.caption === title) return + this.caption = title + this.opts.tabTitle(title) } private checkpoint(sid: string, run: () => Promise): void { @@ -1037,6 +1048,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), + activity: (state) => { + if (!isActivity(state)) return + this.activity = state + this.updateTitle() + }, speechToTextModels: () => this.fetchAndSendSpeechToTextModels(), modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)), backgroundJobs: (sessionID, requestID) => this.fetchAndSendBackgroundJobs(sessionID, requestID), diff --git a/packages/kilo-vscode/src/SubAgentViewerProvider.ts b/packages/kilo-vscode/src/SubAgentViewerProvider.ts index a11a98bf1a..046d19325f 100644 --- a/packages/kilo-vscode/src/SubAgentViewerProvider.ts +++ b/packages/kilo-vscode/src/SubAgentViewerProvider.ts @@ -42,7 +42,13 @@ export class SubAgentViewerProvider implements vscode.Disposable { dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), } - const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { hideTopBar: true }) + const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { + hideTopBar: true, + tabTitle: (title) => { + panel.title = title + }, + tabLabel: label, + }) if (directory) provider.setSessionDirectory(sessionID, directory) // Start accepting this session's SSE events as soon as the panel subscribes. // Reasoning deltas are not persisted until the reasoning part finishes. diff --git a/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts b/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts index 04f7235252..601c119c86 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts @@ -1,3 +1,4 @@ +import { zeroID } from "@opencode-ai/core/kilocode/zero-id" import { imageMime } from "../diff/shared/image" import type { Batch, Meta } from "./local-diff-batch" import type { WorktreeDiffEntry } from "./types" @@ -58,7 +59,7 @@ export function createDiffCache(load: Loader) { } const identity = (dir: string, base: string, anc: string, meta: Meta) => - [ + zeroID( dir, base, anc, @@ -69,7 +70,7 @@ export function createDiffCache(load: Loader) { meta.deletions, meta.binary, meta.stamp, - ].join("\0") + ) const cached = (id: string) => { const value = details.get(id) @@ -236,8 +237,8 @@ export function createDiffCache(load: Loader) { } const queued = (id: string, dir: string, base: string, anc: string, meta: Meta, signal?: AbortSignal) => { - const scope = `${dir}\0${base}` - const key = `${scope}\0${anc}` + const scope = zeroID(dir, base) + const key = zeroID(scope, anc) let queue = queues.get(key) if (!queue) { queue = new Map() @@ -270,7 +271,7 @@ export function createDiffCache(load: Loader) { const file = (dir: string, base: string, path: string, signal?: AbortSignal): Promise => { if (signal?.aborted) return Promise.reject(new Error("Diff detail aborted")) - const state = states.get(`${dir}\0${base}`) + const state = states.get(zeroID(dir, base)) if (!state) return load.file(dir, base, path, signal) const meta = state.metas.get(path) if (!meta) return Promise.resolve(null) @@ -292,7 +293,7 @@ export function createDiffCache(load: Loader) { return { summary: async (dir: string, base: string): Promise => { - const id = `${dir}\0${base}` + const id = zeroID(dir, base) const generation = (generations.get(id) ?? 0) + 1 generations.set(id, generation) const result = await load.summary(dir, base) diff --git a/packages/kilo-vscode/src/agent-manager/project/route.ts b/packages/kilo-vscode/src/agent-manager/project/route.ts index 8504e65227..90c93079fe 100644 --- a/packages/kilo-vscode/src/agent-manager/project/route.ts +++ b/packages/kilo-vscode/src/agent-manager/project/route.ts @@ -1,5 +1,7 @@ /** Strict project/resource ownership for Agent Manager multi-project routing. */ +import { zeroID as key } from "@opencode-ai/core/kilocode/zero-id" + export interface ProjectRef { projectId: string } @@ -45,8 +47,6 @@ interface SessionRoute { generation: number } -const key = (projectId: string, id: string) => `${projectId}\0${id}` - export class ProjectRouteService { private readonly projects = new Map() private readonly sessions = new Map() diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 83b307727c..a015fc45af 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -104,6 +104,10 @@ export class VscodeHost implements Host { }) const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { + tabTitle: (title) => { + panel.title = title + }, + tabLabel: "Agent Manager", platform: PLATFORM, snapshotInitialization: SNAPSHOT_INITIALIZATION, slimEditMetadata: true, diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c1284b1d48..1aafaacf42 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -411,7 +411,12 @@ export type WebviewMessage = message: Record } | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } - | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" } + | { + type: "sessionTurnClosed" + sessionID: string + reason: "completed" | "error" | "interrupted" | "superseded" + parentID?: string + } | { type: "permissionRequest" permission: { @@ -556,6 +561,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin type: "sessionTurnClosed", sessionID: event.properties.sessionID, reason: event.properties.reason, + ...(event.properties.parentID ? { parentID: event.properties.parentID } : {}), } case "permission.asked": return { diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 04c5cdb0a5..8b9454e568 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -19,6 +19,7 @@ type Ctx = { exportTranscript: (sessionID: string) => Promise copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void + activity: (state: unknown) => void speechToTextModels: () => Promise modelUsage: (message: ModelUsageMessage) => Promise backgroundJobs: (sessionID: string, requestID: string) => Promise @@ -56,7 +57,7 @@ async function routeBackgroundMessage( } export async function routeEarlyMessage( - message: { type: string; id?: unknown; text?: unknown }, + message: { type: string; id?: unknown; text?: unknown; state?: unknown }, ctx: Ctx, ): Promise { if (message.type === "copyToClipboard") { @@ -88,6 +89,10 @@ export async function routeEarlyMessage( if (typeof input.sessionID === "string") await ctx.exportTranscript(input.sessionID) return true } + if (message.type === "sessionActivity") { + ctx.activity(message.state) + return true + } if (message.type === "sidebar.openSessions") { const input = message as { sessionIDs?: unknown } const ids = Array.isArray(input.sessionIDs) diff --git a/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts b/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts index 7166b9cd9a..c4b7a75716 100644 --- a/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts +++ b/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts @@ -1,12 +1,21 @@ import type { Session } from "@kilocode/sdk/v2/client" +import type { Activity } from "../../webview-ui/src/utils/session-activity" import { EXTENSION_DISPLAY_NAME } from "../constants" const DEFAULT_SESSION_TITLE = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ const TITLE_LIMIT = 19 - -export const nativeTitle = (session: Session | null) => { - const title = session?.title?.trim() - if (!title || DEFAULT_SESSION_TITLE.test(title)) return EXTENSION_DISPLAY_NAME - if (title.length <= TITLE_LIMIT) return title - return `${title.slice(0, TITLE_LIMIT)}...` +const icons: Record = { + idle: "", + busy: "◔", + retry: "◔", + waiting: "⚠", + error: "⚠", + done: "✓", +} + +export const nativeTitle = (session: Session | null, state: Activity = "idle", label?: string) => { + const value = session?.title?.trim() + const title = label ?? (!value || DEFAULT_SESSION_TITLE.test(value) ? EXTENSION_DISPLAY_NAME : value) + const text = label || title.length <= TITLE_LIMIT ? title : `${title.slice(0, TITLE_LIMIT)}...` + return icons[state] ? `${icons[state]} ${text}` : text } diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index b9e9994d89..fa3f56c05c 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -17,6 +17,7 @@ export type KiloProviderOptions = { snapshotInitialization?: "wait" slimEditMetadata?: boolean tabTitle?: (title: string) => void + tabLabel?: string worktreeDirectories?: () => string[] /** * Dynamic root directory override. When present, it replaces the diff --git a/packages/kilo-vscode/src/kilo-provider/session-search.ts b/packages/kilo-vscode/src/kilo-provider/session-search.ts index e525b5a854..0d0e3d33b2 100644 --- a/packages/kilo-vscode/src/kilo-provider/session-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/session-search.ts @@ -44,7 +44,7 @@ export async function handleSessionSearch(input: Input): Promise { try { const res = await client.experimental.session.list( - { worktrees: true, roots: true, directory: dir, limit: 50 }, + { worktrees: true, roots: true, directory: dir, limit: 5_000 }, { throwOnError: true }, ) const sessions: Item[] = res.data diff --git a/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts index 93987b0db3..23ae7e86a6 100644 --- a/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts +++ b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts @@ -1,4 +1,5 @@ import path from "node:path" +import { zeroID } from "@opencode-ai/core/kilocode/zero-id" import type { SSEPayload } from "./sdk-sse-adapter" type Buffered = { event: SSEPayload; directory?: string } @@ -75,12 +76,12 @@ export class ExplicitAbortState { const key = scope(sessionID, directory) return this.states.has(key) ? [key] : [] } - const prefix = `${sessionID}\0` + const prefix = zeroID(sessionID, "") return [...this.states.keys()].filter((key) => key.startsWith(prefix)) } } function scope(sessionID: string, directory: string) { const dir = path.resolve(directory) - return `${sessionID}\0${process.platform === "win32" ? dir.toLowerCase() : dir}` + return zeroID(sessionID, process.platform === "win32" ? dir.toLowerCase() : dir) } diff --git a/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx b/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx index b530ab148b..641d44d59d 100644 --- a/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx +++ b/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx @@ -2,6 +2,8 @@ import { Window } from "happy-dom" import type { QuestionRequest } from "../../webview-ui/src/types/messages" const window = new Window() +const frames: FrameRequestCallback[] = [] +window.document.hasFocus = () => true Object.assign(globalThis, { window, document: window.document, @@ -9,7 +11,7 @@ Object.assign(globalThis, { Element: window.Element, HTMLElement: window.HTMLElement, SVGElement: window.SVGElement, - requestAnimationFrame: () => 0, + requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback), }) const { Show, createSignal } = await import("solid-js") @@ -49,7 +51,10 @@ const language = { t: (key: string) => key, } const root = document.createElement("div") -document.body.append(root) +const prompt = document.createElement("textarea") +prompt.className = "prompt-input" +document.body.append(prompt, root) +prompt.focus() const dispose = render( () => ( @@ -61,9 +66,34 @@ const dispose = render( root, ) +const flush = () => { + while (frames.length) frames.shift()?.(0) +} +flush() +if (document.activeElement !== prompt) throw new Error("New question stole composer focus") +setActive(structuredClone(request)) +flush() +if (document.activeElement !== prompt) throw new Error("Repeated question stole composer focus") + +setActive(undefined) +prompt.blur() +setActive(structuredClone(request)) +prompt.focus() +flush() +if (document.activeElement !== prompt) throw new Error("Scheduled question focus interrupted typing") + +setActive(undefined) +prompt.blur() +setActive(structuredClone(request)) +flush() const option = root.querySelector('[data-slot="question-option"]') const submit = root.querySelector('[data-slot="question-footer-actions"] button') if (!option || !submit) throw new Error("Question controls did not render") +if (document.activeElement !== option) throw new Error("Question did not focus when no text field was active") +option.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })) +if (document.activeElement !== root.querySelector('[data-custom="true"]')) { + throw new Error("Question keyboard navigation did not move to the next option") +} option.click() if (submit.disabled) throw new Error("Submit did not enable after selecting an answer") submit.click() diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx new file mode 100644 index 0000000000..dafc7c6b47 --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -0,0 +1,418 @@ +import assert from "node:assert/strict" +import { Window } from "happy-dom" + +const window = new Window({ url: "http://localhost" }) +const sent: unknown[] = [] +const api = { + postMessage: (message: unknown) => sent.push(message), + getState: () => undefined, + setState: () => {}, +} + +Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + HTMLInputElement: window.HTMLInputElement, + HTMLTextAreaElement: window.HTMLTextAreaElement, + SVGElement: window.SVGElement, + MutationObserver: window.MutationObserver, + IntersectionObserver: window.IntersectionObserver, + ResizeObserver: window.ResizeObserver, + CustomEvent: window.CustomEvent, + Event: window.Event, + MessageEvent: window.MessageEvent, + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + getComputedStyle: window.getComputedStyle.bind(window), + acquireVsCodeApi: () => api, +}) + +const { render } = await import("solid-js/web") +const { For } = await import("solid-js") +const { DragDropProvider, SortableProvider } = await import("@thisbeyond/solid-dnd") +const { renderTab } = await import("../../webview-ui/agent-manager/tab-rendering") +const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode") +const { ServerProvider } = await import("../../webview-ui/src/context/server") +const { ConfigContext } = await import("../../webview-ui/src/context/config") +const { LanguageContext } = await import("../../webview-ui/src/context/language") +const { ProviderContext } = await import("../../webview-ui/src/context/provider") +const { SessionProvider, useSession } = await import("../../webview-ui/src/context/session") + +const provider = { + providers: () => ({}), + connected: () => [], + defaults: () => ({}), + defaultSelection: () => ({ providerID: "kilocode", modelID: "auto" }), + models: () => [], + findModel: () => undefined, + authMethods: () => ({}), + authStates: () => ({}), + isModelValid: () => true, +} +const config = { + config: () => ({}), + globalConfig: () => ({}), + globalDraft: () => ({}), + projectConfig: () => ({}), + collections: () => ({}), + settings: () => ({}), + features: () => ({ indexing: false, sandboxControls: false, backgroundSubagents: false }), + loading: () => false, + isDirty: () => false, + saving: () => false, + saveError: () => null, + updateConfig: () => {}, + updateGlobalConfig: () => {}, + updateProjectConfig: () => {}, + updateSetting: () => {}, + applySetting: () => {}, + saveConfig: () => {}, + discardConfig: () => {}, +} +const language = { + locale: () => "en", + setLocale: () => {}, + userOverride: () => "", + t: (key: string) => key, +} + +const ref = { value: undefined as ReturnType | undefined } +const Probe = () => { + const session = useSession() + ref.value = session + const ids = ["root", "background"] + const deps = { + terms: { activeId: () => undefined }, + REVIEW_TAB_ID: "review", + tabIds: () => ids, + kb: () => ({}), + reviewActive: () => false, + currentSessionID: session.currentSessionID, + visibleTabId: session.currentSessionID, + activePendingId: () => undefined, + isPending: () => false, + activityFor: session.activityFor, + stateLabel: (state: string) => state, + tabLookup: () => new Map(ids.map((id) => [id, { id, title: id }])), + adjacentHint: () => "", + } as Parameters[1] + return ( + + + {(id) => renderTab(id, deps)} + + + ) +} +const host = document.createElement("div") +document.body.append(host) +const step = { value: 0 } +const failures: string[] = [] + +const dispose = render( + () => ( + + + + + + + + + + + + + + ), + host, +) + +const settle = async () => { + await Promise.resolve() + await window.happyDOM.waitUntilComplete() +} +const emit = async (data: unknown) => { + window.dispatchEvent(new MessageEvent("message", { data })) + await settle() +} +const state = (id: string) => { + const value = ref.value + assert(value) + return value.activityFor(id) +} +const check = async (id: string, expected: string) => { + await settle() + step.value += 1 + const value = ref.value + assert(value) + const actual = state(id) + const tab = host.querySelector(`[data-tab-id="${id}"] [data-activity]`) + if (id === "root" || id === "background") assert(tab, `Missing rendered tab for ${id}`) + if (tab && tab.getAttribute("data-activity") !== expected) { + failures.push( + `step ${step.value} ${id}: rendered tab expected ${expected}, got ${tab.getAttribute("data-activity")}`, + ) + } + if (actual !== expected) { + failures.push( + `step ${step.value} ${id}: expected ${expected}, got ${actual}, status=${value.status()}, close=${value.closeReason() ?? "none"}`, + ) + } +} +const info = (id: string, parentID?: string) => ({ + id, + ...(parentID ? { parentID } : {}), + title: id, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}) +const task = (id: string, parentID: string, childID: string, nested: boolean) => ({ + type: "partUpdated", + sessionID: parentID, + messageID: `${parentID}-part-message`, + part: { + type: "tool", + id, + sessionID: parentID, + messageID: `${parentID}-part-message`, + tool: "task", + state: { status: "running", input: {}, ...(nested ? { metadata: { sessionId: childID } } : {}) }, + ...(!nested ? { metadata: { sessionId: childID } } : {}), + }, +}) + +try { + await settle() + await emit({ type: "ready", serverInfo: { port: 1 } }) + await emit({ + type: "sessionsLoaded", + sessions: [info("root"), info("background"), info("durable-child", "root"), info("durable-grand", "durable-child")], + }) + + const value = ref.value + assert(value) + value.setCurrentSessionID("root") + await check("root", "idle") + await check("background", "idle") + + await emit({ type: "sessionStatus", sessionID: "background", status: "busy" }) + await check("background", "busy") + await check("root", "idle") + await emit({ type: "sessionStatus", sessionID: "background", status: "idle" }) + + await emit(task("root-task", "root", "task-child", false)) + await emit(task("child-task", "task-child", "task-grand", true)) + await emit({ type: "sessionStatus", sessionID: "durable-grand", status: "busy" }) + await check("root", "busy") + await check("durable-child", "busy") + await check("durable-grand", "busy") + await emit({ type: "sessionStatus", sessionID: "durable-grand", status: "idle" }) + + await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" }) + await check("root", "busy") + await check("task-child", "busy") + await emit({ type: "sessionStatus", sessionID: "task-child", status: "retry", attempt: 1, message: "retry", next: 1 }) + await check("root", "retry") + await check("task-child", "retry") + await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" }) + await emit({ type: "sessionStatus", sessionID: "task-grand", status: "busy" }) + await check("root", "busy") + await check("task-child", "busy") + await check("task-grand", "busy") + await emit({ type: "sessionStatus", sessionID: "task-grand", status: "offline" }) + await check("root", "error") + await check("task-child", "error") + await check("task-grand", "error") + assert.equal(value.inUseFor("root"), true) + await emit({ type: "sessionStatus", sessionID: "task-grand", status: "idle" }) + await check("root", "idle") + assert.equal(value.inUseFor("root"), false) + + await emit({ + type: "permissionRequest", + permission: { id: "permission", sessionID: "task-grand", toolName: "bash", patterns: [], always: [], args: {} }, + }) + await check("root", "waiting") + await check("task-child", "waiting") + await check("task-grand", "waiting") + await emit({ type: "permissionError", permissionID: "permission", stale: true }) + await check("root", "idle") + assert.equal(value.permissions().length, 0) + await emit({ + type: "permissionRequest", + permission: { id: "permission", sessionID: "durable-grand", toolName: "bash", patterns: [], always: [], args: {} }, + }) + await check("root", "waiting") + await emit({ type: "permissionResolved", permissionID: "permission", sessionID: "durable-grand", response: "once" }) + await check("root", "idle") + assert.equal(value.permissions().length, 0) + + await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" }) + await emit({ + type: "questionRequest", + question: { id: "notice", sessionID: "task-child", blocking: false, questions: [] }, + }) + await check("root", "busy") + await check("task-child", "busy") + await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" }) + await check("root", "idle") + assert.equal(value.inUseFor("root"), true) + assert.equal(value.inUseFor("background"), false) + await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" }) + await emit({ + type: "questionRequest", + question: { id: "notice", sessionID: "task-child", blocking: true, questions: [] }, + }) + await check("root", "waiting") + await emit({ type: "questionResolved", requestID: "notice" }) + await check("root", "busy") + await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" }) + + await emit({ + type: "questionRequest", + question: { + id: "question", + sessionID: "task-child", + questions: [{ question: "Continue?", header: "Confirm", options: [] }], + }, + }) + await check("root", "waiting") + await emit({ type: "questionResolved", requestID: "question" }) + await check("root", "idle") + assert.equal(value.questions().length, 0) + + await emit({ + type: "suggestionRequest", + suggestion: { id: "suggestion", sessionID: "task-grand", text: "Try this", actions: [] }, + }) + await check("root", "waiting") + await emit({ type: "suggestionResolved", requestID: "suggestion" }) + await check("root", "idle") + assert.equal(value.suggestions().length, 0) + + await emit({ type: "sessionTurnClosed", sessionID: "task-child", reason: "completed", parentID: "root" }) + await check("task-child", "done") + await check("root", "idle") + await emit({ type: "sessionTurnClosed", sessionID: "task-child", reason: "error", parentID: "root" }) + await check("task-child", "error") + await check("root", "idle") + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await check("root", "busy") + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await check("root", "idle") + + await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" }) + await check("root", "done") + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await check("root", "busy") + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await check("root", "idle") + + await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" }) + await check("root", "done") + await emit({ + type: "sessionUpdated", + session: { ...info("root"), revert: { messageID: "root-message" } }, + }) + await check("root", "idle") + await emit({ type: "sessionUpdated", session: { ...info("root"), revert: null } }) + await check("root", "idle") + + await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" }) + await check("root", "done") + value.sendMessage("next turn") + await settle() + await check("root", "busy") + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await check("root", "busy") + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await check("root", "idle") + + value.setCurrentSessionID("background") + await emit({ type: "sessionStatus", sessionID: "background", status: "busy" }) + await check("background", "busy") + await emit({ type: "sessionError", eventID: "background-aborted", error: { name: "MessageAbortedError" } }) + await check("background", "busy") + await emit({ type: "connectionState", state: "disconnected", error: "offline" }) + await check("background", "error") + await emit({ type: "connectionState", state: "connecting" }) + await check("background", "error") + await emit({ type: "connectionState", state: "connected" }) + await check("background", "busy") + + await emit({ + type: "questionRequest", + question: { + id: "connection-question", + sessionID: "background", + questions: [{ question: "Reconnect?", header: "Confirm", options: [] }], + }, + }) + await check("background", "waiting") + await emit({ type: "connectionState", state: "error", error: "failed" }) + await check("background", "error") + await emit({ type: "connectionState", state: "connecting" }) + await check("background", "error") + await emit({ type: "connectionState", state: "connected" }) + await check("background", "waiting") + await emit({ type: "questionResolved", requestID: "connection-question" }) + await check("background", "busy") + await emit({ type: "sessionStatus", sessionID: "background", status: "idle" }) + await check("background", "idle") + + value.setCurrentSessionID("root") + await emit({ type: "sessionError", eventID: "aborted", error: { name: "MessageAbortedError" } }) + await check("root", "idle") + await emit({ type: "sessionError", eventID: "root-error", error: { name: "ProviderError" } }) + await check("root", "error") + assert.equal(value.inUseFor("root"), false) + await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" }) + await check("root", "error") + + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await check("root", "busy") + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await check("root", "idle") + + await emit({ + type: "questionRequest", + question: { + id: "deleted-question", + sessionID: "root", + questions: [{ question: "Delete?", header: "Confirm", options: [] }], + }, + }) + await check("root", "waiting") + await emit({ type: "sessionDeleted", sessionID: "root" }) + await check("root", "idle") + assert.equal(value.currentSessionID(), undefined) + assert.equal( + value.sessions().some((item) => item.id === "root"), + false, + ) + assert.equal( + value.questions().some((item) => item.sessionID === "root"), + false, + ) + assert.equal( + sent.some((item) => (item as { type?: string }).type === "sendMessage"), + true, + ) + assert.deepEqual(failures, []) +} finally { + const before = state("background") + dispose() + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sessionStatus", sessionID: "background", status: "retry" } }), + ) + assert.equal(state("background"), before) + await window.happyDOM.cancelAsync() + await window.happyDOM.close() +} + +process.exit(0) diff --git a/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx b/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx index 0817ffc545..4230b002a0 100644 --- a/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx @@ -14,6 +14,7 @@ Object.assign(globalThis, { HTMLTextAreaElement: window.HTMLTextAreaElement, SVGElement: window.SVGElement, MutationObserver: window.MutationObserver, + IntersectionObserver: window.IntersectionObserver, ResizeObserver: window.ResizeObserver, CustomEvent: window.CustomEvent, Event: window.Event, diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index 9d01986cc8..05cfb52e82 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -40,6 +40,7 @@ const mockVscode = { language: "en", machineId: "test-machine", isTelemetryEnabled: false, + onDidChangeTelemetryEnabled: () => ({ dispose: noop }), shell: "/bin/bash", openExternal: noop, }, @@ -171,6 +172,9 @@ const mockVscode = { }, Disposable: class { constructor(private callback: () => void = noop) {} + static from(...items: { dispose: () => void }[]) { + return { dispose: () => items.forEach((item) => item.dispose()) } + } dispose() { this.callback() } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts index 4488414e83..5ebac14cda 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import { Window } from "happy-dom" import { agentManagerFocusTarget, + createChatFocus, focusQuestionOption, hasQuestionOption, preservesTextFocus, @@ -9,6 +10,61 @@ import { import { isTextControl } from "../../webview-ui/src/utils/focus" describe("Agent Manager focus", () => { + it("preserves composer focus through retries unless focus is explicitly requested", async () => { + const window = new Window() + const document = window.document + const frames: FrameRequestCallback[] = [] + const original = { + document: Object.getOwnPropertyDescriptor(globalThis, "document"), + requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"), + } + document.hasFocus = () => true + Object.assign(globalThis, { + document, + requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback), + }) + const prompt = document.createElement("textarea") + prompt.className = "prompt-input" + const dock = document.createElement("div") + dock.setAttribute("data-component", "question-dock") + const option = document.createElement("button") + option.setAttribute("data-slot", "question-option") + dock.append(option) + document.body.append(prompt, dock) + const focus = createChatFocus({ term: () => undefined, history: () => false, review: () => false }) + const flush = () => { + while (frames.length) frames.shift()?.(0) + } + + try { + prompt.focus() + focus() + await Promise.resolve() + expect(document.activeElement).toBe(prompt) + flush() + expect(document.activeElement).toBe(prompt) + + prompt.blur() + focus() + await Promise.resolve() + expect(document.activeElement).toBe(option) + prompt.focus() + flush() + expect(document.activeElement).toBe(prompt) + + focus(true) + await Promise.resolve() + flush() + expect(document.activeElement).toBe(option) + } finally { + for (const [key, descriptor] of Object.entries(original)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + await window.happyDOM.close() + } + }) + it("focuses the first enabled question option", () => { const window = new Window() const root = window.document.createElement("div") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index c647fbcbda..d9b2e2dd19 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import { createRoot } from "solid-js" -import { createWorktreeDiffs } from "../../webview-ui/agent-manager/worktree-diffs" +import { createWorktreeDiffs, diffDataKey } from "../../webview-ui/agent-manager/worktree-diffs" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" const diff = (file: string, additions = 1): WorktreeFileDiff => ({ @@ -30,7 +30,37 @@ const withDiffs = (fn: (diffs: ReturnType, sent: Sen }) } +describe("diffDataKey", () => { + it("preserves the nullish fallback without replacing an empty project", () => { + expect(diffDataKey(undefined, "s1")).toBe("single\0s1") + expect(diffDataKey("single", "s1")).toBe("single\0s1") + expect(diffDataKey("", "s1")).toBe("\0s1") + expect(diffDataKey("project", "")).toBe("project\0") + expect(diffDataKey("project", "s1\0file.ts")).toBe("project\0s1\0file.ts") + }) +}) + describe("createWorktreeDiffs", () => { + it.each([undefined, "", "project"])("prunes only the complete project namespace %j", (project) => { + createRoot((dispose) => { + const store = createWorktreeDiffs(vscode([]), () => project) + const sibling = `${project ?? "single"}-other` + for (const owner of [project, sibling]) { + store.onWorktreeDiff({ + type: "agentManager.worktreeDiff", + projectId: owner, + sessionId: "gone#branch", + diffs: [diff("a.ts")], + }) + } + + store.prune(new Set()) + expect(store.diffDatas()[`${project ?? "single"}\0gone#branch`]).toBeUndefined() + expect(store.diffDatas()[`${sibling}\0gone#branch`]).toHaveLength(1) + dispose() + }) + }) + it("stores full diffs per session", () => { withDiffs((diffs) => { diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) diff --git a/packages/kilo-vscode/tests/unit/agent-project-route.test.ts b/packages/kilo-vscode/tests/unit/agent-project-route.test.ts index 9ee54dc8fc..e1487d094f 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-route.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-route.test.ts @@ -50,6 +50,13 @@ describe("ProjectRouteService", () => { ) }) + it("preserves fallback, precedence, and empty fields in composite UI keys", () => { + expect(ProjectRouteService.key({ projectId: "a" })).toBe("a\0local") + expect(ProjectRouteService.key({ projectId: "a", sessionId: "s", worktreeId: "wt" })).toBe("a\0s") + expect(ProjectRouteService.key({ projectId: "a", sessionId: "", worktreeId: "wt" })).toBe("a\0") + expect(ProjectRouteService.key({ projectId: "", worktreeId: "" })).toBe("\0") + }) + describe("safe resolution (non-throwing)", () => { it("trySessionDirectory returns the exact dir for an unambiguous raw id", () => { const routes = new ProjectRouteService() diff --git a/packages/kilo-vscode/tests/unit/config-utils.test.ts b/packages/kilo-vscode/tests/unit/config-utils.test.ts index 5ca90cb714..3bbb320979 100644 --- a/packages/kilo-vscode/tests/unit/config-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/config-utils.test.ts @@ -392,6 +392,27 @@ describe("ConfigState", () => { }) }) + it("sets and clears the compaction model without changing other settings", () => { + const s = new ConfigState() + const cfg: Config = { + model: "kilo/openai/gpt-4.1", + agent: { compaction: { prompt: "Keep task details" }, code: { model: "kilo/openai/gpt-4.1" } }, + } + const model = "kilo/anthropic/claude-haiku-4-5" + s.handleConfigLoaded(cfg) + s.updateConfig({ agent: { compaction: { model } } }) + + expect(s.config).toEqual({ + ...cfg, + agent: { ...cfg.agent, compaction: { prompt: "Keep task details", model } }, + }) + s.updateConfig({ agent: { compaction: { model: null } } }) + + expect(s.config).toEqual(cfg) + expect(s.draft.agent?.compaction?.model).toBeNull() + expect(configUnsetPaths(s.draft)).toEqual([["agent", "compaction", "model"]]) + }) + describe("agent permission patches", () => { it("merges nested per-agent permission patches into existing rules", () => { const s = new ConfigState() diff --git a/packages/kilo-vscode/tests/unit/early-message.test.ts b/packages/kilo-vscode/tests/unit/early-message.test.ts index cad4c42b43..5329af16d8 100644 --- a/packages/kilo-vscode/tests/unit/early-message.test.ts +++ b/packages/kilo-vscode/tests/unit/early-message.test.ts @@ -43,6 +43,17 @@ describe("routeEarlyMessage clipboard handling", () => { }) }) +describe("routeEarlyMessage activity", () => { + it("forwards authoritative webview presentation state without interpreting session events", async () => { + const calls: unknown[] = [] + const ctx = { activity: (state: unknown) => calls.push(state) } as Ctx + for (const state of ["busy", "waiting", "done", "error", "idle"]) { + expect(await routeEarlyMessage({ type: "sessionActivity", state }, ctx)).toBe(true) + } + expect(calls).toEqual(["busy", "waiting", "done", "error", "idle"]) + }) +}) + describe("routeEarlyMessage background jobs", () => { it("forwards list request correlation", async () => { const calls: unknown[] = [] diff --git a/packages/kilo-vscode/tests/unit/explicit-abort.test.ts b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts index 1d2f8d06f9..dd3c93fbe5 100644 --- a/packages/kilo-vscode/tests/unit/explicit-abort.test.ts +++ b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts @@ -78,6 +78,30 @@ describe("explicit abort state", () => { expect(state.event(close("interrupted"), "/repo/a")).toBe(false) }) + it.each(["", "session"])("removes only the exact session prefix %j across directories", (session) => { + const state = new ExplicitAbortState() + for (const directory of ["/repo/a", "/repo/b"]) { + const id = state.begin(session, directory) + state.finish(session, directory, id, true) + } + const sibling = `${session}-other` + const id = state.begin(sibling, "/repo/a") + state.finish(sibling, "/repo/a", id, true) + + state.remove(session) + expect(state.event(close("interrupted", session), "/repo/a")).toBe(true) + expect(state.event(close("interrupted", session), "/repo/b")).toBe(true) + expect(state.event(close("interrupted", sibling))).toBe(false) + }) + + it("normalizes directories before building scope keys", () => { + const state = new ExplicitAbortState() + const id = state.begin("session", "/repo/nested/..") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo/.")).toBe(false) + }) + it("clears suppression when an idle session becomes busy again", () => { const state = new ExplicitAbortState() const id = state.begin("session", "/repo") diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 30df1c1210..add791708e 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -7,6 +7,7 @@ import { buildMentionResults, buildSessionAttachments, filterMentionResults, + filterSessions, getMentionRemovalRange, getPastChatsMentionResult, isCursorAtMentionEnd, @@ -119,6 +120,39 @@ describe("filterMentionResults", () => { }) }) +describe("filterSessions", () => { + const sessions = Array.from({ length: 60 }, (_, index) => ({ + id: `ses_${index}`, + title: `Recent session ${index}`, + updated: 60 - index, + worktreeName: "branch", + })) + + it("shows the first 50 sessions in their existing order without a query", () => { + expect(filterSessions(sessions, "")).toEqual(sessions.slice(0, 50)) + }) + + it("limits broad search results to 50 matches", () => { + expect(filterSessions(sessions, "recent")).toHaveLength(50) + }) + + it.each(["ORCHID", "old-branch"])("finds older sessions beyond the display limit by %s", (query) => { + const source = { id: "ses_old", title: "Orchid reference source", updated: 0, worktreeName: "old-branch" } + expect(filterSessions([...sessions, source], query)).toEqual([source]) + }) + + it("ranks an older exact match before newer partial matches", () => { + const source = { id: "ses_old", title: "Recent", updated: 0 } + const matches = filterSessions([...sessions, source], "recent") + expect(matches).toHaveLength(50) + expect(matches[0]).toBe(source) + }) + + it("returns no results when nothing matches", () => { + expect(filterSessions(sessions, "zzzz")).toEqual([]) + }) +}) + describe("syncMentionedPaths", () => { it("keeps paths still referenced in text", () => { const paths = new Set(["foo.ts", "bar.ts"]) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 75ed68671e..2c8ce4fb6e 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -376,6 +376,21 @@ describe("mapSSEEventToWebviewMessage", () => { expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" }) }) + it("forwards the parent session ID when a child turn closes", () => { + const event: EventSessionTurnClose = { + id: "evt-child-turn", + type: "session.turn.close", + properties: { sessionID: "child", parentID: "parent", reason: "completed" }, + } + + expect(mapSSEEventToWebviewMessage(event, "child")).toEqual({ + type: "sessionTurnClosed", + sessionID: "child", + reason: "completed", + parentID: "parent", + }) + }) + it("maps session errors with their event identity and message", () => { const event: EventSessionError = { id: "evt-error", diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index dfaf6679d3..352f2679d3 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -467,6 +467,26 @@ describe("diffFile", () => { }) }) + it("keeps empty and named base cache identities separate", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncommitted\n") + runSync(dir, ["commit", "-am", "change seed"]) + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncommitted\nworking\n") + const local = createLocalDiff(git()) + await local.summary(dir, "") + await local.summary(dir, base) + + const current = await local.file(dir, "", "seed.txt") + const ancestor = await local.file(dir, base, "seed.txt") + expect(current?.before).toBe("seed\ncommitted\n") + expect(ancestor?.before).toBe("seed\n") + expect(current?.after).toBe("seed\ncommitted\nworking\n") + expect(ancestor?.after).toBe(current?.after) + expect(await local.file(dir, "", "seed.txt")).toBe(current) + expect(await local.file(dir, base, "seed.txt")).toBe(ancestor) + }) + }) + it("does not cache detail that is aborted before Git completes", async () => { await withRepo(async (dir, base) => { await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") diff --git a/packages/kilo-vscode/tests/unit/native-tab-title.test.ts b/packages/kilo-vscode/tests/unit/native-tab-title.test.ts index f949f88e3e..d1775b1c76 100644 --- a/packages/kilo-vscode/tests/unit/native-tab-title.test.ts +++ b/packages/kilo-vscode/tests/unit/native-tab-title.test.ts @@ -18,4 +18,38 @@ describe("nativeTitle", () => { it("truncates long session titles", () => { expect(nativeTitle(session("Dynamic VS Code tab titles for Kilo sessions"))).toBe("Dynamic VS Code tab...") }) + + it("updates the native panel only from valid webview activity reports", async () => { + const { KiloProvider } = await import("../../src/KiloProvider") + const titles: string[] = [] + const listener: { current?: (message: { type: string; state: unknown }) => Promise } = {} + const provider = new KiloProvider( + { fsPath: "/extension" } as never, + { unregisterVisible: () => {}, unregisterAttached: () => {} } as never, + undefined, + { tabTitle: (title) => titles.push(title) }, + ) + const internal = provider as unknown as { setupWebviewMessageHandler: (webview: unknown) => void } + internal.setupWebviewMessageHandler({ + onDidReceiveMessage: (handler: NonNullable) => { + listener.current = handler + return { dispose: () => {} } + }, + }) + for (const state of ["busy", "waiting", "done", "error", "idle", "idle", "invalid", null]) { + await listener.current?.({ type: "sessionActivity", state }) + } + expect(titles).toEqual(["◔ Kilo Code", "⚠ Kilo Code", "✓ Kilo Code", "⚠ Kilo Code", "Kilo Code"]) + provider.dispose() + }) + + it("renders the same activity values used by webview tabs and worktrees", () => { + expect(nativeTitle(session("Greeting"), "busy")).toBe("◔ Greeting") + expect(nativeTitle(session("Greeting"), "retry")).toBe("◔ Greeting") + expect(nativeTitle(session("Greeting"), "waiting")).toBe("⚠ Greeting") + expect(nativeTitle(session("Greeting"), "error")).toBe("⚠ Greeting") + expect(nativeTitle(session("Greeting"), "done")).toBe("✓ Greeting") + expect(nativeTitle(session("Greeting"), "idle")).toBe("Greeting") + expect(nativeTitle(session("Greeting"), "waiting", "Agent Manager")).toBe("⚠ Agent Manager") + }) }) diff --git a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts index 53515eb2dd..54388802f9 100644 --- a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts +++ b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts @@ -1,66 +1,72 @@ import { describe, expect, it } from "bun:test" -import { createSessionBusy, createWorktreeBusy } from "../../webview-ui/agent-manager/project/session-busy" +import { createSessionActivity, createWorktreeActivity } from "../../webview-ui/agent-manager/project/session-busy" import type { ExtensionMessage } from "../../webview-ui/src/types/messages" +import type { Activity } from "../../webview-ui/src/utils/session-activity" -const options = (statuses: Record) => ({ - statuses: () => statuses, - permissions: () => [], - questions: () => [], +const options = (values: Record) => ({ managed: () => [ - { id: "unknown", worktreeId: "wt-unknown" }, - { id: "idle", worktreeId: "wt-idle" }, - { id: "working", worktreeId: "wt-working" }, + { id: "current-wt", worktreeId: "wt-current" }, + { id: "current-other", worktreeId: "wt-other" }, + { id: "priority-busy", worktreeId: "wt-priority" }, + { id: "priority-waiting", worktreeId: "wt-priority" }, ], - local: () => [], - projects: () => ({ background: [{ id: "unknown", worktreeId: "wt-unknown" }] }), - active: () => "project-a", + local: () => ["current-local"], + projects: () => ({ + background: [ + { id: "background-local", worktreeId: null }, + { id: "background-wt", worktreeId: "wt-background" }, + ], + }), + active: () => "current", + activityFor: (id: string) => values[id] ?? "idle", + inUseFor: (id: string) => ["busy", "retry", "waiting"].includes(values[id] ?? "idle"), }) -const busy = (statuses: Record) => createSessionBusy(options(statuses)) +const activity = (values: Record) => createSessionActivity(options(values)) -describe("createSessionBusy", () => { - it("does not mark stopped or unknown sessions as busy", () => { - const state = busy({ idle: { type: "idle" } }) - - expect(state.agent("wt-unknown")).toBe(false) - expect(state.agent("wt-idle")).toBe(false) - expect(state.project("background", "wt-unknown")).toBe(false) +describe("createSessionActivity", () => { + it("returns idle for groups without sessions", () => { + const state = activity({}) + expect(state.agent("wt-missing")).toBe("idle") + expect(state.project("background", "wt-missing")).toBe("idle") }) - it.each(["busy", "retry"])("marks sessions with an active %s status as busy", (type) => { - expect(busy({ working: { type } }).agent("wt-working")).toBe(true) - }) - - it("keeps running for non-blocking questions", () => { - const questions: { sessionID: string; blocking?: boolean }[] = [{ sessionID: "working", blocking: false }] - const state = createSessionBusy({ - ...options({ working: { type: "busy" } }), - questions: () => questions, + it("scopes local, current, and background project activity", () => { + const state = activity({ + "current-local": "done", + "current-wt": "busy", + "current-other": "error", + "background-local": "retry", + "background-wt": "error", }) - expect(state.agent("wt-working")).toBe(true) - questions[0].blocking = true - expect(state.agent("wt-working")).toBe(false) - delete questions[0].blocking - expect(state.agent("wt-working")).toBe(false) + expect(state.local()).toBe("done") + expect(state.project("current", null)).toBe("done") + expect(state.project("current", "wt-current")).toBe("busy") + expect(state.project("background", null)).toBe("retry") + expect(state.project("background", "wt-background")).toBe("error") }) - it("does not keep a spinner for an offline session", () => { - const state = busy({ working: { type: "offline" }, unknown: { type: "offline" } }) - - expect(state.agent("wt-working")).toBe(false) - expect(state.session("working")).toBe(false) - expect(state.project("background", "wt-unknown")).toBe(false) - expect(state.agent("wt-working", true)).toBe(true) - expect(state.project("background", "wt-unknown", true)).toBe(true) + it("prioritizes attention over errors and work in a group", () => { + const state = activity({ + "current-wt": "busy", + "current-other": "waiting", + "priority-busy": "busy", + "priority-waiting": "waiting", + "background-local": "error", + "background-wt": "waiting", + }) + expect(state.agent("wt-priority")).toBe("waiting") + expect(state.project("current", "wt-other")).toBe("waiting") + expect(state.project("background", "wt-background")).toBe("waiting") }) }) -describe("createWorktreeBusy", () => { +describe("createWorktreeActivity", () => { it("keeps directory activity separate from parent status and other projects", () => { const listeners = new Set<(message: ExtensionMessage) => void>() - const state = createWorktreeBusy({ - ...options({ idle: { type: "idle" }, working: { type: "busy" } }), + const state = createWorktreeActivity({ + ...options({ "current-wt": "done", "current-other": "busy", "current-local": "done" }), worktrees: (project) => [ - { id: "wt-idle", path: project === "background" ? "/other/worktree" : "/repo/worktree" }, + { id: "wt-current", path: project === "background" ? "/other/worktree" : "/repo/worktree" }, ], subscribe: (callback) => { listeners.add(callback) @@ -70,46 +76,60 @@ describe("createWorktreeBusy", () => { const send = (active: string[]) => { for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active }) } - - expect(state.agent("wt-idle")).toBe(false) - expect(state.agent("wt-working")).toBe(true) + expect(state.agent("wt-current")).toBe("done") + expect(state.blocked("wt-current")).toBe(false) + expect(state.agent("wt-other")).toBe("busy") send(["/repo/worktree"]) - expect(state.agent("wt-idle")).toBe(true) - expect(state.project("project-a", "wt-idle")).toBe(true) - expect(state.project("background", "wt-idle")).toBe(false) - expect(state.project("background", null)).toBe(false) - expect(state.agent("missing")).toBe(false) - expect(state.session("idle")).toBe(false) - expect(state.local()).toBe(false) - + expect(state.agent("wt-current")).toBe("busy") + expect(state.blocked("wt-current")).toBe(true) + expect(state.project("current", "wt-current")).toBe("busy") + expect(state.project("background", "wt-current")).toBe("idle") + expect(state.blocked("wt-current", "background")).toBe(false) + expect(state.project("background", null)).toBe("idle") + expect(state.agent("missing")).toBe("idle") + expect(state.local()).toBe("done") send(["/other/worktree"]) - expect(state.agent("wt-idle")).toBe(false) - expect(state.project("background", "wt-idle")).toBe(true) + expect(state.agent("wt-current")).toBe("done") + expect(state.blocked("wt-current")).toBe(false) + expect(state.project("background", "wt-current")).toBe("busy") + expect(state.blocked("wt-current", "background")).toBe(true) send([]) - expect(state.project("background", "wt-idle")).toBe(false) - expect(state.agent("wt-working")).toBe(true) + expect(state.project("background", "wt-current")).toBe("idle") + expect(state.blocked("wt-current", "background")).toBe(false) + expect(state.agent("wt-other")).toBe("busy") }) - it.each(["permission", "question", "non-blocking question"] as const)( - "blocks deletion for a pending %s without showing a running spinner", - (kind) => { - const state = createWorktreeBusy({ - statuses: () => ({ session: { type: "idle" } }), - permissions: () => (kind === "permission" ? [{ sessionID: "session" }] : []), - questions: () => (kind !== "permission" ? [{ sessionID: "session", blocking: kind === "question" }] : []), - worktrees: () => [], - subscribe: () => () => undefined, - managed: () => [{ id: "session", worktreeId: "worktree" }], - local: () => [], - projects: () => ({ other: [{ id: "session", worktreeId: "worktree" }] }), - active: () => "active", - }) + it.each(["waiting", "error", "retry"] as const)("does not hide %s behind directory activity", (value) => { + const listeners = new Set<(message: ExtensionMessage) => void>() + const state = createWorktreeActivity({ + ...options({ "current-wt": value }), + worktrees: () => [{ id: "wt-current", path: "/repo/worktree" }], + subscribe: (callback) => { + listeners.add(callback) + return () => listeners.delete(callback) + }, + }) + for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active: ["/repo/worktree"] }) + expect(state.agent("wt-current")).toBe(value) + expect(state.project("current", "wt-current")).toBe(value) + }) - expect(state.agent("worktree")).toBe(false) - expect(state.agent("worktree", true)).toBe(true) - expect(state.project("active", "worktree", true)).toBe(true) - expect(state.project("other", "worktree")).toBe(false) - expect(state.project("other", "worktree", true)).toBe(true) - }, - ) + it.each(["idle", "waiting", "error"] as const)("keeps deletion guards independent of the %s icon", (value) => { + let pending = true + const state = createWorktreeActivity({ + ...options({ "current-wt": value }), + inUseFor: (id) => id === "current-wt" && pending, + projects: () => ({ other: [{ id: "current-wt", worktreeId: "wt-current" }] }), + worktrees: () => [], + subscribe: () => () => undefined, + }) + expect(state.agent("wt-current")).toBe(value) + expect(state.blocked("wt-current")).toBe(true) + expect(state.blocked("wt-current", "current")).toBe(true) + expect(state.blocked("wt-current", "other")).toBe(true) + expect(state.blocked("missing")).toBe(false) + pending = false + expect(state.blocked("wt-current")).toBe(false) + expect(state.blocked("wt-current", "other")).toBe(false) + }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 17a9093f48..c0f6e5e2da 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -1,8 +1,8 @@ /** * Source contract tests for prompt send paths. * - * Static analysis — reads session.tsx source and verifies that sendMessage and - * sendCommand still dismiss suggestions and reject questions before dispatching. + * Static analysis — reads the session context source and verifies that sendMessage + * and sendCommand still dismiss suggestions and reject questions before dispatching. * Also reads ChatView.tsx and asserts the prompt-block predicate is fed only * permission counts, never question counts — guarantees that a pending question * cannot re-block the prompt input. @@ -17,6 +17,7 @@ import { clearIfOn } from "../../webview-ui/src/context/session-cloud-prune" const ROOT = path.resolve(import.meta.dir, "../..") const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx") +const SESSION_TYPES_FILE = path.join(ROOT, "webview-ui/src/context/session-types.ts") const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx") const AGENT_MANAGER_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx") const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts") @@ -427,7 +428,7 @@ describe("SessionContext userClearedSession contract", () => { // restoreFailed uses session.userClearedSession() to decide whether :new // is a legitimate restore target after the user clicks New Task or // deletes their current/draft session. The accessor must be exposed. - expect(source).toMatch(/userClearedSession:\s*Accessor/) + expect(readFile(SESSION_TYPES_FILE)).toMatch(/userClearedSession:\s*Accessor/) }) it("clearCurrentSession sets the flag", () => { diff --git a/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts b/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts index cd9c5da3dd..ea589a42c8 100644 --- a/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts +++ b/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts @@ -8,8 +8,8 @@ const ROOT = path.resolve(import.meta.dir, "../..") const WEBVIEW = path.join(ROOT, "webview-ui") const FIXTURE = path.join(ROOT, "tests/fixtures/question-dock-disposal.tsx") -describe("QuestionDock disposal", () => { - it("does not read a stale callback-form Show accessor", async () => { +describe("QuestionDock lifecycle", () => { + it("preserves text focus and disposes without reading a stale Show accessor", async () => { const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW)) const aliases: Record = { "solid-js": path.join(solid, "dist/solid.js"), diff --git a/packages/kilo-vscode/tests/unit/session-activity.test.ts b/packages/kilo-vscode/tests/unit/session-activity.test.ts new file mode 100644 index 0000000000..182036dda9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-activity.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "bun:test" +import { + activities, + activity, + isActivity, + label, + running, + score, + strongest, + type Activity, +} from "../../webview-ui/src/utils/session-activity" +import { ancestry } from "../../webview-ui/src/context/session-utils" + +describe("activity", () => { + it("maps backend states", () => { + expect(activity({})).toBe("idle") + expect(activity({ status: "busy" })).toBe("busy") + expect(activity({ status: "retry" })).toBe("retry") + expect(activity({ status: "offline" })).toBe("error") + }) + + it("prioritizes waiting over terminal and running states", () => { + expect(activity({ status: "busy", blocked: true, errored: true, finished: true })).toBe("waiting") + }) + + it("prioritizes errors over running and completed states", () => { + expect(activity({ status: "retry", errored: true, finished: true })).toBe("error") + }) + + it("only reports done while idle", () => { + expect(activity({ status: "busy", finished: true })).toBe("busy") + expect(activity({ finished: true })).toBe("done") + }) +}) + +describe("activities", () => { + const parents = new Map([ + ["child", "root"], + ["nested", "child"], + ]) + + it("derives nested requests and active work without reading transcript pages", () => { + const result = activities({ + parents, + statuses: { root: { type: "idle" }, child: { type: "retry" }, other: { type: "busy" } }, + outcomes: {}, + blocked: ["nested"], + disconnected: false, + }) + expect(result).toEqual({ root: "waiting", child: "waiting", nested: "waiting", other: "busy" }) + }) + + it("rolls up child work but leaves terminal outcomes with their owning sessions", () => { + const input = { + parents, + statuses: {}, + outcomes: { child: { reason: "error" }, nested: { reason: "completed" } }, + blocked: [], + disconnected: false, + } + expect(activities(input)).toEqual({ child: "error", nested: "done" }) + expect(activities({ ...input, submitting: ["nested"] })).toEqual({ + root: "busy", + child: "error", + nested: "busy", + }) + expect(activities({ ...input, outcomes: { ...input.outcomes, root: { reason: "completed" } } }).root).toBe("done") + }) + + it("shows disconnected active sessions as errors without changing idle or completed sessions", () => { + const input = { + parents, + statuses: { root: { type: "busy" as const }, other: { type: "idle" as const } }, + outcomes: { complete: { reason: "completed" } }, + blocked: ["nested"], + disconnected: true, + } + expect(activities(input)).toEqual({ + root: "error", + child: "error", + nested: "error", + other: "idle", + complete: "done", + }) + expect(activities({ ...input, disconnected: false }).root).toBe("waiting") + }) + + it("does not leak a parent request into child or sibling session indicators", () => { + expect( + activities({ + parents, + statuses: { child: { type: "busy" }, nested: { type: "idle" } }, + outcomes: {}, + blocked: ["root"], + disconnected: false, + }), + ).toEqual({ root: "waiting", child: "busy", nested: "idle" }) + }) + + it("guards cycles and does not mutate its source state", () => { + const parents = new Map([ + ["first", "second"], + ["second", "first"], + ]) + const statuses = { first: { type: "busy" as const } } + const input = { parents, statuses, outcomes: {}, blocked: [], disconnected: false } + expect(activities(input)).toEqual({ first: "busy", second: "busy" }) + expect(activities({ ...input, statuses: {} })).toEqual({}) + expect(statuses).toEqual({ first: { type: "busy" } }) + }) +}) + +describe("ancestry", () => { + it("prefers durable session metadata over task and close-event fallbacks", () => { + const result = ancestry( + { child: { parentID: "root" }, root: { parentID: null } }, + { + other: [ + { type: "tool", tool: "task", metadata: { sessionId: "child" } }, + { type: "tool", tool: "task", state: { metadata: { sessionId: "fallback" } } }, + ], + }, + { child: { parentID: "stale" }, missing: { parentID: "root" } }, + ) + expect(Object.fromEntries(result.parents)).toEqual({ child: "root", fallback: "other", missing: "root" }) + expect(result.children.get("root")).toEqual(["child", "missing"]) + }) +}) + +describe("isActivity", () => { + it("accepts only known presentation states", () => { + expect(isActivity("waiting")).toBe(true) + expect(isActivity("done")).toBe(true) + expect(isActivity("idle")).toBe(true) + expect(isActivity("unknown")).toBe(false) + expect(isActivity({ state: "waiting" })).toBe(false) + expect(isActivity(undefined)).toBe(false) + }) +}) + +describe("running", () => { + it("matches spinner states", () => { + expect(running("busy")).toBe(true) + expect(running("retry")).toBe(true) + expect(running("waiting")).toBe(false) + expect(running("done")).toBe(false) + }) +}) + +describe("score", () => { + it("preserves every activity priority with idle scoring zero", () => { + const states: Activity[] = ["idle", "done", "busy", "retry", "error", "waiting"] + expect(states.map(score)).toEqual([0, 1, 2, 3, 4, 5]) + for (const [index, state] of states.entries()) { + for (const lower of states.slice(0, index + 1)) { + expect(strongest([state, lower])).toBe(state) + expect(strongest([lower, state])).toBe(state) + } + } + }) +}) + +describe("strongest", () => { + it("returns the highest priority state", () => { + expect(strongest(["busy", "waiting", "idle"])).toBe("waiting") + expect(strongest(["done", "error", "retry"])).toBe("error") + expect(strongest(["done", "busy"])).toBe("busy") + expect(strongest([])).toBe("idle") + }) +}) + +describe("label", () => { + it("returns existing translation keys", () => { + const states: Activity[] = ["waiting", "error", "retry", "busy", "done", "idle"] + expect(states.map(label)).toEqual([ + "task.backgroundAgents.needsInput", + "task.backgroundAgents.status.error", + "session.status.retry", + "session.tabs.switcher.busy", + "task.backgroundAgents.status.completed", + "session.current", + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-provider-activity.test.ts b/packages/kilo-vscode/tests/unit/session-provider-activity.test.ts new file mode 100644 index 0000000000..bcf5fe4d24 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-provider-activity.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test" +import { unlinkSync } from "node:fs" +import path from "node:path" +import { build } from "esbuild" +import { solidPlugin } from "esbuild-plugin-solid" + +const root = path.resolve(import.meta.dir, "../..") +const webview = path.join(root, "webview-ui") +const fixture = path.join(root, "tests/fixtures/session-provider-activity.tsx") + +describe("SessionProvider activity", () => { + it("covers real session activity lifecycle messages", async () => { + const solid = path.dirname(Bun.resolveSync("solid-js/package.json", webview)) + const aliases: Record = { + "solid-js": path.join(solid, "dist/solid.js"), + "solid-js/web": path.join(solid, "web/dist/web.js"), + "solid-js/store": path.join(solid, "store/dist/store.js"), + } + const dedupe = { + name: "solid-dedupe", + setup(ctx: Parameters[0]["plugins"]>[number]["setup"]>[0]) { + ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] })) + }, + } + const result = await build({ + entryPoints: [fixture], + bundle: true, + conditions: ["browser"], + external: ["happy-dom"], + format: "esm", + logLevel: "silent", + loader: { ".css": "empty" }, + platform: "node", + plugins: [dedupe, solidPlugin()], + target: "es2022", + write: false, + }) + const file = path.join(root, `.session-provider-activity-${crypto.randomUUID()}.mjs`) + await Bun.write(file, result.outputFiles[0]!.contents) + try { + const child = Bun.spawnSync(["bun", file], { cwd: webview, stdout: "pipe", stderr: "pipe" }) + const output = child.stdout.toString() + child.stderr.toString() + expect(child.exitCode, output).toBe(0) + } finally { + unlinkSync(file) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-search.test.ts b/packages/kilo-vscode/tests/unit/session-search.test.ts index 0bae8bb682..fc14275fbb 100644 --- a/packages/kilo-vscode/tests/unit/session-search.test.ts +++ b/packages/kilo-vscode/tests/unit/session-search.test.ts @@ -35,7 +35,7 @@ describe("handleSessionSearch", () => { post: (msg) => posted.push(msg), }) - expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }]) + expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 5_000 }]) expect(posted).toEqual([ { type: "sessionSearchResult", @@ -89,6 +89,24 @@ describe("handleSessionSearch", () => { expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"]) }) + it.each(["/repo", "/repo/.kilo/worktrees/branch"])("loads older chats in one request from %s", async (dir) => { + const recent = Array.from({ length: 60 }, (_, index) => session(`ses_${index}`, `Recent ${index}`, 2, "branch")) + const source = session("ses_old", "Older chat", 1, "main") + const api = stub([...recent, source]) + const posted: Array<{ sessions: Array<{ id: string }> }> = [] + + await handleSessionSearch({ + client: api.client as never, + message: { requestId: "all", sessionID: "ses_current" }, + dir: (id) => (id === "ses_current" ? dir : "/wrong-project"), + post: (msg) => posted.push(msg as never), + }) + + expect(api.calls).toEqual([{ worktrees: true, roots: true, directory: dir, limit: 5_000 }]) + expect(posted).toHaveLength(1) + expect(posted[0]?.sessions.map((item) => item.id)).toEqual([...recent.map((item) => item.id), source.id]) + }) + it("posts an empty result when the client is missing or the list fails", async () => { const posted: unknown[] = [] diff --git a/packages/kilo-vscode/tests/unit/sidebar-search.test.ts b/packages/kilo-vscode/tests/unit/sidebar-search.test.ts index 3d2f96c6fa..b7f8502cf0 100644 --- a/packages/kilo-vscode/tests/unit/sidebar-search.test.ts +++ b/packages/kilo-vscode/tests/unit/sidebar-search.test.ts @@ -51,9 +51,8 @@ const build = (overrides?: Partial[0]>) => localBranch: "main", untitled: "Untitled", pending: (id) => id.startsWith("pending:"), - status: (id) => (id === "busy-session" ? "busy" : "idle"), + activityFor: (id) => (id === "busy-session" ? "busy" : "idle"), busy: () => false, - localBusy: false, ...overrides, }) @@ -104,6 +103,24 @@ describe("buildSidebarSearch", () => { expect(items[4]).toMatchObject({ state: "busy", updatedAt: "2026-06-03T00:00:00.000Z" }) }) + it("uses the strongest session activity for context rows", () => { + const items = build({ + activityFor: (id) => (id === "busy-session" ? "busy" : id === "recent-session" ? "waiting" : "idle"), + }) + + expect(items.find((item) => item.key === "session:recent-session")).toMatchObject({ state: "waiting" }) + expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "waiting" }) + }) + + it("keeps operation busy separate from session activity", () => { + const items = build({ + activityFor: () => "idle", + busy: () => true, + }) + + expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "idle", busy: true }) + }) + it("uses expanded sidebar visibility before recency as a tie-breaker", () => { const items = build({ worktrees: [ @@ -119,7 +136,7 @@ describe("buildSidebarSearch", () => { }, ], local: [], - status: () => "idle", + activityFor: () => "idle", }) expect(items.filter((item) => item.kind === "session").map((item) => item.sessionId)).toEqual(["visible", "hidden"]) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 334f25166a..c0ca26b35e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -93,7 +93,7 @@ import { createProjectRegistry, type PersistedProjectTabs } from "./project/regi import type { WorktreeBusyState } from "./project/store" import { rememberTarget, restoreProjectTarget } from "./project/restore" import { createProjectStateRouter } from "./project/state" -import { createWorktreeBusy } from "./project/session-busy" +import { createWorktreeActivity } from "./project/session-busy" import { switchProject } from "./project/switch" import { createProjectStateHandlers } from "./project/state-handlers" import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership" @@ -118,6 +118,7 @@ import { DataBridge } from "../src/App" import { LanguageBridge } from "../src/context/language-bridge" import { useLanguage } from "../src/context/language" import { createTabFocus } from "../src/utils/tab-navigation" +import { label, strongest } from "../src/utils/session-activity" import { canOpenRootSession, isKnownRootSession, @@ -877,37 +878,37 @@ const AgentManagerContent: Component = () => { ), ) reportVisibleSession(vscode, visibleSession) - const worktreeLabel = (wt: WorktreeState): string => { - if (wt.label) return wt.label - return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch) - } - + const worktreeLabel = (wt: WorktreeState): string => + wt.label || firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch) const worktreeSubtitle = (wt: WorktreeState): string | undefined => { const label = worktreeLabel(wt) return label !== wt.branch ? wt.branch : undefined } - - const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId) - - const busy = createWorktreeBusy({ - statuses: session.allStatusMap, - permissions: session.permissions, - questions: session.questions, + const activity = createWorktreeActivity({ managed: managedSessions, local: localSessionIDs, projects: projectSessionsLive, active: activeProjectId, + activityFor: session.activityFor, + inUseFor: session.inUseFor, worktrees: (id) => (id ? registry.ensure(id) : registry.active()).worktrees(), subscribe: vscode.onMessage, }) - const isAgentBusy = busy.agent - const isLocalBusy = busy.local - const projectBusy = busy.project - const isSessionBusy = busy.session - + const sessionActivity = createMemo(() => + strongest( + multiProject() + ? projectList() + .filter((project) => projectStates()[project.id]) + .flatMap((project) => [ + activity.project(project.id, null), + ...projectStates()[project.id]!.worktrees.map((worktree) => activity.project(project.id, worktree.id)), + ]) + : [activity.local(), ...worktrees().map((worktree) => activity.agent(worktree.id))], + ), + ) + createEffect(() => vscode.postMessage({ type: "sessionActivity", state: sessionActivity() })) /** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */ const sortedWorktrees = createMemo(() => sortWorktrees(worktrees(), sidebarWorktreeOrder())) - const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id) const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId)) const topLevelItems = createMemo((): TopLevelItem[] => @@ -1041,14 +1042,11 @@ const AgentManagerContent: Component = () => { localBranch: repoBranch, selection, sessionId: session.currentSessionID, - statuses: session.allStatusMap, - permissions: session.permissions, - questions: session.questions, + activityFor: session.activityFor, label: worktreeLabel, sessions: sessionsForWorktree, pending: isPending, busy: (id) => busyWorktrees().has(id) || (runStatuses()[id]?.state ?? "idle") !== "idle", - localBusy: isLocalBusy, t, }) const focusSidebarSearchItem = (item: SidebarSearchItem) => { @@ -1814,7 +1812,7 @@ const AgentManagerContent: Component = () => { const confirmDeleteWorktree = (worktreeId: string) => { const wt = worktrees().find((w) => w.id === worktreeId) const run = runStatuses()[worktreeId]?.state - if (!wt || busyWorktrees().has(worktreeId) || isAgentBusy(worktreeId, true) || (run && run !== "idle")) return + if (!wt || busyWorktrees().has(worktreeId) || activity.blocked(worktreeId) || (run && run !== "idle")) return // Second press/click: execute the delete if (pendingDelete() === worktreeId) { cancelPendingDelete() @@ -2248,7 +2246,8 @@ const AgentManagerContent: Component = () => { activePendingId, visibleTabId, isPending, - isBusy: isSessionBusy, + activityFor: (id) => session.activityFor(id), + stateLabel: (state) => t(label(state)), tabLookup, adjacentHint, activateTerminal: termHandlers.activate, @@ -2312,8 +2311,7 @@ const AgentManagerContent: Component = () => { states={projectStates()} store={(id) => registry.ensure(id)} busy={(projectId, id) => registry.ensure(projectId).busy().has(id)} - working={(projectId, id, waiting) => projectBusy(projectId, id, waiting)} - localBusy={(projectId) => projectBusy(projectId, null)} + blocked={(projectId, id) => activity.blocked(id, projectId)} stats={projectLive.stats()} local={projectLive.local()} prs={projectLive.prs()} @@ -2330,6 +2328,8 @@ const AgentManagerContent: Component = () => { onShortcuts={handleShowKeyboardShortcuts} onHistory={openHistory} shortcutMap={projectShortcutMap} + activityFor={activity.project} + sessionActivity={session.activityFor} /> @@ -2339,7 +2339,7 @@ const AgentManagerContent: Component = () => { currentSessionID={session.currentSessionID} selectLocal={selectLocal} selectWorktree={selectWorktree} - isLocalBusy={isLocalBusy} + activityFor={(id) => (id === null ? activity.local() : activity.agent(id))} repoBranch={repoBranch} localStats={localStats} search={{ items: sidebarSearch.items, current: sidebarSearch.current }} @@ -2377,8 +2377,8 @@ const AgentManagerContent: Component = () => { worktreeSubtitle={worktreeSubtitle} pendingDelete={pendingDelete} busy={(id) => busyWorktrees().has(id)} - isAgentBusy={isAgentBusy} - isStaleWorktree={isStaleWorktree} + blocked={activity.blocked} + isStaleWorktree={(id) => staleWorktreeIds().has(id)} shortcutMap={shortcutMap} worktreeStats={worktreeStats} prStatuses={prStatuses} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 2de694b5ed..9fe1c49cfb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -7,6 +7,7 @@ import type { LocalGitStats, PRStatus, ProjectSessionInfo, + RunStatus, WorktreeGitStats, } from "../src/types/messages" import type { LanguageContextValue } from "../src/context/language" @@ -16,6 +17,7 @@ import { ProjectsSection } from "./ProjectsSection" import { ProjectSidebarBody } from "./ProjectSidebarBody" import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu" import type { SidebarSearchItem } from "./sidebar-search" +import { label, type Activity } from "../src/utils/session-activity" import { LOCAL } from "./navigate" import { NewWorktreeDialog } from "./NewWorktreeDialog" import type { ProjectStore } from "./project/store" @@ -26,6 +28,10 @@ const place = (state: AgentManagerStateMessage, session: ProjectSessionInfo, loc return wt?.label || wt?.branch || local } +const activeRun = (status: RunStatus | undefined) => status?.state === "running" || status?.state === "stopping" +const operationBusy = (store: ProjectStore | undefined, id: string) => + store?.busy().has(id) || activeRun(store?.runStatuses()[id]) + interface Props { projects: AgentProjectSnapshot[] states: Record @@ -40,9 +46,10 @@ interface Props { mode: ModeRouter defaultBase?: (projectId: string) => string | undefined onCreate?: (projectId: string) => void - busy?: (projectId: string, id: string) => boolean - working?: (projectId: string, id: string, waiting?: boolean) => boolean - localBusy?: (projectId: string) => boolean + busy: (projectId: string, id: string) => boolean + blocked: (projectId: string, id: string) => boolean + activityFor: (projectId: string, worktreeId: string | null) => Activity + sessionActivity: (id: string) => Activity bindings: Record t: LanguageContextValue["t"] onSearchRef: (ref: SidebarSearchMenuRef) => void @@ -61,6 +68,7 @@ export const ProjectList: Component = (props) => { for (const project of props.projects) { const state = props.states[project.id] if (!state) continue + const store = props.store?.(project.id) const local = props.sessions[project.id]?.filter((session) => session.worktreeId === null) ?? [] items.push({ key: `${project.id}:local`, @@ -73,7 +81,7 @@ export const ProjectList: Component = (props) => { .filter(Boolean) .join(" "), updatedAt: local.reduce((latest, session) => (session.updatedAt > latest ? session.updatedAt : latest), ""), - state: "idle", + state: props.activityFor(project.id, null), visible: project.expanded, count: local.length, }) @@ -88,10 +96,11 @@ export const ProjectList: Component = (props) => { meta: [project.label, worktree.branch], search: [project.label, worktree.label, worktree.branch, worktree.id].filter(Boolean).join(" "), updatedAt: worktree.createdAt, - state: "idle", + state: props.activityFor(project.id, worktree.id), visible: project.expanded, worktreeId: worktree.id, count: sessions.length, + busy: props.busy(project.id, worktree.id) || operationBusy(store, worktree.id), }) } for (const session of props.sessions[project.id] ?? []) { @@ -106,7 +115,7 @@ export const ProjectList: Component = (props) => { meta: [project.label, where], search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "), updatedAt: session.updatedAt, - state: "idle", + state: props.sessionActivity(session.id), visible: project.expanded, sessionId: session.id, location: session.worktreeId ? "worktree" : "local", @@ -172,8 +181,7 @@ export const ProjectList: Component = (props) => { scope: props.t("agentManager.sidebarSearch.scope"), sessions: props.t("agentManager.section.sessions"), contexts: props.t("agentManager.sidebarSearch.contexts"), - waiting: props.t("agentManager.tabsMenu.status.waiting"), - retry: props.t("agentManager.tabsMenu.status.retry"), + state: (value) => props.t(label(value)), }} onSelect={selectSearch} /> @@ -216,9 +224,9 @@ export const ProjectList: Component = (props) => { project={project} state={props.states[project.id]} store={props.store?.(project.id)} - busy={(id) => props.busy?.(project.id, id) ?? false} - working={(id, waiting) => props.working?.(project.id, id, waiting) ?? false} - localBusy={() => props.localBusy?.(project.id) ?? false} + busy={(id) => props.busy(project.id, id)} + blocked={(id) => props.blocked(project.id, id)} + activityFor={(id) => props.activityFor(project.id, id)} stats={props.stats[project.id]} local={props.local[project.id]} prs={props.prs[project.id]} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 1c7ad15ac8..36eec50b98 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -1,6 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { DragDropProvider, DragDropSensors, @@ -19,6 +18,8 @@ import type { WorktreeGitStats, } from "../src/types/messages" import type { LanguageContextValue } from "../src/context/language" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import { label, type Activity } from "../src/utils/session-activity" import { useVSCode } from "../src/context/vscode" import SectionHeader from "./SectionHeader" import { SidebarSectionHeader } from "./SidebarSectionHeader" @@ -40,9 +41,9 @@ interface Props { project: AgentProjectSnapshot state?: AgentManagerStateMessage store?: ProjectStore - busy?: (id: string) => boolean - working?: (id: string, waiting?: boolean) => boolean - localBusy?: () => boolean + busy: (id: string) => boolean + blocked: (id: string) => boolean + activityFor: (worktreeId: string | null) => Activity stats?: Record local?: LocalGitStats prs?: Record @@ -81,7 +82,7 @@ export const ProjectSidebarBody: Component = (props) => { onCleanup(() => clearTimeout(pendingTimer)) /** Arm on the first click, execute on the second, matching the legacy sidebar. */ const confirmDelete = (worktreeId: string) => { - if (props.busy?.(worktreeId) || props.working?.(worktreeId, true)) return + if (props.busy(worktreeId) || props.blocked(worktreeId)) return if (pending() === worktreeId) { clearTimeout(pendingTimer) setPending(undefined) @@ -106,6 +107,7 @@ export const ProjectSidebarBody: Component = (props) => { const sidebarOrder = createMemo(() => projectSidebarOrder(top(), sorted(), sections(), members)) const post = (message: Record) => vscode.postMessage({ ...message, projectId: props.project.id } as never) + const localState = () => props.activityFor(null) const row = (id: string) => projectWorktreeRow({ @@ -237,9 +239,9 @@ export const ProjectSidebarBody: Component = (props) => { subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()} active={active() && props.selection === worktree.id} pendingDelete={pending() === worktree.id} - busy={props.busy?.(worktree.id) ?? false} - working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"} - blocked={props.working?.(worktree.id, true)} + busy={props.busy(worktree.id)} + activity={props.activityFor(worktree.id)} + blocked={props.blocked(worktree.id)} stale={state()?.staleWorktreeIds?.includes(worktree.id) === true} stats={props.stats?.[worktree.id]} shortcut={values().shortcut} @@ -296,13 +298,18 @@ export const ProjectSidebarBody: Component = (props) => { data-sidebar-id={`${props.project.id}:local`} onClick={() => props.onSelectLocal(props.project.id)} > - }> - - - - - - + + + + + + + } + /> +
{props.t("agentManager.local")} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index bc3a3bcd04..07fb71325e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -1,6 +1,5 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { DragDropProvider, DragDropSensors, @@ -32,6 +31,8 @@ import { WorktreeItem } from "./WorktreeItem" import { WorktreeSectionActions } from "./WorktreeSectionActions" import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import { label, type Activity } from "../src/utils/session-activity" const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) @@ -43,7 +44,7 @@ export interface SidebarBodyProps { currentSessionID: () => string | undefined selectLocal: () => void selectWorktree: (id: string) => void - isLocalBusy: () => boolean + activityFor: (id: string | null) => Activity repoBranch: () => string | undefined localStats: () => LocalGitStats | undefined search: { items: () => SidebarSearchItem[]; current: () => SidebarSearchItem | undefined } @@ -80,7 +81,7 @@ export interface SidebarBodyProps { worktreeSubtitle: (wt: WorktreeState) => string | undefined pendingDelete: () => string | null busy: (id: string) => boolean - isAgentBusy: (id: string, waiting?: boolean) => boolean + blocked: (id: string) => boolean isStaleWorktree: (id: string) => boolean shortcutMap: () => Map worktreeStats: () => Record @@ -95,6 +96,7 @@ export interface SidebarBodyProps { /** Legacy single-project sidebar body: local repo, worktrees, unassigned sessions. */ export const SidebarBody: Component = (props) => { const vscode = useVSCode() + const localState = () => props.activityFor(null) return ( <> @@ -104,13 +106,18 @@ export const SidebarBody: Component = (props) => { data-sidebar-id="local" onClick={() => props.selectLocal()} > - }> - - - - - - + + + + + + + } + /> +
{props.t("agentManager.local")} @@ -314,8 +321,8 @@ export const SidebarBody: Component = (props) => { active={props.selection() === wt.id} pendingDelete={props.pendingDelete() === wt.id} busy={props.busy(wt.id)} - working={props.isAgentBusy(wt.id)} - blocked={props.isAgentBusy(wt.id, true)} + activity={props.activityFor(wt.id)} + blocked={props.blocked(wt.id)} stale={props.isStaleWorktree(wt.id)} shortcut={props.shortcutMap().get(wt.id)} stats={props.worktreeStats()[wt.id]} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx index e228c0f7d8..d9661ed7b7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx @@ -9,6 +9,8 @@ import type { ListRef } from "@kilocode/kilo-ui/list" import { Popover } from "@kilocode/kilo-ui/popover" import { Spinner } from "@kilocode/kilo-ui/spinner" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import type { Activity } from "../src/utils/session-activity" import { formatRelativeDate } from "../src/utils/date" import { colorCss } from "./section-colors" import type { SidebarSearchItem } from "./sidebar-search" @@ -20,7 +22,13 @@ export interface SidebarSearchMenuRef { interface SidebarSearchMenuProps { items: Accessor current: Accessor - labels: { search: string; scope: string; contexts: string; sessions: string; waiting: string; retry: string } + labels: { + search: string + scope: string + contexts: string + sessions: string + state: (value: Activity) => string + } keybind: string ref?: (value: SidebarSearchMenuRef) => void onSelect: (item: SidebarSearchItem) => void @@ -104,7 +112,7 @@ export const SidebarSearchMenu: Component = (props) => { }} > {(item) => { - const working = item.state === "busy" || item.state === "retry" + const stateLabel = () => props.labels.state(item.state) return ( = (props) => { data-session-id={item.kind === "session" ? item.sessionId : undefined} data-worktree-id={item.kind === "worktree" ? item.worktreeId : undefined} > - - }> - + + + } + /> + } + > + @@ -143,13 +162,12 @@ export const SidebarSearchMenu: Component = (props) => { {item.meta.join(" · ")} - - {props.labels.waiting} + + + {stateLabel()} + - - {props.labels.retry} - - + {item.kind !== "session" ? item.count : ""} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index e48404ffca..dcd77dfd8c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -12,6 +12,8 @@ import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { Button } from "@kilocode/kilo-ui/button" import type { WorktreeState, WorktreeGitStats, SectionState, RunStatus } from "../src/types/messages" import type { PRStatus } from "../src/types/messages" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import { label, running, type Activity } from "../src/utils/session-activity" import { colorCss } from "./section-colors" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" @@ -31,8 +33,7 @@ interface WorktreeItemProps { active: boolean pendingDelete: boolean busy: boolean - /** Whether an agent session on this worktree is actively working (shows spinner instead of branch icon). */ - working: boolean + activity: Activity blocked?: boolean stale: boolean /** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */ @@ -156,6 +157,13 @@ export const WorktreeItem: Component = (props) => { const { t } = useLanguage() const [hovered, setHovered] = createSignal(false) const [overClose, setOverClose] = createSignal(false) + const state = () => props.activity + const blocked = () => + props.busy || + props.blocked || + running(state()) || + props.runStatus?.state === "running" || + props.runStatus?.state === "stopping" const handleOpenPR = (e: MouseEvent) => { e.stopPropagation() @@ -200,9 +208,12 @@ export const WorktreeItem: Component = (props) => { data-sidebar-id={props.sidebarId ?? props.worktree.id} onClick={() => props.onClick()} > -
- }> - +
+ } + > + } />
@@ -304,7 +315,7 @@ export const WorktreeItem: Component = (props) => { {props.shortcut} - +
setOverClose(true)} @@ -521,7 +532,7 @@ export const WorktreeItem: Component = (props) => { {t("agentManager.worktree.rename")} - + props.onDelete(new MouseEvent("click"))}> {t("agentManager.worktree.delete")} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx index 1eaebdcce6..ecb20232cd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx @@ -7,6 +7,7 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { WorktreeCreate, type WorktreeCreateProps } from "./ProjectActions" import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu" import type { SidebarSearchItem } from "./sidebar-search" +import { label } from "../src/utils/session-activity" interface WorktreeSectionActionsProps extends WorktreeCreateProps { items: Accessor @@ -31,8 +32,7 @@ export const WorktreeSectionActions: Component = (p scope: props.t("agentManager.sidebarSearch.scope"), sessions: props.t("agentManager.section.sessions"), contexts: props.t("agentManager.sidebarSearch.contexts"), - waiting: props.t("agentManager.tabsMenu.status.waiting"), - retry: props.t("agentManager.tabsMenu.status.retry"), + state: (value) => props.t(label(value)), }} onSelect={props.onSelect} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 11674a7db6..fae341978d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -151,6 +151,53 @@ html[data-theme="kilo-vscode"] color: var(--text-weak); } +.am-local-status { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +:is( + .am-tab-icon, + .am-wt-icon, + .am-local-status, + .am-sidebar-search-icon, + .am-sidebar-search-status +)[data-activity="waiting"] { + --am-activity-color: var(--icon-warning-base, var(--vscode-notificationsWarningIcon-foreground, #d9a13a)); + color: var(--am-activity-color); +} + +:is( + .am-tab-icon, + .am-wt-icon, + .am-local-status, + .am-sidebar-search-icon, + .am-sidebar-search-status +)[data-activity="error"] { + --am-activity-color: var(--icon-critical-base, var(--vscode-errorForeground, #f14c4c)); + color: var(--am-activity-color); +} + +:is( + .am-tab-icon, + .am-wt-icon, + .am-local-status, + .am-sidebar-search-icon, + .am-sidebar-search-status +)[data-activity="done"] { + --am-activity-color: var(--icon-success-base, var(--vscode-testing-iconPassed, #73c991)); + color: var(--am-activity-color); +} + +:is(.am-tab-icon, .am-wt-icon, .am-local-status, .am-sidebar-search-icon)[data-activity]:not([data-activity="idle"]) + [data-component="icon"] { + color: var(--am-activity-color); +} + .am-local-text { display: flex; flex-direction: column; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/focus.ts b/packages/kilo-vscode/webview-ui/agent-manager/focus.ts index e5e1175ba6..65eb7ebb7c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/focus.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/focus.ts @@ -55,7 +55,7 @@ export function createChatFocus(deps: { }) { const focus = (force: boolean) => { if ((!force && (!document.hasFocus() || deps.term())) || deps.history() || deps.review()) return - if (preservesTextFocus(document.activeElement)) return + if (preservesTextFocus(document.activeElement) || (!force && isTextControl(document.activeElement))) return if (!force && document.activeElement?.matches('[role="tab"]')) return if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return if (focusQuestionOption()) return diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index d85fb94135..359fbe357b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "الطرفية", "agentManager.tab.openTerminal": "فتح الطرفية", "agentManager.tab.newOptions": "المزيد من خيارات علامات التبويب الجديدة", - "agentManager.tabsMenu.status.waiting": "انتظار", - "agentManager.tabsMenu.status.retry": "إعادة", "agentManager.sidebarSearch.label": "البحث في Worktrees والجلسات", "agentManager.sidebarSearch.scope": "يبحث في مساحة العمل المحلية والجلسات المحلية وWorktrees وجلساتها", "agentManager.sidebarSearch.contexts": "محلي & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 8b66f7b1c2..935efbf530 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Abrir Terminal", "agentManager.tab.newOptions": "Mais opções de nova aba", - "agentManager.tabsMenu.status.waiting": "Espera", - "agentManager.tabsMenu.status.retry": "Repetir", "agentManager.sidebarSearch.label": "Pesquisar Worktrees e sessões", "agentManager.sidebarSearch.scope": "Pesquisa o espaço de trabalho local, as sessões locais, os Worktrees e suas sessões", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index bdeeb4db90..b2ccf4ce7e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Otvori Terminal", "agentManager.tab.newOptions": "Više opcija nove kartice", - "agentManager.tabsMenu.status.waiting": "Čeka", - "agentManager.tabsMenu.status.retry": "Pokušaj", "agentManager.sidebarSearch.label": "Pretraži Worktree-ove i sesije", "agentManager.sidebarSearch.scope": "Pretražuje lokalni radni prostor, lokalne sesije, Worktree-ove i njihove sesije", "agentManager.sidebarSearch.contexts": "LOKALNO & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index fd2a2ff091..4c060fc44c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.openTerminal": "Åbn Terminal", "agentManager.tab.newOptions": "Flere nye faneindstillinger", - "agentManager.tabsMenu.status.waiting": "Venter", - "agentManager.tabsMenu.status.retry": "Igen", "agentManager.sidebarSearch.label": "Søg i Worktrees og sessioner", "agentManager.sidebarSearch.scope": "Søger i det lokale arbejdsområde, lokale sessioner, Worktrees og deres sessioner", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 3e309f5d2b..91ced0592d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -64,8 +64,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminal öffnen", "agentManager.tab.newOptions": "Weitere Tab-Optionen", - "agentManager.tabsMenu.status.waiting": "Warten", - "agentManager.tabsMenu.status.retry": "Erneut", "agentManager.sidebarSearch.label": "Worktrees und Sitzungen durchsuchen", "agentManager.sidebarSearch.scope": "Durchsucht den lokalen Arbeitsbereich, lokale Sitzungen, Worktrees und deren Sitzungen", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index c7df879ee9..a885178ff3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -66,8 +66,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Open Terminal", "agentManager.tab.newOptions": "More new-tab options", - "agentManager.tabsMenu.status.waiting": "Wait", - "agentManager.tabsMenu.status.retry": "Retry", "agentManager.sidebarSearch.label": "Search worktrees and sessions", "agentManager.sidebarSearch.scope": "Searches the local workspace, local sessions, worktrees, and their sessions", "agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 147728c6f1..d8d1ee92f1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Abrir Terminal", "agentManager.tab.newOptions": "Más opciones de nueva pestaña", - "agentManager.tabsMenu.status.waiting": "Espera", - "agentManager.tabsMenu.status.retry": "Reintento", "agentManager.sidebarSearch.label": "Buscar Worktrees y sesiones", "agentManager.sidebarSearch.scope": "Busca en el espacio de trabajo local, las sesiones locales, los Worktrees y sus sesiones", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index 85428065cd..384721115f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "ترمینال", "agentManager.tab.openTerminal": "باز کردن ترمینال", "agentManager.tab.newOptions": "گزینه‌های بیشتر برای تب جدید", - "agentManager.tabsMenu.status.waiting": "انتظار", - "agentManager.tabsMenu.status.retry": "تلاش مجدد", "agentManager.sidebarSearch.label": "جستجوی worktree‌ها و جلسات", "agentManager.sidebarSearch.scope": "جستجو در فضای کاری محلی، جلسات محلی، worktree‌ها و جلسات آن‌ها", "agentManager.sidebarSearch.contexts": "محلی و WORKTREE‌ها", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 7241565da4..ded5b2fa1c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -64,8 +64,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Ouvrir le Terminal", "agentManager.tab.newOptions": "Plus d'options de nouvel onglet", - "agentManager.tabsMenu.status.waiting": "Attente", - "agentManager.tabsMenu.status.retry": "Réessai", "agentManager.sidebarSearch.label": "Rechercher des Worktrees et des sessions", "agentManager.sidebarSearch.scope": "Recherche dans l'espace de travail local, les sessions locales, les Worktrees et leurs sessions", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 966b63cd32..feac34121b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -68,8 +68,6 @@ export const dict = { "agentManager.tab.terminal": "Terminale", "agentManager.tab.openTerminal": "Apri terminale", "agentManager.tab.newOptions": "Altre opzioni nuova scheda", - "agentManager.tabsMenu.status.waiting": "Attendi", - "agentManager.tabsMenu.status.retry": "Riprova", "agentManager.sidebarSearch.label": "Cerca Worktree e sessioni", "agentManager.sidebarSearch.scope": "Cerca nell'area di lavoro locale, nelle sessioni locali, nei Worktree e nelle relative sessioni", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 2e31584d2e..6354108a8e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "ターミナル", "agentManager.tab.openTerminal": "ターミナルを開く", "agentManager.tab.newOptions": "新しいタブのその他のオプション", - "agentManager.tabsMenu.status.waiting": "待機", - "agentManager.tabsMenu.status.retry": "再試行", "agentManager.sidebarSearch.label": "Worktreeとセッションを検索", "agentManager.sidebarSearch.scope": "ローカルワークスペース、ローカルセッション、Worktree、および各Worktreeのセッションを検索", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 8676fcceee..8be5f56b8f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "터미널", "agentManager.tab.openTerminal": "터미널 열기", "agentManager.tab.newOptions": "더 많은 새 탭 옵션", - "agentManager.tabsMenu.status.waiting": "대기", - "agentManager.tabsMenu.status.retry": "재시도", "agentManager.sidebarSearch.label": "Worktree 및 세션 검색", "agentManager.sidebarSearch.scope": "로컬 워크스페이스, 로컬 세션, Worktree 및 각 Worktree의 세션 검색", "agentManager.sidebarSearch.contexts": "로컬 & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index edeaa6af0d..9f142e69e9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminal openen", "agentManager.tab.newOptions": "Meer opties voor nieuwe tabblad", - "agentManager.tabsMenu.status.waiting": "Wacht", - "agentManager.tabsMenu.status.retry": "Opnieuw", "agentManager.sidebarSearch.label": "Worktrees en sessies doorzoeken", "agentManager.sidebarSearch.scope": "Doorzoekt de lokale werkruimte, lokale sessies, Worktrees en hun sessies", "agentManager.sidebarSearch.contexts": "LOKAAL & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 234a9e5aac..ed8e270a8f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Åpne Terminal", "agentManager.tab.newOptions": "Flere alternativer for ny fane", - "agentManager.tabsMenu.status.waiting": "Venter", - "agentManager.tabsMenu.status.retry": "Igjen", "agentManager.sidebarSearch.label": "Søk i Worktrees og økter", "agentManager.sidebarSearch.scope": "Søker i det lokale arbeidsområdet, lokale økter, Worktrees og øktene deres", "agentManager.sidebarSearch.contexts": "LOKAL & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 7433c355dd..62583d9ba3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Otwórz Terminal", "agentManager.tab.newOptions": "Więcej opcji nowej karty", - "agentManager.tabsMenu.status.waiting": "Czeka", - "agentManager.tabsMenu.status.retry": "Ponów", "agentManager.sidebarSearch.label": "Wyszukaj Worktree i sesje", "agentManager.sidebarSearch.scope": "Przeszukuje lokalny obszar roboczy, lokalne sesje, Worktree i przypisane do nich sesje", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 5be558d0a0..de9e60aa93 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.terminal": "Терминал", "agentManager.tab.openTerminal": "Открыть терминал", "agentManager.tab.newOptions": "Другие параметры новой вкладки", - "agentManager.tabsMenu.status.waiting": "Ожидание", - "agentManager.tabsMenu.status.retry": "Повтор", "agentManager.sidebarSearch.label": "Поиск по Worktree и сессиям", "agentManager.sidebarSearch.scope": "Поиск локального рабочего пространства, локальных сессий, Worktree и связанных с ними сессий", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 8adf316435..86de0544e5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "เทอร์มินัล", "agentManager.tab.openTerminal": "เปิดเทอร์มินัล", "agentManager.tab.newOptions": "ตัวเลือกแท็บใหม่เพิ่มเติม", - "agentManager.tabsMenu.status.waiting": "รอ", - "agentManager.tabsMenu.status.retry": "ลองใหม่", "agentManager.sidebarSearch.label": "ค้นหา Worktree และเซสชัน", "agentManager.sidebarSearch.scope": "ค้นหาพื้นที่ทำงานในเครื่อง เซสชันในเครื่อง Worktree และเซสชันของ Worktree", "agentManager.sidebarSearch.contexts": "ในเครื่อง & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 532c1a99dc..90f3da6288 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminali Aç", "agentManager.tab.newOptions": "Daha fazla yeni sekme seçeneği", - "agentManager.tabsMenu.status.waiting": "Bekliyor", - "agentManager.tabsMenu.status.retry": "Yeniden", "agentManager.sidebarSearch.label": "Worktree'leri ve oturumları ara", "agentManager.sidebarSearch.scope": "Yerel çalışma alanını, yerel oturumları, Worktree'leri ve bunların oturumlarını arar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index a643972827..11aa8d8af1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "Термінал", "agentManager.tab.openTerminal": "Відкрити термінал", "agentManager.tab.newOptions": "Інші параметри нової вкладки", - "agentManager.tabsMenu.status.waiting": "Очікує", - "agentManager.tabsMenu.status.retry": "Повтор", "agentManager.sidebarSearch.label": "Пошук робочих дерев і сесій", "agentManager.sidebarSearch.scope": "Пошук локального робочого простору, локальних сесій, робочих дерев і призначених їм сесій", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 375bf143ad..aeaf425a7d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "终端", "agentManager.tab.openTerminal": "打开终端", "agentManager.tab.newOptions": "更多新建标签页选项", - "agentManager.tabsMenu.status.waiting": "等待", - "agentManager.tabsMenu.status.retry": "重试", "agentManager.sidebarSearch.label": "搜索 Worktree 和会话", "agentManager.sidebarSearch.scope": "搜索本地工作区、本地会话、Worktree 及其会话", "agentManager.sidebarSearch.contexts": "本地 & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 8f25f7f661..d6030bb2b2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "終端機", "agentManager.tab.openTerminal": "開啟終端機", "agentManager.tab.newOptions": "更多新增分頁選項", - "agentManager.tabsMenu.status.waiting": "等待", - "agentManager.tabsMenu.status.retry": "重試", "agentManager.sidebarSearch.label": "搜尋 Worktree 與工作階段", "agentManager.sidebarSearch.scope": "搜尋本機工作區、本機工作階段、Worktree 及其工作階段", "agentManager.sidebarSearch.contexts": "本機 & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts index 4f45a751a5..f7bbfb3646 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts @@ -1,85 +1,71 @@ -import { createSignal, onCleanup } from "solid-js" +import { createMemo, createSignal, onCleanup } from "solid-js" import type { ExtensionMessage } from "../../src/types/messages" +import { strongest, type Activity } from "../../src/utils/session-activity" interface Item { id: string worktreeId?: string | null } -interface Status { - type: string -} - -interface Prompt { - sessionID: string - blocking?: boolean -} - -export function createSessionBusy(opts: { - statuses: () => Record - permissions: () => Prompt[] - questions: () => Prompt[] +export function createSessionActivity(opts: { managed: () => Item[] local: () => string[] projects: () => Record active: () => string | undefined + activityFor: (id: string) => Activity }) { - const any = (ids: string[], waiting = false) => { - if (ids.length === 0) return false - const statuses = opts.statuses() - const blocked = new Set( - [...opts.permissions(), ...opts.questions().filter((item) => item.blocking !== false)].map( - (item) => item.sessionID, - ), - ) - return ids.some((id) => { - const status = statuses[id] - if (waiting) - return ( - (!!status && status.type !== "idle") || - [...opts.permissions(), ...opts.questions()].some((prompt) => prompt.sessionID === id) - ) - return (status?.type === "busy" || status?.type === "retry") && !blocked.has(id) - }) + const group = (items: Item[]) => { + const states = new Map() + for (const item of items) { + const id = item.worktreeId ?? null + const values = states.get(id) ?? [] + values.push(opts.activityFor(item.id)) + states.set(id, values) + } + return new Map([...states].map(([id, values]) => [id, strongest(values)])) } - const agent = (id: string, waiting = false) => - any( - opts - .managed() - .filter((item) => item.worktreeId === id) - .map((item) => item.id), - waiting, - ) - const local = () => any(opts.local()) - const project = (id: string, worktreeId: string | null, waiting = false) => { - if (id === opts.active()) return worktreeId === null ? any(opts.local(), waiting) : agent(worktreeId, waiting) - return any( - (opts.projects()[id] ?? []).filter((item) => item.worktreeId === worktreeId).map((item) => item.id), - waiting, - ) + const local = createMemo(() => strongest(opts.local().map(opts.activityFor))) + const managed = createMemo(() => group(opts.managed())) + const projects = createMemo(() => { + const values = new Map>() + for (const [id, items] of Object.entries(opts.projects())) values.set(id, group(items)) + return values + }) + return { + local: () => local(), + agent: (id: string) => managed().get(id) ?? "idle", + project: (id: string, worktree: string | null): Activity => { + if (id === opts.active()) return worktree === null ? local() : (managed().get(worktree) ?? "idle") + return projects().get(id)?.get(worktree) ?? "idle" + }, } - return { any, agent, local, project, session: (id: string) => any([id]) } } -export function createWorktreeBusy( - opts: Parameters[0] & { +export function createWorktreeActivity( + opts: Parameters[0] & { + inUseFor: (id: string) => boolean worktrees: (project?: string) => { id: string; path: string }[] subscribe: (callback: (message: ExtensionMessage) => void) => () => void }, ) { - const busy = createSessionBusy(opts) + const activity = createSessionActivity(opts) const [active, setActive] = createSignal(new Set()) onCleanup( opts.subscribe((message) => { if (message.type === "agentManager.worktreeActivity") setActive(new Set(message.active)) }), ) - const working = (id: string, project?: string) => - active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "") + const working = (id: string, project?: string): Activity => + active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "") ? "busy" : "idle" return { - ...busy, - agent: (id: string, waiting = false) => busy.agent(id, waiting) || working(id), - project: (project: string, id: string | null, waiting = false) => - busy.project(project, id, waiting) || (id !== null && working(id, project)), + ...activity, + agent: (id: string) => strongest([activity.agent(id), working(id)]), + project: (project: string, id: string | null) => + strongest([activity.project(project, id), id === null ? "idle" : working(id, project)]), + blocked: (id: string, project?: string) => { + if (working(id, project) === "busy") return true + const items = project && project !== opts.active() ? (opts.projects()[project] ?? []) : opts.managed() + return items.some((item) => item.worktreeId === id && opts.inUseFor(item.id)) + }, } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts index ddfaa13a54..c1991aa35c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts @@ -1,16 +1,10 @@ import { createMemo } from "solid-js" import type { Accessor } from "solid-js" -import type { - PermissionRequest, - QuestionRequest, - SectionState, - SessionInfo, - SessionStatusInfo, - WorktreeState, -} from "../src/types/messages" +import type { SectionState, SessionInfo, WorktreeState } from "../src/types/messages" +import { score, strongest, type Activity } from "../src/utils/session-activity" import { LOCAL } from "./navigate" -export type SidebarSearchState = "idle" | "busy" | "retry" | "waiting" +export type SidebarSearchState = Activity type SearchItem = { key: string @@ -20,6 +14,7 @@ type SearchItem = { search: string updatedAt: string state: SidebarSearchState + busy?: boolean visible: boolean section?: SectionState } @@ -58,14 +53,12 @@ interface SidebarSearchInput { localBranch?: string untitled: string pending: (id: string) => boolean - status: (id: string) => SidebarSearchState + activityFor: (id: string) => Activity busy: (id: string) => boolean - localBusy: boolean } const root = (item: SessionInfo) => !item.parentID const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase() -const score = (state: SidebarSearchState) => (state === "waiting" ? 3 : state === "idle" ? 0 : 2) const newest = (items: SessionInfo[], fallback: string) => items.reduce((latest, item) => (item.updatedAt > latest ? item.updatedAt : latest), fallback) @@ -79,6 +72,7 @@ export function sortSidebarSearch(a: SidebarSearchItem, b: SidebarSearchItem) { } export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem[] { + const state = input.activityFor const sections = new Map(input.sections.map((item) => [item.id, item])) const owned = new Set(input.worktrees.flatMap((item) => item.sessions.map((session) => session.id))) const local = input.local.filter((session) => root(session) && !input.pending(session.id) && !owned.has(session.id)) @@ -92,10 +86,10 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem sessionId: session.id, location: "local" as const, updatedAt: session.updatedAt, - state: input.status(session.id), + state: state(session.id), visible: true, })) - const localState = local.map((session) => input.status(session.id)).sort((a, b) => score(b) - score(a))[0] ?? "idle" + const localState = strongest(local.map((session) => state(session.id))) const contexts: SidebarSearchItem[] = [ { key: LOCAL, @@ -105,7 +99,7 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem meta: input.localBranch ? [input.localBranch] : [], search: [input.localLabel, input.localBranch].filter(Boolean).join(" "), updatedAt: newest(local, ""), - state: input.localBusy && localState === "idle" ? "busy" : localState, + state: localState, visible: true, count: local.length, }, @@ -132,13 +126,13 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem location: "worktree", worktreeId: wt.id, updatedAt: session.updatedAt, - state: input.status(session.id), + state: state(session.id), visible: !section?.collapsed, section, }) } - const state = roots.map((session) => input.status(session.id)).sort((a, b) => score(b) - score(a))[0] ?? "idle" + const context = strongest(roots.map((session) => state(session.id))) contexts.push({ key: `worktree:${wt.id}`, kind: "worktree", @@ -150,7 +144,8 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem .join(" "), worktreeId: wt.id, updatedAt: newest(roots, wt.createdAt), - state: input.busy(wt.id) && state === "idle" ? "busy" : state, + state: context, + busy: input.busy(wt.id), visible: !section?.collapsed, section, count: roots.length, @@ -168,24 +163,16 @@ interface SidebarSearchDeps { localBranch: Accessor selection: Accessor sessionId: Accessor - statuses: Accessor> - permissions: Accessor - questions: Accessor + activityFor: (id: string) => Activity label: (worktree: WorktreeState) => string sessions: (id: string) => SessionInfo[] pending: (id: string) => boolean busy: (id: string) => boolean - localBusy: Accessor t: (key: string) => string } export function createSidebarSearch(deps: SidebarSearchDeps) { const items = createMemo(() => { - const statuses = deps.statuses() - const blocked = new Set([ - ...deps.permissions().map((item) => item.sessionID), - ...deps.questions().map((item) => item.sessionID), - ]) return buildSidebarSearch({ worktrees: deps.worktrees().map((worktree) => ({ worktree, @@ -198,13 +185,8 @@ export function createSidebarSearch(deps: SidebarSearchDeps) { localBranch: deps.localBranch(), untitled: deps.t("agentManager.session.untitled"), pending: deps.pending, - status: (id) => { - if (blocked.has(id)) return "waiting" - const status = statuses[id]?.type - return status === "busy" || status === "retry" ? status : "idle" - }, + activityFor: deps.activityFor, busy: deps.busy, - localBusy: deps.localBusy(), }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx index 6c0222716d..9c5c43805c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx @@ -12,13 +12,15 @@ import { useLanguage } from "../src/context/language" import { SessionTab } from "../src/components/chat/SessionTab" import { SessionTabMenu } from "../src/components/chat/SessionTabMenu" import { SortableTabContainer } from "../src/components/chat/TabDnd" +import type { Activity } from "../src/utils/session-activity" import { parseBindingTokens } from "./keybind-tokens" /** Individual sortable tab wrapper using the `use:sortable` directive. */ export const SortableTab: Component<{ tab: SessionInfo active: boolean - busy: boolean + state: Activity + stateLabel: string keybind?: string closeKeybind?: string onSelect: () => void @@ -52,7 +54,8 @@ export const SortableTab: Component<{ string | undefined isPending: (id: string) => boolean - isBusy: (id: string) => boolean + activityFor: (id: string) => Activity + stateLabel: (state: Activity) => string tabLookup: () => Map adjacentHint: (id: string, activeId: string, ids: string[], prev: string, next: string) => string // Handlers @@ -164,6 +166,7 @@ function renderReviewTab(deps: TabRenderDeps): JSX.Element { function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element { const pending = deps.isPending(s.id) + const state = createMemo(() => deps.activityFor(s.id)) const active = () => !deps.terms.activeId() && (pending ? s.id === deps.activePendingId() && !deps.currentSessionID() : s.id === deps.currentSessionID()) @@ -181,7 +184,8 @@ function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element { ) => { - const prefix = `${project() ?? "single"}\0` + const prefix = key("") const keys = new Set([ ...Object.keys(diffDatas()), ...Object.keys(diffLoadings()), diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 8e838807a9..a46b6ae510 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, createMemo, Switch, Match, Show, onMount, onCleanup } from "solid-js" +import { Component, createSignal, createMemo, createEffect, Switch, Match, Show, onMount, onCleanup } from "solid-js" import { DataProvider } from "@kilocode/kilo-ui/context/data" import Settings from "./components/settings/Settings" import ProfileView from "./components/profile/ProfileView" @@ -17,6 +17,7 @@ import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrid import { useWorktreeMode } from "./context/worktree-mode" import { useDiffStyle } from "./context/diff-style" import { dispatchAgentManagerEditPreview } from "./utils/agent-manager-events" +import { strongest } from "./utils/session-activity" import type { PermissionFileDiff } from "./types/messages" // Override the upstream "task" tool renderer with the fully-expanded version @@ -229,6 +230,10 @@ const AppContent: Component = () => { const tabs = useLocalTabs() const server = useServer() const vscode = useVSCode() + const activity = createMemo(() => + strongest([session.currentSessionID(), ...(tabs?.ids() ?? [])].map(session.activityFor)), + ) + createEffect(() => vscode.postMessage({ type: "sessionActivity", state: activity() })) const handleViewAction = (action: string) => { switch (action) { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 74fa8f1ba7..da55702e85 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -22,6 +22,7 @@ import { tr, } from "./question-dock-utils" import { isEnterKeyCommitNotIme } from "../../utils/ime-enter" +import { isTextControl } from "../../utils/focus" export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const session = useSession() @@ -328,7 +329,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => void store.tab if (store.collapsed || store.editing || confirm()) return requestAnimationFrame(() => { - if (!document.hasFocus()) return + if (!document.hasFocus() || isTextControl(document.activeElement)) return const btn = root?.querySelector("button[data-slot='question-option']:not(:disabled)") btn?.focus({ preventScroll: true }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx index 7471b99610..80767cb068 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx @@ -3,6 +3,7 @@ import { onMount } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { List } from "@kilocode/kilo-ui/list" +import { filterSessions } from "../../hooks/file-mention-utils" import type { SessionSearchItem } from "../../types/messages" import { formatRelativeDate } from "../../utils/date" @@ -40,9 +41,9 @@ export function SessionMentionPicker(props: Props) { }} > - items={props.sessions} + items={(query) => filterSessions(props.sessions, query)} key={(item) => item.id} - filterKeys={["title", "worktreeName"]} + skipFilter={() => true} search={{ placeholder: "Search sessions", autofocus: true }} onSelect={(item) => { if (item) props.onSelect(item) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx index d51c4fab86..6da998d87a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx @@ -1,12 +1,14 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { Show, type Component, type JSX } from "solid-js" +import { ActivityIcon } from "../shared/ActivityIcon" +import type { Activity } from "../../utils/session-activity" export const SessionTab: Component<{ title: string active: boolean - busy: boolean + state: Activity + stateLabel: string closeTitle: string closeLabel: string keybind?: string @@ -21,7 +23,7 @@ export const SessionTab: Component<{ onKeyDown?: JSX.EventHandlerUnion onClose: () => void }> = (props) => ( -
+
- - - + + + {props.title} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx index 02b58ee5aa..51530fd068 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx @@ -8,6 +8,7 @@ import { isPendingTab } from "../../utils/local-tabs" import { useTabScroll } from "../../utils/tab-scroll" import { focusPrompt, focusSelectedTab, focusTabElement, handleTabKey } from "../../utils/tab-navigation" import { setTabWidths } from "../../utils/tab-widths" +import { label, running } from "../../utils/session-activity" import { useVSCode } from "../../context/vscode" import { SessionTab } from "./SessionTab" import { SessionTabMenu } from "./SessionTabMenu" @@ -28,10 +29,8 @@ export const SessionTabStrip: Component = () => { if (isPendingTab(id)) return language.t("sidebar.session.newSession") return items().get(id)?.title || language.t("session.untitled") } - const working = (id: string) => { - const status = session.allStatusMap()[id] - return status?.type === "busy" || status?.type === "retry" - } + const state = (id: string) => (isPendingTab(id) ? "idle" : session.activityFor(id)) + const working = (id: string) => running(state(id)) const middle = (id: string, event: MouseEvent) => { if (event.button !== 1) return event.preventDefault() @@ -62,7 +61,8 @@ export const SessionTabStrip: Component = () => { id, title: title(id), active: tabs.active() === id, - busy: working(id), + state: state(id), + stateLabel: language.t(label(state(id))), pending: isPendingTab(id), })), ) @@ -140,7 +140,8 @@ export const SessionTabStrip: Component = () => { { close: language.t("common.closeTab"), current: language.t("session.tabs.switcher.current"), pending: language.t("session.tabs.switcher.pending"), - busy: language.t("session.tabs.switcher.busy"), }} onSelect={tabs.select} onRestore={focusPrompt} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx index 3ba5618a02..1069f22b4f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx @@ -1,17 +1,18 @@ -import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { List } from "@kilocode/kilo-ui/list" import type { ListRef } from "@kilocode/kilo-ui/list" import { Popover } from "@kilocode/kilo-ui/popover" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { Show, createEffect, createMemo, createSignal, type Component, type JSX } from "solid-js" +import { ActivityIcon } from "../shared/ActivityIcon" +import type { Activity } from "../../utils/session-activity" interface SessionTabSwitcherItem { id: string title: string active: boolean - busy: boolean + state: Activity + stateLabel: string pending: boolean } @@ -23,7 +24,6 @@ interface SessionTabSwitcherProps { close: string current: string pending: string - busy: string } onSelect: (id: string) => void onRestore: () => void @@ -144,19 +144,17 @@ export const SessionTabSwitcher: Component = (props) => > {(item) => ( - - }> - - + + {item.title} - - - - {props.labels.busy} + + + + {item.stateLabel} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ContextTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ContextTab.tsx index e1c92c1211..999f2e86e2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ContextTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ContextTab.tsx @@ -8,6 +8,8 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" import { useMemory } from "../../context/memory" +import { parseModelString } from "../../../../src/shared/provider-model" +import { ModelSelectorBase } from "../shared/ModelSelector" import SettingsRow from "./SettingsRow" const ContextTab: Component = () => { @@ -139,6 +141,24 @@ const ContextTab: Component = () => { {language.t("settings.context.autoCompaction.title")} + + + updateConfig({ + agent: { compaction: { model: providerID && modelID ? `${providerID}/${modelID}` : null } }, + }) + } + placement="bottom-start" + allowClear + clearLabel={language.t("settings.context.compactionModel.useChatModel")} + label={language.t("settings.context.compactionModel.title")} + description={language.t("settings.context.compactionModel.description")} + /> + = (props) => ( + }> + + + + + + + + + + +) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts new file mode 100644 index 0000000000..511a75b18d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts @@ -0,0 +1,213 @@ +import type { Accessor } from "solid-js" +import type { ReviewMessageData } from "../../../src/shared/review-comments" +import type { + AgentInfo, + ContextUsage, + FileAttachment, + McpStatusEntry, + Message, + ModelSelection, + ModelUsageMap, + Part, + PermissionRequest, + QuestionRequest, + SessionCloseReason, + SessionInfo, + SessionModelUsage, + SessionStatus, + SessionStatusInfo, + SkillInfo, + SuggestionRequest, + TodoItem, + ToolPart, +} from "../types/messages" +import type { Activity } from "../utils/session-activity" +import type { MessageMutation } from "./session-utils" + +export interface SessionContextValue { + // Current session + currentSessionID: Accessor + currentSession: Accessor + setCurrentSessionID: (id: string | undefined) => void + + // All sessions (sorted most recent first) + sessions: Accessor + + // Session status + status: Accessor + statusInfo: Accessor + closeReason: Accessor + statusText: Accessor + busySince: Accessor + submitting: Accessor + isSubmitting: (id: string) => boolean + loading: Accessor + loadingOlderMessages: Accessor + hasOlderMessages: Accessor + messageMutation: Accessor + + // Messages for current session + messages: Accessor + + // Messages for current session with soft-reverted turns hidden + visibleMessages: Accessor + + // User messages for current session (role === "user") + userMessages: Accessor + + // All messages keyed by sessionID (includes child sessions) + allMessages: () => Record + + // All parts keyed by messageID (includes child sessions) + allParts: () => Record + + // All session statuses keyed by sessionID (for DataBridge) + allStatusMap: () => Record + + activityFor: (sessionID: string | undefined) => Activity + inUseFor: (sessionID: string) => boolean + + // Parts for a specific message + getParts: (messageID: string) => Part[] + + // Tool parts for a specific session, maintained incrementally for streaming views + getSessionToolParts: (sessionID: string) => ToolPart[] + getSessionToolCount: (sessionID: string) => number + + // Hidden after model changes so switching models can clear stale provider errors + // without removing messages and their checkpoint restore actions. + isErrorHidden: (messageID: string) => boolean + + // Move stashed parts into the reactive store for the given message IDs. + hydrateParts: (messageIDs: string[]) => void + + // Todos for current session + todos: Accessor + + // Pending permission requests (unscoped — all tracked sessions) + permissions: Accessor + respondingPermissions: Accessor> + + // Pending question requests (unscoped — all tracked sessions) + questions: Accessor + questionErrors: Accessor> + suggestions: Accessor + suggestionErrors: Accessor> + respondingSuggestions: Accessor> + + // Scoped permissions/questions — filtered to a session's family (self + subagents) + scopedPermissions: (sessionID: string | undefined) => PermissionRequest[] + scopedQuestions: (sessionID: string | undefined) => QuestionRequest[] + scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[] + + // Model selection (global, extension-lifetime) + selected: (sessionID?: string) => ModelSelection | null + modelForAgent: (agent: string) => ModelSelection | null + selectModel: (providerID: string, modelID: string, sessionID?: string) => void + + // Cost and context usage for the current session + costBreakdown: Accessor> + contextUsage: Accessor + modelUsage: Accessor + + // Skills loaded from the CLI backend + skills: Accessor + refreshSkills: () => void + removeSkill: (location: string) => void + + // Agent/mode selection (per-session) + agents: Accessor + allAgents: Accessor + removeAgent: (name: string) => void + removeMcp: (name: string) => void + + // MCP server status (runtime connect/disconnect) + mcpStatus: Accessor> + mcpLoading: Accessor + connectMcp: (name: string) => void + disconnectMcp: (name: string) => void + authenticateMcp: (name: string) => void + selectedAgent: (sessionID?: string) => string + selectAgent: (name: string, sessionID?: string) => void + getSessionAgent: (sessionID: string) => string + setSessionModel: (sessionID: string, providerID: string, modelID: string) => void + setSessionAgent: (sessionID: string, name: string) => void + setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void + + // Thinking variant for the selected model + variantList: (sessionID?: string) => string[] + currentVariant: (sessionID?: string) => string | undefined + variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined + selectVariant: (value: string | undefined, sessionID?: string) => void + + // Model favorites + recentModels: Accessor + modelUsageHistory: Accessor + favoriteModels: Accessor + toggleFavorite: (providerID: string, modelID: string) => void + + // Revert/undo state for the current session + revert: Accessor + revertedCount: Accessor + summary: Accessor + + // Live worktree diff stats (polled from CLI backend) + worktreeStats: Accessor<{ files: number; additions: number; deletions: number } | undefined> + + // Actions + revertSession: (messageID: string, partID?: string) => void + unrevertSession: () => void + deleteQueuedMessage: (sessionID: string, messageID: string) => void + sendMessage: ( + text: string, + providerID?: string, + modelID?: string, + files?: FileAttachment[], + draftID?: string, + context?: string, + review?: ReviewMessageData, + origin?: string | null, + ) => void + sendCommand: ( + command: string, + args: string, + providerID?: string, + modelID?: string, + files?: FileAttachment[], + draftID?: string, + context?: string, + origin?: string | null, + overrides?: { agent?: string; model?: string; variant?: string }, + ) => void + abort: () => void + compact: () => void + respondToPermission: ( + permissionId: string, + response: "once" | "always" | "reject", + approvedAlways: string[], + deniedAlways: string[], + ) => void + replyToQuestion: (requestID: string, answers: string[][]) => void + rejectQuestion: (requestID: string) => void + closeQuestion: (requestID: string) => void + acceptSuggestion: (requestID: string, index: number) => void + dismissSuggestion: (requestID: string) => void + createSession: () => void + clearCurrentSession: () => void + loadSessions: () => void + loadOlderMessages: () => boolean + selectSession: (id: string, options?: { focus?: boolean }) => void + releaseSession: (id: string) => void + deleteSession: (id: string) => void + renameSession: (id: string, title: string) => void + exportSessionTranscript: (id: string) => void + syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void + unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void + + // Cloud session preview + cloudPreviewId: Accessor + selectCloudSession: (cloudSessionId: string) => void + draftSessionID: Accessor + setDraftSessionID: (id: string | undefined) => void + userClearedSession: Accessor +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index 84212c2c4b..39f0c5f285 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -117,6 +117,45 @@ export function childID(part: TaskPart): string | undefined { return part.metadata?.sessionId ?? part.state?.metadata?.sessionId } +export function inUse( + family: ReadonlySet, + statuses: Record, + prompts: readonly { sessionID: string }[], +): boolean { + return ( + [...family].some((id) => !!statuses[id] && statuses[id].type !== "idle") || + prompts.some((item) => family.has(item.sessionID)) + ) +} + +export function ancestry( + sessions: Record, + tools: Record, + outcomes: Record, +) { + const parents = new Map() + for (const [id, parts] of Object.entries(tools)) { + for (const part of parts) { + const child = childID(part) + if (child) parents.set(child, id) + } + } + for (const [id, close] of Object.entries(outcomes)) { + if (close?.parentID) parents.set(id, close.parentID) + } + for (const [id, session] of Object.entries(sessions)) { + if (session.parentID === null) parents.delete(id) + if (session.parentID) parents.set(id, session.parentID) + } + const children = new Map() + for (const [child, parent] of parents) { + const ids = children.get(parent) ?? [] + ids.push(child) + children.set(parent, ids) + } + return { parents, children } +} + export function latestTaskPart(partID: string | undefined, child: string | undefined, parts: readonly TaskPart[]) { if (!partID || !child) return false return parts.findLast((part) => childID(part) === child)?.id === partID diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 87c099e6f8..1f7f246e64 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -9,6 +9,7 @@ import { useContext, createSignal, createMemo, + createComputed, createEffect, on, onMount, @@ -16,7 +17,7 @@ import { batch, untrack, } from "solid-js" -import type { ParentComponent, Accessor } from "solid-js" +import type { ParentComponent } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useVSCode } from "./vscode" import { useServer } from "./server" @@ -62,6 +63,8 @@ import { buildCostBreakdown, buildSessionToolParts, childID, + ancestry, + inUse, dropSet, emptyPageState, messageParts, @@ -70,7 +73,6 @@ import { removeSessionToolPartsForMessage, revertPromptState, upsertSessionToolPart, - type MessageMutation, type MessagePageState, } from "./session-utils" import { Identifier } from "../utils/id" @@ -93,6 +95,8 @@ import { clearIfOn, createCloudPrune } from "./session-cloud-prune" import { isSameSessionTree } from "./model-usage" import { createDraftAgentSeed, resolvePromptAgent } from "./session-agent" import { createModelSelector } from "./session-model-selector" +import { activities, type Activity } from "../utils/session-activity" +import type { SessionContextValue } from "./session-types" const RECENT_LIMIT = 5 const MESSAGE_PAGE_LIMIT = 80 @@ -114,189 +118,9 @@ interface SessionStore { modelUsage: Record } -interface SessionContextValue { - // Current session - currentSessionID: Accessor - currentSession: Accessor - setCurrentSessionID: (id: string | undefined) => void - - // All sessions (sorted most recent first) - sessions: Accessor - - // Session status - status: Accessor - statusInfo: Accessor - closeReason: Accessor - statusText: Accessor - busySince: Accessor - submitting: Accessor - isSubmitting: (id: string) => boolean - loading: Accessor - loadingOlderMessages: Accessor - hasOlderMessages: Accessor - messageMutation: Accessor - - // Messages for current session - messages: Accessor - - // Messages for current session with soft-reverted turns hidden - visibleMessages: Accessor - - // User messages for current session (role === "user") - userMessages: Accessor - - // All messages keyed by sessionID (includes child sessions) - allMessages: () => Record - - // All parts keyed by messageID (includes child sessions) - allParts: () => Record - - // All session statuses keyed by sessionID (for DataBridge) - allStatusMap: () => Record - - // Parts for a specific message - getParts: (messageID: string) => Part[] - - // Tool parts for a specific session, maintained incrementally for streaming views - getSessionToolParts: (sessionID: string) => ToolPart[] - getSessionToolCount: (sessionID: string) => number - - // Hidden after model changes so switching models can clear stale provider errors - // without removing messages and their checkpoint restore actions. - isErrorHidden: (messageID: string) => boolean - - // Move stashed parts into the reactive store for the given message IDs. - hydrateParts: (messageIDs: string[]) => void - - // Todos for current session - todos: Accessor - - // Pending permission requests (unscoped — all tracked sessions) - permissions: Accessor - respondingPermissions: Accessor> - - // Pending question requests (unscoped — all tracked sessions) - questions: Accessor - questionErrors: Accessor> - suggestions: Accessor - suggestionErrors: Accessor> - respondingSuggestions: Accessor> - - // Scoped permissions/questions — filtered to a session's family (self + subagents) - scopedPermissions: (sessionID: string | undefined) => PermissionRequest[] - scopedQuestions: (sessionID: string | undefined) => QuestionRequest[] - scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[] - - // Model selection (global, extension-lifetime) - selected: (sessionID?: string) => ModelSelection | null - modelForAgent: (agent: string) => ModelSelection | null - selectModel: (providerID: string, modelID: string, sessionID?: string) => void - - // Cost and context usage for the current session - costBreakdown: Accessor> - contextUsage: Accessor - modelUsage: Accessor - - // Skills loaded from the CLI backend - skills: Accessor - refreshSkills: () => void - removeSkill: (location: string) => void - - // Agent/mode selection (per-session) - agents: Accessor - allAgents: Accessor - removeAgent: (name: string) => void - removeMcp: (name: string) => void - - // MCP server status (runtime connect/disconnect) - mcpStatus: Accessor> - mcpLoading: Accessor - connectMcp: (name: string) => void - disconnectMcp: (name: string) => void - authenticateMcp: (name: string) => void - selectedAgent: (sessionID?: string) => string - selectAgent: (name: string, sessionID?: string) => void - getSessionAgent: (sessionID: string) => string - setSessionModel: (sessionID: string, providerID: string, modelID: string) => void - setSessionAgent: (sessionID: string, name: string) => void - setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void - - // Thinking variant for the selected model - variantList: (sessionID?: string) => string[] - currentVariant: (sessionID?: string) => string | undefined - variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined - selectVariant: (value: string | undefined, sessionID?: string) => void - - // Model favorites - recentModels: Accessor - modelUsageHistory: Accessor - favoriteModels: Accessor - toggleFavorite: (providerID: string, modelID: string) => void - - // Revert/undo state for the current session - revert: Accessor - revertedCount: Accessor - summary: Accessor - - // Live worktree diff stats (polled from CLI backend) - worktreeStats: Accessor<{ files: number; additions: number; deletions: number } | undefined> - - // Actions - revertSession: (messageID: string, partID?: string) => void - unrevertSession: () => void - deleteQueuedMessage: (sessionID: string, messageID: string) => void - sendMessage: ( - text: string, - providerID?: string, - modelID?: string, - files?: FileAttachment[], - draftID?: string, - context?: string, - review?: ReviewMessageData, - origin?: string | null, - ) => void - sendCommand: ( - command: string, - args: string, - providerID?: string, - modelID?: string, - files?: FileAttachment[], - draftID?: string, - context?: string, - origin?: string | null, - overrides?: { agent?: string; model?: string; variant?: string }, - ) => void - abort: () => void - compact: () => void - respondToPermission: ( - permissionId: string, - response: "once" | "always" | "reject", - approvedAlways: string[], - deniedAlways: string[], - ) => void - replyToQuestion: (requestID: string, answers: string[][]) => void - rejectQuestion: (requestID: string) => void - closeQuestion: (requestID: string) => void - acceptSuggestion: (requestID: string, index: number) => void - dismissSuggestion: (requestID: string) => void - createSession: () => void - clearCurrentSession: () => void - loadSessions: () => void - loadOlderMessages: () => boolean - selectSession: (id: string, options?: { focus?: boolean }) => void - releaseSession: (id: string) => void - deleteSession: (id: string) => void - renameSession: (id: string, title: string) => void - exportSessionTranscript: (id: string) => void - syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void - unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void - - // Cloud session preview - cloudPreviewId: Accessor - selectCloudSession: (cloudSessionId: string) => void - draftSessionID: Accessor - setDraftSessionID: (id: string | undefined) => void - userClearedSession: Accessor +interface CloseState { + reason: SessionCloseReason + parentID?: string } export const SessionContext = createContext() @@ -322,7 +146,7 @@ export const SessionProvider: ParentComponent = (props) => { // Per-session status map — keyed by sessionID const [statusMap, setStatusMap] = createStore>({}) - const [closeMap, setCloseMap] = createStore>({}) + const [closeMap, setCloseMap] = createStore>({}) const [busySinceMap, setBusySinceMap] = createStore>({}) const [submissionMap, setSubmissionMap] = createStore>({}) const pendingSubmissions = new Map() @@ -339,7 +163,7 @@ export const SessionProvider: ParentComponent = (props) => { const status = () => statusInfo().type as SessionStatus const closeReason = () => { const id = currentSessionID() - return id ? closeMap[id] : undefined + return id ? closeMap[id]?.reason : undefined } const clearClose = (id: string) => setCloseMap( @@ -990,6 +814,15 @@ export const SessionProvider: ParentComponent = (props) => { if (message.sessionID) patchPage(message.sessionID, { loadingInitial: false, loadingOlder: false }) } + function closed(message: Extract) { + if (message.reason === "completed" && closeMap[message.sessionID]?.reason === "error") return + setCloseMap(message.sessionID, { reason: message.reason, parentID: message.parentID }) + } + + function failed(id: string) { + setCloseMap(id, { reason: "error", parentID: store.sessions[id]?.parentID ?? undefined }) + } + function toggleFavorite(providerID: string, modelID: string) { const key = `${providerID}/${modelID}` const idx = store.favoriteModels.findIndex((f) => `${f.providerID}/${f.modelID}` === key) @@ -1089,7 +922,7 @@ export const SessionProvider: ParentComponent = (props) => { break case "sessionTurnClosed": - setCloseMap(message.sessionID, message.reason) + closed(message) break case "todoUpdated": @@ -1137,6 +970,7 @@ export const SessionProvider: ParentComponent = (props) => { if (!message.error || message.error.name === "MessageAbortedError") break const sid = message.sessionID ?? currentSessionID() if (!sid) break + failed(sid) // Find the last user message in this session to use as parentID const msgs = store.messages[sid] ?? [] const parent = [...msgs].reverse().find((m) => m.role === "user") @@ -1450,6 +1284,19 @@ export const SessionProvider: ParentComponent = (props) => { // proxy creation for each message object dominated the trace (~900ms). // "prepend" / "reconcile": reconcile to preserve existing proxies. if (mode === "replace") { + const keep = new Set(merged.map((message) => message.id)) + const removed = current.filter((message) => !keep.has(message.id)).map((message) => message.id) + clearHiddenErrors(removed) + setStore( + "parts", + produce((parts) => { + for (const id of removed) { + stash.remove(id) + optimisticParts.delete(id) + delete parts[id] + } + }), + ) setStore("messages", sessionID, merged) } else { setStore("messages", sessionID, reconcile(merged, { key: "id" })) @@ -1663,7 +1510,7 @@ export const SessionProvider: ParentComponent = (props) => { if (removedSessions.has(sessionID)) return const shouldAbort = aborts.update(sessionID, newStatus) confirmSubmissions(sessionID) - const prev = statusMap[sessionID] ?? { type: "idle" } + const prev = statusMap[sessionID]?.type ?? "idle" const info: SessionStatusInfo = newStatus === "retry" ? { type: "retry", attempt: attempt ?? 0, message: message ?? "", next: next ?? 0 } @@ -1672,7 +1519,7 @@ export const SessionProvider: ParentComponent = (props) => { : { type: newStatus } setStatusMap(sessionID, info) // Track busy start time and discard the previous turn's terminal state. - if (prev.type === "idle" && newStatus !== "idle") { + if (prev === "idle" && newStatus !== "idle") { clearClose(sessionID) if (!busySinceMap[sessionID]) setBusySinceMap(sessionID, Date.now()) } @@ -1876,8 +1723,14 @@ export const SessionProvider: ParentComponent = (props) => { return ids } + const lineage = createMemo(() => ancestry(store.sessions, store.toolParts, closeMap)) + function sessionFamily(rootID: string): Set { - return sessionIDs(rootID, (sid) => store.messages[sid] ?? []) + const ids = new Set([rootID]) + for (const id of ids) { + for (const child of lineage().children.get(id) ?? []) ids.add(child) + } + return ids } function modelUsageRelated(sessionID: string, parentID?: string | null): boolean { @@ -1919,6 +1772,27 @@ export const SessionProvider: ParentComponent = (props) => { return suggestions().filter((item) => family.has(item.sessionID)) } + const [activityMap, setActivityMap] = createStore>({}) + createComputed(() => { + const connection = server.connectionState() + setActivityMap( + reconcile( + activities({ + parents: lineage().parents, + statuses: statusMap, + outcomes: closeMap, + blocked: [...permissions(), ...questions().filter((item) => item.blocking !== false), ...suggestions()].map( + (item) => item.sessionID, + ), + submitting: Object.keys(submissionMap), + disconnected: connection !== "connected", + }), + ), + ) + }) + const activityFor = (id: string | undefined): Activity => (id ? (activityMap[id] ?? "idle") : "idle") + const inUseFor = (id: string) => inUse(sessionFamily(id), statusMap, [...permissions(), ...questions()]) + function handleTodoUpdated(sessionID: string, items: TodoItem[]) { setStore("todos", sessionID, items) } @@ -2997,6 +2871,8 @@ export const SessionProvider: ParentComponent = (props) => { allMessages, allParts, allStatusMap, + activityFor, + inUseFor, recentModels: () => store.recentModels, modelUsageHistory: () => store.modelUsageHistory, favoriteModels: () => store.favoriteModels, diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index ce324670db..3005ee0952 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -1,3 +1,4 @@ +import fuzzysort from "fuzzysort" import type { FileAttachment, FileSearchItem, SessionSearchItem } from "../types/messages" import { GIT_CHANGES_MENTION } from "./git-changes-context-utils" import { TERMINAL_MENTION } from "./terminal-context-utils" @@ -101,6 +102,13 @@ export function buildMentionResults( ] } +export function filterSessions(sessions: SessionSearchItem[], query: string) { + if (!query) return sessions.slice(0, 50) + return fuzzysort + .go(query.toLowerCase(), sessions, { keys: ["title", "worktreeName"], limit: 50 }) + .map((item) => item.obj) +} + /** Single-line, safe display/filename forms for a session mention. */ export function sessionMentionText(title: string) { return title.replace(/\s+/g, " ").trim() diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 8b776301ae..e393868723 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1034,6 +1034,10 @@ export const dict = { "settings.context.autoCompaction.title": "ضغط تلقائي", "settings.context.autoCompaction.description": "ضغط السياق تلقائياً قبل أن يصل إلى الحد", "settings.context.compaction.title": "الضغط", + "settings.context.compactionModel.title": "نموذج الضغط", + "settings.context.compactionModel.description": + "النموذج المستخدم للضغط التلقائي واليدوي. اتركه فارغاً لاستخدام نموذج الدردشة. تعتمد التكلفة والسرعة وجودة الملخص على النموذج.", + "settings.context.compactionModel.useChatModel": "استخدام نموذج الدردشة", "settings.context.compactionLimit.title": "حد الضغط التلقائي", "settings.context.compactionLimit.description": "اضغط عندما يصل السياق إلى هذه النسبة المئوية من نافذة النموذج. اتركه فارغاً لاستخدام هامش الأمان فقط.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index eedfaa69dd..47a30ce659 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1074,6 +1074,10 @@ export const dict = { "settings.context.autoCompaction.title": "Compactação automática", "settings.context.autoCompaction.description": "Compactar automaticamente o contexto antes que atinja o limite", "settings.context.compaction.title": "Compactação", + "settings.context.compactionModel.title": "Modelo de compactação", + "settings.context.compactionModel.description": + "Modelo usado para compactação automática e manual. Deixe sem definir para usar o modelo de chat. O custo, a velocidade e a qualidade do resumo dependem do modelo.", + "settings.context.compactionModel.useChatModel": "Usar modelo de chat", "settings.context.compactionLimit.title": "Limite de compactação automática", "settings.context.compactionLimit.description": "Compacte quando o contexto atingir esta porcentagem da janela do modelo. Deixe em branco para usar apenas a margem de segurança.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index e2e6946b90..8083ef5be5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1066,6 +1066,10 @@ export const dict = { "settings.context.autoCompaction.title": "Automatska kompresija", "settings.context.autoCompaction.description": "Automatski komprimiraj kontekst prije nego dostigne limit", "settings.context.compaction.title": "Kompresija", + "settings.context.compactionModel.title": "Model za kompresiju", + "settings.context.compactionModel.description": + "Model koji se koristi za automatsku i ručnu kompresiju. Ostavite nepostavljeno da biste koristili model za chat. Trošak, brzina i kvalitet sažetka zavise od modela.", + "settings.context.compactionModel.useChatModel": "Koristi model za chat", "settings.context.compactionLimit.title": "Limit automatske kompresije", "settings.context.compactionLimit.description": "Komprimiraj kada kontekst dostigne ovaj procenat prozora modela. Ostavite prazno da koristite samo sigurnosnu rezervu.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 23e1ccc423..ab2cf15a19 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1061,6 +1061,10 @@ export const dict = { "settings.context.autoCompaction.title": "Automatisk komprimering", "settings.context.autoCompaction.description": "Komprimér automatisk kontekst, før den når grænsen", "settings.context.compaction.title": "Komprimering", + "settings.context.compactionModel.title": "Komprimeringsmodel", + "settings.context.compactionModel.description": + "Model, der bruges til automatisk og manuel komprimering. Lad feltet være tomt for at bruge chatmodellen. Omkostninger, hastighed og kvaliteten af opsummeringen afhænger af modellen.", + "settings.context.compactionModel.useChatModel": "Brug chatmodel", "settings.context.compactionLimit.title": "Grænse for automatisk komprimering", "settings.context.compactionLimit.description": "Komprimér, når konteksten når denne procentdel af modelvinduet. Lad feltet være tomt for kun at bruge sikkerhedsbufferen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index dd4b6af334..50394047b1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1086,6 +1086,10 @@ export const dict = { "settings.context.autoCompaction.title": "Automatische Komprimierung", "settings.context.autoCompaction.description": "Kontext automatisch komprimieren, bevor er das Limit erreicht", "settings.context.compaction.title": "Komprimierung", + "settings.context.compactionModel.title": "Komprimierungsmodell", + "settings.context.compactionModel.description": + "Modell für die automatische und manuelle Komprimierung. Leer lassen, um das Chatmodell zu verwenden. Kosten, Geschwindigkeit und Zusammenfassungsqualität hängen vom Modell ab.", + "settings.context.compactionModel.useChatModel": "Chatmodell verwenden", "settings.context.compactionLimit.title": "Limit für automatische Komprimierung", "settings.context.compactionLimit.description": "Komprimieren, wenn der Kontext diesen Prozentsatz des Modellfensters erreicht. Leer lassen, um nur den Sicherheitspuffer zu verwenden.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 128277b4d6..21eda8c055 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1038,6 +1038,10 @@ export const dict = { "settings.context.autoCompaction.title": "Auto Compaction", "settings.context.autoCompaction.description": "Automatically compact context before it reaches the limit", "settings.context.compaction.title": "Compaction", + "settings.context.compactionModel.title": "Compaction model", + "settings.context.compactionModel.description": + "Model used for automatic and manual compaction. Leave unset to use the chat model. Cost, speed, and summary quality depend on the model.", + "settings.context.compactionModel.useChatModel": "Use chat model", "settings.context.compactionLimit.title": "Auto Compaction Limit", "settings.context.compactionLimit.description": "Compact when context reaches this percentage of the model window. Leave blank to use the safety buffer only.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index fe91bf741e..4f803a6b0a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1076,6 +1076,10 @@ export const dict = { "settings.context.autoCompaction.title": "Compactación automática", "settings.context.autoCompaction.description": "Compactar automáticamente el contexto antes de que alcance el límite", "settings.context.compaction.title": "Compactación", + "settings.context.compactionModel.title": "Modelo de compactación", + "settings.context.compactionModel.description": + "Modelo utilizado para la compactación automática y manual. Déjalo sin configurar para usar el modelo de chat. El coste, la velocidad y la calidad del resumen dependen del modelo.", + "settings.context.compactionModel.useChatModel": "Usar modelo de chat", "settings.context.compactionLimit.title": "Límite de compactación automática", "settings.context.compactionLimit.description": "Compactar cuando el contexto alcance este porcentaje de la ventana del modelo. Déjalo en blanco para usar solo el búfer de seguridad.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index eee02c10fb..41a374d876 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -1048,6 +1048,10 @@ export const dict = { "settings.context.autoCompaction.title": "فشرده‌سازی خودکار", "settings.context.autoCompaction.description": "قبل از رسیدن به محدودیت، زمینه را به‌طور خودکار فشرده کنید", "settings.context.compaction.title": "فشرده‌سازی", + "settings.context.compactionModel.title": "مدل فشرده‌سازی", + "settings.context.compactionModel.description": + "مدل مورد استفاده برای فشرده‌سازی خودکار و دستی. برای استفاده از مدل چت، خالی بگذارید. هزینه، سرعت و کیفیت خلاصه به مدل بستگی دارند.", + "settings.context.compactionModel.useChatModel": "استفاده از مدل چت", "settings.context.compactionLimit.title": "محدودیت فشرده‌سازی خودکار", "settings.context.compactionLimit.description": "زمانی فشرده‌سازی انجام شود که زمینه به این درصد از پنجره مدل برسد. برای استفاده تنها از بافر ایمنی، خالی بگذارید.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 84ae4791ec..d2bab363ee 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1093,6 +1093,10 @@ export const dict = { "settings.context.autoCompaction.description": "Compacter automatiquement le contexte avant qu'il n'atteigne la limite", "settings.context.compaction.title": "Compactage", + "settings.context.compactionModel.title": "Modèle de compactage", + "settings.context.compactionModel.description": + "Modèle utilisé pour le compactage automatique et manuel. Laissez ce champ vide pour utiliser le modèle de chat. Le coût, la vitesse et la qualité du résumé dépendent du modèle.", + "settings.context.compactionModel.useChatModel": "Utiliser le modèle de chat", "settings.context.compactionLimit.title": "Limite de compactage automatique", "settings.context.compactionLimit.description": "Compacter lorsque le contexte atteint ce pourcentage de la fenêtre du modèle. Laissez vide pour utiliser uniquement la marge de sécurité.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 0eebe695e3..baef1ed87d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -949,6 +949,10 @@ export const dict = { "settings.context.autoCompaction.title": "Compattazione automatica", "settings.context.autoCompaction.description": "Compatta automaticamente il contesto quando è pieno", "settings.context.compaction.title": "Compattazione", + "settings.context.compactionModel.title": "Modello di compattazione", + "settings.context.compactionModel.description": + "Modello usato per la compattazione automatica e manuale. Lascia non impostato per usare il modello di chat. Costo, velocità e qualità del riepilogo dipendono dal modello.", + "settings.context.compactionModel.useChatModel": "Usa il modello di chat", "settings.context.prune.title": "Riduci output vecchi", "settings.context.prune.description": "Rimuovi output tool vecchi durante la compattazione", "settings.context.watcherPatterns": "Pattern ignore file watcher", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 855f955515..1e6d6f119a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1055,6 +1055,10 @@ export const dict = { "settings.context.autoCompaction.title": "自動圧縮", "settings.context.autoCompaction.description": "コンテキストが上限に達する前に自動的に圧縮", "settings.context.compaction.title": "圧縮", + "settings.context.compactionModel.title": "圧縮モデル", + "settings.context.compactionModel.description": + "自動および手動の圧縮に使用するモデル。チャットモデルを使用するには未設定のままにしてください。コスト、速度、要約の品質はモデルによって異なります。", + "settings.context.compactionModel.useChatModel": "チャットモデルを使用", "settings.context.compactionLimit.title": "自動圧縮の上限", "settings.context.compactionLimit.description": "コンテキストがモデルウィンドウのこの割合に達したら圧縮します。安全バッファーのみを使用するには空欄のままにしてください。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 0ed648f104..b1c962a442 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1044,6 +1044,10 @@ export const dict = { "settings.context.autoCompaction.title": "자동 압축", "settings.context.autoCompaction.description": "컨텍스트가 한도에 도달하기 전에 자동으로 압축", "settings.context.compaction.title": "압축", + "settings.context.compactionModel.title": "압축 모델", + "settings.context.compactionModel.description": + "자동 및 수동 압축에 사용하는 모델입니다. 채팅 모델을 사용하려면 설정하지 않은 상태로 두세요. 비용, 속도 및 요약 품질은 모델에 따라 달라집니다.", + "settings.context.compactionModel.useChatModel": "채팅 모델 사용", "settings.context.compactionLimit.title": "자동 압축 한도", "settings.context.compactionLimit.description": "컨텍스트가 모델 창의 이 비율에 도달하면 압축합니다. 안전 버퍼만 사용하려면 비워 두세요.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 939fdb72fa..699bb11f4c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1033,6 +1033,10 @@ export const dict = { "settings.context.autoCompaction.title": "Automatische Compactie", "settings.context.autoCompaction.description": "Context automatisch compacteren voordat deze de limiet bereikt", "settings.context.compaction.title": "Compactie", + "settings.context.compactionModel.title": "Compactiemodel", + "settings.context.compactionModel.description": + "Model dat wordt gebruikt voor automatische en handmatige compactie. Laat dit leeg om het chatmodel te gebruiken. Kosten, snelheid en de kwaliteit van de samenvatting hangen af van het model.", + "settings.context.compactionModel.useChatModel": "Chatmodel gebruiken", "settings.context.compactionLimit.title": "Limiet voor automatisch compacteren", "settings.context.compactionLimit.description": "Compacteer wanneer de context dit percentage van het modelvenster bereikt. Laat leeg om alleen de veiligheidsbuffer te gebruiken.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 9de613801e..70ee0091cd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1060,6 +1060,10 @@ export const dict = { "settings.context.autoCompaction.title": "Automatisk komprimering", "settings.context.autoCompaction.description": "Komprimer automatisk kontekst før den når grensen", "settings.context.compaction.title": "Komprimering", + "settings.context.compactionModel.title": "Komprimeringsmodell", + "settings.context.compactionModel.description": + "Modell som brukes for automatisk og manuell komprimering. La feltet stå tomt for å bruke chatmodellen. Kostnad, hastighet og kvaliteten på sammendraget avhenger av modellen.", + "settings.context.compactionModel.useChatModel": "Bruk chatmodell", "settings.context.compactionLimit.title": "Grense for automatisk komprimering", "settings.context.compactionLimit.description": "Komprimer når konteksten når denne prosentandelen av modellvinduet. La stå tomt for å bare bruke sikkerhetsbufferen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 1a2901fc89..b47f539cae 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1066,6 +1066,10 @@ export const dict = { "settings.context.autoCompaction.title": "Automatyczna kompakcja", "settings.context.autoCompaction.description": "Automatycznie kompaktuj kontekst, zanim osiągnie limit", "settings.context.compaction.title": "Kompaktowanie", + "settings.context.compactionModel.title": "Model kompakcji", + "settings.context.compactionModel.description": + "Model używany do automatycznej i ręcznej kompakcji. Pozostaw puste, aby używać modelu czatu. Koszt, szybkość i jakość podsumowania zależą od modelu.", + "settings.context.compactionModel.useChatModel": "Użyj modelu czatu", "settings.context.compactionLimit.title": "Limit automatycznego kompaktowania", "settings.context.compactionLimit.description": "Kompaktuj, gdy kontekst osiągnie ten procent okna modelu. Pozostaw puste, aby używać tylko bufora bezpieczeństwa.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 7befd9fec0..54b4ce1b8f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1061,6 +1061,10 @@ export const dict = { "settings.context.autoCompaction.title": "Автоматическое сжатие", "settings.context.autoCompaction.description": "Автоматически сжимать контекст до достижения лимита", "settings.context.compaction.title": "Сжатие", + "settings.context.compactionModel.title": "Модель сжатия", + "settings.context.compactionModel.description": + "Модель, используемая для автоматического и ручного сжатия. Оставьте поле пустым, чтобы использовать модель чата. Стоимость, скорость и качество сводки зависят от модели.", + "settings.context.compactionModel.useChatModel": "Использовать модель чата", "settings.context.compactionLimit.title": "Лимит автоматического сжатия", "settings.context.compactionLimit.description": "Сжимать, когда контекст достигает этого процента окна модели. Оставьте пустым, чтобы использовать только буфер безопасности.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 088d9be14c..2899cd345e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1041,6 +1041,10 @@ export const dict = { "settings.context.autoCompaction.title": "การบีบอัดอัตโนมัติ", "settings.context.autoCompaction.description": "บีบอัดบริบทอัตโนมัติก่อนถึงขีดจำกัด", "settings.context.compaction.title": "การบีบอัด", + "settings.context.compactionModel.title": "โมเดลสำหรับการบีบอัด", + "settings.context.compactionModel.description": + "โมเดลที่ใช้สำหรับการบีบอัดอัตโนมัติและด้วยตนเอง เว้นว่างไว้เพื่อใช้โมเดลแชท ค่าใช้จ่าย ความเร็ว และคุณภาพของสรุปขึ้นอยู่กับโมเดล", + "settings.context.compactionModel.useChatModel": "ใช้โมเดลแชท", "settings.context.compactionLimit.title": "ขีดจำกัดการบีบอัดอัตโนมัติ", "settings.context.compactionLimit.description": "บีบอัดเมื่อบริบทถึงเปอร์เซ็นต์นี้ของหน้าต่างโมเดล เว้นว่างไว้เพื่อใช้เฉพาะบัฟเฟอร์ความปลอดภัย", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 13703de831..314bb7322b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1021,6 +1021,10 @@ export const dict = { "settings.context.autoCompaction.title": "Otomatik Sıkıştırma", "settings.context.autoCompaction.description": "Bağlam sınıra ulaşmadan önce otomatik olarak sıkıştır", "settings.context.compaction.title": "Sıkıştırma", + "settings.context.compactionModel.title": "Sıkıştırma modeli", + "settings.context.compactionModel.description": + "Otomatik ve manuel sıkıştırma için kullanılan model. Sohbet modelini kullanmak için boş bırakın. Maliyet, hız ve özet kalitesi modele bağlıdır.", + "settings.context.compactionModel.useChatModel": "Sohbet modelini kullan", "settings.context.compactionLimit.title": "Otomatik sıkıştırma sınırı", "settings.context.compactionLimit.description": "Bağlam model penceresinin bu yüzdesine ulaştığında sıkıştır. Yalnızca güvenlik tamponunu kullanmak için boş bırakın.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 75b33a9ebc..471751da84 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1020,6 +1020,10 @@ export const dict = { "settings.context.autoCompaction.title": "Автоматичне стиснення", "settings.context.autoCompaction.description": "Автоматично стискати контекст до досягнення ліміту", "settings.context.compaction.title": "Стискання", + "settings.context.compactionModel.title": "Модель стискання", + "settings.context.compactionModel.description": + "Модель, що використовується для автоматичного та ручного стискання. Залиште поле порожнім, щоб використовувати модель чату. Вартість, швидкість і якість підсумку залежать від моделі.", + "settings.context.compactionModel.useChatModel": "Використовувати модель чату", "settings.context.compactionLimit.title": "Ліміт автоматичного стискання", "settings.context.compactionLimit.description": "Стискати, коли контекст досягає цього відсотка вікна моделі. Залиште порожнім, щоб використовувати лише буфер безпеки.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index ce894d95f1..d069dfe961 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1005,6 +1005,10 @@ export const dict = { "settings.context.autoCompaction.title": "自动压缩", "settings.context.autoCompaction.description": "在上下文达到限制前自动压缩", "settings.context.compaction.title": "压缩", + "settings.context.compactionModel.title": "压缩模型", + "settings.context.compactionModel.description": + "用于自动和手动压缩的模型。留空以使用聊天模型。成本、速度和摘要质量取决于模型。", + "settings.context.compactionModel.useChatModel": "使用聊天模型", "settings.context.compactionLimit.title": "自动压缩限制", "settings.context.compactionLimit.description": "当上下文达到模型窗口的此百分比时进行压缩。留空则仅使用安全缓冲区。", "settings.context.prune.title": "修剪旧输出", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 377da7647c..d87cb9de2d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -968,6 +968,10 @@ export const dict = { "settings.context.autoCompaction.title": "自動壓縮", "settings.context.autoCompaction.description": "在上下文達到限制前自動壓縮", "settings.context.compaction.title": "壓縮", + "settings.context.compactionModel.title": "壓縮模型", + "settings.context.compactionModel.description": + "用於自動和手動壓縮的模型。留空以使用聊天模型。成本、速度和摘要品質取決於模型。", + "settings.context.compactionModel.useChatModel": "使用聊天模型", "settings.context.compactionLimit.title": "自動壓縮限制", "settings.context.compactionLimit.description": "當上下文達到模型視窗的此百分比時進行壓縮。留空則僅使用安全緩衝區。", "settings.context.prune.title": "修剪舊輸出", diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 42a07682c5..5e7768b360 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -12,6 +12,7 @@ import { DiffPanelCache } from "../../agent-manager/DiffPanelCache" import { createReviewComposers } from "../../agent-manager/review-composers" import { FullScreenDiffView } from "../../diff-viewer/FullScreenDiffView" import { WorktreeItem } from "../../agent-manager/WorktreeItem" +import { SessionTab } from "../components/chat/SessionTab" import { ChatView } from "../components/chat/ChatView" import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides" import { SessionContext } from "../context/session" @@ -31,7 +32,7 @@ import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector" import { DeferredPopover } from "../components/shared/DeferredPopover" import { ProjectSelect } from "../../agent-manager/ProjectSelect" import { PRComments } from "../../agent-manager/pr/PRComments" -import { createSignal, onCleanup, onMount, type JSX } from "solid-js" +import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js" import type { AgentProjectSnapshot, WorktreeFileDiff, @@ -775,7 +776,7 @@ const defaultProps = { active: false, pendingDelete: false, busy: false, - working: false, + activity: "idle" as const, stale: false, shortcut: 2, sessions: 1, @@ -802,6 +803,81 @@ const defaultProps = { // WorktreeItem stories // --------------------------------------------------------------------------- +const activityStates = [ + ["busy", "Running"], + ["waiting", "Needs input"], + ["done", "Completed"], + ["retry", "Retrying"], + ["error", "Error"], + ["idle", "Idle"], +] as const + +export const WorktreeActivityStates: Story = { + name: "Worktree cards - all activity states", + render: (args: { active?: boolean }) => ( + +
+ + + {([state, title]) => ( + + )} + +
+
+ ), +} + +export const WorktreeActivityStatesActive: Story = { + ...WorktreeActivityStates, + name: "Worktree cards - selected activity states", + args: { active: true }, +} + +export const SessionTabActivityStates: Story = { + name: "Agent Manager session tabs - activity states", + render: () => ( + +
+ + + {([state, title]) => ( +
+ +
+ )} +
+
+
+ ), +} + export const WorktreeItemDefault: Story = { name: "WorktreeItem — default", render: () => ( @@ -1613,8 +1689,7 @@ export const SidebarSearchOpen: Story = { scope: "Searches the local workspace, local sessions, worktrees, and their sessions", contexts: "LOCAL & WORKTREES", sessions: "SESSIONS", - waiting: "Wait", - retry: "Retry", + state: (value) => value, }} onSelect={(item) => setSelected(item.key)} defaultOpen @@ -1773,6 +1848,8 @@ export const MultiProjectSidebar: Story = { [projectB.id]: storyLocal("master", 0, 0, 0, 2), }} prs={{ [projectA.id]: {}, [projectB.id]: {} }} + busy={() => false} + blocked={() => false} sessions={{ [projectA.id]: [ projectSession("ses-a1", null, "Refine project accordion layout", "2026-07-24T08:30:00Z"), @@ -1782,6 +1859,8 @@ export const MultiProjectSidebar: Story = { }} selectedProject={projectA.id} selection="local" + activityFor={() => "idle"} + sessionActivity={() => "idle"} bindings={{ search: "⌘F", showShortcuts: "⌘⇧/", newWorktree: "⌘N", quickWorktree: "⌘⇧N" }} t={t} onSearchRef={() => {}} diff --git a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx index a359eefea5..744b76ad0b 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx @@ -58,7 +58,7 @@ const wtProps = { active: false, pendingDelete: false, busy: false, - working: false, + activity: "idle" as const, stale: false, sessions: 1, grouped: false, @@ -402,7 +402,7 @@ export const WithBusyWorktree: Story = {
- +
- +
diff --git a/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx index 2633744054..9ab6847342 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx @@ -1,14 +1,61 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" +import { For } from "solid-js" +import { SessionTab } from "../components/chat/SessionTab" import { SessionTabSwitcher } from "../components/chat/SessionTabSwitcher" +import type { Activity } from "../utils/session-activity" import { StoryProviders } from "./StoryProviders" const rows = [ - { id: "refactor", title: "Refactor shared search menu styles", active: false, busy: false, pending: false }, - { id: "current", title: "Run the extension test suite", active: true, busy: true, pending: false }, - { id: "pending", title: "Untitled session", active: false, busy: false, pending: true }, - { id: "idle", title: "Review keyboard navigation behavior", active: false, busy: false, pending: false }, + { + id: "refactor", + title: "Refactor shared search menu styles", + active: false, + state: "done" as const, + stateLabel: "Done", + pending: false, + }, + { + id: "current", + title: "Run the extension test suite", + active: true, + state: "busy" as const, + stateLabel: "Running", + pending: false, + }, + { + id: "waiting", + title: "Approve a pending command", + active: false, + state: "waiting" as const, + stateLabel: "Needs input", + pending: false, + }, + { + id: "error", + title: "Review the failed session", + active: false, + state: "error" as const, + stateLabel: "Error", + pending: false, + }, + { + id: "pending", + title: "Untitled session", + active: false, + state: "idle" as const, + stateLabel: "Current session", + pending: true, + }, + { + id: "idle", + title: "Review keyboard navigation behavior", + active: false, + state: "idle" as const, + stateLabel: "Current session", + pending: false, + }, ] const noop = () => {} @@ -21,6 +68,71 @@ const meta: Meta = { export default meta type Story = StoryObj +const states: { state: Activity; title: string }[] = [ + { state: "busy", title: "Running" }, + { state: "waiting", title: "Needs input" }, + { state: "done", title: "Completed" }, + { state: "retry", title: "Retrying" }, + { state: "error", title: "Error" }, + { state: "idle", title: "Idle" }, +] + +const tabs = (items: typeof states) => ( +
+
+ + {(item) => ( +
+ +
+ )} +
+
+
+) + +const gallery = (groups: (typeof states)[]) => ( + +
+ + {tabs} +
+
+) + +export const ActivityStates: Story = { + name: "Session tabs - all activity states", + render: () => gallery(states.map((item) => [item])), +} + +export const ActivityStates1280: Story = { + name: "Session tabs - all activity states - 1280px", + render: () => gallery([states]), +} + +export const MultipleSessions: Story = { + name: "Sidebar session tabs - multiple sessions", + render: () => gallery([states.slice(0, 3)]), +} + +export const MultipleSessions200: Story = { + name: "Sidebar session tabs - multiple sessions - 200px", + render: () => gallery([states.slice(0, 3)]), +} + export const SwitcherOpen: Story = { name: "Session tab switcher — open", render: () => ( @@ -28,7 +140,7 @@ export const SwitcherOpen: Story = {
diff --git a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css index 4306826c51..e0b4358614 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css +++ b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css @@ -177,6 +177,27 @@ flex-shrink: 0; } +.session-tab-bar .am-tab-icon[data-activity="waiting"], +.session-tab-switcher-popover .search-menu-icon[data-activity="waiting"], +.session-tab-switcher-popover .session-tab-switcher-meta[data-activity="waiting"] { + --icon-base: var(--icon-warning-base); + color: var(--icon-warning-base); +} + +.session-tab-bar .am-tab-icon[data-activity="error"], +.session-tab-switcher-popover .search-menu-icon[data-activity="error"], +.session-tab-switcher-popover .session-tab-switcher-meta[data-activity="error"] { + --icon-base: var(--icon-critical-base); + color: var(--icon-critical-base); +} + +.session-tab-bar .am-tab-icon[data-activity="done"], +.session-tab-switcher-popover .search-menu-icon[data-activity="done"], +.session-tab-switcher-popover .session-tab-switcher-meta[data-activity="done"] { + --icon-base: var(--icon-success-base); + color: var(--icon-success-base); +} + .session-tab-bar .am-worktree-spinner { width: 16px; height: 16px; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index c31825b265..51625b18b1 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -170,6 +170,7 @@ export interface SessionTurnClosedMessage { type: "sessionTurnClosed" sessionID: string reason: SessionCloseReason + parentID?: string } export interface SessionErrorMessage { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index e8ccea2d37..319dd99424 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -17,6 +17,7 @@ import type { StartMigrationMessage, } from "./migration" import type { MemoryShowMessage, MemoryOperationMessage, RequestMemoryMessage } from "./memory" +import type { Activity } from "../../utils/session-activity" // ============================================ // Messages FROM webview TO extension @@ -1490,6 +1491,7 @@ export interface DismissAgentMigrationBannerMessage { } export type WebviewMessage = + | { type: "sessionActivity"; state: Activity } | DocumentRequestMessage | DocumentOpenFileMessage | DocumentCloseMessage diff --git a/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts b/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts new file mode 100644 index 0000000000..0cf0837a64 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts @@ -0,0 +1,86 @@ +export type Activity = "waiting" | "error" | "retry" | "busy" | "done" | "idle" + +export type Status = "idle" | "busy" | "retry" | "offline" + +export interface ActivityInput { + status?: Status + blocked?: boolean + errored?: boolean + finished?: boolean + disconnected?: boolean +} + +export function activity(input: ActivityInput): Activity { + if (input.disconnected && (input.blocked || input.status === "busy" || input.status === "retry")) return "error" + if (input.blocked) return "waiting" + if (input.errored || input.status === "offline") return "error" + if (input.status === "retry") return "retry" + if (input.status === "busy") return "busy" + if (input.finished) return "done" + return "idle" +} + +export function activities(input: { + parents: ReadonlyMap + statuses: Record + outcomes: Record + blocked: Iterable + submitting?: Iterable + disconnected: boolean +}): Record { + const blocked = new Set(input.blocked) + const submitting = new Set(input.submitting) + const ids = new Set([...Object.keys(input.statuses), ...Object.keys(input.outcomes), ...blocked, ...submitting]) + const result: Record = {} + for (const id of ids) { + const status = submitting.has(id) ? "busy" : input.statuses[id]?.type + const close = input.outcomes[id]?.reason + const active = activity({ status, blocked: blocked.has(id), disconnected: input.disconnected }) + const own = activity({ + status, + blocked: blocked.has(id), + disconnected: input.disconnected, + errored: close === "error", + finished: close === "completed", + }) + result[id] = strongest([result[id] ?? "idle", own]) + if (active === "idle") continue + const seen = new Set([id]) + for (let parent = input.parents.get(id); parent && !seen.has(parent); parent = input.parents.get(parent)) { + seen.add(parent) + result[parent] = strongest([result[parent] ?? "idle", active]) + } + } + return result +} + +export function running(state: Activity): boolean { + return state === "busy" || state === "retry" +} + +const STATES: Activity[] = ["done", "busy", "retry", "error", "waiting"] + +export function score(state: Activity): number { + return STATES.indexOf(state) + 1 +} + +export function isActivity(value: unknown): value is Activity { + return value === "idle" || (typeof value === "string" && STATES.includes(value as Activity)) +} + +export function strongest(states: Activity[]): Activity { + return states.reduce((best, state) => (score(state) > score(best) ? state : best), "idle") +} + +const LABELS: Record = { + waiting: "task.backgroundAgents.needsInput", + error: "task.backgroundAgents.status.error", + retry: "session.status.retry", + busy: "session.tabs.switcher.busy", + done: "task.backgroundAgents.status.completed", + idle: "session.current", +} + +export function label(state: Activity): string { + return LABELS[state] +} diff --git a/packages/opencode/src/kilocode/indexing-warning.ts b/packages/opencode/src/kilocode/indexing-warning.ts index 1223efb9cc..e4dd2054d4 100644 --- a/packages/opencode/src/kilocode/indexing-warning.ts +++ b/packages/opencode/src/kilocode/indexing-warning.ts @@ -1,4 +1,5 @@ import type { IndexingStatus } from "@kilocode/kilo-indexing/status" +import { zeroID } from "@opencode-ai/core/kilocode/zero-id" export const INDEXING_WARNING_CODES = ["qdrant.version-incompatible", "qdrant.version-unavailable"] as const @@ -21,7 +22,7 @@ export function parseQdrantWarning(value: unknown): IndexingWarning | undefined } export function indexingWarningKey(warning: IndexingWarning): string { - return `${warning.code}\u0000${warning.message}` + return zeroID(warning.code, warning.message) } export function indexingErrorMessage(status: IndexingStatus): string | undefined { diff --git a/packages/opencode/src/kilocode/indexing-worker-client.ts b/packages/opencode/src/kilocode/indexing-worker-client.ts index 0f055c5db1..1ba8f7b221 100644 --- a/packages/opencode/src/kilocode/indexing-worker-client.ts +++ b/packages/opencode/src/kilocode/indexing-worker-client.ts @@ -4,6 +4,7 @@ import type { VectorStoreSearchResult, } from "@kilocode/kilo-indexing/engine" import type { IndexingStatus } from "@kilocode/kilo-indexing/status" +import { zeroID } from "@opencode-ai/core/kilocode/zero-id" import { withTimeout } from "@/util/timeout" import type { Event, Log, Message, Request, Result } from "./indexing-worker-protocol" import type { IndexingWarning } from "./indexing-warning" @@ -123,7 +124,7 @@ export namespace IndexingWorker { } const worker = (directory: string, root: string, hooks: Hooks): Host => { - const key = `${directory}\0${root}` + const key = zeroID(directory, root) const state = channel() let active = true let callbacks = hooks @@ -205,7 +206,7 @@ export namespace IndexingWorker { export function create(directory: string, root: string, hooks: Hooks) { if (factory) return factory(directory, root, hooks) - const key = `${directory}\0${root}` + const key = zeroID(directory, root) const existing = pool.get(key) if (existing) { existing.use(hooks) diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 679b844072..43d4112311 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -58,7 +58,7 @@ export namespace KiloSession { } }) if (!ctx) return - Bus.publish(ctx, Event.QueueChanged, input).catch(err => log.warn("queue changed publish failed", { err })) + Bus.publish(ctx, Event.QueueChanged, input).catch((err) => log.warn("queue changed publish failed", { err })) } // --------------------------------------------------------------------------- @@ -137,16 +137,29 @@ export namespace KiloSession { rows: Array>, directories: string[] = [], ): string[] { + const resolve = (dir: string) => { + try { + return Filesystem.resolve(dir) + } catch (err) { + const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined + if (code !== "EPERM" && code !== "EACCES") throw err + log.warn("Ignoring inaccessible saved project directory", { dir, code }) + return undefined + } + } const current = rows.find((row) => row.id === id) - const root = current?.worktree ? Filesystem.resolve(current.worktree) : undefined + const root = current?.worktree ? resolve(current.worktree) : undefined // Combine the stored root with Git's current sibling worktrees. const roots = new Set([...(root && root !== "/" ? [root] : []), ...directories.map(Filesystem.resolve)]) if (roots.size === 0) return [id] // Match both each project's recorded root and its saved worktrees. const ids = rows.flatMap((row) => { - const dirs = [row.worktree, ...row.sandboxes].map(Filesystem.resolve) - return dirs.some((dir) => roots.has(dir)) ? [row.id] : [] + const match = [row.worktree, ...row.sandboxes].some((dir) => { + const value = resolve(dir) + return value !== undefined && roots.has(value) + }) + return match ? [row.id] : [] }) // Always keep the requested ID and remove duplicates. return [...new Set([id, ...ids])] diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 7614e371cf..81a680c253 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -345,6 +345,18 @@ const layer = Layer.effect( for (let i = idx - 1; i >= 0; i--) { const msg = input.messages[i] if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { + const progress = input.messages + .slice(i + 1, idx) + .some( + (item) => + item.info.role === "assistant" && + (item.info.finish || + item.parts.some( + (part) => + part.type === "tool" || ((part.type === "text" || part.type === "reasoning") && !!part.text), + )), + ) + if (progress) break replay = { info: msg.info, parts: msg.parts } messages = input.messages.slice(0, i) break @@ -368,11 +380,12 @@ const layer = Layer.effect( const prior = completedCompactions(history) const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) const previousSummary = prior.at(-1)?.summary - const selected = yield* select({ - messages: history.filter((_, index) => !hidden.has(index)), - cfg, - model, - }) + // kilocode_change start + const available = history.filter((_, index) => !hidden.has(index)) + const selected = replay + ? { head: available, tail_start_id: undefined } + : yield* select({ messages: available, cfg, model }) + // kilocode_change end // Allow plugins to inject context or replace compaction prompt. const compacting = yield* plugin.trigger( "experimental.session.compacting", @@ -520,6 +533,7 @@ const layer = Layer.effect( format: original.format, tools: original.tools, system: original.system, + editorContext: original.editorContext, // kilocode_change }) KiloSessionPromptQueue.retarget(input.sessionID, replayMsg.id) // kilocode_change - expose replay to scope() for (const part of replay.parts) { @@ -531,6 +545,7 @@ const layer = Layer.effect( : part yield* session.updatePart({ ...replayPart, + ...(replayPart.type === "text" && { synthetic: true }), id: PartID.ascending(), messageID: replayMsg.id, sessionID: input.sessionID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 979cde0859..284f8d51a1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1581,7 +1581,7 @@ export const layer = Layer.effect( if (task?.type === "compaction") { const result = yield* compaction.process({ messages: msgs, - parentID: lastUser.id, + parentID: task.messageID, // kilocode_change sessionID, auto: task.auto, overflow: task.overflow, @@ -1617,7 +1617,7 @@ export const layer = Layer.effect( } compactionAttempts++ // kilocode_change end - yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true, overflow: false }) // kilocode_change continue } @@ -1839,27 +1839,34 @@ export const layer = Layer.effect( // kilocode_change end if (result === "compact") { // kilocode_change start - const guard = KiloSessionPrompt.guardCompactionAttempt({ - sessionID, - attempts: compactionAttempts, - closeReasons, - message: handle.message, - }) - if (guard.exhausted) { - yield* sessions.updateMessage(handle.message) - yield* events.publish(Session.Event.Error, { sessionID, error: guard.error }) - return "break" as const + const parts = yield* MessageV2.parts(handle.message.id).pipe( + Effect.provideService(Database.Service, database), + ) + const tools = parts.some( + (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part), + ) + if (!handle.message.finish || ["tool-calls", "unknown"].includes(handle.message.finish) || tools) { + const guard = KiloSessionPrompt.guardCompactionAttempt({ + sessionID, + attempts: compactionAttempts, + closeReasons, + message: handle.message, + }) + if (guard.exhausted) { + yield* sessions.updateMessage(handle.message) + yield* events.publish(Session.Event.Error, { sessionID, error: guard.error }) + return "break" as const + } + compactionAttempts++ + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + overflow: handle.message.finish ? undefined : handle.compactError?.() !== undefined, + }) } - compactionAttempts++ // kilocode_change end - yield* compaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - // kilocode_change - preflight compaction replays the pending turn without treating media as provider overflow - overflow: !handle.message.finish && handle.compactError?.() !== undefined, // kilocode_change - }) } // kilocode_change start — break out so a newer queued prompt can take over // instead of starting another LLM step for the now-superseded turn. The diff --git a/packages/opencode/test/kilocode/indexing-warning.test.ts b/packages/opencode/test/kilocode/indexing-warning.test.ts index ad97671aca..4255dac966 100644 --- a/packages/opencode/test/kilocode/indexing-warning.test.ts +++ b/packages/opencode/test/kilocode/indexing-warning.test.ts @@ -41,3 +41,11 @@ test("indexingWarningKey includes the warning code and message", () => { "qdrant.version-unavailable\u0000warning", ) }) + +test("indexingWarningKey preserves empty messages and embedded delimiters", () => { + for (const code of ["qdrant.version-unavailable", "qdrant.version-incompatible"] as const) { + for (const message of ["", "\0", "warning\0detail", "null", "undefined", "警告"]) { + expect(indexingWarningKey({ code, message })).toBe(`${code}\0${message}`) + } + } +}) diff --git a/packages/opencode/test/kilocode/indexing-worker.test.ts b/packages/opencode/test/kilocode/indexing-worker.test.ts index 12214b0a07..a8b409705c 100644 --- a/packages/opencode/test/kilocode/indexing-worker.test.ts +++ b/packages/opencode/test/kilocode/indexing-worker.test.ts @@ -57,6 +57,38 @@ test("routes multiple directories through the shared indexing worker", async () expect(failures).toEqual([]) }) +test("pools workers by both directory and root", async () => { + await using tmp = await tmpdir() + await using root = await tmpdir() + const failures: unknown[] = [] + const hooks: IndexingWorker.Hooks = { + status() {}, + telemetry() {}, + warning() {}, + log() {}, + failure(err) { + failures.push(err) + }, + } + const first = IndexingWorker.create(tmp.path, tmp.path, hooks) + const again = IndexingWorker.create(tmp.path, tmp.path, hooks) + const second = IndexingWorker.create(tmp.path, root.path, hooks) + + try { + expect(again).toBe(first) + expect(second).not.toBe(first) + const statuses = await Promise.all([ + first.init({ enabled: false, embedderProvider: "openai" }), + second.init({ enabled: false, embedderProvider: "openai" }), + ]) + expect(statuses.map((status) => status.state)).toEqual(["Disabled", "Disabled"]) + } finally { + await Promise.all([first.dispose(), second.dispose()]) + } + + expect(failures).toEqual([]) +}) + test("waits for the primary index instead of scanning a worktree independently", async () => { await using tmp = await tmpdir() const main = path.join(tmp.path, "main") diff --git a/packages/opencode/test/kilocode/server/session-mentions.test.ts b/packages/opencode/test/kilocode/server/session-mentions.test.ts new file mode 100644 index 0000000000..abfb4f4368 --- /dev/null +++ b/packages/opencode/test/kilocode/server/session-mentions.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, spyOn } from "bun:test" +import path from "path" +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Effect, Layer, Schema } from "effect" +import { eq, inArray } from "drizzle-orm" +import { Session } from "../../../src/session/session" +import { Filesystem } from "../../../src/util/filesystem" +import { resetDatabase } from "../../fixture/db" +import { disposeAllInstances, provideInstance, TestInstance, tmpdir } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" +import { httpApiLayer, requestInDirectory } from "../../server/httpapi-layer" +import { HttpClientResponse } from "effect/unstable/http" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) + +function request(directory: string) { + const query = new URLSearchParams({ + directory, + worktrees: "true", + roots: "true", + limit: String(5_000), + }) + return requestInDirectory(`/experimental/session?${query}`, directory) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + return Effect.gen(function* () { + const body: unknown = yield* response.json + if (!Schema.is(Schema.Array(Session.GlobalInfo))(body)) { + return yield* Effect.fail(new Error("Invalid session metadata")) + } + return body + }) +} + +function updated(ids: Array, time: number) { + return Database.Service.use(({ db }) => + db.update(SessionTable).set({ time_updated: time }).where(inArray(SessionTable.id, ids)).run().pipe(Effect.orDie), + ) +} + +function repo() { + return Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true })), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()).pipe(Effect.ignore), + ) +} + +function deny(dir: string, code: "EACCES" | "EPERM") { + const real = Filesystem.resolve + const spy = spyOn(Filesystem, "resolve").mockImplementation((input) => { + if (input === dir) throw Object.assign(new Error(`cannot resolve ${dir}`), { code }) + return real(input) + }) + return Effect.addFinalizer(() => Effect.sync(() => spy.mockRestore())) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe.serial("Kilo session mentions", () => { + it.instance( + "lists more than 50 root sessions without a cursor", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessions = yield* Effect.forEach(Array.from({ length: 51 }), (_, index) => + Session.use.create({ title: `session ${index}` }), + ) + yield* updated( + sessions.map((session) => session.id), + 1, + ) + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + expect(response.headers["x-next-cursor"]).toBeUndefined() + const body = yield* json(response) + expect(new Set(body.map((item) => item.id))).toEqual(new Set(sessions.map((session) => session.id))) + }), + { git: true }, + 30_000, + ) + + it.instance( + "ignores an inaccessible worktree from an unrelated project", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const other = yield* repo() + const current = yield* Session.use.create({ title: "current project" }) + const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path)) + const denied = path.join(other.path, "denied-worktree") + const { db } = yield* Database.Service + + yield* db + .update(ProjectTable) + .set({ worktree: AbsolutePath.make(denied) }) + .where(eq(ProjectTable.id, unrelated.projectID)) + .run() + .pipe(Effect.orDie) + yield* deny(denied, "EPERM") + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + const body = yield* json(response) + const ids = body.map((item) => item.id) + expect(ids).toContain(current.id) + expect(ids).not.toContain(unrelated.id) + }), + { git: true }, + ) + + it.instance( + "ignores an inaccessible saved sandbox", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const other = yield* repo() + const current = yield* Session.use.create({ title: "current project" }) + const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path)) + const denied = path.join(test.directory, "denied-sandbox") + const { db } = yield* Database.Service + + yield* db + .update(ProjectTable) + .set({ sandboxes: [AbsolutePath.make(denied)] }) + .where(eq(ProjectTable.id, unrelated.projectID)) + .run() + .pipe(Effect.orDie) + yield* deny(denied, "EACCES") + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + const body = yield* json(response) + expect(body.map((item) => item.id)).toEqual([current.id]) + }), + { git: true }, + ) + + it.instance( + "keeps a legacy root chat when a denied sandbox precedes a current-worktree alias", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const legacy = yield* repo() + const other = yield* repo() + const current = yield* Session.use.create({ title: "current project" }) + const root = yield* Session.use.create({ title: "legacy root" }) + const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path)) + const denied = path.join(legacy.path, "denied-sandbox") + const project = ProjectV2.ID.make("legacy-mentions-project") + const { db } = yield* Database.Service + + yield* db + .insert(ProjectTable) + .values({ + id: project, + worktree: AbsolutePath.make(legacy.path), + vcs: "git", + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [AbsolutePath.make(denied), AbsolutePath.make(test.directory)], + }) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ project_id: project }) + .where(eq(SessionTable.id, root.id)) + .run() + .pipe(Effect.orDie) + yield* deny(denied, "EPERM") + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + const body = yield* json(response) + const ids = body.map((item) => item.id) + expect(ids).toContain(current.id) + expect(ids).toContain(root.id) + expect(ids).not.toContain(unrelated.id) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index 3ae6024893..d89344262b 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -52,7 +52,7 @@ import * as Log from "@opencode-ai/core/util/log" import { MemoryService } from "@kilocode/kilo-memory/effect/service" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { TestLLMServer } from "../lib/llm-server" +import { reply, TestLLMServer } from "../lib/llm-server" Log.init({ print: false }) @@ -254,7 +254,7 @@ const user = Effect.fn("prompt-safety.user")(function* ( const assistant = Effect.fn("prompt-safety.assistant")(function* ( sessionID: SessionID, parentID: MessageID, - input?: { text?: string; summary?: boolean }, + input?: { text?: string; summary?: boolean; tokens?: MessageV2.Assistant["tokens"]; finish?: string }, ) { const sessions = yield* Session.Service const msg = yield* sessions.updateMessage({ @@ -266,11 +266,11 @@ const assistant = Effect.fn("prompt-safety.assistant")(function* ( agent: "code", path: { cwd: "/tmp", root: "/tmp" }, cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + tokens: input?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: ref.modelID, providerID: ref.providerID, time: { created: Date.now() }, - finish: "end_turn", + finish: input?.finish ?? "end_turn", summary: input?.summary, } satisfies MessageV2.Assistant) yield* sessions.updatePart({ @@ -363,6 +363,127 @@ describe("SessionPrompt compaction safety", () => { ), ) + it.live( + "answers a pending request once after prior usage triggers compaction", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({}) + const old = yield* user(chat.id, "old request") + yield* assistant(chat.id, old.id, { + tokens: { input: 95_000, output: 100, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + const editor = { activeFile: "src/app.ts", openTabs: ["src/app.ts"] } + const pending = yield* user(chat.id, "pending request", { editorContext: editor }) + yield* file(chat.id, pending.id, { mime: "image/png", name: "pending.png", body: "PENDINGIMAGE" }) + yield* llm.text("summary") + yield* llm.text("answer") + + const result = yield* prompt.loop({ sessionID: chat.id }) + + expect(yield* llm.calls).toBe(2) + expect(result.parts.some((part) => part.type === "text" && part.text === "answer")).toBe(true) + const body = JSON.stringify((yield* llm.inputs).at(-1)?.messages) + expect(body.match(/pending request/g)).toHaveLength(1) + expect(body).toContain("PENDINGIMAGE") + const messages = yield* sessions.messages({ sessionID: chat.id }) + const resumed = messages.findLast((msg) => msg.info.role === "user") + expect(resumed?.info.role === "user" ? resumed.info.editorContext : undefined).toEqual(editor) + expect( + messages + .flatMap((msg) => msg.parts) + .filter((part) => part.type === "text" && part.text === "pending request" && !part.synthetic), + ).toHaveLength(1) + }), + { git: true, config: providerCfg }, + ), + 30_000, + ) + + for (const finish of ["stop", "tool_calls", "length"] as const) { + it.live( + `does not replay completed work after a ${finish} response exceeds the budget`, + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const tools = finish !== "stop" + const chat = yield* sessions.create({ permission: [{ permission: "*", pattern: "*", action: "allow" }] }) + yield* llm.push( + (tools ? reply().tool("glob", { pattern: "*.txt" }) : reply().text("answer")) + .finish(finish) + .usage({ input: 95_000, output: 100 }), + ) + if (tools) { + yield* llm.text("tool progress summary") + yield* llm.text("answer") + } + yield* user(chat.id, "perform this once") + + const result = yield* prompt.loop({ sessionID: chat.id }) + + expect(yield* llm.calls).toBe(tools ? 3 : 1) + expect(result.parts.some((part) => part.type === "text" && part.text === "answer")).toBe(true) + const parts = (yield* sessions.messages({ sessionID: chat.id })).flatMap((msg) => msg.parts) + expect(parts.filter((part) => part.type === "text" && part.text === "perform this once")).toHaveLength(1) + expect(parts.filter((part) => part.type === "tool")).toHaveLength(tools ? 1 : 0) + }), + { git: true, config: providerCfg }, + ), + 30_000, + ) + } + + it.live( + "includes completed tool progress when a saved marker requests replay", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compaction = yield* SessionCompaction.Service + const chat = yield* sessions.create({}) + const old = yield* user(chat.id, "old request") + yield* assistant(chat.id, old.id) + const request = yield* user(chat.id, "run the tool") + const response = yield* assistant(chat.id, request.id, { finish: "tool-calls" }) + yield* sessions.updatePart({ + id: PartID.ascending(), + sessionID: chat.id, + messageID: response.id, + type: "tool", + callID: "call_glob", + tool: "glob", + state: { + status: "completed", + input: { pattern: "*.txt" }, + output: "already ran glob", + title: "glob", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + yield* compaction.create({ sessionID: chat.id, agent: "code", model: ref, auto: true, overflow: false }) + yield* llm.text("tool progress summary") + yield* llm.text("answer") + + yield* prompt.loop({ sessionID: chat.id }) + + expect(yield* llm.calls).toBe(2) + expect(JSON.stringify((yield* llm.inputs)[0]?.messages)).toContain("already ran glob") + const messages = yield* sessions.messages({ sessionID: chat.id }) + expect( + messages.flatMap((msg) => msg.parts).filter((part) => part.type === "text" && part.text === "run the tool"), + ).toHaveLength(1) + }), + { git: true, config: providerCfg }, + ), + 30_000, + ) + it.live("trims plain-text summary history before provider request", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { diff --git a/packages/ui/src/components/spinner.css b/packages/ui/src/components/spinner.css index 2ca474dc3c..1630282f0b 100644 --- a/packages/ui/src/components/spinner.css +++ b/packages/ui/src/components/spinner.css @@ -4,3 +4,9 @@ width: 18px; aspect-ratio: 1; } + +/* kilocode_change start */ +[data-component="spinner"][data-paused] > rect { + animation: none !important; +} +/* kilocode_change end */ diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index f8aacb6e29..1df533acd0 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -1,4 +1,5 @@ import { ComponentProps, For } from "solid-js" +import { observe } from "../kilocode/spinner" // kilocode_change const outerIndices = new Set([1, 2, 4, 7, 8, 11, 13, 14]) const cornerIndices = new Set([0, 3, 12, 15]) @@ -19,6 +20,7 @@ export function Spinner(props: { }) { return ( () +let observer: IntersectionObserver | undefined + +function update(root: Element, visible: boolean) { + root.toggleAttribute("data-paused", !visible || document.visibilityState === "hidden") +} + +function refresh() { + for (const [root, visible] of roots) update(root, visible) +} + +export function observe(root: SVGSVGElement) { + onMount(() => { + if (!observer) { + observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!roots.has(entry.target)) continue + const visible = entry.isIntersecting && entry.intersectionRatio > 0 + roots.set(entry.target, visible) + update(entry.target, visible) + } + }) + document.addEventListener("visibilitychange", refresh) + } + roots.set(root, false) + update(root, false) + observer.observe(root) + onCleanup(() => { + observer?.unobserve(root) + roots.delete(root) + root.removeAttribute("data-paused") + if (roots.size > 0) return + observer?.disconnect() + observer = undefined + document.removeEventListener("visibilitychange", refresh) + }) + }) +}