chore: merge main into UI cleanup

This commit is contained in:
marius-kilocode
2026-08-28 11:18:37 +02:00
139 changed files with 2840 additions and 626 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep focus in active text fields when questions appear or refresh.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Choose a separate model for conversation compaction in Context settings.
+7
View File
@@ -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.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show consistent running, completion, and input-required indicators across session tabs and Agent Manager worktrees.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Resume pending requests after automatic compaction without replaying requests that already completed.
+1
View File
@@ -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<T>()` for deferreds when runtime/types support it; allow callback/event executors, not async executors or redundant Promise wrapping.
### Avoid let statements
+5
View File
@@ -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")
}
@@ -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<Parameters<typeof zeroID>>().toEqualTypeOf<(string | number | boolean)[]>()
expectTypeOf<ReturnType<typeof zeroID>>().toEqualTypeOf<string>()
})
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<typeof zeroID>[] = [
[],
[""],
[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"))
}
})
})
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d55567442d6b3d627e64b583e7d4f49a522685efd8455013b17c4c4f0a84d84
size 8753
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d4601147436962bb2fb3db3eede146e2f83a6d88d50f5d7dce016c34817062d
size 18551
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f86a360b9c84e63f8614898edf4fd64e0ce41a570e306326f80868ce99483720
size 18265
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c071338a92316478623abbe23cdb262c366917266511a599f405ccdd7e399225
size 5710
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e275e0ec4ce304fc29c71eaac017872a6828d52fa73a504acce4b779fa389228
size 7407
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f924b317c2eafaf5112229cc31404591d7526a7e0fba8c1d46cd97597c3a4392
size 1778
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:268efbc7a6121b9fa3c2c843308b70f41a2235d2b9447e61ef6ac5c38b23c5b5
size 3729
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:273c76976df0e83fa404ae2af2d561f8c38458e3ef1b32f4f5b1990a5906a195
size 14239
oid sha256:f62edc4cf5786ca40fececbec0ae08e20096f0bc2bc08258de42c8a524a2366e
size 20730
@@ -0,0 +1,5 @@
@media (prefers-reduced-motion: reduce) {
[data-component="spinner"] > rect {
animation: none !important;
}
}
@@ -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<typeof Spinner> = {
title: "Components/Spinner",
@@ -20,6 +20,16 @@ export const Large: Story = {
render: () => <Spinner style={{ width: "48px", height: "48px" }} />,
}
export const Parallel: Story = {
render: () => (
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
{Array.from({ length: 24 }, () => (
<Spinner style={{ width: "16px", height: "16px" }} />
))}
</div>
),
}
export const Colored: Story = {
render: () => <Spinner style={{ width: "24px", height: "24px", color: "var(--text-interactive-base)" }} />,
}
+1
View File
@@ -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";
+17 -1
View File
@@ -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<string, number>()
private readonly anacondaDesktop = new AnacondaDesktopBridge()
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
private activity: Activity = "idle"
private caption: string | undefined
private readonly epochs = new Map<string, Map<string, number>>()
private readonly requests = new Map<string, number>()
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>): 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),
@@ -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.
@@ -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<Value> => {
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<WorktreeDiffEntry[]> => {
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)
@@ -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<string, ProjectRoute>()
private readonly sessions = new Map<string, SessionRoute>()
@@ -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,
@@ -411,7 +411,12 @@ export type WebviewMessage =
message: Record<string, unknown>
}
| { 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 {
@@ -19,6 +19,7 @@ type Ctx = {
exportTranscript: (sessionID: string) => Promise<void>
copy: (text: string) => PromiseLike<void>
openSessions: (ids: string[]) => void
activity: (state: unknown) => void
speechToTextModels: () => Promise<void>
modelUsage: (message: ModelUsageMessage) => Promise<void>
backgroundJobs: (sessionID: string, requestID: string) => Promise<void>
@@ -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<boolean> {
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)
@@ -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<Activity, string> = {
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
}
@@ -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
@@ -44,7 +44,7 @@ export async function handleSessionSearch(input: Input): Promise<void> {
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
@@ -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)
}
@@ -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(
() => (
<SessionContext.Provider value={session as never}>
@@ -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<HTMLButtonElement>('[data-slot="question-option"]')
const submit = root.querySelector<HTMLButtonElement>('[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()
@@ -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<typeof useSession> | 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<typeof renderTab>[1]
return (
<DragDropProvider>
<SortableProvider ids={ids}>
<For each={ids}>{(id) => renderTab(id, deps)}</For>
</SortableProvider>
</DragDropProvider>
)
}
const host = document.createElement("div")
document.body.append(host)
const step = { value: 0 }
const failures: string[] = []
const dispose = render(
() => (
<VSCodeProvider>
<ServerProvider>
<ProviderContext.Provider value={provider as never}>
<ConfigContext.Provider value={config as never}>
<LanguageContext.Provider value={language as never}>
<SessionProvider>
<Probe />
</SessionProvider>
</LanguageContext.Provider>
</ConfigContext.Provider>
</ProviderContext.Provider>
</ServerProvider>
</VSCodeProvider>
),
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)
@@ -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,
@@ -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()
}
@@ -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")
@@ -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<typeof createWorktreeDiffs>, 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")] })
@@ -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()
@@ -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()
@@ -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[] = []
@@ -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")
@@ -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"])
@@ -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",
@@ -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")
@@ -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<void> } = {}
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<typeof listener.current>) => {
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")
})
})
@@ -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<string, { type: string }>) => ({
statuses: () => statuses,
permissions: () => [],
questions: () => [],
const options = (values: Record<string, Activity>) => ({
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<string, { type: string }>) => createSessionBusy(options(statuses))
const activity = (values: Record<string, Activity>) => 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)
})
})
@@ -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<boolean>/)
expect(readFile(SESSION_TYPES_FILE)).toMatch(/userClearedSession:\s*Accessor<boolean>/)
})
it("clearCurrentSession sets the flag", () => {
@@ -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<string, string> = {
"solid-js": path.join(solid, "dist/solid.js"),
@@ -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",
])
})
})
@@ -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<string, string> = {
"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<NonNullable<Parameters<typeof build>[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)
}
})
})
@@ -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[] = []
@@ -51,9 +51,8 @@ const build = (overrides?: Partial<Parameters<typeof buildSidebarSearch>[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"])
@@ -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}
/>
</Show>
<Show when={!multiProject()}>
@@ -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}
@@ -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<string, AgentManagerStateMessage>
@@ -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<string, string>
t: LanguageContextValue["t"]
onSearchRef: (ref: SidebarSearchMenuRef) => void
@@ -61,6 +68,7 @@ export const ProjectList: Component<Props> = (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> = (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> = (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> = (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> = (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> = (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]}
@@ -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<string, WorktreeGitStats>
local?: LocalGitStats
prs?: Record<string, PRStatus | null>
@@ -81,7 +82,7 @@ export const ProjectSidebarBody: Component<Props> = (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> = (props) => {
const sidebarOrder = createMemo(() => projectSidebarOrder(top(), sorted(), sections(), members))
const post = (message: Record<string, unknown>) =>
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> = (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> = (props) => {
data-sidebar-id={`${props.project.id}:local`}
onClick={() => props.onSelectLocal(props.project.id)}
>
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
</Show>
<span class="am-local-status" data-activity={localState()} aria-label={props.t(label(localState()))}>
<ActivityIcon
state={localState()}
idle={
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
}
/>
</span>
<div class="am-local-text">
<span class="am-local-label">{props.t("agentManager.local")}</span>
<Show when={props.local === undefined}>
@@ -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<string, number>
worktreeStats: () => Record<string, WorktreeGitStats>
@@ -95,6 +96,7 @@ export interface SidebarBodyProps {
/** Legacy single-project sidebar body: local repo, worktrees, unassigned sessions. */
export const SidebarBody: Component<SidebarBodyProps> = (props) => {
const vscode = useVSCode()
const localState = () => props.activityFor(null)
return (
<>
@@ -104,13 +106,18 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
data-sidebar-id="local"
onClick={() => props.selectLocal()}
>
<Show when={!props.isLocalBusy()} fallback={<Spinner class="am-worktree-spinner" />}>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
</Show>
<span class="am-local-status" data-activity={localState()} aria-label={props.t(label(localState()))}>
<ActivityIcon
state={localState()}
idle={
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
}
/>
</span>
<div class="am-local-text">
<span class="am-local-label">{props.t("agentManager.local")}</span>
<Show when={props.repoBranch()}>
@@ -314,8 +321,8 @@ export const SidebarBody: Component<SidebarBodyProps> = (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]}
@@ -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<SidebarSearchItem[]>
current: Accessor<SidebarSearchItem | undefined>
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<SidebarSearchMenuProps> = (props) => {
}}
>
{(item) => {
const working = item.state === "busy" || item.state === "retry"
const stateLabel = () => props.labels.state(item.state)
return (
<span
class="search-menu-row"
@@ -114,19 +122,30 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
data-session-id={item.kind === "session" ? item.sessionId : undefined}
data-worktree-id={item.kind === "worktree" ? item.worktreeId : undefined}
>
<span class="search-menu-icon">
<Show when={!working} fallback={<Spinner class="search-menu-spinner" />}>
<Show
when={item.kind !== "local"}
fallback={
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14M10 13.5V16.5" stroke="currentColor" />
</svg>
}
>
<Icon name={item.kind === "worktree" ? "branch" : "speech-bubble"} size="small" />
</Show>
<span class="search-menu-icon am-sidebar-search-icon" data-activity={item.state}>
<Show
when={item.busy}
fallback={
<ActivityIcon
state={item.state}
spinner="search-menu-spinner"
idle={
<Show
when={item.kind !== "local"}
fallback={
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14M10 13.5V16.5" stroke="currentColor" />
</svg>
}
>
<Icon name={item.kind === "worktree" ? "branch" : "speech-bubble"} size="small" />
</Show>
}
/>
}
>
<Spinner class="search-menu-spinner" />
</Show>
</span>
<span class="search-menu-copy">
@@ -143,13 +162,12 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
<span>{item.meta.join(" · ")}</span>
</span>
</span>
<Show when={item.state === "waiting"}>
<span class="search-menu-status am-sidebar-search-status">{props.labels.waiting}</span>
<Show when={item.state !== "idle" && item.state !== "busy"}>
<span class="search-menu-status am-sidebar-search-status" data-activity={item.state}>
{stateLabel()}
</span>
</Show>
<Show when={item.state === "retry"}>
<span class="search-menu-status am-sidebar-search-status">{props.labels.retry}</span>
</Show>
<Show when={item.kind !== "session" && item.state === "idle"}>
<Show when={item.kind !== "session" && item.state === "idle" && !item.busy}>
<span class="search-menu-status am-sidebar-search-count">
{item.kind !== "session" ? item.count : ""}
</span>
@@ -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<WorktreeItemProps> = (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<WorktreeItemProps> = (props) => {
data-sidebar-id={props.sidebarId ?? props.worktree.id}
onClick={() => props.onClick()}
>
<div class="am-wt-icon">
<Show when={!props.busy && !props.working} fallback={<Spinner class="am-worktree-spinner" />}>
<Icon name="branch" size="small" />
<div class="am-wt-icon" data-activity={state()} aria-label={t(label(state()))}>
<Show
when={!props.busy && props.runStatus?.state !== "running"}
fallback={<Spinner class="am-worktree-spinner" />}
>
<ActivityIcon state={state()} idle={<Icon name="branch" size="small" />} />
</Show>
</div>
<div class="am-wt-content">
@@ -304,7 +315,7 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
{props.shortcut}
</span>
</Show>
<Show when={!props.busy && !props.working && !props.blocked && !props.pendingDelete}>
<Show when={!blocked() && !props.pendingDelete}>
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
@@ -521,7 +532,7 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
<Icon name="edit" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.worktree.rename")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={!props.busy && !props.working && !props.blocked}>
<Show when={!blocked()}>
<ContextMenu.Item onSelect={() => props.onDelete(new MouseEvent("click"))}>
<Icon name="trash" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.worktree.delete")}</ContextMenu.ItemLabel>
@@ -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<SidebarSearchItem[]>
@@ -31,8 +32,7 @@ export const WorktreeSectionActions: Component<WorktreeSectionActionsProps> = (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}
/>
@@ -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;
@@ -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
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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‌ها",
@@ -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",
@@ -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",
@@ -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のセッションを検索",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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 и связанных с ними сессий",
@@ -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",
@@ -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",
@@ -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":
"Пошук локального робочого простору, локальних сесій, робочих дерев і призначених їм сесій",
@@ -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",
@@ -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",
@@ -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<string, Status>
permissions: () => Prompt[]
questions: () => Prompt[]
export function createSessionActivity(opts: {
managed: () => Item[]
local: () => string[]
projects: () => Record<string, Item[]>
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<string | null, Activity[]>()
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<string, Map<string | null, Activity>>()
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<typeof createSessionBusy>[0] & {
export function createWorktreeActivity(
opts: Parameters<typeof createSessionActivity>[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<string>())
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))
},
}
}
@@ -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<string | undefined>
selection: Accessor<string | null>
sessionId: Accessor<string | undefined>
statuses: Accessor<Record<string, SessionStatusInfo>>
permissions: Accessor<PermissionRequest[]>
questions: Accessor<QuestionRequest[]>
activityFor: (id: string) => Activity
label: (worktree: WorktreeState) => string
sessions: (id: string) => SessionInfo[]
pending: (id: string) => boolean
busy: (id: string) => boolean
localBusy: Accessor<boolean>
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(),
})
})
@@ -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<{
<SessionTab
title={props.tab.title || t("agentManager.session.untitled")}
active={props.active}
busy={props.busy}
state={props.state}
stateLabel={props.stateLabel}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
closeTabIndex={props.active ? 0 : -1}
@@ -7,7 +7,7 @@
* and content area.
*/
import { Show } from "solid-js"
import { Show, createMemo } from "solid-js"
import type { Accessor, JSX } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
@@ -17,6 +17,7 @@ import { SortableTab, SortableReviewTab } from "./sortable-tab"
import type { TerminalStateControls } from "./terminal"
import { isTerminalTabId, renderTerminalTab } from "./terminal"
import type { SessionInfo } from "../src/types/messages"
import type { Activity } from "../src/utils/session-activity"
import { parseBindingTokens } from "./keybind-tokens"
interface FocusTabDeps {
@@ -72,7 +73,8 @@ export interface TabRenderDeps {
* getter so Solid tracks its reactivity inside rendered JSX. */
visibleTabId: () => string | undefined
isPending: (id: string) => boolean
isBusy: (id: string) => boolean
activityFor: (id: string) => Activity
stateLabel: (state: Activity) => string
tabLookup: () => Map<string, SessionInfo>
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 {
<SortableTab
tab={s}
active={active() && !deps.reviewActive()}
busy={deps.isBusy(s.id)}
state={state()}
stateLabel={deps.stateLabel(state())}
role="tab"
selected={deps.visibleTabId() === s.id}
tabIndex={deps.visibleTabId() === s.id ? 0 : -1}
@@ -8,6 +8,7 @@
*/
import { createSignal, type Accessor } from "solid-js"
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
import { parseDiffId } from "./diff-scope-state"
import type { useVSCode } from "../src/context/vscode"
@@ -33,7 +34,7 @@ export function wireDiffId(id: string) {
}
export function diffDataKey(project: string | undefined, id: string): string {
return `${project ?? "single"}\0${id}`
return zeroID(project ?? "single", id)
}
export function createWorktreeDiffs(
@@ -79,7 +80,7 @@ export function createWorktreeDiffs(
}
const prune = (ids: Set<string>) => {
const prefix = `${project() ?? "single"}\0`
const prefix = key("")
const keys = new Set([
...Object.keys(diffDatas()),
...Object.keys(diffLoadings()),
+6 -1
View File
@@ -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) {
@@ -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<HTMLButtonElement>("button[data-slot='question-option']:not(:disabled)")
btn?.focus({ preventScroll: true })
})
@@ -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) {
}}
>
<List<SessionSearchItem>
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)
@@ -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<HTMLDivElement, KeyboardEvent>
onClose: () => void
}> = (props) => (
<div class={`am-tab ${props.active ? "am-tab-active" : ""}`}>
<div class={`am-tab ${props.active ? "am-tab-active" : ""}`} data-activity={props.state}>
<div
class="am-tab-target"
role={props.role}
@@ -41,9 +43,9 @@ export const SessionTab: Component<{
openDelay={0}
>
<span class="am-tab-title">
<Show when={props.busy}>
<span class="am-tab-icon">
<Spinner class="am-worktree-spinner" />
<Show when={props.state !== "idle"}>
<span class="am-tab-icon" data-activity={props.state} aria-label={props.stateLabel}>
<ActivityIcon state={props.state} />
</span>
</Show>
<span class="am-tab-label">{props.title}</span>
@@ -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 = () => {
<SessionTab
title={title(id)}
active={tabs.active() === id}
busy={working(id)}
state={state(id)}
stateLabel={language.t(label(state(id)))}
closeTitle={language.t("common.closeTab")}
closeLabel={language.t("common.closeTab")}
role="tab"
@@ -171,7 +172,6 @@ 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}
@@ -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<SessionTabSwitcherProps> = (props) =>
>
{(item) => (
<span class="search-menu-row">
<span class="search-menu-icon">
<Show when={!item.busy} fallback={<Spinner class="search-menu-spinner" />}>
<Icon name="speech-bubble" size="small" />
</Show>
<span class="search-menu-icon" data-activity={item.state} aria-label={item.stateLabel}>
<ActivityIcon state={item.state} spinner="search-menu-spinner" />
</span>
<span class="search-menu-copy">
<span class="search-menu-title" dir="auto">
{item.title}
</span>
<Show when={item.busy || item.pending}>
<span class="search-menu-meta session-tab-switcher-meta">
<Show when={item.busy} fallback={props.labels.pending}>
{props.labels.busy}
<Show when={item.state !== "idle" || item.pending}>
<span class="search-menu-meta session-tab-switcher-meta" data-activity={item.state}>
<Show when={item.state !== "idle"} fallback={props.labels.pending}>
{item.stateLabel}
</Show>
</span>
</Show>
@@ -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")}
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.context.compactionModel.title")}
description={language.t("settings.context.compactionModel.description")}
>
<ModelSelectorBase
value={parseModelString(config().agent?.compaction?.model ?? undefined)}
onSelect={(providerID, modelID) =>
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")}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.context.compactionLimit.title")}
description={language.t("settings.context.compactionLimit.description")}
@@ -0,0 +1,22 @@
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Match, Switch, type Component, type JSX } from "solid-js"
import { running, type Activity } from "../../utils/session-activity"
export const ActivityIcon: Component<{
state: Activity
idle?: JSX.Element
spinner?: string
}> = (props) => (
<Switch fallback={props.idle ?? <Icon name="speech-bubble" size="small" />}>
<Match when={running(props.state)}>
<Spinner class={props.spinner ?? "am-worktree-spinner"} />
</Match>
<Match when={props.state === "waiting" || props.state === "error"}>
<Icon name="warning" size="small" />
</Match>
<Match when={props.state === "done"}>
<Icon name="circle-check" size="small" />
</Match>
</Switch>
)
@@ -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<string | undefined>
currentSession: Accessor<SessionInfo | undefined>
setCurrentSessionID: (id: string | undefined) => void
// All sessions (sorted most recent first)
sessions: Accessor<SessionInfo[]>
// Session status
status: Accessor<SessionStatus>
statusInfo: Accessor<SessionStatusInfo>
closeReason: Accessor<SessionCloseReason | undefined>
statusText: Accessor<string | undefined>
busySince: Accessor<number | undefined>
submitting: Accessor<boolean>
isSubmitting: (id: string) => boolean
loading: Accessor<boolean>
loadingOlderMessages: Accessor<boolean>
hasOlderMessages: Accessor<boolean>
messageMutation: Accessor<MessageMutation | undefined>
// Messages for current session
messages: Accessor<Message[]>
// Messages for current session with soft-reverted turns hidden
visibleMessages: Accessor<Message[]>
// User messages for current session (role === "user")
userMessages: Accessor<Message[]>
// All messages keyed by sessionID (includes child sessions)
allMessages: () => Record<string, Message[]>
// All parts keyed by messageID (includes child sessions)
allParts: () => Record<string, Part[]>
// All session statuses keyed by sessionID (for DataBridge)
allStatusMap: () => Record<string, SessionStatusInfo>
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<TodoItem[]>
// Pending permission requests (unscoped — all tracked sessions)
permissions: Accessor<PermissionRequest[]>
respondingPermissions: Accessor<Set<string>>
// Pending question requests (unscoped — all tracked sessions)
questions: Accessor<QuestionRequest[]>
questionErrors: Accessor<Set<string>>
suggestions: Accessor<SuggestionRequest[]>
suggestionErrors: Accessor<Set<string>>
respondingSuggestions: Accessor<Set<string>>
// 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<Array<{ label: string; cost: number }>>
contextUsage: Accessor<ContextUsage | undefined>
modelUsage: Accessor<SessionModelUsage | undefined>
// Skills loaded from the CLI backend
skills: Accessor<SkillInfo[]>
refreshSkills: () => void
removeSkill: (location: string) => void
// Agent/mode selection (per-session)
agents: Accessor<AgentInfo[]>
allAgents: Accessor<AgentInfo[]>
removeAgent: (name: string) => void
removeMcp: (name: string) => void
// MCP server status (runtime connect/disconnect)
mcpStatus: Accessor<Record<string, McpStatusEntry>>
mcpLoading: Accessor<string | null>
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<ModelSelection[]>
modelUsageHistory: Accessor<ModelUsageMap>
favoriteModels: Accessor<ModelSelection[]>
toggleFavorite: (providerID: string, modelID: string) => void
// Revert/undo state for the current session
revert: Accessor<SessionInfo["revert"]>
revertedCount: Accessor<number>
summary: Accessor<SessionInfo["summary"]>
// 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<string | null>
selectCloudSession: (cloudSessionId: string) => void
draftSessionID: Accessor<string | undefined>
setDraftSessionID: (id: string | undefined) => void
userClearedSession: Accessor<boolean>
}
@@ -117,6 +117,45 @@ export function childID(part: TaskPart): string | undefined {
return part.metadata?.sessionId ?? part.state?.metadata?.sessionId
}
export function inUse(
family: ReadonlySet<string>,
statuses: Record<string, { type: string }>,
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<string, ParentSession>,
tools: Record<string, readonly TaskPart[]>,
outcomes: Record<string, ParentSession | undefined>,
) {
const parents = new Map<string, string>()
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<string, string[]>()
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
@@ -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<string, { requestID: string; data?: SessionModelUsage }>
}
interface SessionContextValue {
// Current session
currentSessionID: Accessor<string | undefined>
currentSession: Accessor<SessionInfo | undefined>
setCurrentSessionID: (id: string | undefined) => void
// All sessions (sorted most recent first)
sessions: Accessor<SessionInfo[]>
// Session status
status: Accessor<SessionStatus>
statusInfo: Accessor<SessionStatusInfo>
closeReason: Accessor<SessionCloseReason | undefined>
statusText: Accessor<string | undefined>
busySince: Accessor<number | undefined>
submitting: Accessor<boolean>
isSubmitting: (id: string) => boolean
loading: Accessor<boolean>
loadingOlderMessages: Accessor<boolean>
hasOlderMessages: Accessor<boolean>
messageMutation: Accessor<MessageMutation | undefined>
// Messages for current session
messages: Accessor<Message[]>
// Messages for current session with soft-reverted turns hidden
visibleMessages: Accessor<Message[]>
// User messages for current session (role === "user")
userMessages: Accessor<Message[]>
// All messages keyed by sessionID (includes child sessions)
allMessages: () => Record<string, Message[]>
// All parts keyed by messageID (includes child sessions)
allParts: () => Record<string, Part[]>
// All session statuses keyed by sessionID (for DataBridge)
allStatusMap: () => Record<string, SessionStatusInfo>
// 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<TodoItem[]>
// Pending permission requests (unscoped — all tracked sessions)
permissions: Accessor<PermissionRequest[]>
respondingPermissions: Accessor<Set<string>>
// Pending question requests (unscoped — all tracked sessions)
questions: Accessor<QuestionRequest[]>
questionErrors: Accessor<Set<string>>
suggestions: Accessor<SuggestionRequest[]>
suggestionErrors: Accessor<Set<string>>
respondingSuggestions: Accessor<Set<string>>
// 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<Array<{ label: string; cost: number }>>
contextUsage: Accessor<ContextUsage | undefined>
modelUsage: Accessor<SessionModelUsage | undefined>
// Skills loaded from the CLI backend
skills: Accessor<SkillInfo[]>
refreshSkills: () => void
removeSkill: (location: string) => void
// Agent/mode selection (per-session)
agents: Accessor<AgentInfo[]>
allAgents: Accessor<AgentInfo[]>
removeAgent: (name: string) => void
removeMcp: (name: string) => void
// MCP server status (runtime connect/disconnect)
mcpStatus: Accessor<Record<string, McpStatusEntry>>
mcpLoading: Accessor<string | null>
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<ModelSelection[]>
modelUsageHistory: Accessor<ModelUsageMap>
favoriteModels: Accessor<ModelSelection[]>
toggleFavorite: (providerID: string, modelID: string) => void
// Revert/undo state for the current session
revert: Accessor<SessionInfo["revert"]>
revertedCount: Accessor<number>
summary: Accessor<SessionInfo["summary"]>
// 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<string | null>
selectCloudSession: (cloudSessionId: string) => void
draftSessionID: Accessor<string | undefined>
setDraftSessionID: (id: string | undefined) => void
userClearedSession: Accessor<boolean>
interface CloseState {
reason: SessionCloseReason
parentID?: string
}
export const SessionContext = createContext<SessionContextValue>()
@@ -322,7 +146,7 @@ export const SessionProvider: ParentComponent = (props) => {
// Per-session status map — keyed by sessionID
const [statusMap, setStatusMap] = createStore<Record<string, SessionStatusInfo>>({})
const [closeMap, setCloseMap] = createStore<Record<string, SessionCloseReason | undefined>>({})
const [closeMap, setCloseMap] = createStore<Record<string, CloseState | undefined>>({})
const [busySinceMap, setBusySinceMap] = createStore<Record<string, number>>({})
const [submissionMap, setSubmissionMap] = createStore<Record<string, number>>({})
const pendingSubmissions = new Map<string, string>()
@@ -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<ExtensionMessage, { type: "sessionTurnClosed" }>) {
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<string> {
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<Record<string, Activity>>({})
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,
@@ -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()
+4
View File
@@ -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":
"اضغط عندما يصل السياق إلى هذه النسبة المئوية من نافذة النموذج. اتركه فارغاً لاستخدام هامش الأمان فقط.",

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