From f293ecbba849384ae8b10bc7735dc79791c62cdf Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 27 Aug 2026 12:03:59 +0200 Subject: [PATCH 1/5] fix(vscode): show consistent session activity indicators --- .changeset/session-tab-attention.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 18 +- .../kilo-vscode/src/SubAgentViewerProvider.ts | 8 +- .../src/agent-manager/vscode-host.ts | 4 + .../kilo-vscode/src/kilo-provider-utils.ts | 8 +- .../src/kilo-provider/early-message.ts | 7 +- .../src/kilo-provider/native-tab-title.ts | 19 +- .../kilo-vscode/src/kilo-provider/options.ts | 1 + .../fixtures/session-provider-activity.tsx | 393 ++++++++++++++++++ .../kilo-vscode/tests/setup/vscode-mock.ts | 4 + .../tests/unit/early-message.test.ts | 11 + .../tests/unit/kilo-provider-utils.test.ts | 15 + .../tests/unit/native-tab-title.test.ts | 34 ++ .../tests/unit/project-session-busy.test.ts | 70 +++- .../tests/unit/session-activity.test.ts | 169 ++++++++ .../unit/session-provider-activity.test.ts | 48 +++ .../tests/unit/sidebar-search.test.ts | 23 +- .../agent-manager/AgentManagerApp.tsx | 47 ++- .../webview-ui/agent-manager/ProjectList.tsx | 28 +- .../agent-manager/ProjectSidebarBody.tsx | 32 +- .../webview-ui/agent-manager/SidebarBody.tsx | 28 +- .../agent-manager/SidebarSearchMenu.tsx | 60 ++- .../webview-ui/agent-manager/WorktreeItem.tsx | 15 +- .../agent-manager/WorktreeSectionActions.tsx | 4 +- .../agent-manager/agent-manager.css | 27 ++ .../webview-ui/agent-manager/i18n/ar.ts | 2 - .../webview-ui/agent-manager/i18n/br.ts | 2 - .../webview-ui/agent-manager/i18n/bs.ts | 2 - .../webview-ui/agent-manager/i18n/da.ts | 2 - .../webview-ui/agent-manager/i18n/de.ts | 2 - .../webview-ui/agent-manager/i18n/en.ts | 2 - .../webview-ui/agent-manager/i18n/es.ts | 2 - .../webview-ui/agent-manager/i18n/fa.ts | 2 - .../webview-ui/agent-manager/i18n/fr.ts | 2 - .../webview-ui/agent-manager/i18n/it.ts | 2 - .../webview-ui/agent-manager/i18n/ja.ts | 2 - .../webview-ui/agent-manager/i18n/ko.ts | 2 - .../webview-ui/agent-manager/i18n/nl.ts | 2 - .../webview-ui/agent-manager/i18n/no.ts | 2 - .../webview-ui/agent-manager/i18n/pl.ts | 2 - .../webview-ui/agent-manager/i18n/ru.ts | 2 - .../webview-ui/agent-manager/i18n/th.ts | 2 - .../webview-ui/agent-manager/i18n/tr.ts | 2 - .../webview-ui/agent-manager/i18n/uk.ts | 2 - .../webview-ui/agent-manager/i18n/zh.ts | 2 - .../webview-ui/agent-manager/i18n/zht.ts | 2 - .../agent-manager/project/session-busy.ts | 60 ++- .../agent-manager/sidebar-search.ts | 60 ++- .../webview-ui/agent-manager/sortable-tab.tsx | 7 +- .../agent-manager/tab-rendering.tsx | 10 +- packages/kilo-vscode/webview-ui/src/App.tsx | 7 +- .../src/components/chat/SessionTab.tsx | 14 +- .../src/components/chat/SessionTabStrip.tsx | 14 +- .../components/chat/SessionTabSwitcher.tsx | 22 +- .../src/components/shared/ActivityIcon.tsx | 22 + .../webview-ui/src/context/session-utils.ts | 28 ++ .../webview-ui/src/context/session.tsx | 57 ++- .../src/stories/agent-manager.stories.tsx | 50 ++- .../src/stories/section-header.stories.tsx | 6 +- .../src/stories/session-tabs.stories.tsx | 105 ++++- .../webview-ui/src/styles/session-tabs.css | 21 + .../src/types/messages/extension-messages.ts | 1 + .../src/types/messages/webview-messages.ts | 2 + .../webview-ui/src/utils/session-activity.ts | 85 ++++ 64 files changed, 1418 insertions(+), 273 deletions(-) create mode 100644 .changeset/session-tab-attention.md create mode 100644 packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx create mode 100644 packages/kilo-vscode/tests/unit/session-activity.test.ts create mode 100644 packages/kilo-vscode/tests/unit/session-provider-activity.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/shared/ActivityIcon.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/utils/session-activity.ts diff --git a/.changeset/session-tab-attention.md b/.changeset/session-tab-attention.md new file mode 100644 index 00000000000..2a0d3b3225e --- /dev/null +++ b/.changeset/session-tab-attention.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show consistent running, completion, and input-required indicators across session tabs and Agent Manager worktrees. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1c1450bebc9..8bd0bb43379 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -142,6 +142,7 @@ import { } from "./kilo-provider/handlers/question" import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" +import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity" import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments" import { completesWithoutStatus } from "./kilo-provider/command-completion" import { KiloProviderMemory } from "./kilo-provider/memory" @@ -391,6 +392,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly refreshes = new Map() private readonly anacondaDesktop = new AnacondaDesktopBridge() private sessionStatusMap = new Map() // Latest status used for destructive config warnings. + private activity: Activity = "idle" + private caption: string | undefined private readonly epochs = new Map>() private readonly requests = new Map() private epoch = 0 @@ -508,7 +511,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 { @@ -1035,6 +1046,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), + activity: (state) => { + if (!isActivity(state)) return + this.activity = state + this.updateTitle() + }, speechToTextModels: () => this.fetchAndSendSpeechToTextModels(), modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)), backgroundJobs: (sessionID, requestID) => this.fetchAndSendBackgroundJobs(sessionID, requestID), diff --git a/packages/kilo-vscode/src/SubAgentViewerProvider.ts b/packages/kilo-vscode/src/SubAgentViewerProvider.ts index a11a98bf1af..046d19325fe 100644 --- a/packages/kilo-vscode/src/SubAgentViewerProvider.ts +++ b/packages/kilo-vscode/src/SubAgentViewerProvider.ts @@ -42,7 +42,13 @@ export class SubAgentViewerProvider implements vscode.Disposable { dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), } - const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { hideTopBar: true }) + const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { + hideTopBar: true, + tabTitle: (title) => { + panel.title = title + }, + tabLabel: label, + }) if (directory) provider.setSessionDirectory(sessionID, directory) // Start accepting this session's SSE events as soon as the panel subscribes. // Reasoning deltas are not persisted until the reasoning part finishes. diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 83b307727c3..a015fc45af8 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -104,6 +104,10 @@ export class VscodeHost implements Host { }) const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { + tabTitle: (title) => { + panel.title = title + }, + tabLabel: "Agent Manager", platform: PLATFORM, snapshotInitialization: SNAPSHOT_INITIALIZATION, slimEditMetadata: true, diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c1284b1d481..1aafaacf42f 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -411,7 +411,12 @@ export type WebviewMessage = message: Record } | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } - | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" } + | { + type: "sessionTurnClosed" + sessionID: string + reason: "completed" | "error" | "interrupted" | "superseded" + parentID?: string + } | { type: "permissionRequest" permission: { @@ -556,6 +561,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin type: "sessionTurnClosed", sessionID: event.properties.sessionID, reason: event.properties.reason, + ...(event.properties.parentID ? { parentID: event.properties.parentID } : {}), } case "permission.asked": return { diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 04c5cdb0a50..8b9454e5686 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -19,6 +19,7 @@ type Ctx = { exportTranscript: (sessionID: string) => Promise copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void + activity: (state: unknown) => void speechToTextModels: () => Promise modelUsage: (message: ModelUsageMessage) => Promise backgroundJobs: (sessionID: string, requestID: string) => Promise @@ -56,7 +57,7 @@ async function routeBackgroundMessage( } export async function routeEarlyMessage( - message: { type: string; id?: unknown; text?: unknown }, + message: { type: string; id?: unknown; text?: unknown; state?: unknown }, ctx: Ctx, ): Promise { if (message.type === "copyToClipboard") { @@ -88,6 +89,10 @@ export async function routeEarlyMessage( if (typeof input.sessionID === "string") await ctx.exportTranscript(input.sessionID) return true } + if (message.type === "sessionActivity") { + ctx.activity(message.state) + return true + } if (message.type === "sidebar.openSessions") { const input = message as { sessionIDs?: unknown } const ids = Array.isArray(input.sessionIDs) diff --git a/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts b/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts index 7166b9cd9a9..c4b7a757164 100644 --- a/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts +++ b/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts @@ -1,12 +1,21 @@ import type { Session } from "@kilocode/sdk/v2/client" +import type { Activity } from "../../webview-ui/src/utils/session-activity" import { EXTENSION_DISPLAY_NAME } from "../constants" const DEFAULT_SESSION_TITLE = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ const TITLE_LIMIT = 19 +const icons: Record = { + idle: "", + busy: "◔", + retry: "◔", + waiting: "⚠", + error: "⚠", + done: "✓", +} -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)}...` +export const nativeTitle = (session: Session | null, state: Activity = "idle", label?: string) => { + const value = session?.title?.trim() + const title = label ?? (!value || DEFAULT_SESSION_TITLE.test(value) ? EXTENSION_DISPLAY_NAME : value) + const text = label || title.length <= TITLE_LIMIT ? title : `${title.slice(0, TITLE_LIMIT)}...` + return icons[state] ? `${icons[state]} ${text}` : text } diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index b9e9994d891..fa3f56c05ce 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -17,6 +17,7 @@ export type KiloProviderOptions = { snapshotInitialization?: "wait" slimEditMetadata?: boolean tabTitle?: (title: string) => void + tabLabel?: string worktreeDirectories?: () => string[] /** * Dynamic root directory override. When present, it replaces the diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx new file mode 100644 index 00000000000..7de89185d6e --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -0,0 +1,393 @@ +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, + ResizeObserver: window.ResizeObserver, + CustomEvent: window.CustomEvent, + Event: window.Event, + MessageEvent: window.MessageEvent, + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + getComputedStyle: window.getComputedStyle.bind(window), + acquireVsCodeApi: () => api, +}) + +const { render } = await import("solid-js/web") +const { For } = await import("solid-js") +const { DragDropProvider, SortableProvider } = await import("@thisbeyond/solid-dnd") +const { renderTab } = await import("../../webview-ui/agent-manager/tab-rendering") +const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode") +const { ServerProvider } = await import("../../webview-ui/src/context/server") +const { ConfigContext } = await import("../../webview-ui/src/context/config") +const { LanguageContext } = await import("../../webview-ui/src/context/language") +const { ProviderContext } = await import("../../webview-ui/src/context/provider") +const { SessionProvider, useSession } = await import("../../webview-ui/src/context/session") + +const provider = { + providers: () => ({}), + connected: () => [], + defaults: () => ({}), + defaultSelection: () => ({ providerID: "kilocode", modelID: "auto" }), + models: () => [], + findModel: () => undefined, + authMethods: () => ({}), + authStates: () => ({}), + isModelValid: () => true, +} +const config = { + config: () => ({}), + globalConfig: () => ({}), + globalDraft: () => ({}), + projectConfig: () => ({}), + collections: () => ({}), + settings: () => ({}), + features: () => ({ indexing: false, sandboxControls: false, backgroundSubagents: false }), + loading: () => false, + isDirty: () => false, + saving: () => false, + saveError: () => null, + updateConfig: () => {}, + updateGlobalConfig: () => {}, + updateProjectConfig: () => {}, + updateSetting: () => {}, + applySetting: () => {}, + saveConfig: () => {}, + discardConfig: () => {}, +} +const language = { + locale: () => "en", + setLocale: () => {}, + userOverride: () => "", + t: (key: string) => key, +} + +const ref = { value: undefined as ReturnType | undefined } +const Probe = () => { + const session = useSession() + ref.value = session + const ids = ["root", "background"] + const deps = { + terms: { activeId: () => undefined }, + REVIEW_TAB_ID: "review", + tabIds: () => ids, + kb: () => ({}), + reviewActive: () => false, + currentSessionID: session.currentSessionID, + visibleTabId: session.currentSessionID, + activePendingId: () => undefined, + isPending: () => false, + activityFor: session.activityFor, + stateLabel: (state: string) => state, + tabLookup: () => new Map(ids.map((id) => [id, { id, title: id }])), + adjacentHint: () => "", + } as Parameters[1] + return ( + + + {(id) => renderTab(id, deps)} + + + ) +} +const host = document.createElement("div") +document.body.append(host) +const step = { value: 0 } +const failures: string[] = [] + +const dispose = render( + () => ( + + + + + + + + + + + + + + ), + host, +) + +const settle = async () => { + await Promise.resolve() + await window.happyDOM.waitUntilComplete() +} +const emit = async (data: unknown) => { + window.dispatchEvent(new MessageEvent("message", { data })) + await settle() +} +const state = (id: string) => { + const value = ref.value + assert(value) + return value.activityFor(id) +} +const check = async (id: string, expected: string) => { + await settle() + step.value += 1 + const value = ref.value + assert(value) + const actual = state(id) + const tab = host.querySelector(`[data-tab-id="${id}"] [data-activity]`) + if (id === "root" || id === "background") assert(tab, `Missing rendered tab for ${id}`) + if (tab && tab.getAttribute("data-activity") !== expected) { + failures.push( + `step ${step.value} ${id}: rendered tab expected ${expected}, got ${tab.getAttribute("data-activity")}`, + ) + } + if (actual !== expected) { + failures.push( + `step ${step.value} ${id}: expected ${expected}, got ${actual}, status=${value.status()}, close=${value.closeReason() ?? "none"}`, + ) + } +} +const info = (id: string, parentID?: string) => ({ + id, + ...(parentID ? { parentID } : {}), + title: id, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}) +const task = (id: string, parentID: string, childID: string, nested: boolean) => ({ + type: "partUpdated", + sessionID: parentID, + messageID: `${parentID}-part-message`, + part: { + type: "tool", + id, + sessionID: parentID, + messageID: `${parentID}-part-message`, + tool: "task", + state: { status: "running", input: {}, ...(nested ? { metadata: { sessionId: childID } } : {}) }, + ...(!nested ? { metadata: { sessionId: childID } } : {}), + }, +}) + +try { + await settle() + await emit({ type: "ready", serverInfo: { port: 1 } }) + await emit({ + type: "sessionsLoaded", + sessions: [info("root"), info("background"), info("durable-child", "root"), info("durable-grand", "durable-child")], + }) + + const value = ref.value + assert(value) + value.setCurrentSessionID("root") + await check("root", "idle") + await check("background", "idle") + + await emit({ type: "sessionStatus", sessionID: "background", status: "busy" }) + await check("background", "busy") + await check("root", "idle") + await emit({ type: "sessionStatus", sessionID: "background", status: "idle" }) + + await emit(task("root-task", "root", "task-child", false)) + await emit(task("child-task", "task-child", "task-grand", true)) + await emit({ type: "sessionStatus", sessionID: "durable-grand", status: "busy" }) + await check("root", "busy") + await check("durable-child", "busy") + await check("durable-grand", "busy") + await emit({ type: "sessionStatus", sessionID: "durable-grand", status: "idle" }) + + await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" }) + await check("root", "busy") + await check("task-child", "busy") + await emit({ type: "sessionStatus", sessionID: "task-child", status: "retry", attempt: 1, message: "retry", next: 1 }) + await check("root", "retry") + await check("task-child", "retry") + await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" }) + await emit({ type: "sessionStatus", sessionID: "task-grand", status: "busy" }) + await check("root", "busy") + await check("task-child", "busy") + await check("task-grand", "busy") + await emit({ type: "sessionStatus", sessionID: "task-grand", status: "offline" }) + await check("root", "error") + await check("task-child", "error") + await check("task-grand", "error") + await emit({ type: "sessionStatus", sessionID: "task-grand", status: "idle" }) + await check("root", "idle") + + 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: "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") + await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" }) + await check("root", "error") + + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await check("root", "busy") + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await check("root", "idle") + + await emit({ + type: "questionRequest", + question: { + id: "deleted-question", + sessionID: "root", + questions: [{ question: "Delete?", header: "Confirm", options: [] }], + }, + }) + await check("root", "waiting") + await emit({ type: "sessionDeleted", sessionID: "root" }) + await check("root", "idle") + assert.equal(value.currentSessionID(), undefined) + assert.equal( + value.sessions().some((item) => item.id === "root"), + false, + ) + assert.equal( + value.questions().some((item) => item.sessionID === "root"), + false, + ) + assert.equal( + sent.some((item) => (item as { type?: string }).type === "sendMessage"), + true, + ) + assert.deepEqual(failures, []) +} finally { + const before = state("background") + dispose() + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sessionStatus", sessionID: "background", status: "retry" } }), + ) + assert.equal(state("background"), before) + await window.happyDOM.cancelAsync() + await window.happyDOM.close() +} + +process.exit(0) diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index 9d01986cc87..05cfb52e829 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -40,6 +40,7 @@ const mockVscode = { language: "en", machineId: "test-machine", isTelemetryEnabled: false, + onDidChangeTelemetryEnabled: () => ({ dispose: noop }), shell: "/bin/bash", openExternal: noop, }, @@ -171,6 +172,9 @@ const mockVscode = { }, Disposable: class { constructor(private callback: () => void = noop) {} + static from(...items: { dispose: () => void }[]) { + return { dispose: () => items.forEach((item) => item.dispose()) } + } dispose() { this.callback() } diff --git a/packages/kilo-vscode/tests/unit/early-message.test.ts b/packages/kilo-vscode/tests/unit/early-message.test.ts index cad4c42b43b..5329af16d82 100644 --- a/packages/kilo-vscode/tests/unit/early-message.test.ts +++ b/packages/kilo-vscode/tests/unit/early-message.test.ts @@ -43,6 +43,17 @@ describe("routeEarlyMessage clipboard handling", () => { }) }) +describe("routeEarlyMessage activity", () => { + it("forwards authoritative webview presentation state without interpreting session events", async () => { + const calls: unknown[] = [] + const ctx = { activity: (state: unknown) => calls.push(state) } as Ctx + for (const state of ["busy", "waiting", "done", "error", "idle"]) { + expect(await routeEarlyMessage({ type: "sessionActivity", state }, ctx)).toBe(true) + } + expect(calls).toEqual(["busy", "waiting", "done", "error", "idle"]) + }) +}) + describe("routeEarlyMessage background jobs", () => { it("forwards list request correlation", async () => { const calls: unknown[] = [] diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 75ed68671e5..2c8ce4fb6e6 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -376,6 +376,21 @@ describe("mapSSEEventToWebviewMessage", () => { expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" }) }) + it("forwards the parent session ID when a child turn closes", () => { + const event: EventSessionTurnClose = { + id: "evt-child-turn", + type: "session.turn.close", + properties: { sessionID: "child", parentID: "parent", reason: "completed" }, + } + + expect(mapSSEEventToWebviewMessage(event, "child")).toEqual({ + type: "sessionTurnClosed", + sessionID: "child", + reason: "completed", + parentID: "parent", + }) + }) + it("maps session errors with their event identity and message", () => { const event: EventSessionError = { id: "evt-error", diff --git a/packages/kilo-vscode/tests/unit/native-tab-title.test.ts b/packages/kilo-vscode/tests/unit/native-tab-title.test.ts index f949f88e3ec..d1775b1c76a 100644 --- a/packages/kilo-vscode/tests/unit/native-tab-title.test.ts +++ b/packages/kilo-vscode/tests/unit/native-tab-title.test.ts @@ -18,4 +18,38 @@ describe("nativeTitle", () => { it("truncates long session titles", () => { expect(nativeTitle(session("Dynamic VS Code tab titles for Kilo sessions"))).toBe("Dynamic VS Code tab...") }) + + it("updates the native panel only from valid webview activity reports", async () => { + const { KiloProvider } = await import("../../src/KiloProvider") + const titles: string[] = [] + const listener: { current?: (message: { type: string; state: unknown }) => Promise } = {} + const provider = new KiloProvider( + { fsPath: "/extension" } as never, + { unregisterVisible: () => {}, unregisterAttached: () => {} } as never, + undefined, + { tabTitle: (title) => titles.push(title) }, + ) + const internal = provider as unknown as { setupWebviewMessageHandler: (webview: unknown) => void } + internal.setupWebviewMessageHandler({ + onDidReceiveMessage: (handler: NonNullable) => { + listener.current = handler + return { dispose: () => {} } + }, + }) + for (const state of ["busy", "waiting", "done", "error", "idle", "idle", "invalid", null]) { + await listener.current?.({ type: "sessionActivity", state }) + } + expect(titles).toEqual(["◔ Kilo Code", "⚠ Kilo Code", "✓ Kilo Code", "⚠ Kilo Code", "Kilo Code"]) + provider.dispose() + }) + + it("renders the same activity values used by webview tabs and worktrees", () => { + expect(nativeTitle(session("Greeting"), "busy")).toBe("◔ Greeting") + expect(nativeTitle(session("Greeting"), "retry")).toBe("◔ Greeting") + expect(nativeTitle(session("Greeting"), "waiting")).toBe("⚠ Greeting") + expect(nativeTitle(session("Greeting"), "error")).toBe("⚠ Greeting") + expect(nativeTitle(session("Greeting"), "done")).toBe("✓ Greeting") + expect(nativeTitle(session("Greeting"), "idle")).toBe("Greeting") + expect(nativeTitle(session("Greeting"), "waiting", "Agent Manager")).toBe("⚠ Agent Manager") + }) }) diff --git a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts index 27d480fcf56..4de226f9859 100644 --- a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts +++ b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts @@ -1,31 +1,61 @@ import { describe, expect, it } from "bun:test" -import { createSessionBusy } from "../../webview-ui/agent-manager/project/session-busy" +import { createSessionActivity } from "../../webview-ui/agent-manager/project/session-busy" -const busy = (statuses: Record) => - createSessionBusy({ - statuses: () => statuses, - permissions: () => [], - questions: () => [], +const activity = (values: Record) => + createSessionActivity({ 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) => values[id] ?? "idle", }) -describe("createSessionBusy", () => { - it("does not mark stopped or unknown sessions as busy", () => { - const state = busy({ idle: { type: "idle" } }) +describe("createSessionActivity", () => { + it("returns idle for groups without sessions", () => { + const state = activity({}) - expect(state.agent("wt-unknown")).toBe(false) - expect(state.agent("wt-idle")).toBe(false) - expect(state.project("background", "wt-unknown")).toBe(false) + expect(state.agent("wt-missing")).toBe("idle") + expect(state.project("background", "wt-missing")).toBe("idle") }) - it("marks sessions with an active status as busy", () => { - expect(busy({ working: { type: "busy" } }).agent("wt-working")).toBe(true) + 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.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("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") }) }) diff --git a/packages/kilo-vscode/tests/unit/session-activity.test.ts b/packages/kilo-vscode/tests/unit/session-activity.test.ts new file mode 100644 index 00000000000..6d4d282bd42 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-activity.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "bun:test" +import { + activities, + activity, + isActivity, + label, + running, + 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("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("strongest", () => { + it("returns the highest priority state", () => { + expect(strongest(["busy", "waiting", "idle"])).toBe("waiting") + expect(strongest(["done", "error", "retry"])).toBe("error") + expect(strongest(["done", "busy"])).toBe("busy") + expect(strongest([])).toBe("idle") + }) +}) + +describe("label", () => { + it("returns existing translation keys", () => { + const states: Activity[] = ["waiting", "error", "retry", "busy", "done", "idle"] + expect(states.map(label)).toEqual([ + "task.backgroundAgents.needsInput", + "task.backgroundAgents.status.error", + "session.status.retry", + "session.tabs.switcher.busy", + "task.backgroundAgents.status.completed", + "session.current", + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-provider-activity.test.ts b/packages/kilo-vscode/tests/unit/session-provider-activity.test.ts new file mode 100644 index 00000000000..bcf5fe4d245 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-provider-activity.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test" +import { unlinkSync } from "node:fs" +import path from "node:path" +import { build } from "esbuild" +import { solidPlugin } from "esbuild-plugin-solid" + +const root = path.resolve(import.meta.dir, "../..") +const webview = path.join(root, "webview-ui") +const fixture = path.join(root, "tests/fixtures/session-provider-activity.tsx") + +describe("SessionProvider activity", () => { + it("covers real session activity lifecycle messages", async () => { + const solid = path.dirname(Bun.resolveSync("solid-js/package.json", webview)) + const aliases: Record = { + "solid-js": path.join(solid, "dist/solid.js"), + "solid-js/web": path.join(solid, "web/dist/web.js"), + "solid-js/store": path.join(solid, "store/dist/store.js"), + } + const dedupe = { + name: "solid-dedupe", + setup(ctx: Parameters[0]["plugins"]>[number]["setup"]>[0]) { + ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] })) + }, + } + const result = await build({ + entryPoints: [fixture], + bundle: true, + conditions: ["browser"], + external: ["happy-dom"], + format: "esm", + logLevel: "silent", + loader: { ".css": "empty" }, + platform: "node", + plugins: [dedupe, solidPlugin()], + target: "es2022", + write: false, + }) + const file = path.join(root, `.session-provider-activity-${crypto.randomUUID()}.mjs`) + await Bun.write(file, result.outputFiles[0]!.contents) + try { + const child = Bun.spawnSync(["bun", file], { cwd: webview, stdout: "pipe", stderr: "pipe" }) + const output = child.stdout.toString() + child.stderr.toString() + expect(child.exitCode, output).toBe(0) + } finally { + unlinkSync(file) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/sidebar-search.test.ts b/packages/kilo-vscode/tests/unit/sidebar-search.test.ts index 3d2f96c6fab..b7f8502cf0a 100644 --- a/packages/kilo-vscode/tests/unit/sidebar-search.test.ts +++ b/packages/kilo-vscode/tests/unit/sidebar-search.test.ts @@ -51,9 +51,8 @@ const build = (overrides?: Partial[0]>) => localBranch: "main", untitled: "Untitled", pending: (id) => id.startsWith("pending:"), - status: (id) => (id === "busy-session" ? "busy" : "idle"), + activityFor: (id) => (id === "busy-session" ? "busy" : "idle"), busy: () => false, - localBusy: false, ...overrides, }) @@ -104,6 +103,24 @@ describe("buildSidebarSearch", () => { expect(items[4]).toMatchObject({ state: "busy", updatedAt: "2026-06-03T00:00:00.000Z" }) }) + it("uses the strongest session activity for context rows", () => { + const items = build({ + activityFor: (id) => (id === "busy-session" ? "busy" : id === "recent-session" ? "waiting" : "idle"), + }) + + expect(items.find((item) => item.key === "session:recent-session")).toMatchObject({ state: "waiting" }) + expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "waiting" }) + }) + + it("keeps operation busy separate from session activity", () => { + const items = build({ + activityFor: () => "idle", + busy: () => true, + }) + + expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "idle", busy: true }) + }) + it("uses expanded sidebar visibility before recency as a tie-breaker", () => { const items = build({ worktrees: [ @@ -119,7 +136,7 @@ describe("buildSidebarSearch", () => { }, ], local: [], - status: () => "idle", + activityFor: () => "idle", }) expect(items.filter((item) => item.kind === "session").map((item) => item.sessionId)).toEqual(["visible", "hidden"]) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 6c1f9494351..7561ad12a5b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -91,7 +91,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 { createSessionBusy } from "./project/session-busy" +import { createSessionActivity } from "./project/session-busy" import { switchProject } from "./project/switch" import { createProjectStateHandlers } from "./project/state-handlers" import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership" @@ -116,6 +116,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, @@ -123,6 +124,7 @@ import { adjacentHint, focusChatSearch, LOCAL, + remoteSessions, } from "./navigate" import { buildProjectNavEntries, createProjectNav } from "./project-nav" import { @@ -898,22 +900,26 @@ const AgentManagerContent: Component = () => { return label !== wt.branch ? wt.branch : undefined } - const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId) - - const busy = createSessionBusy({ - statuses: session.allStatusMap, - permissions: session.permissions, - questions: session.questions, + const activity = createSessionActivity({ managed: managedSessions, local: localSessionIDs, projects: projectSessionsLive, active: activeProjectId, + activityFor: session.activityFor, }) - 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)), + ]) + : remoteSessions(localSessionIDs(), managedSessions(), isPending).map(session.activityFor), + ), + ) + 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())) @@ -1050,14 +1056,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) => { @@ -2255,7 +2258,8 @@ const AgentManagerContent: Component = () => { activePendingId, visibleTabId, isPending, - isBusy: isSessionBusy, + activityFor: (id) => session.activityFor(id), + stateLabel: (state) => t(label(state)), tabLookup, adjacentHint, activateTerminal: termHandlers.activate, @@ -2319,8 +2323,6 @@ const AgentManagerContent: Component = () => { states={projectStates()} store={(id) => registry.ensure(id)} busy={(projectId, id) => registry.ensure(projectId).busy().has(id)} - working={(projectId, id) => projectBusy(projectId, id)} - localBusy={(projectId) => projectBusy(projectId, null)} stats={projectLive.stats()} local={projectLive.local()} prs={projectLive.prs()} @@ -2337,6 +2339,8 @@ const AgentManagerContent: Component = () => { onShortcuts={handleShowKeyboardShortcuts} onHistory={openHistory} shortcutMap={projectShortcutMap} + activityFor={activity.project} + sessionActivity={session.activityFor} /> @@ -2346,7 +2350,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 }} @@ -2384,8 +2388,7 @@ const AgentManagerContent: Component = () => { worktreeSubtitle={worktreeSubtitle} pendingDelete={pendingDelete} busy={(id) => busyWorktrees().has(id)} - isAgentBusy={isAgentBusy} - isStaleWorktree={isStaleWorktree} + isStaleWorktree={(id) => staleWorktreeIds().has(id)} shortcutMap={shortcutMap} worktreeStats={worktreeStats} prStatuses={prStatuses} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index ee2c86e9be5..eb1aeb922a5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -7,6 +7,7 @@ import type { LocalGitStats, PRStatus, ProjectSessionInfo, + RunStatus, WorktreeGitStats, } from "../src/types/messages" import type { LanguageContextValue } from "../src/context/language" @@ -16,6 +17,7 @@ import { ProjectsSection } from "./ProjectsSection" import { ProjectSidebarBody } from "./ProjectSidebarBody" import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu" import type { SidebarSearchItem } from "./sidebar-search" +import { label, type Activity } from "../src/utils/session-activity" import { LOCAL } from "./navigate" import { NewWorktreeDialog } from "./NewWorktreeDialog" import type { ProjectStore } from "./project/store" @@ -26,6 +28,10 @@ const place = (state: AgentManagerStateMessage, session: ProjectSessionInfo, loc return wt?.label || wt?.branch || local } +const activeRun = (status: RunStatus | undefined) => status?.state === "running" || status?.state === "stopping" +const operationBusy = (store: ProjectStore | undefined, id: string) => + store?.busy().has(id) || activeRun(store?.runStatuses()[id]) + interface Props { projects: AgentProjectSnapshot[] states: Record @@ -40,9 +46,9 @@ interface Props { mode: ModeRouter defaultBase?: (projectId: string) => string | undefined onCreate?: (projectId: string) => void - busy?: (projectId: string, id: string) => boolean - working?: (projectId: string, id: string) => boolean - localBusy?: (projectId: string) => boolean + busy: (projectId: string, id: string) => boolean + activityFor: (projectId: string, worktreeId: string | null) => Activity + sessionActivity: (id: string) => Activity bindings: Record t: LanguageContextValue["t"] onSearchRef: (ref: SidebarSearchMenuRef) => void @@ -61,6 +67,7 @@ export const ProjectList: Component = (props) => { for (const project of props.projects) { const state = props.states[project.id] if (!state) continue + const store = props.store?.(project.id) const local = props.sessions[project.id]?.filter((session) => session.worktreeId === null) ?? [] items.push({ key: `${project.id}:local`, @@ -73,7 +80,7 @@ export const ProjectList: Component = (props) => { .filter(Boolean) .join(" "), updatedAt: local.reduce((latest, session) => (session.updatedAt > latest ? session.updatedAt : latest), ""), - state: "idle", + state: props.activityFor(project.id, null), visible: project.expanded, count: local.length, }) @@ -88,10 +95,11 @@ export const ProjectList: Component = (props) => { meta: [project.label, worktree.branch], search: [project.label, worktree.label, worktree.branch, worktree.id].filter(Boolean).join(" "), updatedAt: worktree.createdAt, - state: "idle", + state: props.activityFor(project.id, worktree.id), visible: project.expanded, worktreeId: worktree.id, count: sessions.length, + busy: props.busy(project.id, worktree.id) || operationBusy(store, worktree.id), }) } for (const session of props.sessions[project.id] ?? []) { @@ -106,7 +114,7 @@ export const ProjectList: Component = (props) => { meta: [project.label, where], search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "), updatedAt: session.updatedAt, - state: "idle", + state: props.sessionActivity(session.id), visible: project.expanded, sessionId: session.id, location: session.worktreeId ? "worktree" : "local", @@ -172,8 +180,7 @@ export const ProjectList: Component = (props) => { scope: props.t("agentManager.sidebarSearch.scope"), sessions: props.t("agentManager.section.sessions"), contexts: props.t("agentManager.sidebarSearch.contexts"), - waiting: props.t("agentManager.tabsMenu.status.waiting"), - retry: props.t("agentManager.tabsMenu.status.retry"), + state: (value) => props.t(label(value)), }} onSelect={selectSearch} /> @@ -216,9 +223,8 @@ export const ProjectList: Component = (props) => { project={project} state={props.states[project.id]} store={props.store?.(project.id)} - busy={(id) => props.busy?.(project.id, id) ?? false} - working={(id) => props.working?.(project.id, id) ?? false} - localBusy={() => props.localBusy?.(project.id) ?? false} + busy={(id) => props.busy(project.id, id)} + activityFor={(id) => props.activityFor(project.id, id)} stats={props.stats[project.id]} local={props.local[project.id]} prs={props.prs[project.id]} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 52741e14402..c5d2b5bbbd5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -1,6 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { DragDropProvider, DragDropSensors, @@ -19,6 +18,8 @@ import type { WorktreeGitStats, } from "../src/types/messages" import type { LanguageContextValue } from "../src/context/language" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import { label, type Activity } from "../src/utils/session-activity" import { useVSCode } from "../src/context/vscode" import SectionHeader from "./SectionHeader" import { SidebarSectionHeader } from "./SidebarSectionHeader" @@ -40,9 +41,8 @@ interface Props { project: AgentProjectSnapshot state?: AgentManagerStateMessage store?: ProjectStore - busy?: (id: string) => boolean - working?: (id: string) => boolean - localBusy?: () => boolean + busy: (id: string) => boolean + activityFor: (worktreeId: string | null) => Activity stats?: Record local?: LocalGitStats prs?: Record @@ -105,6 +105,7 @@ export const ProjectSidebarBody: Component = (props) => { const sidebarOrder = createMemo(() => projectSidebarOrder(top(), sorted(), sections(), members)) const post = (message: Record) => vscode.postMessage({ ...message, projectId: props.project.id } as never) + const localState = () => props.activityFor(null) const row = (id: string) => projectWorktreeRow({ @@ -236,8 +237,8 @@ export const ProjectSidebarBody: Component = (props) => { subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()} active={active() && props.selection === worktree.id} pendingDelete={pending() === worktree.id} - busy={props.busy?.(worktree.id) ?? false} - working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"} + busy={props.busy(worktree.id)} + activity={props.activityFor(worktree.id)} stale={state()?.staleWorktreeIds?.includes(worktree.id) === true} stats={props.stats?.[worktree.id]} shortcut={values().shortcut} @@ -294,13 +295,18 @@ export const ProjectSidebarBody: Component = (props) => { data-sidebar-id={`${props.project.id}:local`} onClick={() => props.onSelectLocal(props.project.id)} > - }> - - - - - - + + + + + + + } + /> +
{props.t("agentManager.local")} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index 5e5e8ded6de..3d381e6e396 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -1,6 +1,5 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { DragDropProvider, DragDropSensors, @@ -32,6 +31,8 @@ import { WorktreeItem } from "./WorktreeItem" import { WorktreeSectionActions } from "./WorktreeSectionActions" import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import { label, type Activity } from "../src/utils/session-activity" const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) @@ -43,7 +44,7 @@ export interface SidebarBodyProps { currentSessionID: () => string | undefined selectLocal: () => void selectWorktree: (id: string) => void - isLocalBusy: () => boolean + activityFor: (id: string | null) => Activity repoBranch: () => string | undefined localStats: () => LocalGitStats | undefined search: { items: () => SidebarSearchItem[]; current: () => SidebarSearchItem | undefined } @@ -80,7 +81,6 @@ export interface SidebarBodyProps { worktreeSubtitle: (wt: WorktreeState) => string | undefined pendingDelete: () => string | null busy: (id: string) => boolean - isAgentBusy: (id: string) => boolean isStaleWorktree: (id: string) => boolean shortcutMap: () => Map worktreeStats: () => Record @@ -95,6 +95,7 @@ export interface SidebarBodyProps { /** Legacy single-project sidebar body: local repo, worktrees, unassigned sessions. */ export const SidebarBody: Component = (props) => { const vscode = useVSCode() + const localState = () => props.activityFor(null) return ( <> @@ -104,13 +105,18 @@ export const SidebarBody: Component = (props) => { data-sidebar-id="local" onClick={() => props.selectLocal()} > - }> - - - - - - + + + + + + + } + /> +
{props.t("agentManager.local")} @@ -314,7 +320,7 @@ export const SidebarBody: Component = (props) => { active={props.selection() === wt.id} pendingDelete={props.pendingDelete() === wt.id} busy={props.busy(wt.id)} - working={props.isAgentBusy(wt.id)} + activity={props.activityFor(wt.id)} stale={props.isStaleWorktree(wt.id)} shortcut={props.shortcutMap().get(wt.id)} stats={props.worktreeStats()[wt.id]} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx index e228c0f7d86..7bb9fecdd52 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx @@ -9,6 +9,8 @@ import type { ListRef } from "@kilocode/kilo-ui/list" import { Popover } from "@kilocode/kilo-ui/popover" import { Spinner } from "@kilocode/kilo-ui/spinner" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import type { Activity } from "../src/utils/session-activity" import { formatRelativeDate } from "../src/utils/date" import { colorCss } from "./section-colors" import type { SidebarSearchItem } from "./sidebar-search" @@ -20,7 +22,13 @@ export interface SidebarSearchMenuRef { interface SidebarSearchMenuProps { items: Accessor current: Accessor - labels: { search: string; scope: string; contexts: string; sessions: string; waiting: string; retry: string } + labels: { + search: string + scope: string + contexts: string + sessions: string + state: (value: Activity) => string + } keybind: string ref?: (value: SidebarSearchMenuRef) => void onSelect: (item: SidebarSearchItem) => void @@ -104,7 +112,7 @@ export const SidebarSearchMenu: Component = (props) => { }} > {(item) => { - const working = item.state === "busy" || item.state === "retry" + const stateLabel = () => props.labels.state(item.state) return ( = (props) => { data-session-id={item.kind === "session" ? item.sessionId : undefined} data-worktree-id={item.kind === "worktree" ? item.worktreeId : undefined} > - - }> - + + + } + /> + } + > + @@ -143,13 +162,12 @@ export const SidebarSearchMenu: Component = (props) => { {item.meta.join(" · ")} - - {props.labels.waiting} - - - {props.labels.retry} + + + {stateLabel()} + - + {item.kind !== "session" ? item.count : ""} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index c44b767cffd..5c36b39f66f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -12,6 +12,8 @@ import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { Button } from "@kilocode/kilo-ui/button" import type { WorktreeState, WorktreeGitStats, SectionState, RunStatus } from "../src/types/messages" import type { PRStatus } from "../src/types/messages" +import { ActivityIcon } from "../src/components/shared/ActivityIcon" +import { label, 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 stale: boolean /** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */ shortcut?: number @@ -155,6 +156,7 @@ export const WorktreeItem: Component = (props) => { const { t } = useLanguage() const [hovered, setHovered] = createSignal(false) const [overClose, setOverClose] = createSignal(false) + const state = () => props.activity const handleOpenPR = (e: MouseEvent) => { e.stopPropagation() @@ -199,9 +201,12 @@ export const WorktreeItem: Component = (props) => { data-sidebar-id={props.sidebarId ?? props.worktree.id} onClick={() => props.onClick()} > -
- }> - +
+ } + > + } />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx index e0241e39082..db002c85c3d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx @@ -10,6 +10,7 @@ import type { LanguageContextValue } from "../src/context/language" import { parseBindingTokens } from "./keybind-tokens" import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu" import type { SidebarSearchItem } from "./sidebar-search" +import { label } from "../src/utils/session-activity" interface WorktreeSectionActionsProps { items: Accessor @@ -41,8 +42,7 @@ export const WorktreeSectionActions: Component = (p scope: props.t("agentManager.sidebarSearch.scope"), sessions: props.t("agentManager.section.sessions"), contexts: props.t("agentManager.sidebarSearch.contexts"), - waiting: props.t("agentManager.tabsMenu.status.waiting"), - retry: props.t("agentManager.tabsMenu.status.retry"), + state: (value) => props.t(label(value)), }} onSelect={props.onSelect} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index d3c8961a14d..68a11704e2c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -151,6 +151,33 @@ 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; +} + +[data-activity="waiting"] [data-component="icon"], +[data-activity="waiting"] [data-slot="icon-svg"], +.am-sidebar-search-status[data-activity="waiting"] { + color: var(--icon-warning-base, var(--vscode-notificationsWarningIcon-foreground, #d9a13a)); +} + +[data-activity="error"] [data-component="icon"], +[data-activity="error"] [data-slot="icon-svg"], +.am-sidebar-search-status[data-activity="error"] { + color: var(--icon-critical-base, var(--vscode-errorForeground, #f14c4c)); +} + +[data-activity="done"] [data-component="icon"], +[data-activity="done"] [data-slot="icon-svg"], +.am-sidebar-search-status[data-activity="done"] { + color: var(--icon-success-base, var(--vscode-testing-iconPassed, #73c991)); +} + .am-local-text { display: flex; flex-direction: column; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index d85fb94135d..359fbe357b9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "الطرفية", "agentManager.tab.openTerminal": "فتح الطرفية", "agentManager.tab.newOptions": "المزيد من خيارات علامات التبويب الجديدة", - "agentManager.tabsMenu.status.waiting": "انتظار", - "agentManager.tabsMenu.status.retry": "إعادة", "agentManager.sidebarSearch.label": "البحث في Worktrees والجلسات", "agentManager.sidebarSearch.scope": "يبحث في مساحة العمل المحلية والجلسات المحلية وWorktrees وجلساتها", "agentManager.sidebarSearch.contexts": "محلي & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 8b66f7b1c23..935efbf5309 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Abrir Terminal", "agentManager.tab.newOptions": "Mais opções de nova aba", - "agentManager.tabsMenu.status.waiting": "Espera", - "agentManager.tabsMenu.status.retry": "Repetir", "agentManager.sidebarSearch.label": "Pesquisar Worktrees e sessões", "agentManager.sidebarSearch.scope": "Pesquisa o espaço de trabalho local, as sessões locais, os Worktrees e suas sessões", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index bdeeb4db900..b2ccf4ce7e1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Otvori Terminal", "agentManager.tab.newOptions": "Više opcija nove kartice", - "agentManager.tabsMenu.status.waiting": "Čeka", - "agentManager.tabsMenu.status.retry": "Pokušaj", "agentManager.sidebarSearch.label": "Pretraži Worktree-ove i sesije", "agentManager.sidebarSearch.scope": "Pretražuje lokalni radni prostor, lokalne sesije, Worktree-ove i njihove sesije", "agentManager.sidebarSearch.contexts": "LOKALNO & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index fd2a2ff091d..4c060fc44ca 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.openTerminal": "Åbn Terminal", "agentManager.tab.newOptions": "Flere nye faneindstillinger", - "agentManager.tabsMenu.status.waiting": "Venter", - "agentManager.tabsMenu.status.retry": "Igen", "agentManager.sidebarSearch.label": "Søg i Worktrees og sessioner", "agentManager.sidebarSearch.scope": "Søger i det lokale arbejdsområde, lokale sessioner, Worktrees og deres sessioner", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 3e309f5d2b7..91ced0592d9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -64,8 +64,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminal öffnen", "agentManager.tab.newOptions": "Weitere Tab-Optionen", - "agentManager.tabsMenu.status.waiting": "Warten", - "agentManager.tabsMenu.status.retry": "Erneut", "agentManager.sidebarSearch.label": "Worktrees und Sitzungen durchsuchen", "agentManager.sidebarSearch.scope": "Durchsucht den lokalen Arbeitsbereich, lokale Sitzungen, Worktrees und deren Sitzungen", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index c7df879ee93..a885178ff3f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -66,8 +66,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Open Terminal", "agentManager.tab.newOptions": "More new-tab options", - "agentManager.tabsMenu.status.waiting": "Wait", - "agentManager.tabsMenu.status.retry": "Retry", "agentManager.sidebarSearch.label": "Search worktrees and sessions", "agentManager.sidebarSearch.scope": "Searches the local workspace, local sessions, worktrees, and their sessions", "agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 147728c6f19..d8d1ee92f16 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Abrir Terminal", "agentManager.tab.newOptions": "Más opciones de nueva pestaña", - "agentManager.tabsMenu.status.waiting": "Espera", - "agentManager.tabsMenu.status.retry": "Reintento", "agentManager.sidebarSearch.label": "Buscar Worktrees y sesiones", "agentManager.sidebarSearch.scope": "Busca en el espacio de trabajo local, las sesiones locales, los Worktrees y sus sesiones", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index 85428065cd0..384721115fb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "ترمینال", "agentManager.tab.openTerminal": "باز کردن ترمینال", "agentManager.tab.newOptions": "گزینه‌های بیشتر برای تب جدید", - "agentManager.tabsMenu.status.waiting": "انتظار", - "agentManager.tabsMenu.status.retry": "تلاش مجدد", "agentManager.sidebarSearch.label": "جستجوی worktree‌ها و جلسات", "agentManager.sidebarSearch.scope": "جستجو در فضای کاری محلی، جلسات محلی، worktree‌ها و جلسات آن‌ها", "agentManager.sidebarSearch.contexts": "محلی و WORKTREE‌ها", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 7241565da49..ded5b2fa1cc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -64,8 +64,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Ouvrir le Terminal", "agentManager.tab.newOptions": "Plus d'options de nouvel onglet", - "agentManager.tabsMenu.status.waiting": "Attente", - "agentManager.tabsMenu.status.retry": "Réessai", "agentManager.sidebarSearch.label": "Rechercher des Worktrees et des sessions", "agentManager.sidebarSearch.scope": "Recherche dans l'espace de travail local, les sessions locales, les Worktrees et leurs sessions", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 966b63cd321..feac34121b8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -68,8 +68,6 @@ export const dict = { "agentManager.tab.terminal": "Terminale", "agentManager.tab.openTerminal": "Apri terminale", "agentManager.tab.newOptions": "Altre opzioni nuova scheda", - "agentManager.tabsMenu.status.waiting": "Attendi", - "agentManager.tabsMenu.status.retry": "Riprova", "agentManager.sidebarSearch.label": "Cerca Worktree e sessioni", "agentManager.sidebarSearch.scope": "Cerca nell'area di lavoro locale, nelle sessioni locali, nei Worktree e nelle relative sessioni", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 2e31584d2e6..6354108a8e0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "ターミナル", "agentManager.tab.openTerminal": "ターミナルを開く", "agentManager.tab.newOptions": "新しいタブのその他のオプション", - "agentManager.tabsMenu.status.waiting": "待機", - "agentManager.tabsMenu.status.retry": "再試行", "agentManager.sidebarSearch.label": "Worktreeとセッションを検索", "agentManager.sidebarSearch.scope": "ローカルワークスペース、ローカルセッション、Worktree、および各Worktreeのセッションを検索", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 8676fcceee8..8be5f56b8fc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "터미널", "agentManager.tab.openTerminal": "터미널 열기", "agentManager.tab.newOptions": "더 많은 새 탭 옵션", - "agentManager.tabsMenu.status.waiting": "대기", - "agentManager.tabsMenu.status.retry": "재시도", "agentManager.sidebarSearch.label": "Worktree 및 세션 검색", "agentManager.sidebarSearch.scope": "로컬 워크스페이스, 로컬 세션, Worktree 및 각 Worktree의 세션 검색", "agentManager.sidebarSearch.contexts": "로컬 & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index edeaa6af0dc..9f142e69e9d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminal openen", "agentManager.tab.newOptions": "Meer opties voor nieuwe tabblad", - "agentManager.tabsMenu.status.waiting": "Wacht", - "agentManager.tabsMenu.status.retry": "Opnieuw", "agentManager.sidebarSearch.label": "Worktrees en sessies doorzoeken", "agentManager.sidebarSearch.scope": "Doorzoekt de lokale werkruimte, lokale sessies, Worktrees en hun sessies", "agentManager.sidebarSearch.contexts": "LOKAAL & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 234a9e5aacf..ed8e270a8ff 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Åpne Terminal", "agentManager.tab.newOptions": "Flere alternativer for ny fane", - "agentManager.tabsMenu.status.waiting": "Venter", - "agentManager.tabsMenu.status.retry": "Igjen", "agentManager.sidebarSearch.label": "Søk i Worktrees og økter", "agentManager.sidebarSearch.scope": "Søker i det lokale arbeidsområdet, lokale økter, Worktrees og øktene deres", "agentManager.sidebarSearch.contexts": "LOKAL & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 7433c355dd4..62583d9ba31 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Otwórz Terminal", "agentManager.tab.newOptions": "Więcej opcji nowej karty", - "agentManager.tabsMenu.status.waiting": "Czeka", - "agentManager.tabsMenu.status.retry": "Ponów", "agentManager.sidebarSearch.label": "Wyszukaj Worktree i sesje", "agentManager.sidebarSearch.scope": "Przeszukuje lokalny obszar roboczy, lokalne sesje, Worktree i przypisane do nich sesje", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 5be558d0a00..de9e60aa938 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -63,8 +63,6 @@ export const dict = { "agentManager.tab.terminal": "Терминал", "agentManager.tab.openTerminal": "Открыть терминал", "agentManager.tab.newOptions": "Другие параметры новой вкладки", - "agentManager.tabsMenu.status.waiting": "Ожидание", - "agentManager.tabsMenu.status.retry": "Повтор", "agentManager.sidebarSearch.label": "Поиск по Worktree и сессиям", "agentManager.sidebarSearch.scope": "Поиск локального рабочего пространства, локальных сессий, Worktree и связанных с ними сессий", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 8adf3164359..86de0544e50 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "เทอร์มินัล", "agentManager.tab.openTerminal": "เปิดเทอร์มินัล", "agentManager.tab.newOptions": "ตัวเลือกแท็บใหม่เพิ่มเติม", - "agentManager.tabsMenu.status.waiting": "รอ", - "agentManager.tabsMenu.status.retry": "ลองใหม่", "agentManager.sidebarSearch.label": "ค้นหา Worktree และเซสชัน", "agentManager.sidebarSearch.scope": "ค้นหาพื้นที่ทำงานในเครื่อง เซสชันในเครื่อง Worktree และเซสชันของ Worktree", "agentManager.sidebarSearch.contexts": "ในเครื่อง & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 532c1a99dcb..90f3da62883 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminali Aç", "agentManager.tab.newOptions": "Daha fazla yeni sekme seçeneği", - "agentManager.tabsMenu.status.waiting": "Bekliyor", - "agentManager.tabsMenu.status.retry": "Yeniden", "agentManager.sidebarSearch.label": "Worktree'leri ve oturumları ara", "agentManager.sidebarSearch.scope": "Yerel çalışma alanını, yerel oturumları, Worktree'leri ve bunların oturumlarını arar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index a6439728272..11aa8d8af14 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -67,8 +67,6 @@ export const dict = { "agentManager.tab.terminal": "Термінал", "agentManager.tab.openTerminal": "Відкрити термінал", "agentManager.tab.newOptions": "Інші параметри нової вкладки", - "agentManager.tabsMenu.status.waiting": "Очікує", - "agentManager.tabsMenu.status.retry": "Повтор", "agentManager.sidebarSearch.label": "Пошук робочих дерев і сесій", "agentManager.sidebarSearch.scope": "Пошук локального робочого простору, локальних сесій, робочих дерев і призначених їм сесій", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 375bf143ad5..aeaf425a7dd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "终端", "agentManager.tab.openTerminal": "打开终端", "agentManager.tab.newOptions": "更多新建标签页选项", - "agentManager.tabsMenu.status.waiting": "等待", - "agentManager.tabsMenu.status.retry": "重试", "agentManager.sidebarSearch.label": "搜索 Worktree 和会话", "agentManager.sidebarSearch.scope": "搜索本地工作区、本地会话、Worktree 及其会话", "agentManager.sidebarSearch.contexts": "本地 & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 8f25f7f6614..d6030bb2b27 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -62,8 +62,6 @@ export const dict = { "agentManager.tab.terminal": "終端機", "agentManager.tab.openTerminal": "開啟終端機", "agentManager.tab.newOptions": "更多新增分頁選項", - "agentManager.tabsMenu.status.waiting": "等待", - "agentManager.tabsMenu.status.retry": "重試", "agentManager.sidebarSearch.label": "搜尋 Worktree 與工作階段", "agentManager.sidebarSearch.scope": "搜尋本機工作區、本機工作階段、Worktree 及其工作階段", "agentManager.sidebarSearch.contexts": "本機 & WORKTREES", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts index d417c0209a5..a8849ac468d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts @@ -1,45 +1,41 @@ +import { createMemo } from "solid-js" +import { strongest, type Activity } from "../../src/utils/session-activity" + interface Item { id: string worktreeId?: string | null } -interface Status { - type: string -} - -interface Prompt { - sessionID: string -} - -export function createSessionBusy(opts: { - statuses: () => Record - permissions: () => Prompt[] - questions: () => Prompt[] +export function createSessionActivity(opts: { managed: () => Item[] local: () => string[] projects: () => Record active: () => string | undefined + activityFor: (id: string) => Activity }) { - const any = (ids: string[]) => { - if (ids.length === 0) return false - const statuses = opts.statuses() - const blocked = new Set([...opts.permissions(), ...opts.questions()].map((item) => item.sessionID)) - return ids.some((id) => { - const status = statuses[id] - return !!status && status.type !== "idle" && !blocked.has(id) - }) + const group = (items: Item[]) => { + const states = new Map() + for (const item of items) { + const id = item.worktreeId ?? null + const values = states.get(id) ?? [] + values.push(opts.activityFor(item.id)) + states.set(id, values) + } + return new Map([...states].map(([id, values]) => [id, strongest(values)])) } - const agent = (id: string) => - any( - opts - .managed() - .filter((item) => item.worktreeId === id) - .map((item) => item.id), - ) - const local = () => any(opts.local()) - const project = (id: string, worktreeId: string | null) => { - if (id === opts.active()) return worktreeId === null ? local() : agent(worktreeId) - return any((opts.projects()[id] ?? []).filter((item) => item.worktreeId === worktreeId).map((item) => item.id)) + const local = createMemo(() => strongest(opts.local().map(opts.activityFor))) + const managed = createMemo(() => group(opts.managed())) + const projects = createMemo(() => { + const values = new Map>() + for (const [id, items] of Object.entries(opts.projects())) values.set(id, group(items)) + return values + }) + return { + local: () => local(), + agent: (id: string) => managed().get(id) ?? "idle", + project: (id: string, worktree: string | null): Activity => { + if (id === opts.active()) return worktree === null ? local() : (managed().get(worktree) ?? "idle") + return projects().get(id)?.get(worktree) ?? "idle" + }, } - return { any, agent, local, project, session: (id: string) => any([id]) } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts index ddfaa13a549..fcecd9a28a6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts @@ -1,16 +1,10 @@ import { createMemo } from "solid-js" import type { Accessor } from "solid-js" -import type { - PermissionRequest, - QuestionRequest, - SectionState, - SessionInfo, - SessionStatusInfo, - WorktreeState, -} from "../src/types/messages" +import type { SectionState, SessionInfo, WorktreeState } from "../src/types/messages" +import { 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,24 @@ 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 score = (state: SidebarSearchState) => + state === "waiting" + ? 5 + : state === "error" + ? 4 + : state === "retry" + ? 3 + : state === "busy" + ? 2 + : state === "done" + ? 1 + : 0 const newest = (items: SessionInfo[], fallback: string) => items.reduce((latest, item) => (item.updatedAt > latest ? item.updatedAt : latest), fallback) @@ -79,6 +84,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 +98,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 +111,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 +138,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 +156,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 +175,16 @@ interface SidebarSearchDeps { localBranch: Accessor selection: Accessor sessionId: Accessor - statuses: Accessor> - permissions: Accessor - questions: Accessor + activityFor: (id: string) => Activity label: (worktree: WorktreeState) => string sessions: (id: string) => SessionInfo[] pending: (id: string) => boolean busy: (id: string) => boolean - localBusy: Accessor t: (key: string) => string } export function createSidebarSearch(deps: SidebarSearchDeps) { const items = createMemo(() => { - const statuses = deps.statuses() - const blocked = new Set([ - ...deps.permissions().map((item) => item.sessionID), - ...deps.questions().map((item) => item.sessionID), - ]) return buildSidebarSearch({ worktrees: deps.worktrees().map((worktree) => ({ worktree, @@ -198,13 +197,8 @@ export function createSidebarSearch(deps: SidebarSearchDeps) { localBranch: deps.localBranch(), untitled: deps.t("agentManager.session.untitled"), pending: deps.pending, - status: (id) => { - if (blocked.has(id)) return "waiting" - const status = statuses[id]?.type - return status === "busy" || status === "retry" ? status : "idle" - }, + activityFor: deps.activityFor, busy: deps.busy, - localBusy: deps.localBusy(), }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx index 6c0222716de..9c5c43805cd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx @@ -12,13 +12,15 @@ import { useLanguage } from "../src/context/language" import { SessionTab } from "../src/components/chat/SessionTab" import { SessionTabMenu } from "../src/components/chat/SessionTabMenu" import { SortableTabContainer } from "../src/components/chat/TabDnd" +import type { Activity } from "../src/utils/session-activity" import { parseBindingTokens } from "./keybind-tokens" /** Individual sortable tab wrapper using the `use:sortable` directive. */ export const SortableTab: Component<{ tab: SessionInfo active: boolean - busy: boolean + state: Activity + stateLabel: string keybind?: string closeKeybind?: string onSelect: () => void @@ -52,7 +54,8 @@ export const SortableTab: Component<{ string | undefined isPending: (id: string) => boolean - isBusy: (id: string) => boolean + activityFor: (id: string) => Activity + stateLabel: (state: Activity) => string tabLookup: () => Map adjacentHint: (id: string, activeId: string, ids: string[], prev: string, next: string) => string // Handlers @@ -164,6 +166,7 @@ function renderReviewTab(deps: TabRenderDeps): JSX.Element { function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element { const pending = deps.isPending(s.id) + const state = createMemo(() => deps.activityFor(s.id)) const active = () => !deps.terms.activeId() && (pending ? s.id === deps.activePendingId() && !deps.currentSessionID() : s.id === deps.currentSessionID()) @@ -181,7 +184,8 @@ function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element { { const tabs = useLocalTabs() const server = useServer() const vscode = useVSCode() + const activity = createMemo(() => + strongest([session.currentSessionID(), ...(tabs?.ids() ?? [])].map(session.activityFor)), + ) + createEffect(() => vscode.postMessage({ type: "sessionActivity", state: activity() })) const handleViewAction = (action: string) => { switch (action) { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx index d51c4fab86c..6da998d87a3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTab.tsx @@ -1,12 +1,14 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { Show, type Component, type JSX } from "solid-js" +import { ActivityIcon } from "../shared/ActivityIcon" +import type { Activity } from "../../utils/session-activity" export const SessionTab: Component<{ title: string active: boolean - busy: boolean + state: Activity + stateLabel: string closeTitle: string closeLabel: string keybind?: string @@ -21,7 +23,7 @@ export const SessionTab: Component<{ onKeyDown?: JSX.EventHandlerUnion onClose: () => void }> = (props) => ( -
+
- - - + + + {props.title} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx index 02b58ee5aaf..51530fd0680 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx @@ -8,6 +8,7 @@ import { isPendingTab } from "../../utils/local-tabs" import { useTabScroll } from "../../utils/tab-scroll" import { focusPrompt, focusSelectedTab, focusTabElement, handleTabKey } from "../../utils/tab-navigation" import { setTabWidths } from "../../utils/tab-widths" +import { label, running } from "../../utils/session-activity" import { useVSCode } from "../../context/vscode" import { SessionTab } from "./SessionTab" import { SessionTabMenu } from "./SessionTabMenu" @@ -28,10 +29,8 @@ export const SessionTabStrip: Component = () => { if (isPendingTab(id)) return language.t("sidebar.session.newSession") return items().get(id)?.title || language.t("session.untitled") } - const working = (id: string) => { - const status = session.allStatusMap()[id] - return status?.type === "busy" || status?.type === "retry" - } + const state = (id: string) => (isPendingTab(id) ? "idle" : session.activityFor(id)) + const working = (id: string) => running(state(id)) const middle = (id: string, event: MouseEvent) => { if (event.button !== 1) return event.preventDefault() @@ -62,7 +61,8 @@ export const SessionTabStrip: Component = () => { id, title: title(id), active: tabs.active() === id, - busy: working(id), + state: state(id), + stateLabel: language.t(label(state(id))), pending: isPendingTab(id), })), ) @@ -140,7 +140,8 @@ export const SessionTabStrip: Component = () => { { close: language.t("common.closeTab"), current: language.t("session.tabs.switcher.current"), pending: language.t("session.tabs.switcher.pending"), - busy: language.t("session.tabs.switcher.busy"), }} onSelect={tabs.select} onRestore={focusPrompt} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx index 3ba5618a02b..1069f22b4f7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabSwitcher.tsx @@ -1,17 +1,18 @@ -import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { List } from "@kilocode/kilo-ui/list" import type { ListRef } from "@kilocode/kilo-ui/list" import { Popover } from "@kilocode/kilo-ui/popover" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { Show, createEffect, createMemo, createSignal, type Component, type JSX } from "solid-js" +import { ActivityIcon } from "../shared/ActivityIcon" +import type { Activity } from "../../utils/session-activity" interface SessionTabSwitcherItem { id: string title: string active: boolean - busy: boolean + state: Activity + stateLabel: string pending: boolean } @@ -23,7 +24,6 @@ interface SessionTabSwitcherProps { close: string current: string pending: string - busy: string } onSelect: (id: string) => void onRestore: () => void @@ -144,19 +144,17 @@ export const SessionTabSwitcher: Component = (props) => > {(item) => ( - - }> - - + + {item.title} - - - - {props.labels.busy} + + + + {item.stateLabel} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ActivityIcon.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ActivityIcon.tsx new file mode 100644 index 00000000000..28357724e06 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ActivityIcon.tsx @@ -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) => ( + }> + + + + + + + + + + +) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index 84212c2c4b6..911e76d712a 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -117,6 +117,34 @@ export function childID(part: TaskPart): string | undefined { return part.metadata?.sessionId ?? part.state?.metadata?.sessionId } +export function ancestry( + sessions: Record, + tools: Record, + outcomes: Record, +) { + const parents = new Map() + for (const [id, parts] of Object.entries(tools)) { + for (const part of parts) { + const child = childID(part) + if (child) parents.set(child, id) + } + } + for (const [id, close] of Object.entries(outcomes)) { + if (close?.parentID) parents.set(id, close.parentID) + } + for (const [id, session] of Object.entries(sessions)) { + if (session.parentID === null) parents.delete(id) + if (session.parentID) parents.set(id, session.parentID) + } + const children = new Map() + for (const [child, parent] of parents) { + const ids = children.get(parent) ?? [] + ids.push(child) + children.set(parent, ids) + } + return { parents, children } +} + export function latestTaskPart(partID: string | undefined, child: string | undefined, parts: readonly TaskPart[]) { if (!partID || !child) return false return parts.findLast((part) => childID(part) === child)?.id === partID diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 54841acdae6..e9cc8661b67 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -9,6 +9,7 @@ import { useContext, createSignal, createMemo, + createComputed, createEffect, on, onMount, @@ -62,6 +63,7 @@ import { buildCostBreakdown, buildSessionToolParts, childID, + ancestry, dropSet, emptyPageState, messageParts, @@ -93,6 +95,7 @@ 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" const RECENT_LIMIT = 5 const MESSAGE_PAGE_LIMIT = 80 @@ -114,6 +117,11 @@ interface SessionStore { modelUsage: Record } +interface CloseState { + reason: SessionCloseReason + parentID?: string +} + interface SessionContextValue { // Current session currentSessionID: Accessor @@ -154,6 +162,8 @@ interface SessionContextValue { // All session statuses keyed by sessionID (for DataBridge) allStatusMap: () => Record + activityFor: (sessionID: string | undefined) => Activity + // Parts for a specific message getParts: (messageID: string) => Part[] @@ -323,7 +333,7 @@ export const SessionProvider: ParentComponent = (props) => { // Per-session status map — keyed by sessionID const [statusMap, setStatusMap] = createStore>({}) - const [closeMap, setCloseMap] = createStore>({}) + const [closeMap, setCloseMap] = createStore>({}) const [busySinceMap, setBusySinceMap] = createStore>({}) const [submissionMap, setSubmissionMap] = createStore>({}) const pendingSubmissions = new Map() @@ -339,7 +349,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 +1000,15 @@ export const SessionProvider: ParentComponent = (props) => { if (message.sessionID) patchPage(message.sessionID, { loadingInitial: false, loadingOlder: false }) } + function closed(message: Extract) { + if (message.reason === "completed" && closeMap[message.sessionID]?.reason === "error") return + setCloseMap(message.sessionID, { reason: message.reason, parentID: message.parentID }) + } + + function failed(id: string) { + setCloseMap(id, { reason: "error", parentID: store.sessions[id]?.parentID ?? undefined }) + } + function toggleFavorite(providerID: string, modelID: string) { const key = `${providerID}/${modelID}` const idx = store.favoriteModels.findIndex((f) => `${f.providerID}/${f.modelID}` === key) @@ -1089,7 +1108,7 @@ export const SessionProvider: ParentComponent = (props) => { break case "sessionTurnClosed": - setCloseMap(message.sessionID, message.reason) + closed(message) break case "todoUpdated": @@ -1137,6 +1156,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") @@ -1656,7 +1676,7 @@ export const SessionProvider: ParentComponent = (props) => { ) { 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 } @@ -1665,7 +1685,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()) } @@ -1866,8 +1886,14 @@ export const SessionProvider: ParentComponent = (props) => { return ids } + const lineage = createMemo(() => ancestry(store.sessions, store.toolParts, closeMap)) + function sessionFamily(rootID: string): Set { - return sessionIDs(rootID, (sid) => store.messages[sid] ?? []) + const ids = new Set([rootID]) + for (const id of ids) { + for (const child of lineage().children.get(id) ?? []) ids.add(child) + } + return ids } function modelUsageRelated(sessionID: string, parentID?: string | null): boolean { @@ -1909,6 +1935,24 @@ export const SessionProvider: ParentComponent = (props) => { return suggestions().filter((item) => family.has(item.sessionID)) } + const [activityMap, setActivityMap] = createStore>({}) + createComputed(() => { + const connection = server.connectionState() + setActivityMap( + reconcile( + activities({ + parents: lineage().parents, + statuses: statusMap, + outcomes: closeMap, + blocked: [...permissions(), ...questions(), ...suggestions()].map((item) => item.sessionID), + submitting: Object.keys(submissionMap), + disconnected: connection !== "connected", + }), + ), + ) + }) + const activityFor = (id: string | undefined): Activity => (id ? (activityMap[id] ?? "idle") : "idle") + function handleTodoUpdated(sessionID: string, items: TodoItem[]) { setStore("todos", sessionID, items) } @@ -2986,6 +3030,7 @@ export const SessionProvider: ParentComponent = (props) => { allMessages, allParts, allStatusMap, + activityFor, recentModels: () => store.recentModels, modelUsageHistory: () => store.modelUsageHistory, favoriteModels: () => store.favoriteModels, diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 6c6b87899a4..53f60089200 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -29,7 +29,7 @@ import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector" import { DeferredPopover } from "../components/shared/DeferredPopover" import { ProjectSelect } from "../../agent-manager/ProjectSelect" import { PRComments } from "../../agent-manager/pr/PRComments" -import { createSignal, onCleanup, onMount, type JSX } from "solid-js" +import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js" import type { AgentProjectSnapshot, WorktreeFileDiff, @@ -539,7 +539,7 @@ const defaultProps = { active: false, pendingDelete: false, busy: false, - working: false, + activity: "idle" as const, stale: false, shortcut: 2, sessions: 1, @@ -566,6 +566,46 @@ const defaultProps = { // WorktreeItem stories // --------------------------------------------------------------------------- +export const WorktreeActivityStates: Story = { + name: "Worktree cards - all activity states", + render: () => ( + +
+ + + {([state, title]) => ( + + )} + +
+
+ ), +} + export const WorktreeItemDefault: Story = { name: "WorktreeItem — default", render: () => ( @@ -1377,8 +1417,7 @@ export const SidebarSearchOpen: Story = { scope: "Searches the local workspace, local sessions, worktrees, and their sessions", contexts: "LOCAL & WORKTREES", sessions: "SESSIONS", - waiting: "Wait", - retry: "Retry", + state: (value) => value, }} onSelect={(item) => setSelected(item.key)} defaultOpen @@ -1537,6 +1576,7 @@ export const MultiProjectSidebar: Story = { [projectB.id]: storyLocal("master", 0, 0, 0, 2), }} prs={{ [projectA.id]: {}, [projectB.id]: {} }} + busy={() => false} sessions={{ [projectA.id]: [ projectSession("ses-a1", null, "Refine project accordion layout", "2026-07-24T08:30:00Z"), @@ -1546,6 +1586,8 @@ export const MultiProjectSidebar: Story = { }} selectedProject={projectA.id} selection="local" + activityFor={() => "idle"} + sessionActivity={() => "idle"} bindings={{ search: "⌘F", showShortcuts: "⌘⇧/", newWorktree: "⌘N", quickWorktree: "⌘⇧N" }} t={t} onSearchRef={() => {}} diff --git a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx index a359eefea5f..744b76ad0b2 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx @@ -58,7 +58,7 @@ const wtProps = { active: false, pendingDelete: false, busy: false, - working: false, + activity: "idle" as const, stale: false, sessions: 1, grouped: false, @@ -402,7 +402,7 @@ export const WithBusyWorktree: Story = {
- +
- +
diff --git a/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx index 26337440548..3583eb42acd 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx @@ -1,14 +1,45 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" +import { For } from "solid-js" +import { SessionTab } from "../components/chat/SessionTab" import { SessionTabSwitcher } from "../components/chat/SessionTabSwitcher" +import type { Activity } from "../utils/session-activity" import { StoryProviders } from "./StoryProviders" const rows = [ - { id: "refactor", title: "Refactor shared search menu styles", active: false, busy: false, pending: false }, - { id: "current", title: "Run the extension test suite", active: true, busy: true, pending: false }, - { id: "pending", title: "Untitled session", active: false, busy: false, pending: true }, - { id: "idle", title: "Review keyboard navigation behavior", active: false, busy: false, pending: false }, + { + id: "refactor", + title: "Refactor shared search menu styles", + active: false, + state: "done" as const, + stateLabel: "Done", + pending: false, + }, + { + id: "current", + title: "Run the extension test suite", + active: true, + state: "busy" as const, + stateLabel: "Running", + pending: false, + }, + { + id: "pending", + title: "Untitled session", + active: false, + state: "idle" as const, + stateLabel: "Current session", + pending: true, + }, + { + id: "idle", + title: "Review keyboard navigation behavior", + active: false, + state: "idle" as const, + stateLabel: "Current session", + pending: false, + }, ] const noop = () => {} @@ -21,6 +52,71 @@ const meta: Meta = { export default meta type Story = StoryObj +const states: { state: Activity; title: string }[] = [ + { state: "busy", title: "Running" }, + { state: "waiting", title: "Needs input" }, + { state: "done", title: "Completed" }, + { state: "retry", title: "Retrying" }, + { state: "error", title: "Error" }, + { state: "idle", title: "Idle" }, +] + +const tabs = (items: typeof states) => ( +
+
+ + {(item) => ( +
+ +
+ )} +
+
+
+) + +const gallery = (groups: (typeof states)[]) => ( + +
+ + {tabs} +
+
+) + +export const ActivityStates: Story = { + name: "Session tabs - all activity states", + render: () => gallery(states.map((item) => [item])), +} + +export const ActivityStates1280: Story = { + name: "Session tabs - all activity states - 1280px", + render: () => gallery([states]), +} + +export const MultipleSessions: Story = { + name: "Sidebar session tabs - multiple sessions", + render: () => gallery([states.slice(0, 3)]), +} + +export const MultipleSessions200: Story = { + name: "Sidebar session tabs - multiple sessions - 200px", + render: () => gallery([states.slice(0, 3)]), +} + export const SwitcherOpen: Story = { name: "Session tab switcher — open", render: () => ( @@ -45,7 +141,6 @@ export const SwitcherOpen: Story = { close: "Close tab", current: "Current", pending: "New", - busy: "Working", }} onSelect={noop} onRestore={focus} diff --git a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css index 4306826c51b..d5a6c46e04f 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css +++ b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css @@ -177,6 +177,27 @@ flex-shrink: 0; } +.session-tab-bar .am-tab-icon[data-activity="waiting"], +.session-tab-bar .search-menu-icon[data-activity="waiting"], +.session-tab-bar .session-tab-switcher-meta[data-activity="waiting"] { + --icon-base: var(--icon-warning-base); + color: var(--icon-warning-base); +} + +.session-tab-bar .am-tab-icon[data-activity="error"], +.session-tab-bar .search-menu-icon[data-activity="error"], +.session-tab-bar .session-tab-switcher-meta[data-activity="error"] { + --icon-base: var(--icon-critical-base); + color: var(--icon-critical-base); +} + +.session-tab-bar .am-tab-icon[data-activity="done"], +.session-tab-bar .search-menu-icon[data-activity="done"], +.session-tab-bar .session-tab-switcher-meta[data-activity="done"] { + --icon-base: var(--icon-success-base); + color: var(--icon-success-base); +} + .session-tab-bar .am-worktree-spinner { width: 16px; height: 16px; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index a439ce34dd8..b266a369cce 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -168,6 +168,7 @@ export interface SessionTurnClosedMessage { type: "sessionTurnClosed" sessionID: string reason: SessionCloseReason + parentID?: string } export interface SessionErrorMessage { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index e8ccea2d37c..319dd994244 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -17,6 +17,7 @@ import type { StartMigrationMessage, } from "./migration" import type { MemoryShowMessage, MemoryOperationMessage, RequestMemoryMessage } from "./memory" +import type { Activity } from "../../utils/session-activity" // ============================================ // Messages FROM webview TO extension @@ -1490,6 +1491,7 @@ export interface DismissAgentMigrationBannerMessage { } export type WebviewMessage = + | { type: "sessionActivity"; state: Activity } | DocumentRequestMessage | DocumentOpenFileMessage | DocumentCloseMessage diff --git a/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts b/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts new file mode 100644 index 00000000000..cc5c38632d9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts @@ -0,0 +1,85 @@ +export type Activity = "waiting" | "error" | "retry" | "busy" | "done" | "idle" + +export type Status = "idle" | "busy" | "retry" | "offline" + +export interface ActivityInput { + status?: Status + blocked?: boolean + errored?: boolean + finished?: boolean + disconnected?: boolean +} + +export function activity(input: ActivityInput): Activity { + if (input.disconnected && (input.blocked || input.status === "busy" || input.status === "retry")) return "error" + if (input.blocked) return "waiting" + if (input.errored || input.status === "offline") return "error" + if (input.status === "retry") return "retry" + if (input.status === "busy") return "busy" + if (input.finished) return "done" + return "idle" +} + +export function activities(input: { + parents: ReadonlyMap + statuses: Record + outcomes: Record + blocked: Iterable + submitting?: Iterable + disconnected: boolean +}): Record { + const blocked = new Set(input.blocked) + const submitting = new Set(input.submitting) + const ids = new Set([...Object.keys(input.statuses), ...Object.keys(input.outcomes), ...blocked, ...submitting]) + const result: Record = {} + for (const id of ids) { + const status = submitting.has(id) ? "busy" : input.statuses[id]?.type + const close = input.outcomes[id]?.reason + const active = activity({ status, blocked: blocked.has(id), disconnected: input.disconnected }) + const own = activity({ + status, + blocked: blocked.has(id), + disconnected: input.disconnected, + errored: close === "error", + finished: close === "completed", + }) + result[id] = strongest([result[id] ?? "idle", own]) + if (active === "idle") continue + const seen = new Set([id]) + for (let parent = input.parents.get(id); parent && !seen.has(parent); parent = input.parents.get(parent)) { + seen.add(parent) + result[parent] = strongest([result[parent] ?? "idle", active]) + } + } + return result +} + +export function running(state: Activity): boolean { + return state === "busy" || state === "retry" +} + +const ORDER: Activity[] = ["waiting", "error", "retry", "busy", "done", "idle"] + +export function isActivity(value: unknown): value is Activity { + return typeof value === "string" && ORDER.includes(value as Activity) +} + +export function strongest(states: Activity[]): Activity { + for (const state of ORDER) { + if (states.includes(state)) return state + } + return "idle" +} + +const LABELS: Record = { + waiting: "task.backgroundAgents.needsInput", + error: "task.backgroundAgents.status.error", + retry: "session.status.retry", + busy: "session.tabs.switcher.busy", + done: "task.backgroundAgents.status.completed", + idle: "session.current", +} + +export function label(state: Activity): string { + return LABELS[state] +} From cdcf69e0d8665265d6de63043643aa52dd481bfa Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 27 Aug 2026 13:16:50 +0200 Subject: [PATCH 2/5] fix(vscode): preserve activity colors in selected and portalled views --- .../agent-manager/agent-manager.css | 32 ++++++---- .../src/stories/agent-manager.stories.tsx | 62 +++++++++++++++---- .../src/stories/session-tabs.stories.tsx | 19 +++++- .../webview-ui/src/styles/session-tabs.css | 12 ++-- 4 files changed, 92 insertions(+), 33 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 68a11704e2c..41b476a9d1a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -160,22 +160,30 @@ html[data-theme="kilo-vscode"] flex-shrink: 0; } -[data-activity="waiting"] [data-component="icon"], -[data-activity="waiting"] [data-slot="icon-svg"], -.am-sidebar-search-status[data-activity="waiting"] { - color: var(--icon-warning-base, var(--vscode-notificationsWarningIcon-foreground, #d9a13a)); +:is( + .am-tab-icon, + .am-wt-icon, + .am-local-status, + .search-menu-icon, + .am-sidebar-search-status +)[data-activity="waiting"] { + --icon-base: var(--icon-warning-base, var(--vscode-notificationsWarningIcon-foreground, #d9a13a)); + color: var(--icon-base); } -[data-activity="error"] [data-component="icon"], -[data-activity="error"] [data-slot="icon-svg"], -.am-sidebar-search-status[data-activity="error"] { - color: var(--icon-critical-base, var(--vscode-errorForeground, #f14c4c)); +:is(.am-tab-icon, .am-wt-icon, .am-local-status, .search-menu-icon, .am-sidebar-search-status)[data-activity="error"] { + --icon-base: var(--icon-critical-base, var(--vscode-errorForeground, #f14c4c)); + color: var(--icon-base); } -[data-activity="done"] [data-component="icon"], -[data-activity="done"] [data-slot="icon-svg"], -.am-sidebar-search-status[data-activity="done"] { - color: var(--icon-success-base, var(--vscode-testing-iconPassed, #73c991)); +:is(.am-tab-icon, .am-wt-icon, .am-local-status, .search-menu-icon, .am-sidebar-search-status)[data-activity="done"] { + --icon-base: var(--icon-success-base, var(--vscode-testing-iconPassed, #73c991)); + color: var(--icon-base); +} + +:is(.am-tab-icon, .am-wt-icon, .am-local-status, .search-menu-icon)[data-activity]:not([data-activity="idle"]) + [data-component="icon"] { + color: var(--icon-base); } .am-local-text { diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 53f60089200..a92358eb5c4 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -10,6 +10,7 @@ import { FileTree } from "../../diff-viewer/FileTree" import { DiffPanel } from "../../agent-manager/DiffPanel" import { FullScreenDiffView } from "../../diff-viewer/FullScreenDiffView" import { WorktreeItem } from "../../agent-manager/WorktreeItem" +import { SessionTab } from "../components/chat/SessionTab" import { ChatView } from "../components/chat/ChatView" import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides" import { SessionContext } from "../context/session" @@ -566,24 +567,22 @@ const defaultProps = { // WorktreeItem stories // --------------------------------------------------------------------------- +const activityStates = [ + ["busy", "Running"], + ["waiting", "Needs input"], + ["done", "Completed"], + ["retry", "Retrying"], + ["error", "Error"], + ["idle", "Idle"], +] as const + export const WorktreeActivityStates: Story = { name: "Worktree cards - all activity states", - render: () => ( + render: (args: { active?: boolean }) => (
- + {([state, title]) => ( ( + +
+ + + {([state, title]) => ( +
+ +
+ )} +
+
+
+ ), +} + export const WorktreeItemDefault: Story = { name: "WorktreeItem — default", render: () => ( diff --git a/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx index 3583eb42acd..9ab68473427 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/session-tabs.stories.tsx @@ -24,6 +24,22 @@ const rows = [ stateLabel: "Running", pending: false, }, + { + id: "waiting", + title: "Approve a pending command", + active: false, + state: "waiting" as const, + stateLabel: "Needs input", + pending: false, + }, + { + id: "error", + title: "Review the failed session", + active: false, + state: "error" as const, + stateLabel: "Error", + pending: false, + }, { id: "pending", title: "Untitled session", @@ -124,7 +140,7 @@ export const SwitcherOpen: Story = {
diff --git a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css index d5a6c46e04f..e0b4358614f 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css +++ b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css @@ -178,22 +178,22 @@ } .session-tab-bar .am-tab-icon[data-activity="waiting"], -.session-tab-bar .search-menu-icon[data-activity="waiting"], -.session-tab-bar .session-tab-switcher-meta[data-activity="waiting"] { +.session-tab-switcher-popover .search-menu-icon[data-activity="waiting"], +.session-tab-switcher-popover .session-tab-switcher-meta[data-activity="waiting"] { --icon-base: var(--icon-warning-base); color: var(--icon-warning-base); } .session-tab-bar .am-tab-icon[data-activity="error"], -.session-tab-bar .search-menu-icon[data-activity="error"], -.session-tab-bar .session-tab-switcher-meta[data-activity="error"] { +.session-tab-switcher-popover .search-menu-icon[data-activity="error"], +.session-tab-switcher-popover .session-tab-switcher-meta[data-activity="error"] { --icon-base: var(--icon-critical-base); color: var(--icon-critical-base); } .session-tab-bar .am-tab-icon[data-activity="done"], -.session-tab-bar .search-menu-icon[data-activity="done"], -.session-tab-bar .session-tab-switcher-meta[data-activity="done"] { +.session-tab-switcher-popover .search-menu-icon[data-activity="done"], +.session-tab-switcher-popover .session-tab-switcher-meta[data-activity="done"] { --icon-base: var(--icon-success-base); color: var(--icon-success-base); } From ec20f8f36495dca0da229e9e48938fb04959fecc Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 27 Aug 2026 13:28:02 +0200 Subject: [PATCH 3/5] fix(agent-manager): scope activity colors to owned CSS names --- .../agent-manager/SidebarSearchMenu.tsx | 2 +- .../agent-manager/agent-manager.css | 34 +++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx index 7bb9fecdd52..d9661ed7b74 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx @@ -122,7 +122,7 @@ export const SidebarSearchMenu: Component = (props) => { data-session-id={item.kind === "session" ? item.sessionId : undefined} data-worktree-id={item.kind === "worktree" ? item.worktreeId : undefined} > - + Date: Fri, 28 Aug 2026 08:01:52 +0200 Subject: [PATCH 4/5] refactor(vscode): share array-based activity priority --- .../tests/unit/session-activity.test.ts | 15 +++++++++++++++ .../webview-ui/agent-manager/sidebar-search.ts | 14 +------------- .../webview-ui/src/utils/session-activity.ts | 13 +++++++------ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/session-activity.test.ts b/packages/kilo-vscode/tests/unit/session-activity.test.ts index 6d4d282bd42..182036dda90 100644 --- a/packages/kilo-vscode/tests/unit/session-activity.test.ts +++ b/packages/kilo-vscode/tests/unit/session-activity.test.ts @@ -5,6 +5,7 @@ import { isActivity, label, running, + score, strongest, type Activity, } from "../../webview-ui/src/utils/session-activity" @@ -130,6 +131,7 @@ 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) @@ -145,6 +147,19 @@ describe("running", () => { }) }) +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") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts index fcecd9a28a6..c1991aa35cf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-search.ts @@ -1,7 +1,7 @@ import { createMemo } from "solid-js" import type { Accessor } from "solid-js" import type { SectionState, SessionInfo, WorktreeState } from "../src/types/messages" -import { strongest, type Activity } from "../src/utils/session-activity" +import { score, strongest, type Activity } from "../src/utils/session-activity" import { LOCAL } from "./navigate" export type SidebarSearchState = Activity @@ -59,18 +59,6 @@ interface SidebarSearchInput { const root = (item: SessionInfo) => !item.parentID const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase() -const score = (state: SidebarSearchState) => - state === "waiting" - ? 5 - : state === "error" - ? 4 - : state === "retry" - ? 3 - : state === "busy" - ? 2 - : state === "done" - ? 1 - : 0 const newest = (items: SessionInfo[], fallback: string) => items.reduce((latest, item) => (item.updatedAt > latest ? item.updatedAt : latest), fallback) diff --git a/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts b/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts index cc5c38632d9..0cf0837a64e 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/session-activity.ts @@ -58,17 +58,18 @@ export function running(state: Activity): boolean { return state === "busy" || state === "retry" } -const ORDER: Activity[] = ["waiting", "error", "retry", "busy", "done", "idle"] +const STATES: Activity[] = ["done", "busy", "retry", "error", "waiting"] + +export function score(state: Activity): number { + return STATES.indexOf(state) + 1 +} export function isActivity(value: unknown): value is Activity { - return typeof value === "string" && ORDER.includes(value as Activity) + return value === "idle" || (typeof value === "string" && STATES.includes(value as Activity)) } export function strongest(states: Activity[]): Activity { - for (const state of ORDER) { - if (states.includes(state)) return state - } - return "idle" + return states.reduce((best, state) => (score(state) > score(best) ? state : best), "idle") } const LABELS: Record = { From 8b3e353fceae0f4ec9dbe24023de6209aa1c7c07 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Fri, 28 Aug 2026 06:05:54 +0000 Subject: [PATCH 5/5] chore: update kilo-vscode visual regression baselines --- .../session-tab-activity-states-chromium-linux.png | 3 +++ .../worktree-activity-states-active-chromium-linux.png | 3 +++ .../agentmanager/worktree-activity-states-chromium-linux.png | 3 +++ .../session-tabs/activity-states-1280-chromium-linux.png | 3 +++ .../session-tabs/activity-states-chromium-linux.png | 3 +++ .../session-tabs/multiple-sessions-200-chromium-linux.png | 3 +++ .../session-tabs/multiple-sessions-chromium-linux.png | 3 +++ .../session-tabs/switcher-open-chromium-linux.png | 4 ++-- 8 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/session-tab-activity-states-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-active-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-1280-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-200-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/session-tab-activity-states-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/session-tab-activity-states-chromium-linux.png new file mode 100644 index 00000000000..501f015c53b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/session-tab-activity-states-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d55567442d6b3d627e64b583e7d4f49a522685efd8455013b17c4c4f0a84d84 +size 8753 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-active-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-active-chromium-linux.png new file mode 100644 index 00000000000..fcee3b4a0bd --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-active-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d4601147436962bb2fb3db3eede146e2f83a6d88d50f5d7dce016c34817062d +size 18551 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-chromium-linux.png new file mode 100644 index 00000000000..60a76c6029c --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-activity-states-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f86a360b9c84e63f8614898edf4fd64e0ce41a570e306326f80868ce99483720 +size 18265 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-1280-chromium-linux.png new file mode 100644 index 00000000000..1596eedfdde --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-1280-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c071338a92316478623abbe23cdb262c366917266511a599f405ccdd7e399225 +size 5710 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-chromium-linux.png new file mode 100644 index 00000000000..cf734a5f664 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/activity-states-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e275e0ec4ce304fc29c71eaac017872a6828d52fa73a504acce4b779fa389228 +size 7407 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-200-chromium-linux.png new file mode 100644 index 00000000000..937d23edecb --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f924b317c2eafaf5112229cc31404591d7526a7e0fba8c1d46cd97597c3a4392 +size 1778 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-chromium-linux.png new file mode 100644 index 00000000000..484a1eb1d78 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/multiple-sessions-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:268efbc7a6121b9fa3c2c843308b70f41a2235d2b9447e61ef6ac5c38b23c5b5 +size 3729 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png index 4bb775859c9..c41e9aa40da 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/session-tabs/switcher-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:273c76976df0e83fa404ae2af2d561f8c38458e3ef1b32f4f5b1990a5906a195 -size 14239 +oid sha256:f62edc4cf5786ca40fececbec0ae08e20096f0bc2bc08258de42c8a524a2366e +size 20730