mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
chore: merge main into UI cleanup
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep focus in active text fields when questions appear or refresh.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Choose a separate model for conversation compaction in Context settings.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/kilo-ui": patch
|
||||
"@opencode-ai/ui": patch
|
||||
---
|
||||
|
||||
Stop offscreen loading animations while preserving their original appearance, and release obsolete transcript pages from memory.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Search the latest 5,000 chats across the worktree family and display the best 50 matches in the sidebar and Agent Manager. Skip inaccessible folders from unrelated projects when finding past chats.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show consistent running, completion, and input-required indicators across session tabs and Agent Manager worktrees.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Resume pending requests after automatic compaction without replaying requests that already completed.
|
||||
@@ -93,6 +93,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
- Prefer single word variable names where possible
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer `Promise.withResolvers<T>()` for deferreds when runtime/types support it; allow callback/event executors, not async executors or redundant Promise wrapping.
|
||||
|
||||
### Avoid let statements
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function zeroID(...parts: (string | number | boolean)[]) {
|
||||
if (parts.length === 2) return `${parts[0]}\0${parts[1]}`
|
||||
if (parts.length === 3) return `${parts[0]}\0${parts[1]}\0${parts[2]}`
|
||||
return parts.join("\0")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, expectTypeOf, test } from "bun:test"
|
||||
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
|
||||
|
||||
describe("zeroID", () => {
|
||||
test("accepts only string, number, and boolean parts", () => {
|
||||
expectTypeOf<Parameters<typeof zeroID>>().toEqualTypeOf<(string | number | boolean)[]>()
|
||||
expectTypeOf<ReturnType<typeof zeroID>>().toEqualTypeOf<string>()
|
||||
})
|
||||
|
||||
test("matches template literals for short composite keys", () => {
|
||||
const values = ["", "a", "a\0b", "路径", "null", "undefined", 0, -0, -1, 0.5, NaN, Infinity, -Infinity, true, false]
|
||||
for (const first of values) {
|
||||
for (const second of values) {
|
||||
expect(zeroID(first, second)).toBe(`${first}\0${second}`)
|
||||
for (const third of values) {
|
||||
expect(zeroID(first, second, third)).toBe(`${first}\0${second}\0${third}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("matches array joins across arities and preserves empty parts", () => {
|
||||
const cases: Parameters<typeof zeroID>[] = [
|
||||
[],
|
||||
[""],
|
||||
[false],
|
||||
[0],
|
||||
["", ""],
|
||||
["", "", ""],
|
||||
["", "", "", ""],
|
||||
["prefix", "", 0, false, "suffix"],
|
||||
["/repo", "", "ancestor", "file.ts", true, "modified", 3, 0, false, ""],
|
||||
["\0", "a\0b", "", "路径", NaN, -0, -Infinity, true, false],
|
||||
]
|
||||
for (const parts of cases) {
|
||||
const expected = parts.join("\0")
|
||||
expect(zeroID(...parts)).toBe(expected)
|
||||
expect(Buffer.from(zeroID(...parts))).toEqual(Buffer.from(expected))
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves namespace prefixes, suffixes, and nested keys", () => {
|
||||
expect(zeroID("project", "")).toBe("project\0")
|
||||
expect(zeroID("", "file.ts")).toBe("\0file.ts")
|
||||
expect(zeroID("error", "message")).toBe("error\0message")
|
||||
expect(zeroID(zeroID("project", "session"), "file.ts")).toBe("project\0session\0file.ts")
|
||||
expect(zeroID("ab", "c")).not.toBe(zeroID("a", "bc"))
|
||||
expect(zeroID("project", "session").startsWith(zeroID("project", ""))).toBe(true)
|
||||
expect(zeroID("project-other", "session").startsWith(zeroID("project", ""))).toBe(false)
|
||||
})
|
||||
|
||||
test("supports explicit caller-specific nullish coercion", () => {
|
||||
for (const value of [null, undefined]) {
|
||||
expect(zeroID("scope", String(value))).toBe(`scope\0${value}`)
|
||||
expect(zeroID("scope", value ?? "")).toBe(["scope", value].join("\0"))
|
||||
}
|
||||
})
|
||||
})
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6d55567442d6b3d627e64b583e7d4f49a522685efd8455013b17c4c4f0a84d84
|
||||
size 8753
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0d4601147436962bb2fb3db3eede146e2f83a6d88d50f5d7dce016c34817062d
|
||||
size 18551
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f86a360b9c84e63f8614898edf4fd64e0ce41a570e306326f80868ce99483720
|
||||
size 18265
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c071338a92316478623abbe23cdb262c366917266511a599f405ccdd7e399225
|
||||
size 5710
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e275e0ec4ce304fc29c71eaac017872a6828d52fa73a504acce4b779fa389228
|
||||
size 7407
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f924b317c2eafaf5112229cc31404591d7526a7e0fba8c1d46cd97597c3a4392
|
||||
size 1778
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:268efbc7a6121b9fa3c2c843308b70f41a2235d2b9447e61ef6ac5c38b23c5b5
|
||||
size 3729
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:273c76976df0e83fa404ae2af2d561f8c38458e3ef1b32f4f5b1990a5906a195
|
||||
size 14239
|
||||
oid sha256:f62edc4cf5786ca40fececbec0ae08e20096f0bc2bc08258de42c8a524a2366e
|
||||
size 20730
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="spinner"] > rect {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
|
||||
const meta: Meta<typeof Spinner> = {
|
||||
title: "Components/Spinner",
|
||||
@@ -20,6 +20,16 @@ export const Large: Story = {
|
||||
render: () => <Spinner style={{ width: "48px", height: "48px" }} />,
|
||||
}
|
||||
|
||||
export const Parallel: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
{Array.from({ length: 24 }, () => (
|
||||
<Spinner style={{ width: "16px", height: "16px" }} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Colored: Story = {
|
||||
render: () => <Spinner style={{ width: "24px", height: "24px", color: "var(--text-interactive-base)" }} />,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
@import "../components/select.css";
|
||||
@import "../components/session.css";
|
||||
@import "../components/settings-sidebar.css";
|
||||
@import "../components/spinner.css";
|
||||
@import "../components/status-indicator.css";
|
||||
@import "../components/switch.css";
|
||||
@import "../components/tabs.css";
|
||||
|
||||
@@ -142,6 +142,7 @@ import {
|
||||
} from "./kilo-provider/handlers/question"
|
||||
import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion"
|
||||
import { nativeTitle } from "./kilo-provider/native-tab-title"
|
||||
import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity"
|
||||
import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments"
|
||||
import { completesWithoutStatus } from "./kilo-provider/command-completion"
|
||||
import { KiloProviderMemory } from "./kilo-provider/memory"
|
||||
@@ -392,6 +393,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private readonly refreshes = new Map<string, number>()
|
||||
private readonly anacondaDesktop = new AnacondaDesktopBridge()
|
||||
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
|
||||
private activity: Activity = "idle"
|
||||
private caption: string | undefined
|
||||
private readonly epochs = new Map<string, Map<string, number>>()
|
||||
private readonly requests = new Map<string, number>()
|
||||
private epoch = 0
|
||||
@@ -509,7 +512,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (id) this.refreshes.set(id, (this.refreshes.get(id) ?? 0) + 1)
|
||||
}
|
||||
this.currentSession = session
|
||||
this.opts.tabTitle?.(nativeTitle(session))
|
||||
this.updateTitle()
|
||||
}
|
||||
|
||||
private updateTitle(): void {
|
||||
if (!this.opts.tabTitle) return
|
||||
const title = nativeTitle(this.currentSession, this.activity, this.opts.tabLabel)
|
||||
if (this.caption === title) return
|
||||
this.caption = title
|
||||
this.opts.tabTitle(title)
|
||||
}
|
||||
|
||||
private checkpoint(sid: string, run: () => Promise<void>): void {
|
||||
@@ -1037,6 +1048,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID),
|
||||
copy: (text) => vscode.env.clipboard.writeText(text),
|
||||
openSessions: (ids) => this.trackOpenSessions(ids),
|
||||
activity: (state) => {
|
||||
if (!isActivity(state)) return
|
||||
this.activity = state
|
||||
this.updateTitle()
|
||||
},
|
||||
speechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
|
||||
modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)),
|
||||
backgroundJobs: (sessionID, requestID) => this.fetchAndSendBackgroundJobs(sessionID, requestID),
|
||||
|
||||
@@ -42,7 +42,13 @@ export class SubAgentViewerProvider implements vscode.Disposable {
|
||||
dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"),
|
||||
}
|
||||
|
||||
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { hideTopBar: true })
|
||||
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
|
||||
hideTopBar: true,
|
||||
tabTitle: (title) => {
|
||||
panel.title = title
|
||||
},
|
||||
tabLabel: label,
|
||||
})
|
||||
if (directory) provider.setSessionDirectory(sessionID, directory)
|
||||
// Start accepting this session's SSE events as soon as the panel subscribes.
|
||||
// Reasoning deltas are not persisted until the reasoning part finishes.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
|
||||
import { imageMime } from "../diff/shared/image"
|
||||
import type { Batch, Meta } from "./local-diff-batch"
|
||||
import type { WorktreeDiffEntry } from "./types"
|
||||
@@ -58,7 +59,7 @@ export function createDiffCache(load: Loader) {
|
||||
}
|
||||
|
||||
const identity = (dir: string, base: string, anc: string, meta: Meta) =>
|
||||
[
|
||||
zeroID(
|
||||
dir,
|
||||
base,
|
||||
anc,
|
||||
@@ -69,7 +70,7 @@ export function createDiffCache(load: Loader) {
|
||||
meta.deletions,
|
||||
meta.binary,
|
||||
meta.stamp,
|
||||
].join("\0")
|
||||
)
|
||||
|
||||
const cached = (id: string) => {
|
||||
const value = details.get(id)
|
||||
@@ -236,8 +237,8 @@ export function createDiffCache(load: Loader) {
|
||||
}
|
||||
|
||||
const queued = (id: string, dir: string, base: string, anc: string, meta: Meta, signal?: AbortSignal) => {
|
||||
const scope = `${dir}\0${base}`
|
||||
const key = `${scope}\0${anc}`
|
||||
const scope = zeroID(dir, base)
|
||||
const key = zeroID(scope, anc)
|
||||
let queue = queues.get(key)
|
||||
if (!queue) {
|
||||
queue = new Map()
|
||||
@@ -270,7 +271,7 @@ export function createDiffCache(load: Loader) {
|
||||
|
||||
const file = (dir: string, base: string, path: string, signal?: AbortSignal): Promise<Value> => {
|
||||
if (signal?.aborted) return Promise.reject(new Error("Diff detail aborted"))
|
||||
const state = states.get(`${dir}\0${base}`)
|
||||
const state = states.get(zeroID(dir, base))
|
||||
if (!state) return load.file(dir, base, path, signal)
|
||||
const meta = state.metas.get(path)
|
||||
if (!meta) return Promise.resolve(null)
|
||||
@@ -292,7 +293,7 @@ export function createDiffCache(load: Loader) {
|
||||
|
||||
return {
|
||||
summary: async (dir: string, base: string): Promise<WorktreeDiffEntry[]> => {
|
||||
const id = `${dir}\0${base}`
|
||||
const id = zeroID(dir, base)
|
||||
const generation = (generations.get(id) ?? 0) + 1
|
||||
generations.set(id, generation)
|
||||
const result = await load.summary(dir, base)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Strict project/resource ownership for Agent Manager multi-project routing. */
|
||||
|
||||
import { zeroID as key } from "@opencode-ai/core/kilocode/zero-id"
|
||||
|
||||
export interface ProjectRef {
|
||||
projectId: string
|
||||
}
|
||||
@@ -45,8 +47,6 @@ interface SessionRoute {
|
||||
generation: number
|
||||
}
|
||||
|
||||
const key = (projectId: string, id: string) => `${projectId}\0${id}`
|
||||
|
||||
export class ProjectRouteService {
|
||||
private readonly projects = new Map<string, ProjectRoute>()
|
||||
private readonly sessions = new Map<string, SessionRoute>()
|
||||
|
||||
@@ -104,6 +104,10 @@ export class VscodeHost implements Host {
|
||||
})
|
||||
|
||||
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
|
||||
tabTitle: (title) => {
|
||||
panel.title = title
|
||||
},
|
||||
tabLabel: "Agent Manager",
|
||||
platform: PLATFORM,
|
||||
snapshotInitialization: SNAPSHOT_INITIALIZATION,
|
||||
slimEditMetadata: true,
|
||||
|
||||
@@ -411,7 +411,12 @@ export type WebviewMessage =
|
||||
message: Record<string, unknown>
|
||||
}
|
||||
| { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number }
|
||||
| { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" }
|
||||
| {
|
||||
type: "sessionTurnClosed"
|
||||
sessionID: string
|
||||
reason: "completed" | "error" | "interrupted" | "superseded"
|
||||
parentID?: string
|
||||
}
|
||||
| {
|
||||
type: "permissionRequest"
|
||||
permission: {
|
||||
@@ -556,6 +561,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin
|
||||
type: "sessionTurnClosed",
|
||||
sessionID: event.properties.sessionID,
|
||||
reason: event.properties.reason,
|
||||
...(event.properties.parentID ? { parentID: event.properties.parentID } : {}),
|
||||
}
|
||||
case "permission.asked":
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,7 @@ type Ctx = {
|
||||
exportTranscript: (sessionID: string) => Promise<void>
|
||||
copy: (text: string) => PromiseLike<void>
|
||||
openSessions: (ids: string[]) => void
|
||||
activity: (state: unknown) => void
|
||||
speechToTextModels: () => Promise<void>
|
||||
modelUsage: (message: ModelUsageMessage) => Promise<void>
|
||||
backgroundJobs: (sessionID: string, requestID: string) => Promise<void>
|
||||
@@ -56,7 +57,7 @@ async function routeBackgroundMessage(
|
||||
}
|
||||
|
||||
export async function routeEarlyMessage(
|
||||
message: { type: string; id?: unknown; text?: unknown },
|
||||
message: { type: string; id?: unknown; text?: unknown; state?: unknown },
|
||||
ctx: Ctx,
|
||||
): Promise<boolean> {
|
||||
if (message.type === "copyToClipboard") {
|
||||
@@ -88,6 +89,10 @@ export async function routeEarlyMessage(
|
||||
if (typeof input.sessionID === "string") await ctx.exportTranscript(input.sessionID)
|
||||
return true
|
||||
}
|
||||
if (message.type === "sessionActivity") {
|
||||
ctx.activity(message.state)
|
||||
return true
|
||||
}
|
||||
if (message.type === "sidebar.openSessions") {
|
||||
const input = message as { sessionIDs?: unknown }
|
||||
const ids = Array.isArray(input.sessionIDs)
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import type { Activity } from "../../webview-ui/src/utils/session-activity"
|
||||
import { EXTENSION_DISPLAY_NAME } from "../constants"
|
||||
|
||||
const DEFAULT_SESSION_TITLE = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
const TITLE_LIMIT = 19
|
||||
|
||||
export const nativeTitle = (session: Session | null) => {
|
||||
const title = session?.title?.trim()
|
||||
if (!title || DEFAULT_SESSION_TITLE.test(title)) return EXTENSION_DISPLAY_NAME
|
||||
if (title.length <= TITLE_LIMIT) return title
|
||||
return `${title.slice(0, TITLE_LIMIT)}...`
|
||||
const icons: Record<Activity, string> = {
|
||||
idle: "",
|
||||
busy: "◔",
|
||||
retry: "◔",
|
||||
waiting: "⚠",
|
||||
error: "⚠",
|
||||
done: "✓",
|
||||
}
|
||||
|
||||
export const nativeTitle = (session: Session | null, state: Activity = "idle", label?: string) => {
|
||||
const value = session?.title?.trim()
|
||||
const title = label ?? (!value || DEFAULT_SESSION_TITLE.test(value) ? EXTENSION_DISPLAY_NAME : value)
|
||||
const text = label || title.length <= TITLE_LIMIT ? title : `${title.slice(0, TITLE_LIMIT)}...`
|
||||
return icons[state] ? `${icons[state]} ${text}` : text
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type KiloProviderOptions = {
|
||||
snapshotInitialization?: "wait"
|
||||
slimEditMetadata?: boolean
|
||||
tabTitle?: (title: string) => void
|
||||
tabLabel?: string
|
||||
worktreeDirectories?: () => string[]
|
||||
/**
|
||||
* Dynamic root directory override. When present, it replaces the
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function handleSessionSearch(input: Input): Promise<void> {
|
||||
|
||||
try {
|
||||
const res = await client.experimental.session.list(
|
||||
{ worktrees: true, roots: true, directory: dir, limit: 50 },
|
||||
{ worktrees: true, roots: true, directory: dir, limit: 5_000 },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const sessions: Item[] = res.data
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path"
|
||||
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
|
||||
import type { SSEPayload } from "./sdk-sse-adapter"
|
||||
|
||||
type Buffered = { event: SSEPayload; directory?: string }
|
||||
@@ -75,12 +76,12 @@ export class ExplicitAbortState {
|
||||
const key = scope(sessionID, directory)
|
||||
return this.states.has(key) ? [key] : []
|
||||
}
|
||||
const prefix = `${sessionID}\0`
|
||||
const prefix = zeroID(sessionID, "")
|
||||
return [...this.states.keys()].filter((key) => key.startsWith(prefix))
|
||||
}
|
||||
}
|
||||
|
||||
function scope(sessionID: string, directory: string) {
|
||||
const dir = path.resolve(directory)
|
||||
return `${sessionID}\0${process.platform === "win32" ? dir.toLowerCase() : dir}`
|
||||
return zeroID(sessionID, process.platform === "win32" ? dir.toLowerCase() : dir)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Window } from "happy-dom"
|
||||
import type { QuestionRequest } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const window = new Window()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
window.document.hasFocus = () => true
|
||||
Object.assign(globalThis, {
|
||||
window,
|
||||
document: window.document,
|
||||
@@ -9,7 +11,7 @@ Object.assign(globalThis, {
|
||||
Element: window.Element,
|
||||
HTMLElement: window.HTMLElement,
|
||||
SVGElement: window.SVGElement,
|
||||
requestAnimationFrame: () => 0,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback),
|
||||
})
|
||||
|
||||
const { Show, createSignal } = await import("solid-js")
|
||||
@@ -49,7 +51,10 @@ const language = {
|
||||
t: (key: string) => key,
|
||||
}
|
||||
const root = document.createElement("div")
|
||||
document.body.append(root)
|
||||
const prompt = document.createElement("textarea")
|
||||
prompt.className = "prompt-input"
|
||||
document.body.append(prompt, root)
|
||||
prompt.focus()
|
||||
const dispose = render(
|
||||
() => (
|
||||
<SessionContext.Provider value={session as never}>
|
||||
@@ -61,9 +66,34 @@ const dispose = render(
|
||||
root,
|
||||
)
|
||||
|
||||
const flush = () => {
|
||||
while (frames.length) frames.shift()?.(0)
|
||||
}
|
||||
flush()
|
||||
if (document.activeElement !== prompt) throw new Error("New question stole composer focus")
|
||||
setActive(structuredClone(request))
|
||||
flush()
|
||||
if (document.activeElement !== prompt) throw new Error("Repeated question stole composer focus")
|
||||
|
||||
setActive(undefined)
|
||||
prompt.blur()
|
||||
setActive(structuredClone(request))
|
||||
prompt.focus()
|
||||
flush()
|
||||
if (document.activeElement !== prompt) throw new Error("Scheduled question focus interrupted typing")
|
||||
|
||||
setActive(undefined)
|
||||
prompt.blur()
|
||||
setActive(structuredClone(request))
|
||||
flush()
|
||||
const option = root.querySelector<HTMLButtonElement>('[data-slot="question-option"]')
|
||||
const submit = root.querySelector<HTMLButtonElement>('[data-slot="question-footer-actions"] button')
|
||||
if (!option || !submit) throw new Error("Question controls did not render")
|
||||
if (document.activeElement !== option) throw new Error("Question did not focus when no text field was active")
|
||||
option.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))
|
||||
if (document.activeElement !== root.querySelector('[data-custom="true"]')) {
|
||||
throw new Error("Question keyboard navigation did not move to the next option")
|
||||
}
|
||||
option.click()
|
||||
if (submit.disabled) throw new Error("Submit did not enable after selecting an answer")
|
||||
submit.click()
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { Window } from "happy-dom"
|
||||
|
||||
const window = new Window({ url: "http://localhost" })
|
||||
const sent: unknown[] = []
|
||||
const api = {
|
||||
postMessage: (message: unknown) => sent.push(message),
|
||||
getState: () => undefined,
|
||||
setState: () => {},
|
||||
}
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window,
|
||||
document: window.document,
|
||||
navigator: window.navigator,
|
||||
Node: window.Node,
|
||||
Element: window.Element,
|
||||
HTMLElement: window.HTMLElement,
|
||||
HTMLInputElement: window.HTMLInputElement,
|
||||
HTMLTextAreaElement: window.HTMLTextAreaElement,
|
||||
SVGElement: window.SVGElement,
|
||||
MutationObserver: window.MutationObserver,
|
||||
IntersectionObserver: window.IntersectionObserver,
|
||||
ResizeObserver: window.ResizeObserver,
|
||||
CustomEvent: window.CustomEvent,
|
||||
Event: window.Event,
|
||||
MessageEvent: window.MessageEvent,
|
||||
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
||||
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
|
||||
getComputedStyle: window.getComputedStyle.bind(window),
|
||||
acquireVsCodeApi: () => api,
|
||||
})
|
||||
|
||||
const { render } = await import("solid-js/web")
|
||||
const { For } = await import("solid-js")
|
||||
const { DragDropProvider, SortableProvider } = await import("@thisbeyond/solid-dnd")
|
||||
const { renderTab } = await import("../../webview-ui/agent-manager/tab-rendering")
|
||||
const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode")
|
||||
const { ServerProvider } = await import("../../webview-ui/src/context/server")
|
||||
const { ConfigContext } = await import("../../webview-ui/src/context/config")
|
||||
const { LanguageContext } = await import("../../webview-ui/src/context/language")
|
||||
const { ProviderContext } = await import("../../webview-ui/src/context/provider")
|
||||
const { SessionProvider, useSession } = await import("../../webview-ui/src/context/session")
|
||||
|
||||
const provider = {
|
||||
providers: () => ({}),
|
||||
connected: () => [],
|
||||
defaults: () => ({}),
|
||||
defaultSelection: () => ({ providerID: "kilocode", modelID: "auto" }),
|
||||
models: () => [],
|
||||
findModel: () => undefined,
|
||||
authMethods: () => ({}),
|
||||
authStates: () => ({}),
|
||||
isModelValid: () => true,
|
||||
}
|
||||
const config = {
|
||||
config: () => ({}),
|
||||
globalConfig: () => ({}),
|
||||
globalDraft: () => ({}),
|
||||
projectConfig: () => ({}),
|
||||
collections: () => ({}),
|
||||
settings: () => ({}),
|
||||
features: () => ({ indexing: false, sandboxControls: false, backgroundSubagents: false }),
|
||||
loading: () => false,
|
||||
isDirty: () => false,
|
||||
saving: () => false,
|
||||
saveError: () => null,
|
||||
updateConfig: () => {},
|
||||
updateGlobalConfig: () => {},
|
||||
updateProjectConfig: () => {},
|
||||
updateSetting: () => {},
|
||||
applySetting: () => {},
|
||||
saveConfig: () => {},
|
||||
discardConfig: () => {},
|
||||
}
|
||||
const language = {
|
||||
locale: () => "en",
|
||||
setLocale: () => {},
|
||||
userOverride: () => "",
|
||||
t: (key: string) => key,
|
||||
}
|
||||
|
||||
const ref = { value: undefined as ReturnType<typeof useSession> | undefined }
|
||||
const Probe = () => {
|
||||
const session = useSession()
|
||||
ref.value = session
|
||||
const ids = ["root", "background"]
|
||||
const deps = {
|
||||
terms: { activeId: () => undefined },
|
||||
REVIEW_TAB_ID: "review",
|
||||
tabIds: () => ids,
|
||||
kb: () => ({}),
|
||||
reviewActive: () => false,
|
||||
currentSessionID: session.currentSessionID,
|
||||
visibleTabId: session.currentSessionID,
|
||||
activePendingId: () => undefined,
|
||||
isPending: () => false,
|
||||
activityFor: session.activityFor,
|
||||
stateLabel: (state: string) => state,
|
||||
tabLookup: () => new Map(ids.map((id) => [id, { id, title: id }])),
|
||||
adjacentHint: () => "",
|
||||
} as Parameters<typeof renderTab>[1]
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<SortableProvider ids={ids}>
|
||||
<For each={ids}>{(id) => renderTab(id, deps)}</For>
|
||||
</SortableProvider>
|
||||
</DragDropProvider>
|
||||
)
|
||||
}
|
||||
const host = document.createElement("div")
|
||||
document.body.append(host)
|
||||
const step = { value: 0 }
|
||||
const failures: string[] = []
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<VSCodeProvider>
|
||||
<ServerProvider>
|
||||
<ProviderContext.Provider value={provider as never}>
|
||||
<ConfigContext.Provider value={config as never}>
|
||||
<LanguageContext.Provider value={language as never}>
|
||||
<SessionProvider>
|
||||
<Probe />
|
||||
</SessionProvider>
|
||||
</LanguageContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</ProviderContext.Provider>
|
||||
</ServerProvider>
|
||||
</VSCodeProvider>
|
||||
),
|
||||
host,
|
||||
)
|
||||
|
||||
const settle = async () => {
|
||||
await Promise.resolve()
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
}
|
||||
const emit = async (data: unknown) => {
|
||||
window.dispatchEvent(new MessageEvent("message", { data }))
|
||||
await settle()
|
||||
}
|
||||
const state = (id: string) => {
|
||||
const value = ref.value
|
||||
assert(value)
|
||||
return value.activityFor(id)
|
||||
}
|
||||
const check = async (id: string, expected: string) => {
|
||||
await settle()
|
||||
step.value += 1
|
||||
const value = ref.value
|
||||
assert(value)
|
||||
const actual = state(id)
|
||||
const tab = host.querySelector(`[data-tab-id="${id}"] [data-activity]`)
|
||||
if (id === "root" || id === "background") assert(tab, `Missing rendered tab for ${id}`)
|
||||
if (tab && tab.getAttribute("data-activity") !== expected) {
|
||||
failures.push(
|
||||
`step ${step.value} ${id}: rendered tab expected ${expected}, got ${tab.getAttribute("data-activity")}`,
|
||||
)
|
||||
}
|
||||
if (actual !== expected) {
|
||||
failures.push(
|
||||
`step ${step.value} ${id}: expected ${expected}, got ${actual}, status=${value.status()}, close=${value.closeReason() ?? "none"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const info = (id: string, parentID?: string) => ({
|
||||
id,
|
||||
...(parentID ? { parentID } : {}),
|
||||
title: id,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
const task = (id: string, parentID: string, childID: string, nested: boolean) => ({
|
||||
type: "partUpdated",
|
||||
sessionID: parentID,
|
||||
messageID: `${parentID}-part-message`,
|
||||
part: {
|
||||
type: "tool",
|
||||
id,
|
||||
sessionID: parentID,
|
||||
messageID: `${parentID}-part-message`,
|
||||
tool: "task",
|
||||
state: { status: "running", input: {}, ...(nested ? { metadata: { sessionId: childID } } : {}) },
|
||||
...(!nested ? { metadata: { sessionId: childID } } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await settle()
|
||||
await emit({ type: "ready", serverInfo: { port: 1 } })
|
||||
await emit({
|
||||
type: "sessionsLoaded",
|
||||
sessions: [info("root"), info("background"), info("durable-child", "root"), info("durable-grand", "durable-child")],
|
||||
})
|
||||
|
||||
const value = ref.value
|
||||
assert(value)
|
||||
value.setCurrentSessionID("root")
|
||||
await check("root", "idle")
|
||||
await check("background", "idle")
|
||||
|
||||
await emit({ type: "sessionStatus", sessionID: "background", status: "busy" })
|
||||
await check("background", "busy")
|
||||
await check("root", "idle")
|
||||
await emit({ type: "sessionStatus", sessionID: "background", status: "idle" })
|
||||
|
||||
await emit(task("root-task", "root", "task-child", false))
|
||||
await emit(task("child-task", "task-child", "task-grand", true))
|
||||
await emit({ type: "sessionStatus", sessionID: "durable-grand", status: "busy" })
|
||||
await check("root", "busy")
|
||||
await check("durable-child", "busy")
|
||||
await check("durable-grand", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "durable-grand", status: "idle" })
|
||||
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" })
|
||||
await check("root", "busy")
|
||||
await check("task-child", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "retry", attempt: 1, message: "retry", next: 1 })
|
||||
await check("root", "retry")
|
||||
await check("task-child", "retry")
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" })
|
||||
await emit({ type: "sessionStatus", sessionID: "task-grand", status: "busy" })
|
||||
await check("root", "busy")
|
||||
await check("task-child", "busy")
|
||||
await check("task-grand", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "task-grand", status: "offline" })
|
||||
await check("root", "error")
|
||||
await check("task-child", "error")
|
||||
await check("task-grand", "error")
|
||||
assert.equal(value.inUseFor("root"), true)
|
||||
await emit({ type: "sessionStatus", sessionID: "task-grand", status: "idle" })
|
||||
await check("root", "idle")
|
||||
assert.equal(value.inUseFor("root"), false)
|
||||
|
||||
await emit({
|
||||
type: "permissionRequest",
|
||||
permission: { id: "permission", sessionID: "task-grand", toolName: "bash", patterns: [], always: [], args: {} },
|
||||
})
|
||||
await check("root", "waiting")
|
||||
await check("task-child", "waiting")
|
||||
await check("task-grand", "waiting")
|
||||
await emit({ type: "permissionError", permissionID: "permission", stale: true })
|
||||
await check("root", "idle")
|
||||
assert.equal(value.permissions().length, 0)
|
||||
await emit({
|
||||
type: "permissionRequest",
|
||||
permission: { id: "permission", sessionID: "durable-grand", toolName: "bash", patterns: [], always: [], args: {} },
|
||||
})
|
||||
await check("root", "waiting")
|
||||
await emit({ type: "permissionResolved", permissionID: "permission", sessionID: "durable-grand", response: "once" })
|
||||
await check("root", "idle")
|
||||
assert.equal(value.permissions().length, 0)
|
||||
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" })
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: { id: "notice", sessionID: "task-child", blocking: false, questions: [] },
|
||||
})
|
||||
await check("root", "busy")
|
||||
await check("task-child", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" })
|
||||
await check("root", "idle")
|
||||
assert.equal(value.inUseFor("root"), true)
|
||||
assert.equal(value.inUseFor("background"), false)
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" })
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: { id: "notice", sessionID: "task-child", blocking: true, questions: [] },
|
||||
})
|
||||
await check("root", "waiting")
|
||||
await emit({ type: "questionResolved", requestID: "notice" })
|
||||
await check("root", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" })
|
||||
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: {
|
||||
id: "question",
|
||||
sessionID: "task-child",
|
||||
questions: [{ question: "Continue?", header: "Confirm", options: [] }],
|
||||
},
|
||||
})
|
||||
await check("root", "waiting")
|
||||
await emit({ type: "questionResolved", requestID: "question" })
|
||||
await check("root", "idle")
|
||||
assert.equal(value.questions().length, 0)
|
||||
|
||||
await emit({
|
||||
type: "suggestionRequest",
|
||||
suggestion: { id: "suggestion", sessionID: "task-grand", text: "Try this", actions: [] },
|
||||
})
|
||||
await check("root", "waiting")
|
||||
await emit({ type: "suggestionResolved", requestID: "suggestion" })
|
||||
await check("root", "idle")
|
||||
assert.equal(value.suggestions().length, 0)
|
||||
|
||||
await emit({ type: "sessionTurnClosed", sessionID: "task-child", reason: "completed", parentID: "root" })
|
||||
await check("task-child", "done")
|
||||
await check("root", "idle")
|
||||
await emit({ type: "sessionTurnClosed", sessionID: "task-child", reason: "error", parentID: "root" })
|
||||
await check("task-child", "error")
|
||||
await check("root", "idle")
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "busy" })
|
||||
await check("root", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "idle" })
|
||||
await check("root", "idle")
|
||||
|
||||
await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" })
|
||||
await check("root", "done")
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "busy" })
|
||||
await check("root", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "idle" })
|
||||
await check("root", "idle")
|
||||
|
||||
await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" })
|
||||
await check("root", "done")
|
||||
await emit({
|
||||
type: "sessionUpdated",
|
||||
session: { ...info("root"), revert: { messageID: "root-message" } },
|
||||
})
|
||||
await check("root", "idle")
|
||||
await emit({ type: "sessionUpdated", session: { ...info("root"), revert: null } })
|
||||
await check("root", "idle")
|
||||
|
||||
await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" })
|
||||
await check("root", "done")
|
||||
value.sendMessage("next turn")
|
||||
await settle()
|
||||
await check("root", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "busy" })
|
||||
await check("root", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "idle" })
|
||||
await check("root", "idle")
|
||||
|
||||
value.setCurrentSessionID("background")
|
||||
await emit({ type: "sessionStatus", sessionID: "background", status: "busy" })
|
||||
await check("background", "busy")
|
||||
await emit({ type: "sessionError", eventID: "background-aborted", error: { name: "MessageAbortedError" } })
|
||||
await check("background", "busy")
|
||||
await emit({ type: "connectionState", state: "disconnected", error: "offline" })
|
||||
await check("background", "error")
|
||||
await emit({ type: "connectionState", state: "connecting" })
|
||||
await check("background", "error")
|
||||
await emit({ type: "connectionState", state: "connected" })
|
||||
await check("background", "busy")
|
||||
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: {
|
||||
id: "connection-question",
|
||||
sessionID: "background",
|
||||
questions: [{ question: "Reconnect?", header: "Confirm", options: [] }],
|
||||
},
|
||||
})
|
||||
await check("background", "waiting")
|
||||
await emit({ type: "connectionState", state: "error", error: "failed" })
|
||||
await check("background", "error")
|
||||
await emit({ type: "connectionState", state: "connecting" })
|
||||
await check("background", "error")
|
||||
await emit({ type: "connectionState", state: "connected" })
|
||||
await check("background", "waiting")
|
||||
await emit({ type: "questionResolved", requestID: "connection-question" })
|
||||
await check("background", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "background", status: "idle" })
|
||||
await check("background", "idle")
|
||||
|
||||
value.setCurrentSessionID("root")
|
||||
await emit({ type: "sessionError", eventID: "aborted", error: { name: "MessageAbortedError" } })
|
||||
await check("root", "idle")
|
||||
await emit({ type: "sessionError", eventID: "root-error", error: { name: "ProviderError" } })
|
||||
await check("root", "error")
|
||||
assert.equal(value.inUseFor("root"), false)
|
||||
await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" })
|
||||
await check("root", "error")
|
||||
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "busy" })
|
||||
await check("root", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "root", status: "idle" })
|
||||
await check("root", "idle")
|
||||
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: {
|
||||
id: "deleted-question",
|
||||
sessionID: "root",
|
||||
questions: [{ question: "Delete?", header: "Confirm", options: [] }],
|
||||
},
|
||||
})
|
||||
await check("root", "waiting")
|
||||
await emit({ type: "sessionDeleted", sessionID: "root" })
|
||||
await check("root", "idle")
|
||||
assert.equal(value.currentSessionID(), undefined)
|
||||
assert.equal(
|
||||
value.sessions().some((item) => item.id === "root"),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
value.questions().some((item) => item.sessionID === "root"),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
sent.some((item) => (item as { type?: string }).type === "sendMessage"),
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(failures, [])
|
||||
} finally {
|
||||
const before = state("background")
|
||||
dispose()
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", { data: { type: "sessionStatus", sessionID: "background", status: "retry" } }),
|
||||
)
|
||||
assert.equal(state("background"), before)
|
||||
await window.happyDOM.cancelAsync()
|
||||
await window.happyDOM.close()
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
@@ -14,6 +14,7 @@ Object.assign(globalThis, {
|
||||
HTMLTextAreaElement: window.HTMLTextAreaElement,
|
||||
SVGElement: window.SVGElement,
|
||||
MutationObserver: window.MutationObserver,
|
||||
IntersectionObserver: window.IntersectionObserver,
|
||||
ResizeObserver: window.ResizeObserver,
|
||||
CustomEvent: window.CustomEvent,
|
||||
Event: window.Event,
|
||||
|
||||
@@ -40,6 +40,7 @@ const mockVscode = {
|
||||
language: "en",
|
||||
machineId: "test-machine",
|
||||
isTelemetryEnabled: false,
|
||||
onDidChangeTelemetryEnabled: () => ({ dispose: noop }),
|
||||
shell: "/bin/bash",
|
||||
openExternal: noop,
|
||||
},
|
||||
@@ -171,6 +172,9 @@ const mockVscode = {
|
||||
},
|
||||
Disposable: class {
|
||||
constructor(private callback: () => void = noop) {}
|
||||
static from(...items: { dispose: () => void }[]) {
|
||||
return { dispose: () => items.forEach((item) => item.dispose()) }
|
||||
}
|
||||
dispose() {
|
||||
this.callback()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"
|
||||
import { Window } from "happy-dom"
|
||||
import {
|
||||
agentManagerFocusTarget,
|
||||
createChatFocus,
|
||||
focusQuestionOption,
|
||||
hasQuestionOption,
|
||||
preservesTextFocus,
|
||||
@@ -9,6 +10,61 @@ import {
|
||||
import { isTextControl } from "../../webview-ui/src/utils/focus"
|
||||
|
||||
describe("Agent Manager focus", () => {
|
||||
it("preserves composer focus through retries unless focus is explicitly requested", async () => {
|
||||
const window = new Window()
|
||||
const document = window.document
|
||||
const frames: FrameRequestCallback[] = []
|
||||
const original = {
|
||||
document: Object.getOwnPropertyDescriptor(globalThis, "document"),
|
||||
requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
|
||||
}
|
||||
document.hasFocus = () => true
|
||||
Object.assign(globalThis, {
|
||||
document,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback),
|
||||
})
|
||||
const prompt = document.createElement("textarea")
|
||||
prompt.className = "prompt-input"
|
||||
const dock = document.createElement("div")
|
||||
dock.setAttribute("data-component", "question-dock")
|
||||
const option = document.createElement("button")
|
||||
option.setAttribute("data-slot", "question-option")
|
||||
dock.append(option)
|
||||
document.body.append(prompt, dock)
|
||||
const focus = createChatFocus({ term: () => undefined, history: () => false, review: () => false })
|
||||
const flush = () => {
|
||||
while (frames.length) frames.shift()?.(0)
|
||||
}
|
||||
|
||||
try {
|
||||
prompt.focus()
|
||||
focus()
|
||||
await Promise.resolve()
|
||||
expect(document.activeElement).toBe(prompt)
|
||||
flush()
|
||||
expect(document.activeElement).toBe(prompt)
|
||||
|
||||
prompt.blur()
|
||||
focus()
|
||||
await Promise.resolve()
|
||||
expect(document.activeElement).toBe(option)
|
||||
prompt.focus()
|
||||
flush()
|
||||
expect(document.activeElement).toBe(prompt)
|
||||
|
||||
focus(true)
|
||||
await Promise.resolve()
|
||||
flush()
|
||||
expect(document.activeElement).toBe(option)
|
||||
} finally {
|
||||
for (const [key, descriptor] of Object.entries(original)) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor)
|
||||
else Reflect.deleteProperty(globalThis, key)
|
||||
}
|
||||
await window.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("focuses the first enabled question option", () => {
|
||||
const window = new Window()
|
||||
const root = window.document.createElement("div")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createWorktreeDiffs } from "../../webview-ui/agent-manager/worktree-diffs"
|
||||
import { createWorktreeDiffs, diffDataKey } from "../../webview-ui/agent-manager/worktree-diffs"
|
||||
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const diff = (file: string, additions = 1): WorktreeFileDiff => ({
|
||||
@@ -30,7 +30,37 @@ const withDiffs = (fn: (diffs: ReturnType<typeof createWorktreeDiffs>, sent: Sen
|
||||
})
|
||||
}
|
||||
|
||||
describe("diffDataKey", () => {
|
||||
it("preserves the nullish fallback without replacing an empty project", () => {
|
||||
expect(diffDataKey(undefined, "s1")).toBe("single\0s1")
|
||||
expect(diffDataKey("single", "s1")).toBe("single\0s1")
|
||||
expect(diffDataKey("", "s1")).toBe("\0s1")
|
||||
expect(diffDataKey("project", "")).toBe("project\0")
|
||||
expect(diffDataKey("project", "s1\0file.ts")).toBe("project\0s1\0file.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createWorktreeDiffs", () => {
|
||||
it.each([undefined, "", "project"])("prunes only the complete project namespace %j", (project) => {
|
||||
createRoot((dispose) => {
|
||||
const store = createWorktreeDiffs(vscode([]), () => project)
|
||||
const sibling = `${project ?? "single"}-other`
|
||||
for (const owner of [project, sibling]) {
|
||||
store.onWorktreeDiff({
|
||||
type: "agentManager.worktreeDiff",
|
||||
projectId: owner,
|
||||
sessionId: "gone#branch",
|
||||
diffs: [diff("a.ts")],
|
||||
})
|
||||
}
|
||||
|
||||
store.prune(new Set())
|
||||
expect(store.diffDatas()[`${project ?? "single"}\0gone#branch`]).toBeUndefined()
|
||||
expect(store.diffDatas()[`${sibling}\0gone#branch`]).toHaveLength(1)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("stores full diffs per session", () => {
|
||||
withDiffs((diffs) => {
|
||||
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] })
|
||||
|
||||
@@ -50,6 +50,13 @@ describe("ProjectRouteService", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves fallback, precedence, and empty fields in composite UI keys", () => {
|
||||
expect(ProjectRouteService.key({ projectId: "a" })).toBe("a\0local")
|
||||
expect(ProjectRouteService.key({ projectId: "a", sessionId: "s", worktreeId: "wt" })).toBe("a\0s")
|
||||
expect(ProjectRouteService.key({ projectId: "a", sessionId: "", worktreeId: "wt" })).toBe("a\0")
|
||||
expect(ProjectRouteService.key({ projectId: "", worktreeId: "" })).toBe("\0")
|
||||
})
|
||||
|
||||
describe("safe resolution (non-throwing)", () => {
|
||||
it("trySessionDirectory returns the exact dir for an unambiguous raw id", () => {
|
||||
const routes = new ProjectRouteService()
|
||||
|
||||
@@ -392,6 +392,27 @@ describe("ConfigState", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("sets and clears the compaction model without changing other settings", () => {
|
||||
const s = new ConfigState()
|
||||
const cfg: Config = {
|
||||
model: "kilo/openai/gpt-4.1",
|
||||
agent: { compaction: { prompt: "Keep task details" }, code: { model: "kilo/openai/gpt-4.1" } },
|
||||
}
|
||||
const model = "kilo/anthropic/claude-haiku-4-5"
|
||||
s.handleConfigLoaded(cfg)
|
||||
s.updateConfig({ agent: { compaction: { model } } })
|
||||
|
||||
expect(s.config).toEqual({
|
||||
...cfg,
|
||||
agent: { ...cfg.agent, compaction: { prompt: "Keep task details", model } },
|
||||
})
|
||||
s.updateConfig({ agent: { compaction: { model: null } } })
|
||||
|
||||
expect(s.config).toEqual(cfg)
|
||||
expect(s.draft.agent?.compaction?.model).toBeNull()
|
||||
expect(configUnsetPaths(s.draft)).toEqual([["agent", "compaction", "model"]])
|
||||
})
|
||||
|
||||
describe("agent permission patches", () => {
|
||||
it("merges nested per-agent permission patches into existing rules", () => {
|
||||
const s = new ConfigState()
|
||||
|
||||
@@ -43,6 +43,17 @@ describe("routeEarlyMessage clipboard handling", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("routeEarlyMessage activity", () => {
|
||||
it("forwards authoritative webview presentation state without interpreting session events", async () => {
|
||||
const calls: unknown[] = []
|
||||
const ctx = { activity: (state: unknown) => calls.push(state) } as Ctx
|
||||
for (const state of ["busy", "waiting", "done", "error", "idle"]) {
|
||||
expect(await routeEarlyMessage({ type: "sessionActivity", state }, ctx)).toBe(true)
|
||||
}
|
||||
expect(calls).toEqual(["busy", "waiting", "done", "error", "idle"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("routeEarlyMessage background jobs", () => {
|
||||
it("forwards list request correlation", async () => {
|
||||
const calls: unknown[] = []
|
||||
|
||||
@@ -78,6 +78,30 @@ describe("explicit abort state", () => {
|
||||
expect(state.event(close("interrupted"), "/repo/a")).toBe(false)
|
||||
})
|
||||
|
||||
it.each(["", "session"])("removes only the exact session prefix %j across directories", (session) => {
|
||||
const state = new ExplicitAbortState()
|
||||
for (const directory of ["/repo/a", "/repo/b"]) {
|
||||
const id = state.begin(session, directory)
|
||||
state.finish(session, directory, id, true)
|
||||
}
|
||||
const sibling = `${session}-other`
|
||||
const id = state.begin(sibling, "/repo/a")
|
||||
state.finish(sibling, "/repo/a", id, true)
|
||||
|
||||
state.remove(session)
|
||||
expect(state.event(close("interrupted", session), "/repo/a")).toBe(true)
|
||||
expect(state.event(close("interrupted", session), "/repo/b")).toBe(true)
|
||||
expect(state.event(close("interrupted", sibling))).toBe(false)
|
||||
})
|
||||
|
||||
it("normalizes directories before building scope keys", () => {
|
||||
const state = new ExplicitAbortState()
|
||||
const id = state.begin("session", "/repo/nested/..")
|
||||
state.finish("session", "/repo", id, true)
|
||||
|
||||
expect(state.event(close("interrupted"), "/repo/.")).toBe(false)
|
||||
})
|
||||
|
||||
it("clears suppression when an idle session becomes busy again", () => {
|
||||
const state = new ExplicitAbortState()
|
||||
const id = state.begin("session", "/repo")
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildMentionResults,
|
||||
buildSessionAttachments,
|
||||
filterMentionResults,
|
||||
filterSessions,
|
||||
getMentionRemovalRange,
|
||||
getPastChatsMentionResult,
|
||||
isCursorAtMentionEnd,
|
||||
@@ -119,6 +120,39 @@ describe("filterMentionResults", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterSessions", () => {
|
||||
const sessions = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `ses_${index}`,
|
||||
title: `Recent session ${index}`,
|
||||
updated: 60 - index,
|
||||
worktreeName: "branch",
|
||||
}))
|
||||
|
||||
it("shows the first 50 sessions in their existing order without a query", () => {
|
||||
expect(filterSessions(sessions, "")).toEqual(sessions.slice(0, 50))
|
||||
})
|
||||
|
||||
it("limits broad search results to 50 matches", () => {
|
||||
expect(filterSessions(sessions, "recent")).toHaveLength(50)
|
||||
})
|
||||
|
||||
it.each(["ORCHID", "old-branch"])("finds older sessions beyond the display limit by %s", (query) => {
|
||||
const source = { id: "ses_old", title: "Orchid reference source", updated: 0, worktreeName: "old-branch" }
|
||||
expect(filterSessions([...sessions, source], query)).toEqual([source])
|
||||
})
|
||||
|
||||
it("ranks an older exact match before newer partial matches", () => {
|
||||
const source = { id: "ses_old", title: "Recent", updated: 0 }
|
||||
const matches = filterSessions([...sessions, source], "recent")
|
||||
expect(matches).toHaveLength(50)
|
||||
expect(matches[0]).toBe(source)
|
||||
})
|
||||
|
||||
it("returns no results when nothing matches", () => {
|
||||
expect(filterSessions(sessions, "zzzz")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncMentionedPaths", () => {
|
||||
it("keeps paths still referenced in text", () => {
|
||||
const paths = new Set(["foo.ts", "bar.ts"])
|
||||
|
||||
@@ -376,6 +376,21 @@ describe("mapSSEEventToWebviewMessage", () => {
|
||||
expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" })
|
||||
})
|
||||
|
||||
it("forwards the parent session ID when a child turn closes", () => {
|
||||
const event: EventSessionTurnClose = {
|
||||
id: "evt-child-turn",
|
||||
type: "session.turn.close",
|
||||
properties: { sessionID: "child", parentID: "parent", reason: "completed" },
|
||||
}
|
||||
|
||||
expect(mapSSEEventToWebviewMessage(event, "child")).toEqual({
|
||||
type: "sessionTurnClosed",
|
||||
sessionID: "child",
|
||||
reason: "completed",
|
||||
parentID: "parent",
|
||||
})
|
||||
})
|
||||
|
||||
it("maps session errors with their event identity and message", () => {
|
||||
const event: EventSessionError = {
|
||||
id: "evt-error",
|
||||
|
||||
@@ -467,6 +467,26 @@ describe("diffFile", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps empty and named base cache identities separate", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncommitted\n")
|
||||
runSync(dir, ["commit", "-am", "change seed"])
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncommitted\nworking\n")
|
||||
const local = createLocalDiff(git())
|
||||
await local.summary(dir, "")
|
||||
await local.summary(dir, base)
|
||||
|
||||
const current = await local.file(dir, "", "seed.txt")
|
||||
const ancestor = await local.file(dir, base, "seed.txt")
|
||||
expect(current?.before).toBe("seed\ncommitted\n")
|
||||
expect(ancestor?.before).toBe("seed\n")
|
||||
expect(current?.after).toBe("seed\ncommitted\nworking\n")
|
||||
expect(ancestor?.after).toBe(current?.after)
|
||||
expect(await local.file(dir, "", "seed.txt")).toBe(current)
|
||||
expect(await local.file(dir, base, "seed.txt")).toBe(ancestor)
|
||||
})
|
||||
})
|
||||
|
||||
it("does not cache detail that is aborted before Git completes", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n")
|
||||
|
||||
@@ -18,4 +18,38 @@ describe("nativeTitle", () => {
|
||||
it("truncates long session titles", () => {
|
||||
expect(nativeTitle(session("Dynamic VS Code tab titles for Kilo sessions"))).toBe("Dynamic VS Code tab...")
|
||||
})
|
||||
|
||||
it("updates the native panel only from valid webview activity reports", async () => {
|
||||
const { KiloProvider } = await import("../../src/KiloProvider")
|
||||
const titles: string[] = []
|
||||
const listener: { current?: (message: { type: string; state: unknown }) => Promise<void> } = {}
|
||||
const provider = new KiloProvider(
|
||||
{ fsPath: "/extension" } as never,
|
||||
{ unregisterVisible: () => {}, unregisterAttached: () => {} } as never,
|
||||
undefined,
|
||||
{ tabTitle: (title) => titles.push(title) },
|
||||
)
|
||||
const internal = provider as unknown as { setupWebviewMessageHandler: (webview: unknown) => void }
|
||||
internal.setupWebviewMessageHandler({
|
||||
onDidReceiveMessage: (handler: NonNullable<typeof listener.current>) => {
|
||||
listener.current = handler
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
})
|
||||
for (const state of ["busy", "waiting", "done", "error", "idle", "idle", "invalid", null]) {
|
||||
await listener.current?.({ type: "sessionActivity", state })
|
||||
}
|
||||
expect(titles).toEqual(["◔ Kilo Code", "⚠ Kilo Code", "✓ Kilo Code", "⚠ Kilo Code", "Kilo Code"])
|
||||
provider.dispose()
|
||||
})
|
||||
|
||||
it("renders the same activity values used by webview tabs and worktrees", () => {
|
||||
expect(nativeTitle(session("Greeting"), "busy")).toBe("◔ Greeting")
|
||||
expect(nativeTitle(session("Greeting"), "retry")).toBe("◔ Greeting")
|
||||
expect(nativeTitle(session("Greeting"), "waiting")).toBe("⚠ Greeting")
|
||||
expect(nativeTitle(session("Greeting"), "error")).toBe("⚠ Greeting")
|
||||
expect(nativeTitle(session("Greeting"), "done")).toBe("✓ Greeting")
|
||||
expect(nativeTitle(session("Greeting"), "idle")).toBe("Greeting")
|
||||
expect(nativeTitle(session("Greeting"), "waiting", "Agent Manager")).toBe("⚠ Agent Manager")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,66 +1,72 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createSessionBusy, createWorktreeBusy } from "../../webview-ui/agent-manager/project/session-busy"
|
||||
import { createSessionActivity, createWorktreeActivity } from "../../webview-ui/agent-manager/project/session-busy"
|
||||
import type { ExtensionMessage } from "../../webview-ui/src/types/messages"
|
||||
import type { Activity } from "../../webview-ui/src/utils/session-activity"
|
||||
|
||||
const options = (statuses: Record<string, { type: string }>) => ({
|
||||
statuses: () => statuses,
|
||||
permissions: () => [],
|
||||
questions: () => [],
|
||||
const options = (values: Record<string, Activity>) => ({
|
||||
managed: () => [
|
||||
{ id: "unknown", worktreeId: "wt-unknown" },
|
||||
{ id: "idle", worktreeId: "wt-idle" },
|
||||
{ id: "working", worktreeId: "wt-working" },
|
||||
{ id: "current-wt", worktreeId: "wt-current" },
|
||||
{ id: "current-other", worktreeId: "wt-other" },
|
||||
{ id: "priority-busy", worktreeId: "wt-priority" },
|
||||
{ id: "priority-waiting", worktreeId: "wt-priority" },
|
||||
],
|
||||
local: () => [],
|
||||
projects: () => ({ background: [{ id: "unknown", worktreeId: "wt-unknown" }] }),
|
||||
active: () => "project-a",
|
||||
local: () => ["current-local"],
|
||||
projects: () => ({
|
||||
background: [
|
||||
{ id: "background-local", worktreeId: null },
|
||||
{ id: "background-wt", worktreeId: "wt-background" },
|
||||
],
|
||||
}),
|
||||
active: () => "current",
|
||||
activityFor: (id: string) => values[id] ?? "idle",
|
||||
inUseFor: (id: string) => ["busy", "retry", "waiting"].includes(values[id] ?? "idle"),
|
||||
})
|
||||
const busy = (statuses: Record<string, { type: string }>) => createSessionBusy(options(statuses))
|
||||
const activity = (values: Record<string, Activity>) => createSessionActivity(options(values))
|
||||
|
||||
describe("createSessionBusy", () => {
|
||||
it("does not mark stopped or unknown sessions as busy", () => {
|
||||
const state = busy({ idle: { type: "idle" } })
|
||||
|
||||
expect(state.agent("wt-unknown")).toBe(false)
|
||||
expect(state.agent("wt-idle")).toBe(false)
|
||||
expect(state.project("background", "wt-unknown")).toBe(false)
|
||||
describe("createSessionActivity", () => {
|
||||
it("returns idle for groups without sessions", () => {
|
||||
const state = activity({})
|
||||
expect(state.agent("wt-missing")).toBe("idle")
|
||||
expect(state.project("background", "wt-missing")).toBe("idle")
|
||||
})
|
||||
|
||||
it.each(["busy", "retry"])("marks sessions with an active %s status as busy", (type) => {
|
||||
expect(busy({ working: { type } }).agent("wt-working")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps running for non-blocking questions", () => {
|
||||
const questions: { sessionID: string; blocking?: boolean }[] = [{ sessionID: "working", blocking: false }]
|
||||
const state = createSessionBusy({
|
||||
...options({ working: { type: "busy" } }),
|
||||
questions: () => questions,
|
||||
it("scopes local, current, and background project activity", () => {
|
||||
const state = activity({
|
||||
"current-local": "done",
|
||||
"current-wt": "busy",
|
||||
"current-other": "error",
|
||||
"background-local": "retry",
|
||||
"background-wt": "error",
|
||||
})
|
||||
expect(state.agent("wt-working")).toBe(true)
|
||||
questions[0].blocking = true
|
||||
expect(state.agent("wt-working")).toBe(false)
|
||||
delete questions[0].blocking
|
||||
expect(state.agent("wt-working")).toBe(false)
|
||||
expect(state.local()).toBe("done")
|
||||
expect(state.project("current", null)).toBe("done")
|
||||
expect(state.project("current", "wt-current")).toBe("busy")
|
||||
expect(state.project("background", null)).toBe("retry")
|
||||
expect(state.project("background", "wt-background")).toBe("error")
|
||||
})
|
||||
|
||||
it("does not keep a spinner for an offline session", () => {
|
||||
const state = busy({ working: { type: "offline" }, unknown: { type: "offline" } })
|
||||
|
||||
expect(state.agent("wt-working")).toBe(false)
|
||||
expect(state.session("working")).toBe(false)
|
||||
expect(state.project("background", "wt-unknown")).toBe(false)
|
||||
expect(state.agent("wt-working", true)).toBe(true)
|
||||
expect(state.project("background", "wt-unknown", true)).toBe(true)
|
||||
it("prioritizes attention over errors and work in a group", () => {
|
||||
const state = activity({
|
||||
"current-wt": "busy",
|
||||
"current-other": "waiting",
|
||||
"priority-busy": "busy",
|
||||
"priority-waiting": "waiting",
|
||||
"background-local": "error",
|
||||
"background-wt": "waiting",
|
||||
})
|
||||
expect(state.agent("wt-priority")).toBe("waiting")
|
||||
expect(state.project("current", "wt-other")).toBe("waiting")
|
||||
expect(state.project("background", "wt-background")).toBe("waiting")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createWorktreeBusy", () => {
|
||||
describe("createWorktreeActivity", () => {
|
||||
it("keeps directory activity separate from parent status and other projects", () => {
|
||||
const listeners = new Set<(message: ExtensionMessage) => void>()
|
||||
const state = createWorktreeBusy({
|
||||
...options({ idle: { type: "idle" }, working: { type: "busy" } }),
|
||||
const state = createWorktreeActivity({
|
||||
...options({ "current-wt": "done", "current-other": "busy", "current-local": "done" }),
|
||||
worktrees: (project) => [
|
||||
{ id: "wt-idle", path: project === "background" ? "/other/worktree" : "/repo/worktree" },
|
||||
{ id: "wt-current", path: project === "background" ? "/other/worktree" : "/repo/worktree" },
|
||||
],
|
||||
subscribe: (callback) => {
|
||||
listeners.add(callback)
|
||||
@@ -70,46 +76,60 @@ describe("createWorktreeBusy", () => {
|
||||
const send = (active: string[]) => {
|
||||
for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active })
|
||||
}
|
||||
|
||||
expect(state.agent("wt-idle")).toBe(false)
|
||||
expect(state.agent("wt-working")).toBe(true)
|
||||
expect(state.agent("wt-current")).toBe("done")
|
||||
expect(state.blocked("wt-current")).toBe(false)
|
||||
expect(state.agent("wt-other")).toBe("busy")
|
||||
send(["/repo/worktree"])
|
||||
expect(state.agent("wt-idle")).toBe(true)
|
||||
expect(state.project("project-a", "wt-idle")).toBe(true)
|
||||
expect(state.project("background", "wt-idle")).toBe(false)
|
||||
expect(state.project("background", null)).toBe(false)
|
||||
expect(state.agent("missing")).toBe(false)
|
||||
expect(state.session("idle")).toBe(false)
|
||||
expect(state.local()).toBe(false)
|
||||
|
||||
expect(state.agent("wt-current")).toBe("busy")
|
||||
expect(state.blocked("wt-current")).toBe(true)
|
||||
expect(state.project("current", "wt-current")).toBe("busy")
|
||||
expect(state.project("background", "wt-current")).toBe("idle")
|
||||
expect(state.blocked("wt-current", "background")).toBe(false)
|
||||
expect(state.project("background", null)).toBe("idle")
|
||||
expect(state.agent("missing")).toBe("idle")
|
||||
expect(state.local()).toBe("done")
|
||||
send(["/other/worktree"])
|
||||
expect(state.agent("wt-idle")).toBe(false)
|
||||
expect(state.project("background", "wt-idle")).toBe(true)
|
||||
expect(state.agent("wt-current")).toBe("done")
|
||||
expect(state.blocked("wt-current")).toBe(false)
|
||||
expect(state.project("background", "wt-current")).toBe("busy")
|
||||
expect(state.blocked("wt-current", "background")).toBe(true)
|
||||
send([])
|
||||
expect(state.project("background", "wt-idle")).toBe(false)
|
||||
expect(state.agent("wt-working")).toBe(true)
|
||||
expect(state.project("background", "wt-current")).toBe("idle")
|
||||
expect(state.blocked("wt-current", "background")).toBe(false)
|
||||
expect(state.agent("wt-other")).toBe("busy")
|
||||
})
|
||||
|
||||
it.each(["permission", "question", "non-blocking question"] as const)(
|
||||
"blocks deletion for a pending %s without showing a running spinner",
|
||||
(kind) => {
|
||||
const state = createWorktreeBusy({
|
||||
statuses: () => ({ session: { type: "idle" } }),
|
||||
permissions: () => (kind === "permission" ? [{ sessionID: "session" }] : []),
|
||||
questions: () => (kind !== "permission" ? [{ sessionID: "session", blocking: kind === "question" }] : []),
|
||||
worktrees: () => [],
|
||||
subscribe: () => () => undefined,
|
||||
managed: () => [{ id: "session", worktreeId: "worktree" }],
|
||||
local: () => [],
|
||||
projects: () => ({ other: [{ id: "session", worktreeId: "worktree" }] }),
|
||||
active: () => "active",
|
||||
})
|
||||
it.each(["waiting", "error", "retry"] as const)("does not hide %s behind directory activity", (value) => {
|
||||
const listeners = new Set<(message: ExtensionMessage) => void>()
|
||||
const state = createWorktreeActivity({
|
||||
...options({ "current-wt": value }),
|
||||
worktrees: () => [{ id: "wt-current", path: "/repo/worktree" }],
|
||||
subscribe: (callback) => {
|
||||
listeners.add(callback)
|
||||
return () => listeners.delete(callback)
|
||||
},
|
||||
})
|
||||
for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active: ["/repo/worktree"] })
|
||||
expect(state.agent("wt-current")).toBe(value)
|
||||
expect(state.project("current", "wt-current")).toBe(value)
|
||||
})
|
||||
|
||||
expect(state.agent("worktree")).toBe(false)
|
||||
expect(state.agent("worktree", true)).toBe(true)
|
||||
expect(state.project("active", "worktree", true)).toBe(true)
|
||||
expect(state.project("other", "worktree")).toBe(false)
|
||||
expect(state.project("other", "worktree", true)).toBe(true)
|
||||
},
|
||||
)
|
||||
it.each(["idle", "waiting", "error"] as const)("keeps deletion guards independent of the %s icon", (value) => {
|
||||
let pending = true
|
||||
const state = createWorktreeActivity({
|
||||
...options({ "current-wt": value }),
|
||||
inUseFor: (id) => id === "current-wt" && pending,
|
||||
projects: () => ({ other: [{ id: "current-wt", worktreeId: "wt-current" }] }),
|
||||
worktrees: () => [],
|
||||
subscribe: () => () => undefined,
|
||||
})
|
||||
expect(state.agent("wt-current")).toBe(value)
|
||||
expect(state.blocked("wt-current")).toBe(true)
|
||||
expect(state.blocked("wt-current", "current")).toBe(true)
|
||||
expect(state.blocked("wt-current", "other")).toBe(true)
|
||||
expect(state.blocked("missing")).toBe(false)
|
||||
pending = false
|
||||
expect(state.blocked("wt-current")).toBe(false)
|
||||
expect(state.blocked("wt-current", "other")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Source contract tests for prompt send paths.
|
||||
*
|
||||
* Static analysis — reads session.tsx source and verifies that sendMessage and
|
||||
* sendCommand still dismiss suggestions and reject questions before dispatching.
|
||||
* Static analysis — reads the session context source and verifies that sendMessage
|
||||
* and sendCommand still dismiss suggestions and reject questions before dispatching.
|
||||
* Also reads ChatView.tsx and asserts the prompt-block predicate is fed only
|
||||
* permission counts, never question counts — guarantees that a pending question
|
||||
* cannot re-block the prompt input.
|
||||
@@ -17,6 +17,7 @@ import { clearIfOn } from "../../webview-ui/src/context/session-cloud-prune"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx")
|
||||
const SESSION_TYPES_FILE = path.join(ROOT, "webview-ui/src/context/session-types.ts")
|
||||
const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx")
|
||||
const AGENT_MANAGER_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx")
|
||||
const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts")
|
||||
@@ -427,7 +428,7 @@ describe("SessionContext userClearedSession contract", () => {
|
||||
// restoreFailed uses session.userClearedSession() to decide whether :new
|
||||
// is a legitimate restore target after the user clicks New Task or
|
||||
// deletes their current/draft session. The accessor must be exposed.
|
||||
expect(source).toMatch(/userClearedSession:\s*Accessor<boolean>/)
|
||||
expect(readFile(SESSION_TYPES_FILE)).toMatch(/userClearedSession:\s*Accessor<boolean>/)
|
||||
})
|
||||
|
||||
it("clearCurrentSession sets the flag", () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const WEBVIEW = path.join(ROOT, "webview-ui")
|
||||
const FIXTURE = path.join(ROOT, "tests/fixtures/question-dock-disposal.tsx")
|
||||
|
||||
describe("QuestionDock disposal", () => {
|
||||
it("does not read a stale callback-form Show accessor", async () => {
|
||||
describe("QuestionDock lifecycle", () => {
|
||||
it("preserves text focus and disposes without reading a stale Show accessor", async () => {
|
||||
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
|
||||
const aliases: Record<string, string> = {
|
||||
"solid-js": path.join(solid, "dist/solid.js"),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
activities,
|
||||
activity,
|
||||
isActivity,
|
||||
label,
|
||||
running,
|
||||
score,
|
||||
strongest,
|
||||
type Activity,
|
||||
} from "../../webview-ui/src/utils/session-activity"
|
||||
import { ancestry } from "../../webview-ui/src/context/session-utils"
|
||||
|
||||
describe("activity", () => {
|
||||
it("maps backend states", () => {
|
||||
expect(activity({})).toBe("idle")
|
||||
expect(activity({ status: "busy" })).toBe("busy")
|
||||
expect(activity({ status: "retry" })).toBe("retry")
|
||||
expect(activity({ status: "offline" })).toBe("error")
|
||||
})
|
||||
|
||||
it("prioritizes waiting over terminal and running states", () => {
|
||||
expect(activity({ status: "busy", blocked: true, errored: true, finished: true })).toBe("waiting")
|
||||
})
|
||||
|
||||
it("prioritizes errors over running and completed states", () => {
|
||||
expect(activity({ status: "retry", errored: true, finished: true })).toBe("error")
|
||||
})
|
||||
|
||||
it("only reports done while idle", () => {
|
||||
expect(activity({ status: "busy", finished: true })).toBe("busy")
|
||||
expect(activity({ finished: true })).toBe("done")
|
||||
})
|
||||
})
|
||||
|
||||
describe("activities", () => {
|
||||
const parents = new Map([
|
||||
["child", "root"],
|
||||
["nested", "child"],
|
||||
])
|
||||
|
||||
it("derives nested requests and active work without reading transcript pages", () => {
|
||||
const result = activities({
|
||||
parents,
|
||||
statuses: { root: { type: "idle" }, child: { type: "retry" }, other: { type: "busy" } },
|
||||
outcomes: {},
|
||||
blocked: ["nested"],
|
||||
disconnected: false,
|
||||
})
|
||||
expect(result).toEqual({ root: "waiting", child: "waiting", nested: "waiting", other: "busy" })
|
||||
})
|
||||
|
||||
it("rolls up child work but leaves terminal outcomes with their owning sessions", () => {
|
||||
const input = {
|
||||
parents,
|
||||
statuses: {},
|
||||
outcomes: { child: { reason: "error" }, nested: { reason: "completed" } },
|
||||
blocked: [],
|
||||
disconnected: false,
|
||||
}
|
||||
expect(activities(input)).toEqual({ child: "error", nested: "done" })
|
||||
expect(activities({ ...input, submitting: ["nested"] })).toEqual({
|
||||
root: "busy",
|
||||
child: "error",
|
||||
nested: "busy",
|
||||
})
|
||||
expect(activities({ ...input, outcomes: { ...input.outcomes, root: { reason: "completed" } } }).root).toBe("done")
|
||||
})
|
||||
|
||||
it("shows disconnected active sessions as errors without changing idle or completed sessions", () => {
|
||||
const input = {
|
||||
parents,
|
||||
statuses: { root: { type: "busy" as const }, other: { type: "idle" as const } },
|
||||
outcomes: { complete: { reason: "completed" } },
|
||||
blocked: ["nested"],
|
||||
disconnected: true,
|
||||
}
|
||||
expect(activities(input)).toEqual({
|
||||
root: "error",
|
||||
child: "error",
|
||||
nested: "error",
|
||||
other: "idle",
|
||||
complete: "done",
|
||||
})
|
||||
expect(activities({ ...input, disconnected: false }).root).toBe("waiting")
|
||||
})
|
||||
|
||||
it("does not leak a parent request into child or sibling session indicators", () => {
|
||||
expect(
|
||||
activities({
|
||||
parents,
|
||||
statuses: { child: { type: "busy" }, nested: { type: "idle" } },
|
||||
outcomes: {},
|
||||
blocked: ["root"],
|
||||
disconnected: false,
|
||||
}),
|
||||
).toEqual({ root: "waiting", child: "busy", nested: "idle" })
|
||||
})
|
||||
|
||||
it("guards cycles and does not mutate its source state", () => {
|
||||
const parents = new Map([
|
||||
["first", "second"],
|
||||
["second", "first"],
|
||||
])
|
||||
const statuses = { first: { type: "busy" as const } }
|
||||
const input = { parents, statuses, outcomes: {}, blocked: [], disconnected: false }
|
||||
expect(activities(input)).toEqual({ first: "busy", second: "busy" })
|
||||
expect(activities({ ...input, statuses: {} })).toEqual({})
|
||||
expect(statuses).toEqual({ first: { type: "busy" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("ancestry", () => {
|
||||
it("prefers durable session metadata over task and close-event fallbacks", () => {
|
||||
const result = ancestry(
|
||||
{ child: { parentID: "root" }, root: { parentID: null } },
|
||||
{
|
||||
other: [
|
||||
{ type: "tool", tool: "task", metadata: { sessionId: "child" } },
|
||||
{ type: "tool", tool: "task", state: { metadata: { sessionId: "fallback" } } },
|
||||
],
|
||||
},
|
||||
{ child: { parentID: "stale" }, missing: { parentID: "root" } },
|
||||
)
|
||||
expect(Object.fromEntries(result.parents)).toEqual({ child: "root", fallback: "other", missing: "root" })
|
||||
expect(result.children.get("root")).toEqual(["child", "missing"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isActivity", () => {
|
||||
it("accepts only known presentation states", () => {
|
||||
expect(isActivity("waiting")).toBe(true)
|
||||
expect(isActivity("done")).toBe(true)
|
||||
expect(isActivity("idle")).toBe(true)
|
||||
expect(isActivity("unknown")).toBe(false)
|
||||
expect(isActivity({ state: "waiting" })).toBe(false)
|
||||
expect(isActivity(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("running", () => {
|
||||
it("matches spinner states", () => {
|
||||
expect(running("busy")).toBe(true)
|
||||
expect(running("retry")).toBe(true)
|
||||
expect(running("waiting")).toBe(false)
|
||||
expect(running("done")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("score", () => {
|
||||
it("preserves every activity priority with idle scoring zero", () => {
|
||||
const states: Activity[] = ["idle", "done", "busy", "retry", "error", "waiting"]
|
||||
expect(states.map(score)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
for (const [index, state] of states.entries()) {
|
||||
for (const lower of states.slice(0, index + 1)) {
|
||||
expect(strongest([state, lower])).toBe(state)
|
||||
expect(strongest([lower, state])).toBe(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("strongest", () => {
|
||||
it("returns the highest priority state", () => {
|
||||
expect(strongest(["busy", "waiting", "idle"])).toBe("waiting")
|
||||
expect(strongest(["done", "error", "retry"])).toBe("error")
|
||||
expect(strongest(["done", "busy"])).toBe("busy")
|
||||
expect(strongest([])).toBe("idle")
|
||||
})
|
||||
})
|
||||
|
||||
describe("label", () => {
|
||||
it("returns existing translation keys", () => {
|
||||
const states: Activity[] = ["waiting", "error", "retry", "busy", "done", "idle"]
|
||||
expect(states.map(label)).toEqual([
|
||||
"task.backgroundAgents.needsInput",
|
||||
"task.backgroundAgents.status.error",
|
||||
"session.status.retry",
|
||||
"session.tabs.switcher.busy",
|
||||
"task.backgroundAgents.status.completed",
|
||||
"session.current",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { unlinkSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { build } from "esbuild"
|
||||
import { solidPlugin } from "esbuild-plugin-solid"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../..")
|
||||
const webview = path.join(root, "webview-ui")
|
||||
const fixture = path.join(root, "tests/fixtures/session-provider-activity.tsx")
|
||||
|
||||
describe("SessionProvider activity", () => {
|
||||
it("covers real session activity lifecycle messages", async () => {
|
||||
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", webview))
|
||||
const aliases: Record<string, string> = {
|
||||
"solid-js": path.join(solid, "dist/solid.js"),
|
||||
"solid-js/web": path.join(solid, "web/dist/web.js"),
|
||||
"solid-js/store": path.join(solid, "store/dist/store.js"),
|
||||
}
|
||||
const dedupe = {
|
||||
name: "solid-dedupe",
|
||||
setup(ctx: Parameters<NonNullable<Parameters<typeof build>[0]["plugins"]>[number]["setup"]>[0]) {
|
||||
ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] }))
|
||||
},
|
||||
}
|
||||
const result = await build({
|
||||
entryPoints: [fixture],
|
||||
bundle: true,
|
||||
conditions: ["browser"],
|
||||
external: ["happy-dom"],
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
loader: { ".css": "empty" },
|
||||
platform: "node",
|
||||
plugins: [dedupe, solidPlugin()],
|
||||
target: "es2022",
|
||||
write: false,
|
||||
})
|
||||
const file = path.join(root, `.session-provider-activity-${crypto.randomUUID()}.mjs`)
|
||||
await Bun.write(file, result.outputFiles[0]!.contents)
|
||||
try {
|
||||
const child = Bun.spawnSync(["bun", file], { cwd: webview, stdout: "pipe", stderr: "pipe" })
|
||||
const output = child.stdout.toString() + child.stderr.toString()
|
||||
expect(child.exitCode, output).toBe(0)
|
||||
} finally {
|
||||
unlinkSync(file)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,7 @@ describe("handleSessionSearch", () => {
|
||||
post: (msg) => posted.push(msg),
|
||||
})
|
||||
|
||||
expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }])
|
||||
expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 5_000 }])
|
||||
expect(posted).toEqual([
|
||||
{
|
||||
type: "sessionSearchResult",
|
||||
@@ -89,6 +89,24 @@ describe("handleSessionSearch", () => {
|
||||
expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"])
|
||||
})
|
||||
|
||||
it.each(["/repo", "/repo/.kilo/worktrees/branch"])("loads older chats in one request from %s", async (dir) => {
|
||||
const recent = Array.from({ length: 60 }, (_, index) => session(`ses_${index}`, `Recent ${index}`, 2, "branch"))
|
||||
const source = session("ses_old", "Older chat", 1, "main")
|
||||
const api = stub([...recent, source])
|
||||
const posted: Array<{ sessions: Array<{ id: string }> }> = []
|
||||
|
||||
await handleSessionSearch({
|
||||
client: api.client as never,
|
||||
message: { requestId: "all", sessionID: "ses_current" },
|
||||
dir: (id) => (id === "ses_current" ? dir : "/wrong-project"),
|
||||
post: (msg) => posted.push(msg as never),
|
||||
})
|
||||
|
||||
expect(api.calls).toEqual([{ worktrees: true, roots: true, directory: dir, limit: 5_000 }])
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(posted[0]?.sessions.map((item) => item.id)).toEqual([...recent.map((item) => item.id), source.id])
|
||||
})
|
||||
|
||||
it("posts an empty result when the client is missing or the list fails", async () => {
|
||||
const posted: unknown[] = []
|
||||
|
||||
|
||||
@@ -51,9 +51,8 @@ const build = (overrides?: Partial<Parameters<typeof buildSidebarSearch>[0]>) =>
|
||||
localBranch: "main",
|
||||
untitled: "Untitled",
|
||||
pending: (id) => id.startsWith("pending:"),
|
||||
status: (id) => (id === "busy-session" ? "busy" : "idle"),
|
||||
activityFor: (id) => (id === "busy-session" ? "busy" : "idle"),
|
||||
busy: () => false,
|
||||
localBusy: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -104,6 +103,24 @@ describe("buildSidebarSearch", () => {
|
||||
expect(items[4]).toMatchObject({ state: "busy", updatedAt: "2026-06-03T00:00:00.000Z" })
|
||||
})
|
||||
|
||||
it("uses the strongest session activity for context rows", () => {
|
||||
const items = build({
|
||||
activityFor: (id) => (id === "busy-session" ? "busy" : id === "recent-session" ? "waiting" : "idle"),
|
||||
})
|
||||
|
||||
expect(items.find((item) => item.key === "session:recent-session")).toMatchObject({ state: "waiting" })
|
||||
expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "waiting" })
|
||||
})
|
||||
|
||||
it("keeps operation busy separate from session activity", () => {
|
||||
const items = build({
|
||||
activityFor: () => "idle",
|
||||
busy: () => true,
|
||||
})
|
||||
|
||||
expect(items.find((item) => item.key === "worktree:wt-search")).toMatchObject({ state: "idle", busy: true })
|
||||
})
|
||||
|
||||
it("uses expanded sidebar visibility before recency as a tie-breaker", () => {
|
||||
const items = build({
|
||||
worktrees: [
|
||||
@@ -119,7 +136,7 @@ describe("buildSidebarSearch", () => {
|
||||
},
|
||||
],
|
||||
local: [],
|
||||
status: () => "idle",
|
||||
activityFor: () => "idle",
|
||||
})
|
||||
|
||||
expect(items.filter((item) => item.kind === "session").map((item) => item.sessionId)).toEqual(["visible", "hidden"])
|
||||
|
||||
@@ -93,7 +93,7 @@ import { createProjectRegistry, type PersistedProjectTabs } from "./project/regi
|
||||
import type { WorktreeBusyState } from "./project/store"
|
||||
import { rememberTarget, restoreProjectTarget } from "./project/restore"
|
||||
import { createProjectStateRouter } from "./project/state"
|
||||
import { createWorktreeBusy } from "./project/session-busy"
|
||||
import { createWorktreeActivity } from "./project/session-busy"
|
||||
import { switchProject } from "./project/switch"
|
||||
import { createProjectStateHandlers } from "./project/state-handlers"
|
||||
import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership"
|
||||
@@ -118,6 +118,7 @@ import { DataBridge } from "../src/App"
|
||||
import { LanguageBridge } from "../src/context/language-bridge"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { createTabFocus } from "../src/utils/tab-navigation"
|
||||
import { label, strongest } from "../src/utils/session-activity"
|
||||
import {
|
||||
canOpenRootSession,
|
||||
isKnownRootSession,
|
||||
@@ -877,37 +878,37 @@ const AgentManagerContent: Component = () => {
|
||||
),
|
||||
)
|
||||
reportVisibleSession(vscode, visibleSession)
|
||||
const worktreeLabel = (wt: WorktreeState): string => {
|
||||
if (wt.label) return wt.label
|
||||
return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch)
|
||||
}
|
||||
|
||||
const worktreeLabel = (wt: WorktreeState): string =>
|
||||
wt.label || firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch)
|
||||
const worktreeSubtitle = (wt: WorktreeState): string | undefined => {
|
||||
const label = worktreeLabel(wt)
|
||||
return label !== wt.branch ? wt.branch : undefined
|
||||
}
|
||||
|
||||
const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId)
|
||||
|
||||
const busy = createWorktreeBusy({
|
||||
statuses: session.allStatusMap,
|
||||
permissions: session.permissions,
|
||||
questions: session.questions,
|
||||
const activity = createWorktreeActivity({
|
||||
managed: managedSessions,
|
||||
local: localSessionIDs,
|
||||
projects: projectSessionsLive,
|
||||
active: activeProjectId,
|
||||
activityFor: session.activityFor,
|
||||
inUseFor: session.inUseFor,
|
||||
worktrees: (id) => (id ? registry.ensure(id) : registry.active()).worktrees(),
|
||||
subscribe: vscode.onMessage,
|
||||
})
|
||||
const isAgentBusy = busy.agent
|
||||
const isLocalBusy = busy.local
|
||||
const projectBusy = busy.project
|
||||
const isSessionBusy = busy.session
|
||||
|
||||
const sessionActivity = createMemo(() =>
|
||||
strongest(
|
||||
multiProject()
|
||||
? projectList()
|
||||
.filter((project) => projectStates()[project.id])
|
||||
.flatMap((project) => [
|
||||
activity.project(project.id, null),
|
||||
...projectStates()[project.id]!.worktrees.map((worktree) => activity.project(project.id, worktree.id)),
|
||||
])
|
||||
: [activity.local(), ...worktrees().map((worktree) => activity.agent(worktree.id))],
|
||||
),
|
||||
)
|
||||
createEffect(() => vscode.postMessage({ type: "sessionActivity", state: sessionActivity() }))
|
||||
/** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */
|
||||
const sortedWorktrees = createMemo(() => sortWorktrees(worktrees(), sidebarWorktreeOrder()))
|
||||
|
||||
const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id)
|
||||
const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId))
|
||||
const topLevelItems = createMemo((): TopLevelItem[] =>
|
||||
@@ -1041,14 +1042,11 @@ const AgentManagerContent: Component = () => {
|
||||
localBranch: repoBranch,
|
||||
selection,
|
||||
sessionId: session.currentSessionID,
|
||||
statuses: session.allStatusMap,
|
||||
permissions: session.permissions,
|
||||
questions: session.questions,
|
||||
activityFor: session.activityFor,
|
||||
label: worktreeLabel,
|
||||
sessions: sessionsForWorktree,
|
||||
pending: isPending,
|
||||
busy: (id) => busyWorktrees().has(id) || (runStatuses()[id]?.state ?? "idle") !== "idle",
|
||||
localBusy: isLocalBusy,
|
||||
t,
|
||||
})
|
||||
const focusSidebarSearchItem = (item: SidebarSearchItem) => {
|
||||
@@ -1814,7 +1812,7 @@ const AgentManagerContent: Component = () => {
|
||||
const confirmDeleteWorktree = (worktreeId: string) => {
|
||||
const wt = worktrees().find((w) => w.id === worktreeId)
|
||||
const run = runStatuses()[worktreeId]?.state
|
||||
if (!wt || busyWorktrees().has(worktreeId) || isAgentBusy(worktreeId, true) || (run && run !== "idle")) return
|
||||
if (!wt || busyWorktrees().has(worktreeId) || activity.blocked(worktreeId) || (run && run !== "idle")) return
|
||||
// Second press/click: execute the delete
|
||||
if (pendingDelete() === worktreeId) {
|
||||
cancelPendingDelete()
|
||||
@@ -2248,7 +2246,8 @@ const AgentManagerContent: Component = () => {
|
||||
activePendingId,
|
||||
visibleTabId,
|
||||
isPending,
|
||||
isBusy: isSessionBusy,
|
||||
activityFor: (id) => session.activityFor(id),
|
||||
stateLabel: (state) => t(label(state)),
|
||||
tabLookup,
|
||||
adjacentHint,
|
||||
activateTerminal: termHandlers.activate,
|
||||
@@ -2312,8 +2311,7 @@ const AgentManagerContent: Component = () => {
|
||||
states={projectStates()}
|
||||
store={(id) => registry.ensure(id)}
|
||||
busy={(projectId, id) => registry.ensure(projectId).busy().has(id)}
|
||||
working={(projectId, id, waiting) => projectBusy(projectId, id, waiting)}
|
||||
localBusy={(projectId) => projectBusy(projectId, null)}
|
||||
blocked={(projectId, id) => activity.blocked(id, projectId)}
|
||||
stats={projectLive.stats()}
|
||||
local={projectLive.local()}
|
||||
prs={projectLive.prs()}
|
||||
@@ -2330,6 +2328,8 @@ const AgentManagerContent: Component = () => {
|
||||
onShortcuts={handleShowKeyboardShortcuts}
|
||||
onHistory={openHistory}
|
||||
shortcutMap={projectShortcutMap}
|
||||
activityFor={activity.project}
|
||||
sessionActivity={session.activityFor}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!multiProject()}>
|
||||
@@ -2339,7 +2339,7 @@ const AgentManagerContent: Component = () => {
|
||||
currentSessionID={session.currentSessionID}
|
||||
selectLocal={selectLocal}
|
||||
selectWorktree={selectWorktree}
|
||||
isLocalBusy={isLocalBusy}
|
||||
activityFor={(id) => (id === null ? activity.local() : activity.agent(id))}
|
||||
repoBranch={repoBranch}
|
||||
localStats={localStats}
|
||||
search={{ items: sidebarSearch.items, current: sidebarSearch.current }}
|
||||
@@ -2377,8 +2377,8 @@ const AgentManagerContent: Component = () => {
|
||||
worktreeSubtitle={worktreeSubtitle}
|
||||
pendingDelete={pendingDelete}
|
||||
busy={(id) => busyWorktrees().has(id)}
|
||||
isAgentBusy={isAgentBusy}
|
||||
isStaleWorktree={isStaleWorktree}
|
||||
blocked={activity.blocked}
|
||||
isStaleWorktree={(id) => staleWorktreeIds().has(id)}
|
||||
shortcutMap={shortcutMap}
|
||||
worktreeStats={worktreeStats}
|
||||
prStatuses={prStatuses}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
LocalGitStats,
|
||||
PRStatus,
|
||||
ProjectSessionInfo,
|
||||
RunStatus,
|
||||
WorktreeGitStats,
|
||||
} from "../src/types/messages"
|
||||
import type { LanguageContextValue } from "../src/context/language"
|
||||
@@ -16,6 +17,7 @@ import { ProjectsSection } from "./ProjectsSection"
|
||||
import { ProjectSidebarBody } from "./ProjectSidebarBody"
|
||||
import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
import type { SidebarSearchItem } from "./sidebar-search"
|
||||
import { label, type Activity } from "../src/utils/session-activity"
|
||||
import { LOCAL } from "./navigate"
|
||||
import { NewWorktreeDialog } from "./NewWorktreeDialog"
|
||||
import type { ProjectStore } from "./project/store"
|
||||
@@ -26,6 +28,10 @@ const place = (state: AgentManagerStateMessage, session: ProjectSessionInfo, loc
|
||||
return wt?.label || wt?.branch || local
|
||||
}
|
||||
|
||||
const activeRun = (status: RunStatus | undefined) => status?.state === "running" || status?.state === "stopping"
|
||||
const operationBusy = (store: ProjectStore | undefined, id: string) =>
|
||||
store?.busy().has(id) || activeRun(store?.runStatuses()[id])
|
||||
|
||||
interface Props {
|
||||
projects: AgentProjectSnapshot[]
|
||||
states: Record<string, AgentManagerStateMessage>
|
||||
@@ -40,9 +46,10 @@ interface Props {
|
||||
mode: ModeRouter
|
||||
defaultBase?: (projectId: string) => string | undefined
|
||||
onCreate?: (projectId: string) => void
|
||||
busy?: (projectId: string, id: string) => boolean
|
||||
working?: (projectId: string, id: string, waiting?: boolean) => boolean
|
||||
localBusy?: (projectId: string) => boolean
|
||||
busy: (projectId: string, id: string) => boolean
|
||||
blocked: (projectId: string, id: string) => boolean
|
||||
activityFor: (projectId: string, worktreeId: string | null) => Activity
|
||||
sessionActivity: (id: string) => Activity
|
||||
bindings: Record<string, string>
|
||||
t: LanguageContextValue["t"]
|
||||
onSearchRef: (ref: SidebarSearchMenuRef) => void
|
||||
@@ -61,6 +68,7 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
for (const project of props.projects) {
|
||||
const state = props.states[project.id]
|
||||
if (!state) continue
|
||||
const store = props.store?.(project.id)
|
||||
const local = props.sessions[project.id]?.filter((session) => session.worktreeId === null) ?? []
|
||||
items.push({
|
||||
key: `${project.id}:local`,
|
||||
@@ -73,7 +81,7 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
updatedAt: local.reduce((latest, session) => (session.updatedAt > latest ? session.updatedAt : latest), ""),
|
||||
state: "idle",
|
||||
state: props.activityFor(project.id, null),
|
||||
visible: project.expanded,
|
||||
count: local.length,
|
||||
})
|
||||
@@ -88,10 +96,11 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
meta: [project.label, worktree.branch],
|
||||
search: [project.label, worktree.label, worktree.branch, worktree.id].filter(Boolean).join(" "),
|
||||
updatedAt: worktree.createdAt,
|
||||
state: "idle",
|
||||
state: props.activityFor(project.id, worktree.id),
|
||||
visible: project.expanded,
|
||||
worktreeId: worktree.id,
|
||||
count: sessions.length,
|
||||
busy: props.busy(project.id, worktree.id) || operationBusy(store, worktree.id),
|
||||
})
|
||||
}
|
||||
for (const session of props.sessions[project.id] ?? []) {
|
||||
@@ -106,7 +115,7 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
meta: [project.label, where],
|
||||
search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "),
|
||||
updatedAt: session.updatedAt,
|
||||
state: "idle",
|
||||
state: props.sessionActivity(session.id),
|
||||
visible: project.expanded,
|
||||
sessionId: session.id,
|
||||
location: session.worktreeId ? "worktree" : "local",
|
||||
@@ -172,8 +181,7 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
scope: props.t("agentManager.sidebarSearch.scope"),
|
||||
sessions: props.t("agentManager.section.sessions"),
|
||||
contexts: props.t("agentManager.sidebarSearch.contexts"),
|
||||
waiting: props.t("agentManager.tabsMenu.status.waiting"),
|
||||
retry: props.t("agentManager.tabsMenu.status.retry"),
|
||||
state: (value) => props.t(label(value)),
|
||||
}}
|
||||
onSelect={selectSearch}
|
||||
/>
|
||||
@@ -216,9 +224,9 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
project={project}
|
||||
state={props.states[project.id]}
|
||||
store={props.store?.(project.id)}
|
||||
busy={(id) => props.busy?.(project.id, id) ?? false}
|
||||
working={(id, waiting) => props.working?.(project.id, id, waiting) ?? false}
|
||||
localBusy={() => props.localBusy?.(project.id) ?? false}
|
||||
busy={(id) => props.busy(project.id, id)}
|
||||
blocked={(id) => props.blocked(project.id, id)}
|
||||
activityFor={(id) => props.activityFor(project.id, id)}
|
||||
stats={props.stats[project.id]}
|
||||
local={props.local[project.id]}
|
||||
prs={props.prs[project.id]}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
@@ -19,6 +18,8 @@ import type {
|
||||
WorktreeGitStats,
|
||||
} from "../src/types/messages"
|
||||
import type { LanguageContextValue } from "../src/context/language"
|
||||
import { ActivityIcon } from "../src/components/shared/ActivityIcon"
|
||||
import { label, type Activity } from "../src/utils/session-activity"
|
||||
import { useVSCode } from "../src/context/vscode"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
@@ -40,9 +41,9 @@ interface Props {
|
||||
project: AgentProjectSnapshot
|
||||
state?: AgentManagerStateMessage
|
||||
store?: ProjectStore
|
||||
busy?: (id: string) => boolean
|
||||
working?: (id: string, waiting?: boolean) => boolean
|
||||
localBusy?: () => boolean
|
||||
busy: (id: string) => boolean
|
||||
blocked: (id: string) => boolean
|
||||
activityFor: (worktreeId: string | null) => Activity
|
||||
stats?: Record<string, WorktreeGitStats>
|
||||
local?: LocalGitStats
|
||||
prs?: Record<string, PRStatus | null>
|
||||
@@ -81,7 +82,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
onCleanup(() => clearTimeout(pendingTimer))
|
||||
/** Arm on the first click, execute on the second, matching the legacy sidebar. */
|
||||
const confirmDelete = (worktreeId: string) => {
|
||||
if (props.busy?.(worktreeId) || props.working?.(worktreeId, true)) return
|
||||
if (props.busy(worktreeId) || props.blocked(worktreeId)) return
|
||||
if (pending() === worktreeId) {
|
||||
clearTimeout(pendingTimer)
|
||||
setPending(undefined)
|
||||
@@ -106,6 +107,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
const sidebarOrder = createMemo(() => projectSidebarOrder(top(), sorted(), sections(), members))
|
||||
const post = (message: Record<string, unknown>) =>
|
||||
vscode.postMessage({ ...message, projectId: props.project.id } as never)
|
||||
const localState = () => props.activityFor(null)
|
||||
|
||||
const row = (id: string) =>
|
||||
projectWorktreeRow({
|
||||
@@ -237,9 +239,9 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()}
|
||||
active={active() && props.selection === worktree.id}
|
||||
pendingDelete={pending() === worktree.id}
|
||||
busy={props.busy?.(worktree.id) ?? false}
|
||||
working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"}
|
||||
blocked={props.working?.(worktree.id, true)}
|
||||
busy={props.busy(worktree.id)}
|
||||
activity={props.activityFor(worktree.id)}
|
||||
blocked={props.blocked(worktree.id)}
|
||||
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
|
||||
stats={props.stats?.[worktree.id]}
|
||||
shortcut={values().shortcut}
|
||||
@@ -296,13 +298,18 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
data-sidebar-id={`${props.project.id}:local`}
|
||||
onClick={() => props.onSelectLocal(props.project.id)}
|
||||
>
|
||||
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
|
||||
<path d="M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
</Show>
|
||||
<span class="am-local-status" data-activity={localState()} aria-label={props.t(label(localState()))}>
|
||||
<ActivityIcon
|
||||
state={localState()}
|
||||
idle={
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
|
||||
<path d="M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<div class="am-local-text">
|
||||
<span class="am-local-label">{props.t("agentManager.local")}</span>
|
||||
<Show when={props.local === undefined}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
@@ -32,6 +31,8 @@ import { WorktreeItem } from "./WorktreeItem"
|
||||
import { WorktreeSectionActions } from "./WorktreeSectionActions"
|
||||
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
|
||||
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
import { ActivityIcon } from "../src/components/shared/ActivityIcon"
|
||||
import { label, type Activity } from "../src/utils/session-activity"
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
|
||||
@@ -43,7 +44,7 @@ export interface SidebarBodyProps {
|
||||
currentSessionID: () => string | undefined
|
||||
selectLocal: () => void
|
||||
selectWorktree: (id: string) => void
|
||||
isLocalBusy: () => boolean
|
||||
activityFor: (id: string | null) => Activity
|
||||
repoBranch: () => string | undefined
|
||||
localStats: () => LocalGitStats | undefined
|
||||
search: { items: () => SidebarSearchItem[]; current: () => SidebarSearchItem | undefined }
|
||||
@@ -80,7 +81,7 @@ export interface SidebarBodyProps {
|
||||
worktreeSubtitle: (wt: WorktreeState) => string | undefined
|
||||
pendingDelete: () => string | null
|
||||
busy: (id: string) => boolean
|
||||
isAgentBusy: (id: string, waiting?: boolean) => boolean
|
||||
blocked: (id: string) => boolean
|
||||
isStaleWorktree: (id: string) => boolean
|
||||
shortcutMap: () => Map<string, number>
|
||||
worktreeStats: () => Record<string, WorktreeGitStats>
|
||||
@@ -95,6 +96,7 @@ export interface SidebarBodyProps {
|
||||
/** Legacy single-project sidebar body: local repo, worktrees, unassigned sessions. */
|
||||
export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const localState = () => props.activityFor(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -104,13 +106,18 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
data-sidebar-id="local"
|
||||
onClick={() => props.selectLocal()}
|
||||
>
|
||||
<Show when={!props.isLocalBusy()} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
|
||||
<path d="M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
</Show>
|
||||
<span class="am-local-status" data-activity={localState()} aria-label={props.t(label(localState()))}>
|
||||
<ActivityIcon
|
||||
state={localState()}
|
||||
idle={
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
|
||||
<path d="M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<div class="am-local-text">
|
||||
<span class="am-local-label">{props.t("agentManager.local")}</span>
|
||||
<Show when={props.repoBranch()}>
|
||||
@@ -314,8 +321,8 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
active={props.selection() === wt.id}
|
||||
pendingDelete={props.pendingDelete() === wt.id}
|
||||
busy={props.busy(wt.id)}
|
||||
working={props.isAgentBusy(wt.id)}
|
||||
blocked={props.isAgentBusy(wt.id, true)}
|
||||
activity={props.activityFor(wt.id)}
|
||||
blocked={props.blocked(wt.id)}
|
||||
stale={props.isStaleWorktree(wt.id)}
|
||||
shortcut={props.shortcutMap().get(wt.id)}
|
||||
stats={props.worktreeStats()[wt.id]}
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { ListRef } from "@kilocode/kilo-ui/list"
|
||||
import { Popover } from "@kilocode/kilo-ui/popover"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { ActivityIcon } from "../src/components/shared/ActivityIcon"
|
||||
import type { Activity } from "../src/utils/session-activity"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import { colorCss } from "./section-colors"
|
||||
import type { SidebarSearchItem } from "./sidebar-search"
|
||||
@@ -20,7 +22,13 @@ export interface SidebarSearchMenuRef {
|
||||
interface SidebarSearchMenuProps {
|
||||
items: Accessor<SidebarSearchItem[]>
|
||||
current: Accessor<SidebarSearchItem | undefined>
|
||||
labels: { search: string; scope: string; contexts: string; sessions: string; waiting: string; retry: string }
|
||||
labels: {
|
||||
search: string
|
||||
scope: string
|
||||
contexts: string
|
||||
sessions: string
|
||||
state: (value: Activity) => string
|
||||
}
|
||||
keybind: string
|
||||
ref?: (value: SidebarSearchMenuRef) => void
|
||||
onSelect: (item: SidebarSearchItem) => void
|
||||
@@ -104,7 +112,7 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
{(item) => {
|
||||
const working = item.state === "busy" || item.state === "retry"
|
||||
const stateLabel = () => props.labels.state(item.state)
|
||||
return (
|
||||
<span
|
||||
class="search-menu-row"
|
||||
@@ -114,19 +122,30 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
|
||||
data-session-id={item.kind === "session" ? item.sessionId : undefined}
|
||||
data-worktree-id={item.kind === "worktree" ? item.worktreeId : undefined}
|
||||
>
|
||||
<span class="search-menu-icon">
|
||||
<Show when={!working} fallback={<Spinner class="search-menu-spinner" />}>
|
||||
<Show
|
||||
when={item.kind !== "local"}
|
||||
fallback={
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Icon name={item.kind === "worktree" ? "branch" : "speech-bubble"} size="small" />
|
||||
</Show>
|
||||
<span class="search-menu-icon am-sidebar-search-icon" data-activity={item.state}>
|
||||
<Show
|
||||
when={item.busy}
|
||||
fallback={
|
||||
<ActivityIcon
|
||||
state={item.state}
|
||||
spinner="search-menu-spinner"
|
||||
idle={
|
||||
<Show
|
||||
when={item.kind !== "local"}
|
||||
fallback={
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Icon name={item.kind === "worktree" ? "branch" : "speech-bubble"} size="small" />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Spinner class="search-menu-spinner" />
|
||||
</Show>
|
||||
</span>
|
||||
<span class="search-menu-copy">
|
||||
@@ -143,13 +162,12 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
|
||||
<span>{item.meta.join(" · ")}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Show when={item.state === "waiting"}>
|
||||
<span class="search-menu-status am-sidebar-search-status">{props.labels.waiting}</span>
|
||||
<Show when={item.state !== "idle" && item.state !== "busy"}>
|
||||
<span class="search-menu-status am-sidebar-search-status" data-activity={item.state}>
|
||||
{stateLabel()}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.state === "retry"}>
|
||||
<span class="search-menu-status am-sidebar-search-status">{props.labels.retry}</span>
|
||||
</Show>
|
||||
<Show when={item.kind !== "session" && item.state === "idle"}>
|
||||
<Show when={item.kind !== "session" && item.state === "idle" && !item.busy}>
|
||||
<span class="search-menu-status am-sidebar-search-count">
|
||||
{item.kind !== "session" ? item.count : ""}
|
||||
</span>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import type { WorktreeState, WorktreeGitStats, SectionState, RunStatus } from "../src/types/messages"
|
||||
import type { PRStatus } from "../src/types/messages"
|
||||
import { ActivityIcon } from "../src/components/shared/ActivityIcon"
|
||||
import { label, running, type Activity } from "../src/utils/session-activity"
|
||||
import { colorCss } from "./section-colors"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
@@ -31,8 +33,7 @@ interface WorktreeItemProps {
|
||||
active: boolean
|
||||
pendingDelete: boolean
|
||||
busy: boolean
|
||||
/** Whether an agent session on this worktree is actively working (shows spinner instead of branch icon). */
|
||||
working: boolean
|
||||
activity: Activity
|
||||
blocked?: boolean
|
||||
stale: boolean
|
||||
/** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */
|
||||
@@ -156,6 +157,13 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const [overClose, setOverClose] = createSignal(false)
|
||||
const state = () => props.activity
|
||||
const blocked = () =>
|
||||
props.busy ||
|
||||
props.blocked ||
|
||||
running(state()) ||
|
||||
props.runStatus?.state === "running" ||
|
||||
props.runStatus?.state === "stopping"
|
||||
|
||||
const handleOpenPR = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -200,9 +208,12 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
data-sidebar-id={props.sidebarId ?? props.worktree.id}
|
||||
onClick={() => props.onClick()}
|
||||
>
|
||||
<div class="am-wt-icon">
|
||||
<Show when={!props.busy && !props.working} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<Icon name="branch" size="small" />
|
||||
<div class="am-wt-icon" data-activity={state()} aria-label={t(label(state()))}>
|
||||
<Show
|
||||
when={!props.busy && props.runStatus?.state !== "running"}
|
||||
fallback={<Spinner class="am-worktree-spinner" />}
|
||||
>
|
||||
<ActivityIcon state={state()} idle={<Icon name="branch" size="small" />} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="am-wt-content">
|
||||
@@ -304,7 +315,7 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
{props.shortcut}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!props.busy && !props.working && !props.blocked && !props.pendingDelete}>
|
||||
<Show when={!blocked() && !props.pendingDelete}>
|
||||
<div
|
||||
class="am-worktree-close"
|
||||
onMouseEnter={() => setOverClose(true)}
|
||||
@@ -521,7 +532,7 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
<Icon name="edit" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.rename")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<Show when={!props.busy && !props.working && !props.blocked}>
|
||||
<Show when={!blocked()}>
|
||||
<ContextMenu.Item onSelect={() => props.onDelete(new MouseEvent("click"))}>
|
||||
<Icon name="trash" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.delete")}</ContextMenu.ItemLabel>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { WorktreeCreate, type WorktreeCreateProps } from "./ProjectActions"
|
||||
import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
import type { SidebarSearchItem } from "./sidebar-search"
|
||||
import { label } from "../src/utils/session-activity"
|
||||
|
||||
interface WorktreeSectionActionsProps extends WorktreeCreateProps {
|
||||
items: Accessor<SidebarSearchItem[]>
|
||||
@@ -31,8 +32,7 @@ export const WorktreeSectionActions: Component<WorktreeSectionActionsProps> = (p
|
||||
scope: props.t("agentManager.sidebarSearch.scope"),
|
||||
sessions: props.t("agentManager.section.sessions"),
|
||||
contexts: props.t("agentManager.sidebarSearch.contexts"),
|
||||
waiting: props.t("agentManager.tabsMenu.status.waiting"),
|
||||
retry: props.t("agentManager.tabsMenu.status.retry"),
|
||||
state: (value) => props.t(label(value)),
|
||||
}}
|
||||
onSelect={props.onSelect}
|
||||
/>
|
||||
|
||||
@@ -151,6 +151,53 @@ html[data-theme="kilo-vscode"]
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-local-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:is(
|
||||
.am-tab-icon,
|
||||
.am-wt-icon,
|
||||
.am-local-status,
|
||||
.am-sidebar-search-icon,
|
||||
.am-sidebar-search-status
|
||||
)[data-activity="waiting"] {
|
||||
--am-activity-color: var(--icon-warning-base, var(--vscode-notificationsWarningIcon-foreground, #d9a13a));
|
||||
color: var(--am-activity-color);
|
||||
}
|
||||
|
||||
:is(
|
||||
.am-tab-icon,
|
||||
.am-wt-icon,
|
||||
.am-local-status,
|
||||
.am-sidebar-search-icon,
|
||||
.am-sidebar-search-status
|
||||
)[data-activity="error"] {
|
||||
--am-activity-color: var(--icon-critical-base, var(--vscode-errorForeground, #f14c4c));
|
||||
color: var(--am-activity-color);
|
||||
}
|
||||
|
||||
:is(
|
||||
.am-tab-icon,
|
||||
.am-wt-icon,
|
||||
.am-local-status,
|
||||
.am-sidebar-search-icon,
|
||||
.am-sidebar-search-status
|
||||
)[data-activity="done"] {
|
||||
--am-activity-color: var(--icon-success-base, var(--vscode-testing-iconPassed, #73c991));
|
||||
color: var(--am-activity-color);
|
||||
}
|
||||
|
||||
:is(.am-tab-icon, .am-wt-icon, .am-local-status, .am-sidebar-search-icon)[data-activity]:not([data-activity="idle"])
|
||||
[data-component="icon"] {
|
||||
color: var(--am-activity-color);
|
||||
}
|
||||
|
||||
.am-local-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -55,7 +55,7 @@ export function createChatFocus(deps: {
|
||||
}) {
|
||||
const focus = (force: boolean) => {
|
||||
if ((!force && (!document.hasFocus() || deps.term())) || deps.history() || deps.review()) return
|
||||
if (preservesTextFocus(document.activeElement)) return
|
||||
if (preservesTextFocus(document.activeElement) || (!force && isTextControl(document.activeElement))) return
|
||||
if (!force && document.activeElement?.matches('[role="tab"]')) return
|
||||
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
|
||||
if (focusQuestionOption()) return
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "الطرفية",
|
||||
"agentManager.tab.openTerminal": "فتح الطرفية",
|
||||
"agentManager.tab.newOptions": "المزيد من خيارات علامات التبويب الجديدة",
|
||||
"agentManager.tabsMenu.status.waiting": "انتظار",
|
||||
"agentManager.tabsMenu.status.retry": "إعادة",
|
||||
"agentManager.sidebarSearch.label": "البحث في Worktrees والجلسات",
|
||||
"agentManager.sidebarSearch.scope": "يبحث في مساحة العمل المحلية والجلسات المحلية وWorktrees وجلساتها",
|
||||
"agentManager.sidebarSearch.contexts": "محلي & WORKTREES",
|
||||
|
||||
@@ -63,8 +63,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Abrir Terminal",
|
||||
"agentManager.tab.newOptions": "Mais opções de nova aba",
|
||||
"agentManager.tabsMenu.status.waiting": "Espera",
|
||||
"agentManager.tabsMenu.status.retry": "Repetir",
|
||||
"agentManager.sidebarSearch.label": "Pesquisar Worktrees e sessões",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Pesquisa o espaço de trabalho local, as sessões locais, os Worktrees e suas sessões",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Otvori Terminal",
|
||||
"agentManager.tab.newOptions": "Više opcija nove kartice",
|
||||
"agentManager.tabsMenu.status.waiting": "Čeka",
|
||||
"agentManager.tabsMenu.status.retry": "Pokušaj",
|
||||
"agentManager.sidebarSearch.label": "Pretraži Worktree-ove i sesije",
|
||||
"agentManager.sidebarSearch.scope": "Pretražuje lokalni radni prostor, lokalne sesije, Worktree-ove i njihove sesije",
|
||||
"agentManager.sidebarSearch.contexts": "LOKALNO & WORKTREES",
|
||||
|
||||
@@ -63,8 +63,6 @@ export const dict = {
|
||||
"agentManager.tab.openTerminal": "Åbn Terminal",
|
||||
"agentManager.tab.newOptions": "Flere nye faneindstillinger",
|
||||
|
||||
"agentManager.tabsMenu.status.waiting": "Venter",
|
||||
"agentManager.tabsMenu.status.retry": "Igen",
|
||||
"agentManager.sidebarSearch.label": "Søg i Worktrees og sessioner",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Søger i det lokale arbejdsområde, lokale sessioner, Worktrees og deres sessioner",
|
||||
|
||||
@@ -64,8 +64,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Terminal öffnen",
|
||||
"agentManager.tab.newOptions": "Weitere Tab-Optionen",
|
||||
"agentManager.tabsMenu.status.waiting": "Warten",
|
||||
"agentManager.tabsMenu.status.retry": "Erneut",
|
||||
"agentManager.sidebarSearch.label": "Worktrees und Sitzungen durchsuchen",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Durchsucht den lokalen Arbeitsbereich, lokale Sitzungen, Worktrees und deren Sitzungen",
|
||||
|
||||
@@ -66,8 +66,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Open Terminal",
|
||||
"agentManager.tab.newOptions": "More new-tab options",
|
||||
"agentManager.tabsMenu.status.waiting": "Wait",
|
||||
"agentManager.tabsMenu.status.retry": "Retry",
|
||||
"agentManager.sidebarSearch.label": "Search worktrees and sessions",
|
||||
"agentManager.sidebarSearch.scope": "Searches the local workspace, local sessions, worktrees, and their sessions",
|
||||
"agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES",
|
||||
|
||||
@@ -63,8 +63,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Abrir Terminal",
|
||||
"agentManager.tab.newOptions": "Más opciones de nueva pestaña",
|
||||
"agentManager.tabsMenu.status.waiting": "Espera",
|
||||
"agentManager.tabsMenu.status.retry": "Reintento",
|
||||
"agentManager.sidebarSearch.label": "Buscar Worktrees y sesiones",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Busca en el espacio de trabajo local, las sesiones locales, los Worktrees y sus sesiones",
|
||||
|
||||
@@ -67,8 +67,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "ترمینال",
|
||||
"agentManager.tab.openTerminal": "باز کردن ترمینال",
|
||||
"agentManager.tab.newOptions": "گزینههای بیشتر برای تب جدید",
|
||||
"agentManager.tabsMenu.status.waiting": "انتظار",
|
||||
"agentManager.tabsMenu.status.retry": "تلاش مجدد",
|
||||
"agentManager.sidebarSearch.label": "جستجوی worktreeها و جلسات",
|
||||
"agentManager.sidebarSearch.scope": "جستجو در فضای کاری محلی، جلسات محلی، worktreeها و جلسات آنها",
|
||||
"agentManager.sidebarSearch.contexts": "محلی و WORKTREEها",
|
||||
|
||||
@@ -64,8 +64,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Ouvrir le Terminal",
|
||||
"agentManager.tab.newOptions": "Plus d'options de nouvel onglet",
|
||||
"agentManager.tabsMenu.status.waiting": "Attente",
|
||||
"agentManager.tabsMenu.status.retry": "Réessai",
|
||||
"agentManager.sidebarSearch.label": "Rechercher des Worktrees et des sessions",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Recherche dans l'espace de travail local, les sessions locales, les Worktrees et leurs sessions",
|
||||
|
||||
@@ -68,8 +68,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminale",
|
||||
"agentManager.tab.openTerminal": "Apri terminale",
|
||||
"agentManager.tab.newOptions": "Altre opzioni nuova scheda",
|
||||
"agentManager.tabsMenu.status.waiting": "Attendi",
|
||||
"agentManager.tabsMenu.status.retry": "Riprova",
|
||||
"agentManager.sidebarSearch.label": "Cerca Worktree e sessioni",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Cerca nell'area di lavoro locale, nelle sessioni locali, nei Worktree e nelle relative sessioni",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "ターミナル",
|
||||
"agentManager.tab.openTerminal": "ターミナルを開く",
|
||||
"agentManager.tab.newOptions": "新しいタブのその他のオプション",
|
||||
"agentManager.tabsMenu.status.waiting": "待機",
|
||||
"agentManager.tabsMenu.status.retry": "再試行",
|
||||
"agentManager.sidebarSearch.label": "Worktreeとセッションを検索",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"ローカルワークスペース、ローカルセッション、Worktree、および各Worktreeのセッションを検索",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "터미널",
|
||||
"agentManager.tab.openTerminal": "터미널 열기",
|
||||
"agentManager.tab.newOptions": "더 많은 새 탭 옵션",
|
||||
"agentManager.tabsMenu.status.waiting": "대기",
|
||||
"agentManager.tabsMenu.status.retry": "재시도",
|
||||
"agentManager.sidebarSearch.label": "Worktree 및 세션 검색",
|
||||
"agentManager.sidebarSearch.scope": "로컬 워크스페이스, 로컬 세션, Worktree 및 각 Worktree의 세션 검색",
|
||||
"agentManager.sidebarSearch.contexts": "로컬 & WORKTREES",
|
||||
|
||||
@@ -67,8 +67,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Terminal openen",
|
||||
"agentManager.tab.newOptions": "Meer opties voor nieuwe tabblad",
|
||||
"agentManager.tabsMenu.status.waiting": "Wacht",
|
||||
"agentManager.tabsMenu.status.retry": "Opnieuw",
|
||||
"agentManager.sidebarSearch.label": "Worktrees en sessies doorzoeken",
|
||||
"agentManager.sidebarSearch.scope": "Doorzoekt de lokale werkruimte, lokale sessies, Worktrees en hun sessies",
|
||||
"agentManager.sidebarSearch.contexts": "LOKAAL & WORKTREES",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Åpne Terminal",
|
||||
"agentManager.tab.newOptions": "Flere alternativer for ny fane",
|
||||
"agentManager.tabsMenu.status.waiting": "Venter",
|
||||
"agentManager.tabsMenu.status.retry": "Igjen",
|
||||
"agentManager.sidebarSearch.label": "Søk i Worktrees og økter",
|
||||
"agentManager.sidebarSearch.scope": "Søker i det lokale arbeidsområdet, lokale økter, Worktrees og øktene deres",
|
||||
"agentManager.sidebarSearch.contexts": "LOKAL & WORKTREES",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Otwórz Terminal",
|
||||
"agentManager.tab.newOptions": "Więcej opcji nowej karty",
|
||||
"agentManager.tabsMenu.status.waiting": "Czeka",
|
||||
"agentManager.tabsMenu.status.retry": "Ponów",
|
||||
"agentManager.sidebarSearch.label": "Wyszukaj Worktree i sesje",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Przeszukuje lokalny obszar roboczy, lokalne sesje, Worktree i przypisane do nich sesje",
|
||||
|
||||
@@ -63,8 +63,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Терминал",
|
||||
"agentManager.tab.openTerminal": "Открыть терминал",
|
||||
"agentManager.tab.newOptions": "Другие параметры новой вкладки",
|
||||
"agentManager.tabsMenu.status.waiting": "Ожидание",
|
||||
"agentManager.tabsMenu.status.retry": "Повтор",
|
||||
"agentManager.sidebarSearch.label": "Поиск по Worktree и сессиям",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Поиск локального рабочего пространства, локальных сессий, Worktree и связанных с ними сессий",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "เทอร์มินัล",
|
||||
"agentManager.tab.openTerminal": "เปิดเทอร์มินัล",
|
||||
"agentManager.tab.newOptions": "ตัวเลือกแท็บใหม่เพิ่มเติม",
|
||||
"agentManager.tabsMenu.status.waiting": "รอ",
|
||||
"agentManager.tabsMenu.status.retry": "ลองใหม่",
|
||||
"agentManager.sidebarSearch.label": "ค้นหา Worktree และเซสชัน",
|
||||
"agentManager.sidebarSearch.scope": "ค้นหาพื้นที่ทำงานในเครื่อง เซสชันในเครื่อง Worktree และเซสชันของ Worktree",
|
||||
"agentManager.sidebarSearch.contexts": "ในเครื่อง & WORKTREES",
|
||||
|
||||
@@ -67,8 +67,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Terminal",
|
||||
"agentManager.tab.openTerminal": "Terminali Aç",
|
||||
"agentManager.tab.newOptions": "Daha fazla yeni sekme seçeneği",
|
||||
"agentManager.tabsMenu.status.waiting": "Bekliyor",
|
||||
"agentManager.tabsMenu.status.retry": "Yeniden",
|
||||
"agentManager.sidebarSearch.label": "Worktree'leri ve oturumları ara",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Yerel çalışma alanını, yerel oturumları, Worktree'leri ve bunların oturumlarını arar",
|
||||
|
||||
@@ -67,8 +67,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "Термінал",
|
||||
"agentManager.tab.openTerminal": "Відкрити термінал",
|
||||
"agentManager.tab.newOptions": "Інші параметри нової вкладки",
|
||||
"agentManager.tabsMenu.status.waiting": "Очікує",
|
||||
"agentManager.tabsMenu.status.retry": "Повтор",
|
||||
"agentManager.sidebarSearch.label": "Пошук робочих дерев і сесій",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Пошук локального робочого простору, локальних сесій, робочих дерев і призначених їм сесій",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "终端",
|
||||
"agentManager.tab.openTerminal": "打开终端",
|
||||
"agentManager.tab.newOptions": "更多新建标签页选项",
|
||||
"agentManager.tabsMenu.status.waiting": "等待",
|
||||
"agentManager.tabsMenu.status.retry": "重试",
|
||||
"agentManager.sidebarSearch.label": "搜索 Worktree 和会话",
|
||||
"agentManager.sidebarSearch.scope": "搜索本地工作区、本地会话、Worktree 及其会话",
|
||||
"agentManager.sidebarSearch.contexts": "本地 & WORKTREES",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const dict = {
|
||||
"agentManager.tab.terminal": "終端機",
|
||||
"agentManager.tab.openTerminal": "開啟終端機",
|
||||
"agentManager.tab.newOptions": "更多新增分頁選項",
|
||||
"agentManager.tabsMenu.status.waiting": "等待",
|
||||
"agentManager.tabsMenu.status.retry": "重試",
|
||||
"agentManager.sidebarSearch.label": "搜尋 Worktree 與工作階段",
|
||||
"agentManager.sidebarSearch.scope": "搜尋本機工作區、本機工作階段、Worktree 及其工作階段",
|
||||
"agentManager.sidebarSearch.contexts": "本機 & WORKTREES",
|
||||
|
||||
@@ -1,85 +1,71 @@
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import type { ExtensionMessage } from "../../src/types/messages"
|
||||
import { strongest, type Activity } from "../../src/utils/session-activity"
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
worktreeId?: string | null
|
||||
}
|
||||
|
||||
interface Status {
|
||||
type: string
|
||||
}
|
||||
|
||||
interface Prompt {
|
||||
sessionID: string
|
||||
blocking?: boolean
|
||||
}
|
||||
|
||||
export function createSessionBusy(opts: {
|
||||
statuses: () => Record<string, Status>
|
||||
permissions: () => Prompt[]
|
||||
questions: () => Prompt[]
|
||||
export function createSessionActivity(opts: {
|
||||
managed: () => Item[]
|
||||
local: () => string[]
|
||||
projects: () => Record<string, Item[]>
|
||||
active: () => string | undefined
|
||||
activityFor: (id: string) => Activity
|
||||
}) {
|
||||
const any = (ids: string[], waiting = false) => {
|
||||
if (ids.length === 0) return false
|
||||
const statuses = opts.statuses()
|
||||
const blocked = new Set(
|
||||
[...opts.permissions(), ...opts.questions().filter((item) => item.blocking !== false)].map(
|
||||
(item) => item.sessionID,
|
||||
),
|
||||
)
|
||||
return ids.some((id) => {
|
||||
const status = statuses[id]
|
||||
if (waiting)
|
||||
return (
|
||||
(!!status && status.type !== "idle") ||
|
||||
[...opts.permissions(), ...opts.questions()].some((prompt) => prompt.sessionID === id)
|
||||
)
|
||||
return (status?.type === "busy" || status?.type === "retry") && !blocked.has(id)
|
||||
})
|
||||
const group = (items: Item[]) => {
|
||||
const states = new Map<string | null, Activity[]>()
|
||||
for (const item of items) {
|
||||
const id = item.worktreeId ?? null
|
||||
const values = states.get(id) ?? []
|
||||
values.push(opts.activityFor(item.id))
|
||||
states.set(id, values)
|
||||
}
|
||||
return new Map([...states].map(([id, values]) => [id, strongest(values)]))
|
||||
}
|
||||
const agent = (id: string, waiting = false) =>
|
||||
any(
|
||||
opts
|
||||
.managed()
|
||||
.filter((item) => item.worktreeId === id)
|
||||
.map((item) => item.id),
|
||||
waiting,
|
||||
)
|
||||
const local = () => any(opts.local())
|
||||
const project = (id: string, worktreeId: string | null, waiting = false) => {
|
||||
if (id === opts.active()) return worktreeId === null ? any(opts.local(), waiting) : agent(worktreeId, waiting)
|
||||
return any(
|
||||
(opts.projects()[id] ?? []).filter((item) => item.worktreeId === worktreeId).map((item) => item.id),
|
||||
waiting,
|
||||
)
|
||||
const local = createMemo(() => strongest(opts.local().map(opts.activityFor)))
|
||||
const managed = createMemo(() => group(opts.managed()))
|
||||
const projects = createMemo(() => {
|
||||
const values = new Map<string, Map<string | null, Activity>>()
|
||||
for (const [id, items] of Object.entries(opts.projects())) values.set(id, group(items))
|
||||
return values
|
||||
})
|
||||
return {
|
||||
local: () => local(),
|
||||
agent: (id: string) => managed().get(id) ?? "idle",
|
||||
project: (id: string, worktree: string | null): Activity => {
|
||||
if (id === opts.active()) return worktree === null ? local() : (managed().get(worktree) ?? "idle")
|
||||
return projects().get(id)?.get(worktree) ?? "idle"
|
||||
},
|
||||
}
|
||||
return { any, agent, local, project, session: (id: string) => any([id]) }
|
||||
}
|
||||
|
||||
export function createWorktreeBusy(
|
||||
opts: Parameters<typeof createSessionBusy>[0] & {
|
||||
export function createWorktreeActivity(
|
||||
opts: Parameters<typeof createSessionActivity>[0] & {
|
||||
inUseFor: (id: string) => boolean
|
||||
worktrees: (project?: string) => { id: string; path: string }[]
|
||||
subscribe: (callback: (message: ExtensionMessage) => void) => () => void
|
||||
},
|
||||
) {
|
||||
const busy = createSessionBusy(opts)
|
||||
const activity = createSessionActivity(opts)
|
||||
const [active, setActive] = createSignal(new Set<string>())
|
||||
onCleanup(
|
||||
opts.subscribe((message) => {
|
||||
if (message.type === "agentManager.worktreeActivity") setActive(new Set(message.active))
|
||||
}),
|
||||
)
|
||||
const working = (id: string, project?: string) =>
|
||||
active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "")
|
||||
const working = (id: string, project?: string): Activity =>
|
||||
active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "") ? "busy" : "idle"
|
||||
return {
|
||||
...busy,
|
||||
agent: (id: string, waiting = false) => busy.agent(id, waiting) || working(id),
|
||||
project: (project: string, id: string | null, waiting = false) =>
|
||||
busy.project(project, id, waiting) || (id !== null && working(id, project)),
|
||||
...activity,
|
||||
agent: (id: string) => strongest([activity.agent(id), working(id)]),
|
||||
project: (project: string, id: string | null) =>
|
||||
strongest([activity.project(project, id), id === null ? "idle" : working(id, project)]),
|
||||
blocked: (id: string, project?: string) => {
|
||||
if (working(id, project) === "busy") return true
|
||||
const items = project && project !== opts.active() ? (opts.projects()[project] ?? []) : opts.managed()
|
||||
return items.some((item) => item.worktreeId === id && opts.inUseFor(item.id))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SectionState,
|
||||
SessionInfo,
|
||||
SessionStatusInfo,
|
||||
WorktreeState,
|
||||
} from "../src/types/messages"
|
||||
import type { SectionState, SessionInfo, WorktreeState } from "../src/types/messages"
|
||||
import { score, strongest, type Activity } from "../src/utils/session-activity"
|
||||
import { LOCAL } from "./navigate"
|
||||
|
||||
export type SidebarSearchState = "idle" | "busy" | "retry" | "waiting"
|
||||
export type SidebarSearchState = Activity
|
||||
|
||||
type SearchItem = {
|
||||
key: string
|
||||
@@ -20,6 +14,7 @@ type SearchItem = {
|
||||
search: string
|
||||
updatedAt: string
|
||||
state: SidebarSearchState
|
||||
busy?: boolean
|
||||
visible: boolean
|
||||
section?: SectionState
|
||||
}
|
||||
@@ -58,14 +53,12 @@ interface SidebarSearchInput {
|
||||
localBranch?: string
|
||||
untitled: string
|
||||
pending: (id: string) => boolean
|
||||
status: (id: string) => SidebarSearchState
|
||||
activityFor: (id: string) => Activity
|
||||
busy: (id: string) => boolean
|
||||
localBusy: boolean
|
||||
}
|
||||
|
||||
const root = (item: SessionInfo) => !item.parentID
|
||||
const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
||||
const score = (state: SidebarSearchState) => (state === "waiting" ? 3 : state === "idle" ? 0 : 2)
|
||||
const newest = (items: SessionInfo[], fallback: string) =>
|
||||
items.reduce((latest, item) => (item.updatedAt > latest ? item.updatedAt : latest), fallback)
|
||||
|
||||
@@ -79,6 +72,7 @@ export function sortSidebarSearch(a: SidebarSearchItem, b: SidebarSearchItem) {
|
||||
}
|
||||
|
||||
export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem[] {
|
||||
const state = input.activityFor
|
||||
const sections = new Map(input.sections.map((item) => [item.id, item]))
|
||||
const owned = new Set(input.worktrees.flatMap((item) => item.sessions.map((session) => session.id)))
|
||||
const local = input.local.filter((session) => root(session) && !input.pending(session.id) && !owned.has(session.id))
|
||||
@@ -92,10 +86,10 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem
|
||||
sessionId: session.id,
|
||||
location: "local" as const,
|
||||
updatedAt: session.updatedAt,
|
||||
state: input.status(session.id),
|
||||
state: state(session.id),
|
||||
visible: true,
|
||||
}))
|
||||
const localState = local.map((session) => input.status(session.id)).sort((a, b) => score(b) - score(a))[0] ?? "idle"
|
||||
const localState = strongest(local.map((session) => state(session.id)))
|
||||
const contexts: SidebarSearchItem[] = [
|
||||
{
|
||||
key: LOCAL,
|
||||
@@ -105,7 +99,7 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem
|
||||
meta: input.localBranch ? [input.localBranch] : [],
|
||||
search: [input.localLabel, input.localBranch].filter(Boolean).join(" "),
|
||||
updatedAt: newest(local, ""),
|
||||
state: input.localBusy && localState === "idle" ? "busy" : localState,
|
||||
state: localState,
|
||||
visible: true,
|
||||
count: local.length,
|
||||
},
|
||||
@@ -132,13 +126,13 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem
|
||||
location: "worktree",
|
||||
worktreeId: wt.id,
|
||||
updatedAt: session.updatedAt,
|
||||
state: input.status(session.id),
|
||||
state: state(session.id),
|
||||
visible: !section?.collapsed,
|
||||
section,
|
||||
})
|
||||
}
|
||||
|
||||
const state = roots.map((session) => input.status(session.id)).sort((a, b) => score(b) - score(a))[0] ?? "idle"
|
||||
const context = strongest(roots.map((session) => state(session.id)))
|
||||
contexts.push({
|
||||
key: `worktree:${wt.id}`,
|
||||
kind: "worktree",
|
||||
@@ -150,7 +144,8 @@ export function buildSidebarSearch(input: SidebarSearchInput): SidebarSearchItem
|
||||
.join(" "),
|
||||
worktreeId: wt.id,
|
||||
updatedAt: newest(roots, wt.createdAt),
|
||||
state: input.busy(wt.id) && state === "idle" ? "busy" : state,
|
||||
state: context,
|
||||
busy: input.busy(wt.id),
|
||||
visible: !section?.collapsed,
|
||||
section,
|
||||
count: roots.length,
|
||||
@@ -168,24 +163,16 @@ interface SidebarSearchDeps {
|
||||
localBranch: Accessor<string | undefined>
|
||||
selection: Accessor<string | null>
|
||||
sessionId: Accessor<string | undefined>
|
||||
statuses: Accessor<Record<string, SessionStatusInfo>>
|
||||
permissions: Accessor<PermissionRequest[]>
|
||||
questions: Accessor<QuestionRequest[]>
|
||||
activityFor: (id: string) => Activity
|
||||
label: (worktree: WorktreeState) => string
|
||||
sessions: (id: string) => SessionInfo[]
|
||||
pending: (id: string) => boolean
|
||||
busy: (id: string) => boolean
|
||||
localBusy: Accessor<boolean>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function createSidebarSearch(deps: SidebarSearchDeps) {
|
||||
const items = createMemo(() => {
|
||||
const statuses = deps.statuses()
|
||||
const blocked = new Set([
|
||||
...deps.permissions().map((item) => item.sessionID),
|
||||
...deps.questions().map((item) => item.sessionID),
|
||||
])
|
||||
return buildSidebarSearch({
|
||||
worktrees: deps.worktrees().map((worktree) => ({
|
||||
worktree,
|
||||
@@ -198,13 +185,8 @@ export function createSidebarSearch(deps: SidebarSearchDeps) {
|
||||
localBranch: deps.localBranch(),
|
||||
untitled: deps.t("agentManager.session.untitled"),
|
||||
pending: deps.pending,
|
||||
status: (id) => {
|
||||
if (blocked.has(id)) return "waiting"
|
||||
const status = statuses[id]?.type
|
||||
return status === "busy" || status === "retry" ? status : "idle"
|
||||
},
|
||||
activityFor: deps.activityFor,
|
||||
busy: deps.busy,
|
||||
localBusy: deps.localBusy(),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,13 +12,15 @@ import { useLanguage } from "../src/context/language"
|
||||
import { SessionTab } from "../src/components/chat/SessionTab"
|
||||
import { SessionTabMenu } from "../src/components/chat/SessionTabMenu"
|
||||
import { SortableTabContainer } from "../src/components/chat/TabDnd"
|
||||
import type { Activity } from "../src/utils/session-activity"
|
||||
import { parseBindingTokens } from "./keybind-tokens"
|
||||
|
||||
/** Individual sortable tab wrapper using the `use:sortable` directive. */
|
||||
export const SortableTab: Component<{
|
||||
tab: SessionInfo
|
||||
active: boolean
|
||||
busy: boolean
|
||||
state: Activity
|
||||
stateLabel: string
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
onSelect: () => void
|
||||
@@ -52,7 +54,8 @@ export const SortableTab: Component<{
|
||||
<SessionTab
|
||||
title={props.tab.title || t("agentManager.session.untitled")}
|
||||
active={props.active}
|
||||
busy={props.busy}
|
||||
state={props.state}
|
||||
stateLabel={props.stateLabel}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
closeTabIndex={props.active ? 0 : -1}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* and content area.
|
||||
*/
|
||||
|
||||
import { Show } from "solid-js"
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import type { Accessor, JSX } from "solid-js"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
|
||||
@@ -17,6 +17,7 @@ import { SortableTab, SortableReviewTab } from "./sortable-tab"
|
||||
import type { TerminalStateControls } from "./terminal"
|
||||
import { isTerminalTabId, renderTerminalTab } from "./terminal"
|
||||
import type { SessionInfo } from "../src/types/messages"
|
||||
import type { Activity } from "../src/utils/session-activity"
|
||||
import { parseBindingTokens } from "./keybind-tokens"
|
||||
|
||||
interface FocusTabDeps {
|
||||
@@ -72,7 +73,8 @@ export interface TabRenderDeps {
|
||||
* getter so Solid tracks its reactivity inside rendered JSX. */
|
||||
visibleTabId: () => string | undefined
|
||||
isPending: (id: string) => boolean
|
||||
isBusy: (id: string) => boolean
|
||||
activityFor: (id: string) => Activity
|
||||
stateLabel: (state: Activity) => string
|
||||
tabLookup: () => Map<string, SessionInfo>
|
||||
adjacentHint: (id: string, activeId: string, ids: string[], prev: string, next: string) => string
|
||||
// Handlers
|
||||
@@ -164,6 +166,7 @@ function renderReviewTab(deps: TabRenderDeps): JSX.Element {
|
||||
|
||||
function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element {
|
||||
const pending = deps.isPending(s.id)
|
||||
const state = createMemo(() => deps.activityFor(s.id))
|
||||
const active = () =>
|
||||
!deps.terms.activeId() &&
|
||||
(pending ? s.id === deps.activePendingId() && !deps.currentSessionID() : s.id === deps.currentSessionID())
|
||||
@@ -181,7 +184,8 @@ function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element {
|
||||
<SortableTab
|
||||
tab={s}
|
||||
active={active() && !deps.reviewActive()}
|
||||
busy={deps.isBusy(s.id)}
|
||||
state={state()}
|
||||
stateLabel={deps.stateLabel(state())}
|
||||
role="tab"
|
||||
selected={deps.visibleTabId() === s.id}
|
||||
tabIndex={deps.visibleTabId() === s.id ? 0 : -1}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { createSignal, type Accessor } from "solid-js"
|
||||
import { zeroID } from "@opencode-ai/core/kilocode/zero-id"
|
||||
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
|
||||
import { parseDiffId } from "./diff-scope-state"
|
||||
import type { useVSCode } from "../src/context/vscode"
|
||||
@@ -33,7 +34,7 @@ export function wireDiffId(id: string) {
|
||||
}
|
||||
|
||||
export function diffDataKey(project: string | undefined, id: string): string {
|
||||
return `${project ?? "single"}\0${id}`
|
||||
return zeroID(project ?? "single", id)
|
||||
}
|
||||
|
||||
export function createWorktreeDiffs(
|
||||
@@ -79,7 +80,7 @@ export function createWorktreeDiffs(
|
||||
}
|
||||
|
||||
const prune = (ids: Set<string>) => {
|
||||
const prefix = `${project() ?? "single"}\0`
|
||||
const prefix = key("")
|
||||
const keys = new Set([
|
||||
...Object.keys(diffDatas()),
|
||||
...Object.keys(diffLoadings()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, createSignal, createMemo, Switch, Match, Show, onMount, onCleanup } from "solid-js"
|
||||
import { Component, createSignal, createMemo, createEffect, Switch, Match, Show, onMount, onCleanup } from "solid-js"
|
||||
import { DataProvider } from "@kilocode/kilo-ui/context/data"
|
||||
import Settings from "./components/settings/Settings"
|
||||
import ProfileView from "./components/profile/ProfileView"
|
||||
@@ -17,6 +17,7 @@ import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrid
|
||||
import { useWorktreeMode } from "./context/worktree-mode"
|
||||
import { useDiffStyle } from "./context/diff-style"
|
||||
import { dispatchAgentManagerEditPreview } from "./utils/agent-manager-events"
|
||||
import { strongest } from "./utils/session-activity"
|
||||
import type { PermissionFileDiff } from "./types/messages"
|
||||
|
||||
// Override the upstream "task" tool renderer with the fully-expanded version
|
||||
@@ -229,6 +230,10 @@ const AppContent: Component = () => {
|
||||
const tabs = useLocalTabs()
|
||||
const server = useServer()
|
||||
const vscode = useVSCode()
|
||||
const activity = createMemo(() =>
|
||||
strongest([session.currentSessionID(), ...(tabs?.ids() ?? [])].map(session.activityFor)),
|
||||
)
|
||||
createEffect(() => vscode.postMessage({ type: "sessionActivity", state: activity() }))
|
||||
|
||||
const handleViewAction = (action: string) => {
|
||||
switch (action) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
tr,
|
||||
} from "./question-dock-utils"
|
||||
import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
|
||||
import { isTextControl } from "../../utils/focus"
|
||||
|
||||
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
||||
const session = useSession()
|
||||
@@ -328,7 +329,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
void store.tab
|
||||
if (store.collapsed || store.editing || confirm()) return
|
||||
requestAnimationFrame(() => {
|
||||
if (!document.hasFocus()) return
|
||||
if (!document.hasFocus() || isTextControl(document.activeElement)) return
|
||||
const btn = root?.querySelector<HTMLButtonElement>("button[data-slot='question-option']:not(:disabled)")
|
||||
btn?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { List } from "@kilocode/kilo-ui/list"
|
||||
import { filterSessions } from "../../hooks/file-mention-utils"
|
||||
import type { SessionSearchItem } from "../../types/messages"
|
||||
import { formatRelativeDate } from "../../utils/date"
|
||||
|
||||
@@ -40,9 +41,9 @@ export function SessionMentionPicker(props: Props) {
|
||||
}}
|
||||
>
|
||||
<List<SessionSearchItem>
|
||||
items={props.sessions}
|
||||
items={(query) => filterSessions(props.sessions, query)}
|
||||
key={(item) => item.id}
|
||||
filterKeys={["title", "worktreeName"]}
|
||||
skipFilter={() => true}
|
||||
search={{ placeholder: "Search sessions", autofocus: true }}
|
||||
onSelect={(item) => {
|
||||
if (item) props.onSelect(item)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { ActivityIcon } from "../shared/ActivityIcon"
|
||||
import type { Activity } from "../../utils/session-activity"
|
||||
|
||||
export const SessionTab: Component<{
|
||||
title: string
|
||||
active: boolean
|
||||
busy: boolean
|
||||
state: Activity
|
||||
stateLabel: string
|
||||
closeTitle: string
|
||||
closeLabel: string
|
||||
keybind?: string
|
||||
@@ -21,7 +23,7 @@ export const SessionTab: Component<{
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
onClose: () => void
|
||||
}> = (props) => (
|
||||
<div class={`am-tab ${props.active ? "am-tab-active" : ""}`}>
|
||||
<div class={`am-tab ${props.active ? "am-tab-active" : ""}`} data-activity={props.state}>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
@@ -41,9 +43,9 @@ export const SessionTab: Component<{
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<Show when={props.busy}>
|
||||
<span class="am-tab-icon">
|
||||
<Spinner class="am-worktree-spinner" />
|
||||
<Show when={props.state !== "idle"}>
|
||||
<span class="am-tab-icon" data-activity={props.state} aria-label={props.stateLabel}>
|
||||
<ActivityIcon state={props.state} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class="am-tab-label">{props.title}</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isPendingTab } from "../../utils/local-tabs"
|
||||
import { useTabScroll } from "../../utils/tab-scroll"
|
||||
import { focusPrompt, focusSelectedTab, focusTabElement, handleTabKey } from "../../utils/tab-navigation"
|
||||
import { setTabWidths } from "../../utils/tab-widths"
|
||||
import { label, running } from "../../utils/session-activity"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { SessionTab } from "./SessionTab"
|
||||
import { SessionTabMenu } from "./SessionTabMenu"
|
||||
@@ -28,10 +29,8 @@ export const SessionTabStrip: Component = () => {
|
||||
if (isPendingTab(id)) return language.t("sidebar.session.newSession")
|
||||
return items().get(id)?.title || language.t("session.untitled")
|
||||
}
|
||||
const working = (id: string) => {
|
||||
const status = session.allStatusMap()[id]
|
||||
return status?.type === "busy" || status?.type === "retry"
|
||||
}
|
||||
const state = (id: string) => (isPendingTab(id) ? "idle" : session.activityFor(id))
|
||||
const working = (id: string) => running(state(id))
|
||||
const middle = (id: string, event: MouseEvent) => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
@@ -62,7 +61,8 @@ export const SessionTabStrip: Component = () => {
|
||||
id,
|
||||
title: title(id),
|
||||
active: tabs.active() === id,
|
||||
busy: working(id),
|
||||
state: state(id),
|
||||
stateLabel: language.t(label(state(id))),
|
||||
pending: isPendingTab(id),
|
||||
})),
|
||||
)
|
||||
@@ -140,7 +140,8 @@ export const SessionTabStrip: Component = () => {
|
||||
<SessionTab
|
||||
title={title(id)}
|
||||
active={tabs.active() === id}
|
||||
busy={working(id)}
|
||||
state={state(id)}
|
||||
stateLabel={language.t(label(state(id)))}
|
||||
closeTitle={language.t("common.closeTab")}
|
||||
closeLabel={language.t("common.closeTab")}
|
||||
role="tab"
|
||||
@@ -171,7 +172,6 @@ export const SessionTabStrip: Component = () => {
|
||||
close: language.t("common.closeTab"),
|
||||
current: language.t("session.tabs.switcher.current"),
|
||||
pending: language.t("session.tabs.switcher.pending"),
|
||||
busy: language.t("session.tabs.switcher.busy"),
|
||||
}}
|
||||
onSelect={tabs.select}
|
||||
onRestore={focusPrompt}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { List } from "@kilocode/kilo-ui/list"
|
||||
import type { ListRef } from "@kilocode/kilo-ui/list"
|
||||
import { Popover } from "@kilocode/kilo-ui/popover"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Show, createEffect, createMemo, createSignal, type Component, type JSX } from "solid-js"
|
||||
import { ActivityIcon } from "../shared/ActivityIcon"
|
||||
import type { Activity } from "../../utils/session-activity"
|
||||
|
||||
interface SessionTabSwitcherItem {
|
||||
id: string
|
||||
title: string
|
||||
active: boolean
|
||||
busy: boolean
|
||||
state: Activity
|
||||
stateLabel: string
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
@@ -23,7 +24,6 @@ interface SessionTabSwitcherProps {
|
||||
close: string
|
||||
current: string
|
||||
pending: string
|
||||
busy: string
|
||||
}
|
||||
onSelect: (id: string) => void
|
||||
onRestore: () => void
|
||||
@@ -144,19 +144,17 @@ export const SessionTabSwitcher: Component<SessionTabSwitcherProps> = (props) =>
|
||||
>
|
||||
{(item) => (
|
||||
<span class="search-menu-row">
|
||||
<span class="search-menu-icon">
|
||||
<Show when={!item.busy} fallback={<Spinner class="search-menu-spinner" />}>
|
||||
<Icon name="speech-bubble" size="small" />
|
||||
</Show>
|
||||
<span class="search-menu-icon" data-activity={item.state} aria-label={item.stateLabel}>
|
||||
<ActivityIcon state={item.state} spinner="search-menu-spinner" />
|
||||
</span>
|
||||
<span class="search-menu-copy">
|
||||
<span class="search-menu-title" dir="auto">
|
||||
{item.title}
|
||||
</span>
|
||||
<Show when={item.busy || item.pending}>
|
||||
<span class="search-menu-meta session-tab-switcher-meta">
|
||||
<Show when={item.busy} fallback={props.labels.pending}>
|
||||
{props.labels.busy}
|
||||
<Show when={item.state !== "idle" || item.pending}>
|
||||
<span class="search-menu-meta session-tab-switcher-meta" data-activity={item.state}>
|
||||
<Show when={item.state !== "idle"} fallback={props.labels.pending}>
|
||||
{item.stateLabel}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useMemory } from "../../context/memory"
|
||||
import { parseModelString } from "../../../../src/shared/provider-model"
|
||||
import { ModelSelectorBase } from "../shared/ModelSelector"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
|
||||
const ContextTab: Component = () => {
|
||||
@@ -139,6 +141,24 @@ const ContextTab: Component = () => {
|
||||
{language.t("settings.context.autoCompaction.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.context.compactionModel.title")}
|
||||
description={language.t("settings.context.compactionModel.description")}
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={parseModelString(config().agent?.compaction?.model ?? undefined)}
|
||||
onSelect={(providerID, modelID) =>
|
||||
updateConfig({
|
||||
agent: { compaction: { model: providerID && modelID ? `${providerID}/${modelID}` : null } },
|
||||
})
|
||||
}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.context.compactionModel.useChatModel")}
|
||||
label={language.t("settings.context.compactionModel.title")}
|
||||
description={language.t("settings.context.compactionModel.description")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.context.compactionLimit.title")}
|
||||
description={language.t("settings.context.compactionLimit.description")}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Match, Switch, type Component, type JSX } from "solid-js"
|
||||
import { running, type Activity } from "../../utils/session-activity"
|
||||
|
||||
export const ActivityIcon: Component<{
|
||||
state: Activity
|
||||
idle?: JSX.Element
|
||||
spinner?: string
|
||||
}> = (props) => (
|
||||
<Switch fallback={props.idle ?? <Icon name="speech-bubble" size="small" />}>
|
||||
<Match when={running(props.state)}>
|
||||
<Spinner class={props.spinner ?? "am-worktree-spinner"} />
|
||||
</Match>
|
||||
<Match when={props.state === "waiting" || props.state === "error"}>
|
||||
<Icon name="warning" size="small" />
|
||||
</Match>
|
||||
<Match when={props.state === "done"}>
|
||||
<Icon name="circle-check" size="small" />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ReviewMessageData } from "../../../src/shared/review-comments"
|
||||
import type {
|
||||
AgentInfo,
|
||||
ContextUsage,
|
||||
FileAttachment,
|
||||
McpStatusEntry,
|
||||
Message,
|
||||
ModelSelection,
|
||||
ModelUsageMap,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionCloseReason,
|
||||
SessionInfo,
|
||||
SessionModelUsage,
|
||||
SessionStatus,
|
||||
SessionStatusInfo,
|
||||
SkillInfo,
|
||||
SuggestionRequest,
|
||||
TodoItem,
|
||||
ToolPart,
|
||||
} from "../types/messages"
|
||||
import type { Activity } from "../utils/session-activity"
|
||||
import type { MessageMutation } from "./session-utils"
|
||||
|
||||
export interface SessionContextValue {
|
||||
// Current session
|
||||
currentSessionID: Accessor<string | undefined>
|
||||
currentSession: Accessor<SessionInfo | undefined>
|
||||
setCurrentSessionID: (id: string | undefined) => void
|
||||
|
||||
// All sessions (sorted most recent first)
|
||||
sessions: Accessor<SessionInfo[]>
|
||||
|
||||
// Session status
|
||||
status: Accessor<SessionStatus>
|
||||
statusInfo: Accessor<SessionStatusInfo>
|
||||
closeReason: Accessor<SessionCloseReason | undefined>
|
||||
statusText: Accessor<string | undefined>
|
||||
busySince: Accessor<number | undefined>
|
||||
submitting: Accessor<boolean>
|
||||
isSubmitting: (id: string) => boolean
|
||||
loading: Accessor<boolean>
|
||||
loadingOlderMessages: Accessor<boolean>
|
||||
hasOlderMessages: Accessor<boolean>
|
||||
messageMutation: Accessor<MessageMutation | undefined>
|
||||
|
||||
// Messages for current session
|
||||
messages: Accessor<Message[]>
|
||||
|
||||
// Messages for current session with soft-reverted turns hidden
|
||||
visibleMessages: Accessor<Message[]>
|
||||
|
||||
// User messages for current session (role === "user")
|
||||
userMessages: Accessor<Message[]>
|
||||
|
||||
// All messages keyed by sessionID (includes child sessions)
|
||||
allMessages: () => Record<string, Message[]>
|
||||
|
||||
// All parts keyed by messageID (includes child sessions)
|
||||
allParts: () => Record<string, Part[]>
|
||||
|
||||
// All session statuses keyed by sessionID (for DataBridge)
|
||||
allStatusMap: () => Record<string, SessionStatusInfo>
|
||||
|
||||
activityFor: (sessionID: string | undefined) => Activity
|
||||
inUseFor: (sessionID: string) => boolean
|
||||
|
||||
// Parts for a specific message
|
||||
getParts: (messageID: string) => Part[]
|
||||
|
||||
// Tool parts for a specific session, maintained incrementally for streaming views
|
||||
getSessionToolParts: (sessionID: string) => ToolPart[]
|
||||
getSessionToolCount: (sessionID: string) => number
|
||||
|
||||
// Hidden after model changes so switching models can clear stale provider errors
|
||||
// without removing messages and their checkpoint restore actions.
|
||||
isErrorHidden: (messageID: string) => boolean
|
||||
|
||||
// Move stashed parts into the reactive store for the given message IDs.
|
||||
hydrateParts: (messageIDs: string[]) => void
|
||||
|
||||
// Todos for current session
|
||||
todos: Accessor<TodoItem[]>
|
||||
|
||||
// Pending permission requests (unscoped — all tracked sessions)
|
||||
permissions: Accessor<PermissionRequest[]>
|
||||
respondingPermissions: Accessor<Set<string>>
|
||||
|
||||
// Pending question requests (unscoped — all tracked sessions)
|
||||
questions: Accessor<QuestionRequest[]>
|
||||
questionErrors: Accessor<Set<string>>
|
||||
suggestions: Accessor<SuggestionRequest[]>
|
||||
suggestionErrors: Accessor<Set<string>>
|
||||
respondingSuggestions: Accessor<Set<string>>
|
||||
|
||||
// Scoped permissions/questions — filtered to a session's family (self + subagents)
|
||||
scopedPermissions: (sessionID: string | undefined) => PermissionRequest[]
|
||||
scopedQuestions: (sessionID: string | undefined) => QuestionRequest[]
|
||||
scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[]
|
||||
|
||||
// Model selection (global, extension-lifetime)
|
||||
selected: (sessionID?: string) => ModelSelection | null
|
||||
modelForAgent: (agent: string) => ModelSelection | null
|
||||
selectModel: (providerID: string, modelID: string, sessionID?: string) => void
|
||||
|
||||
// Cost and context usage for the current session
|
||||
costBreakdown: Accessor<Array<{ label: string; cost: number }>>
|
||||
contextUsage: Accessor<ContextUsage | undefined>
|
||||
modelUsage: Accessor<SessionModelUsage | undefined>
|
||||
|
||||
// Skills loaded from the CLI backend
|
||||
skills: Accessor<SkillInfo[]>
|
||||
refreshSkills: () => void
|
||||
removeSkill: (location: string) => void
|
||||
|
||||
// Agent/mode selection (per-session)
|
||||
agents: Accessor<AgentInfo[]>
|
||||
allAgents: Accessor<AgentInfo[]>
|
||||
removeAgent: (name: string) => void
|
||||
removeMcp: (name: string) => void
|
||||
|
||||
// MCP server status (runtime connect/disconnect)
|
||||
mcpStatus: Accessor<Record<string, McpStatusEntry>>
|
||||
mcpLoading: Accessor<string | null>
|
||||
connectMcp: (name: string) => void
|
||||
disconnectMcp: (name: string) => void
|
||||
authenticateMcp: (name: string) => void
|
||||
selectedAgent: (sessionID?: string) => string
|
||||
selectAgent: (name: string, sessionID?: string) => void
|
||||
getSessionAgent: (sessionID: string) => string
|
||||
setSessionModel: (sessionID: string, providerID: string, modelID: string) => void
|
||||
setSessionAgent: (sessionID: string, name: string) => void
|
||||
setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void
|
||||
|
||||
// Thinking variant for the selected model
|
||||
variantList: (sessionID?: string) => string[]
|
||||
currentVariant: (sessionID?: string) => string | undefined
|
||||
variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined
|
||||
selectVariant: (value: string | undefined, sessionID?: string) => void
|
||||
|
||||
// Model favorites
|
||||
recentModels: Accessor<ModelSelection[]>
|
||||
modelUsageHistory: Accessor<ModelUsageMap>
|
||||
favoriteModels: Accessor<ModelSelection[]>
|
||||
toggleFavorite: (providerID: string, modelID: string) => void
|
||||
|
||||
// Revert/undo state for the current session
|
||||
revert: Accessor<SessionInfo["revert"]>
|
||||
revertedCount: Accessor<number>
|
||||
summary: Accessor<SessionInfo["summary"]>
|
||||
|
||||
// Live worktree diff stats (polled from CLI backend)
|
||||
worktreeStats: Accessor<{ files: number; additions: number; deletions: number } | undefined>
|
||||
|
||||
// Actions
|
||||
revertSession: (messageID: string, partID?: string) => void
|
||||
unrevertSession: () => void
|
||||
deleteQueuedMessage: (sessionID: string, messageID: string) => void
|
||||
sendMessage: (
|
||||
text: string,
|
||||
providerID?: string,
|
||||
modelID?: string,
|
||||
files?: FileAttachment[],
|
||||
draftID?: string,
|
||||
context?: string,
|
||||
review?: ReviewMessageData,
|
||||
origin?: string | null,
|
||||
) => void
|
||||
sendCommand: (
|
||||
command: string,
|
||||
args: string,
|
||||
providerID?: string,
|
||||
modelID?: string,
|
||||
files?: FileAttachment[],
|
||||
draftID?: string,
|
||||
context?: string,
|
||||
origin?: string | null,
|
||||
overrides?: { agent?: string; model?: string; variant?: string },
|
||||
) => void
|
||||
abort: () => void
|
||||
compact: () => void
|
||||
respondToPermission: (
|
||||
permissionId: string,
|
||||
response: "once" | "always" | "reject",
|
||||
approvedAlways: string[],
|
||||
deniedAlways: string[],
|
||||
) => void
|
||||
replyToQuestion: (requestID: string, answers: string[][]) => void
|
||||
rejectQuestion: (requestID: string) => void
|
||||
closeQuestion: (requestID: string) => void
|
||||
acceptSuggestion: (requestID: string, index: number) => void
|
||||
dismissSuggestion: (requestID: string) => void
|
||||
createSession: () => void
|
||||
clearCurrentSession: () => void
|
||||
loadSessions: () => void
|
||||
loadOlderMessages: () => boolean
|
||||
selectSession: (id: string, options?: { focus?: boolean }) => void
|
||||
releaseSession: (id: string) => void
|
||||
deleteSession: (id: string) => void
|
||||
renameSession: (id: string, title: string) => void
|
||||
exportSessionTranscript: (id: string) => void
|
||||
syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void
|
||||
unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void
|
||||
|
||||
// Cloud session preview
|
||||
cloudPreviewId: Accessor<string | null>
|
||||
selectCloudSession: (cloudSessionId: string) => void
|
||||
draftSessionID: Accessor<string | undefined>
|
||||
setDraftSessionID: (id: string | undefined) => void
|
||||
userClearedSession: Accessor<boolean>
|
||||
}
|
||||
@@ -117,6 +117,45 @@ export function childID(part: TaskPart): string | undefined {
|
||||
return part.metadata?.sessionId ?? part.state?.metadata?.sessionId
|
||||
}
|
||||
|
||||
export function inUse(
|
||||
family: ReadonlySet<string>,
|
||||
statuses: Record<string, { type: string }>,
|
||||
prompts: readonly { sessionID: string }[],
|
||||
): boolean {
|
||||
return (
|
||||
[...family].some((id) => !!statuses[id] && statuses[id].type !== "idle") ||
|
||||
prompts.some((item) => family.has(item.sessionID))
|
||||
)
|
||||
}
|
||||
|
||||
export function ancestry(
|
||||
sessions: Record<string, ParentSession>,
|
||||
tools: Record<string, readonly TaskPart[]>,
|
||||
outcomes: Record<string, ParentSession | undefined>,
|
||||
) {
|
||||
const parents = new Map<string, string>()
|
||||
for (const [id, parts] of Object.entries(tools)) {
|
||||
for (const part of parts) {
|
||||
const child = childID(part)
|
||||
if (child) parents.set(child, id)
|
||||
}
|
||||
}
|
||||
for (const [id, close] of Object.entries(outcomes)) {
|
||||
if (close?.parentID) parents.set(id, close.parentID)
|
||||
}
|
||||
for (const [id, session] of Object.entries(sessions)) {
|
||||
if (session.parentID === null) parents.delete(id)
|
||||
if (session.parentID) parents.set(id, session.parentID)
|
||||
}
|
||||
const children = new Map<string, string[]>()
|
||||
for (const [child, parent] of parents) {
|
||||
const ids = children.get(parent) ?? []
|
||||
ids.push(child)
|
||||
children.set(parent, ids)
|
||||
}
|
||||
return { parents, children }
|
||||
}
|
||||
|
||||
export function latestTaskPart(partID: string | undefined, child: string | undefined, parts: readonly TaskPart[]) {
|
||||
if (!partID || !child) return false
|
||||
return parts.findLast((part) => childID(part) === child)?.id === partID
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useContext,
|
||||
createSignal,
|
||||
createMemo,
|
||||
createComputed,
|
||||
createEffect,
|
||||
on,
|
||||
onMount,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
batch,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import type { ParentComponent, Accessor } from "solid-js"
|
||||
import type { ParentComponent } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useVSCode } from "./vscode"
|
||||
import { useServer } from "./server"
|
||||
@@ -62,6 +63,8 @@ import {
|
||||
buildCostBreakdown,
|
||||
buildSessionToolParts,
|
||||
childID,
|
||||
ancestry,
|
||||
inUse,
|
||||
dropSet,
|
||||
emptyPageState,
|
||||
messageParts,
|
||||
@@ -70,7 +73,6 @@ import {
|
||||
removeSessionToolPartsForMessage,
|
||||
revertPromptState,
|
||||
upsertSessionToolPart,
|
||||
type MessageMutation,
|
||||
type MessagePageState,
|
||||
} from "./session-utils"
|
||||
import { Identifier } from "../utils/id"
|
||||
@@ -93,6 +95,8 @@ import { clearIfOn, createCloudPrune } from "./session-cloud-prune"
|
||||
import { isSameSessionTree } from "./model-usage"
|
||||
import { createDraftAgentSeed, resolvePromptAgent } from "./session-agent"
|
||||
import { createModelSelector } from "./session-model-selector"
|
||||
import { activities, type Activity } from "../utils/session-activity"
|
||||
import type { SessionContextValue } from "./session-types"
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
const MESSAGE_PAGE_LIMIT = 80
|
||||
@@ -114,189 +118,9 @@ interface SessionStore {
|
||||
modelUsage: Record<string, { requestID: string; data?: SessionModelUsage }>
|
||||
}
|
||||
|
||||
interface SessionContextValue {
|
||||
// Current session
|
||||
currentSessionID: Accessor<string | undefined>
|
||||
currentSession: Accessor<SessionInfo | undefined>
|
||||
setCurrentSessionID: (id: string | undefined) => void
|
||||
|
||||
// All sessions (sorted most recent first)
|
||||
sessions: Accessor<SessionInfo[]>
|
||||
|
||||
// Session status
|
||||
status: Accessor<SessionStatus>
|
||||
statusInfo: Accessor<SessionStatusInfo>
|
||||
closeReason: Accessor<SessionCloseReason | undefined>
|
||||
statusText: Accessor<string | undefined>
|
||||
busySince: Accessor<number | undefined>
|
||||
submitting: Accessor<boolean>
|
||||
isSubmitting: (id: string) => boolean
|
||||
loading: Accessor<boolean>
|
||||
loadingOlderMessages: Accessor<boolean>
|
||||
hasOlderMessages: Accessor<boolean>
|
||||
messageMutation: Accessor<MessageMutation | undefined>
|
||||
|
||||
// Messages for current session
|
||||
messages: Accessor<Message[]>
|
||||
|
||||
// Messages for current session with soft-reverted turns hidden
|
||||
visibleMessages: Accessor<Message[]>
|
||||
|
||||
// User messages for current session (role === "user")
|
||||
userMessages: Accessor<Message[]>
|
||||
|
||||
// All messages keyed by sessionID (includes child sessions)
|
||||
allMessages: () => Record<string, Message[]>
|
||||
|
||||
// All parts keyed by messageID (includes child sessions)
|
||||
allParts: () => Record<string, Part[]>
|
||||
|
||||
// All session statuses keyed by sessionID (for DataBridge)
|
||||
allStatusMap: () => Record<string, SessionStatusInfo>
|
||||
|
||||
// Parts for a specific message
|
||||
getParts: (messageID: string) => Part[]
|
||||
|
||||
// Tool parts for a specific session, maintained incrementally for streaming views
|
||||
getSessionToolParts: (sessionID: string) => ToolPart[]
|
||||
getSessionToolCount: (sessionID: string) => number
|
||||
|
||||
// Hidden after model changes so switching models can clear stale provider errors
|
||||
// without removing messages and their checkpoint restore actions.
|
||||
isErrorHidden: (messageID: string) => boolean
|
||||
|
||||
// Move stashed parts into the reactive store for the given message IDs.
|
||||
hydrateParts: (messageIDs: string[]) => void
|
||||
|
||||
// Todos for current session
|
||||
todos: Accessor<TodoItem[]>
|
||||
|
||||
// Pending permission requests (unscoped — all tracked sessions)
|
||||
permissions: Accessor<PermissionRequest[]>
|
||||
respondingPermissions: Accessor<Set<string>>
|
||||
|
||||
// Pending question requests (unscoped — all tracked sessions)
|
||||
questions: Accessor<QuestionRequest[]>
|
||||
questionErrors: Accessor<Set<string>>
|
||||
suggestions: Accessor<SuggestionRequest[]>
|
||||
suggestionErrors: Accessor<Set<string>>
|
||||
respondingSuggestions: Accessor<Set<string>>
|
||||
|
||||
// Scoped permissions/questions — filtered to a session's family (self + subagents)
|
||||
scopedPermissions: (sessionID: string | undefined) => PermissionRequest[]
|
||||
scopedQuestions: (sessionID: string | undefined) => QuestionRequest[]
|
||||
scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[]
|
||||
|
||||
// Model selection (global, extension-lifetime)
|
||||
selected: (sessionID?: string) => ModelSelection | null
|
||||
modelForAgent: (agent: string) => ModelSelection | null
|
||||
selectModel: (providerID: string, modelID: string, sessionID?: string) => void
|
||||
|
||||
// Cost and context usage for the current session
|
||||
costBreakdown: Accessor<Array<{ label: string; cost: number }>>
|
||||
contextUsage: Accessor<ContextUsage | undefined>
|
||||
modelUsage: Accessor<SessionModelUsage | undefined>
|
||||
|
||||
// Skills loaded from the CLI backend
|
||||
skills: Accessor<SkillInfo[]>
|
||||
refreshSkills: () => void
|
||||
removeSkill: (location: string) => void
|
||||
|
||||
// Agent/mode selection (per-session)
|
||||
agents: Accessor<AgentInfo[]>
|
||||
allAgents: Accessor<AgentInfo[]>
|
||||
removeAgent: (name: string) => void
|
||||
removeMcp: (name: string) => void
|
||||
|
||||
// MCP server status (runtime connect/disconnect)
|
||||
mcpStatus: Accessor<Record<string, McpStatusEntry>>
|
||||
mcpLoading: Accessor<string | null>
|
||||
connectMcp: (name: string) => void
|
||||
disconnectMcp: (name: string) => void
|
||||
authenticateMcp: (name: string) => void
|
||||
selectedAgent: (sessionID?: string) => string
|
||||
selectAgent: (name: string, sessionID?: string) => void
|
||||
getSessionAgent: (sessionID: string) => string
|
||||
setSessionModel: (sessionID: string, providerID: string, modelID: string) => void
|
||||
setSessionAgent: (sessionID: string, name: string) => void
|
||||
setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void
|
||||
|
||||
// Thinking variant for the selected model
|
||||
variantList: (sessionID?: string) => string[]
|
||||
currentVariant: (sessionID?: string) => string | undefined
|
||||
variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined
|
||||
selectVariant: (value: string | undefined, sessionID?: string) => void
|
||||
|
||||
// Model favorites
|
||||
recentModels: Accessor<ModelSelection[]>
|
||||
modelUsageHistory: Accessor<ModelUsageMap>
|
||||
favoriteModels: Accessor<ModelSelection[]>
|
||||
toggleFavorite: (providerID: string, modelID: string) => void
|
||||
|
||||
// Revert/undo state for the current session
|
||||
revert: Accessor<SessionInfo["revert"]>
|
||||
revertedCount: Accessor<number>
|
||||
summary: Accessor<SessionInfo["summary"]>
|
||||
|
||||
// Live worktree diff stats (polled from CLI backend)
|
||||
worktreeStats: Accessor<{ files: number; additions: number; deletions: number } | undefined>
|
||||
|
||||
// Actions
|
||||
revertSession: (messageID: string, partID?: string) => void
|
||||
unrevertSession: () => void
|
||||
deleteQueuedMessage: (sessionID: string, messageID: string) => void
|
||||
sendMessage: (
|
||||
text: string,
|
||||
providerID?: string,
|
||||
modelID?: string,
|
||||
files?: FileAttachment[],
|
||||
draftID?: string,
|
||||
context?: string,
|
||||
review?: ReviewMessageData,
|
||||
origin?: string | null,
|
||||
) => void
|
||||
sendCommand: (
|
||||
command: string,
|
||||
args: string,
|
||||
providerID?: string,
|
||||
modelID?: string,
|
||||
files?: FileAttachment[],
|
||||
draftID?: string,
|
||||
context?: string,
|
||||
origin?: string | null,
|
||||
overrides?: { agent?: string; model?: string; variant?: string },
|
||||
) => void
|
||||
abort: () => void
|
||||
compact: () => void
|
||||
respondToPermission: (
|
||||
permissionId: string,
|
||||
response: "once" | "always" | "reject",
|
||||
approvedAlways: string[],
|
||||
deniedAlways: string[],
|
||||
) => void
|
||||
replyToQuestion: (requestID: string, answers: string[][]) => void
|
||||
rejectQuestion: (requestID: string) => void
|
||||
closeQuestion: (requestID: string) => void
|
||||
acceptSuggestion: (requestID: string, index: number) => void
|
||||
dismissSuggestion: (requestID: string) => void
|
||||
createSession: () => void
|
||||
clearCurrentSession: () => void
|
||||
loadSessions: () => void
|
||||
loadOlderMessages: () => boolean
|
||||
selectSession: (id: string, options?: { focus?: boolean }) => void
|
||||
releaseSession: (id: string) => void
|
||||
deleteSession: (id: string) => void
|
||||
renameSession: (id: string, title: string) => void
|
||||
exportSessionTranscript: (id: string) => void
|
||||
syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void
|
||||
unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void
|
||||
|
||||
// Cloud session preview
|
||||
cloudPreviewId: Accessor<string | null>
|
||||
selectCloudSession: (cloudSessionId: string) => void
|
||||
draftSessionID: Accessor<string | undefined>
|
||||
setDraftSessionID: (id: string | undefined) => void
|
||||
userClearedSession: Accessor<boolean>
|
||||
interface CloseState {
|
||||
reason: SessionCloseReason
|
||||
parentID?: string
|
||||
}
|
||||
|
||||
export const SessionContext = createContext<SessionContextValue>()
|
||||
@@ -322,7 +146,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
// Per-session status map — keyed by sessionID
|
||||
const [statusMap, setStatusMap] = createStore<Record<string, SessionStatusInfo>>({})
|
||||
const [closeMap, setCloseMap] = createStore<Record<string, SessionCloseReason | undefined>>({})
|
||||
const [closeMap, setCloseMap] = createStore<Record<string, CloseState | undefined>>({})
|
||||
const [busySinceMap, setBusySinceMap] = createStore<Record<string, number>>({})
|
||||
const [submissionMap, setSubmissionMap] = createStore<Record<string, number>>({})
|
||||
const pendingSubmissions = new Map<string, string>()
|
||||
@@ -339,7 +163,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
const status = () => statusInfo().type as SessionStatus
|
||||
const closeReason = () => {
|
||||
const id = currentSessionID()
|
||||
return id ? closeMap[id] : undefined
|
||||
return id ? closeMap[id]?.reason : undefined
|
||||
}
|
||||
const clearClose = (id: string) =>
|
||||
setCloseMap(
|
||||
@@ -990,6 +814,15 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
if (message.sessionID) patchPage(message.sessionID, { loadingInitial: false, loadingOlder: false })
|
||||
}
|
||||
|
||||
function closed(message: Extract<ExtensionMessage, { type: "sessionTurnClosed" }>) {
|
||||
if (message.reason === "completed" && closeMap[message.sessionID]?.reason === "error") return
|
||||
setCloseMap(message.sessionID, { reason: message.reason, parentID: message.parentID })
|
||||
}
|
||||
|
||||
function failed(id: string) {
|
||||
setCloseMap(id, { reason: "error", parentID: store.sessions[id]?.parentID ?? undefined })
|
||||
}
|
||||
|
||||
function toggleFavorite(providerID: string, modelID: string) {
|
||||
const key = `${providerID}/${modelID}`
|
||||
const idx = store.favoriteModels.findIndex((f) => `${f.providerID}/${f.modelID}` === key)
|
||||
@@ -1089,7 +922,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
break
|
||||
|
||||
case "sessionTurnClosed":
|
||||
setCloseMap(message.sessionID, message.reason)
|
||||
closed(message)
|
||||
break
|
||||
|
||||
case "todoUpdated":
|
||||
@@ -1137,6 +970,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
if (!message.error || message.error.name === "MessageAbortedError") break
|
||||
const sid = message.sessionID ?? currentSessionID()
|
||||
if (!sid) break
|
||||
failed(sid)
|
||||
// Find the last user message in this session to use as parentID
|
||||
const msgs = store.messages[sid] ?? []
|
||||
const parent = [...msgs].reverse().find((m) => m.role === "user")
|
||||
@@ -1450,6 +1284,19 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// proxy creation for each message object dominated the trace (~900ms).
|
||||
// "prepend" / "reconcile": reconcile to preserve existing proxies.
|
||||
if (mode === "replace") {
|
||||
const keep = new Set(merged.map((message) => message.id))
|
||||
const removed = current.filter((message) => !keep.has(message.id)).map((message) => message.id)
|
||||
clearHiddenErrors(removed)
|
||||
setStore(
|
||||
"parts",
|
||||
produce((parts) => {
|
||||
for (const id of removed) {
|
||||
stash.remove(id)
|
||||
optimisticParts.delete(id)
|
||||
delete parts[id]
|
||||
}
|
||||
}),
|
||||
)
|
||||
setStore("messages", sessionID, merged)
|
||||
} else {
|
||||
setStore("messages", sessionID, reconcile(merged, { key: "id" }))
|
||||
@@ -1663,7 +1510,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
if (removedSessions.has(sessionID)) return
|
||||
const shouldAbort = aborts.update(sessionID, newStatus)
|
||||
confirmSubmissions(sessionID)
|
||||
const prev = statusMap[sessionID] ?? { type: "idle" }
|
||||
const prev = statusMap[sessionID]?.type ?? "idle"
|
||||
const info: SessionStatusInfo =
|
||||
newStatus === "retry"
|
||||
? { type: "retry", attempt: attempt ?? 0, message: message ?? "", next: next ?? 0 }
|
||||
@@ -1672,7 +1519,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
: { type: newStatus }
|
||||
setStatusMap(sessionID, info)
|
||||
// Track busy start time and discard the previous turn's terminal state.
|
||||
if (prev.type === "idle" && newStatus !== "idle") {
|
||||
if (prev === "idle" && newStatus !== "idle") {
|
||||
clearClose(sessionID)
|
||||
if (!busySinceMap[sessionID]) setBusySinceMap(sessionID, Date.now())
|
||||
}
|
||||
@@ -1876,8 +1723,14 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return ids
|
||||
}
|
||||
|
||||
const lineage = createMemo(() => ancestry(store.sessions, store.toolParts, closeMap))
|
||||
|
||||
function sessionFamily(rootID: string): Set<string> {
|
||||
return sessionIDs(rootID, (sid) => store.messages[sid] ?? [])
|
||||
const ids = new Set([rootID])
|
||||
for (const id of ids) {
|
||||
for (const child of lineage().children.get(id) ?? []) ids.add(child)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function modelUsageRelated(sessionID: string, parentID?: string | null): boolean {
|
||||
@@ -1919,6 +1772,27 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return suggestions().filter((item) => family.has(item.sessionID))
|
||||
}
|
||||
|
||||
const [activityMap, setActivityMap] = createStore<Record<string, Activity>>({})
|
||||
createComputed(() => {
|
||||
const connection = server.connectionState()
|
||||
setActivityMap(
|
||||
reconcile(
|
||||
activities({
|
||||
parents: lineage().parents,
|
||||
statuses: statusMap,
|
||||
outcomes: closeMap,
|
||||
blocked: [...permissions(), ...questions().filter((item) => item.blocking !== false), ...suggestions()].map(
|
||||
(item) => item.sessionID,
|
||||
),
|
||||
submitting: Object.keys(submissionMap),
|
||||
disconnected: connection !== "connected",
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const activityFor = (id: string | undefined): Activity => (id ? (activityMap[id] ?? "idle") : "idle")
|
||||
const inUseFor = (id: string) => inUse(sessionFamily(id), statusMap, [...permissions(), ...questions()])
|
||||
|
||||
function handleTodoUpdated(sessionID: string, items: TodoItem[]) {
|
||||
setStore("todos", sessionID, items)
|
||||
}
|
||||
@@ -2997,6 +2871,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
allMessages,
|
||||
allParts,
|
||||
allStatusMap,
|
||||
activityFor,
|
||||
inUseFor,
|
||||
recentModels: () => store.recentModels,
|
||||
modelUsageHistory: () => store.modelUsageHistory,
|
||||
favoriteModels: () => store.favoriteModels,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type { FileAttachment, FileSearchItem, SessionSearchItem } from "../types/messages"
|
||||
import { GIT_CHANGES_MENTION } from "./git-changes-context-utils"
|
||||
import { TERMINAL_MENTION } from "./terminal-context-utils"
|
||||
@@ -101,6 +102,13 @@ export function buildMentionResults(
|
||||
]
|
||||
}
|
||||
|
||||
export function filterSessions(sessions: SessionSearchItem[], query: string) {
|
||||
if (!query) return sessions.slice(0, 50)
|
||||
return fuzzysort
|
||||
.go(query.toLowerCase(), sessions, { keys: ["title", "worktreeName"], limit: 50 })
|
||||
.map((item) => item.obj)
|
||||
}
|
||||
|
||||
/** Single-line, safe display/filename forms for a session mention. */
|
||||
export function sessionMentionText(title: string) {
|
||||
return title.replace(/\s+/g, " ").trim()
|
||||
|
||||
+4
@@ -1034,6 +1034,10 @@ export const dict = {
|
||||
"settings.context.autoCompaction.title": "ضغط تلقائي",
|
||||
"settings.context.autoCompaction.description": "ضغط السياق تلقائياً قبل أن يصل إلى الحد",
|
||||
"settings.context.compaction.title": "الضغط",
|
||||
"settings.context.compactionModel.title": "نموذج الضغط",
|
||||
"settings.context.compactionModel.description":
|
||||
"النموذج المستخدم للضغط التلقائي واليدوي. اتركه فارغاً لاستخدام نموذج الدردشة. تعتمد التكلفة والسرعة وجودة الملخص على النموذج.",
|
||||
"settings.context.compactionModel.useChatModel": "استخدام نموذج الدردشة",
|
||||
"settings.context.compactionLimit.title": "حد الضغط التلقائي",
|
||||
"settings.context.compactionLimit.description":
|
||||
"اضغط عندما يصل السياق إلى هذه النسبة المئوية من نافذة النموذج. اتركه فارغاً لاستخدام هامش الأمان فقط.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user