mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 04:46:43 +08:00
Merge remote-tracking branch 'origin/main' into re-disable-pull-10924
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Allow persistent approval for shell access to a specific global skill directory while keeping other Kilo configuration protected.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Release project file handles immediately after reads on Windows so editors and tools can replace existing files without restarting Kilo.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Improve question option visibility in light VS Code themes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Preserve the selected model reasoning variant when forking a session.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Surface an invalid Kilo `indexing.model` configuration as an indexing Error status instead of silently falling back to the default model.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/sdk": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Inherit sandbox state when a sandboxed agent starts new Agent Manager sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Use the IntelliJ stop icon for the JetBrains prompt stop button.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show elapsed time in the JetBrains progress footer while Kilo is working.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Add a separator before the JetBrains prompt send button.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Match the JetBrains prompt send-button right padding to the bottom padding.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix JetBrains rollback and redo scrolling and align plan custom response font with the prompt input.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Match the JetBrains prompt send icon color to the scroll-to-bottom button across themes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-indexing": patch
|
||||
---
|
||||
|
||||
Retry remote embedder validation up to twice on failure
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep Task tool subagents out of Agent Manager tabs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remember initial session prompts when navigating chat input history with the arrow keys.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Report active CLI and VS Code app and session presence.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Enforce read and ignore permissions when file mentions add content to a prompt.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Stop active Agent Manager sessions and their subagents when a session tab or the Agent Manager tab closes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Hovering or focusing a bar in the task timeline now highlights the matching tool call in the transcript, making it easier to see which bar belongs to which tool.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add the prompt enhancer to the New Worktree dialog, so prompts can be enhanced before creating worktree sessions.
|
||||
@@ -802,20 +802,20 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
|
||||
"@ai-sdk/xai@3.0.92": "patches/@ai-sdk%2Fxai@3.0.92.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
@@ -4754,6 +4754,8 @@
|
||||
|
||||
"@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
|
||||
|
||||
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
|
||||
@@ -4764,8 +4766,6 @@
|
||||
|
||||
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
|
||||
|
||||
"@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { expect, test } from "bun:test"
|
||||
|
||||
function setup() {
|
||||
const calls: Array<{ url: string; method: string; body: unknown }> = []
|
||||
const win = {
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(input, init)
|
||||
calls.push({ url: req.url, method: req.method, body: await req.json() })
|
||||
return new Response(JSON.stringify({ permission: { edit: { "*": "allow" } } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
},
|
||||
}
|
||||
// client.ts binds window.fetch once at import time, so every test must share the
|
||||
// same window whose fetch writes into a swappable calls array.
|
||||
let calls: Array<{ url: string; method: string; body: unknown }> = []
|
||||
|
||||
const win = {
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(input, init)
|
||||
calls.push({ url: req.url, method: req.method, body: await req.json() })
|
||||
return new Response(JSON.stringify({ permission: { edit: { "*": "allow" } } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function setup() {
|
||||
calls = []
|
||||
Object.defineProperty(globalThis, "window", { value: win, configurable: true })
|
||||
return calls
|
||||
}
|
||||
@@ -46,3 +50,24 @@ test("config writes include the selected directory", async () => {
|
||||
unset: [["indexing", "model"]],
|
||||
})
|
||||
})
|
||||
|
||||
test("viewed snapshots post the presence payload against the selected directory", async () => {
|
||||
const calls = setup()
|
||||
const client = await import("./client")
|
||||
const query = { url: "http://kilo:secret@127.0.0.1:4097", dir: "/tmp/project" }
|
||||
const viewer = { id: "11111111-1111-4111-8111-111111111111", active: false }
|
||||
|
||||
await client.viewProjectSessions(query, viewer, ["ses_selected", "ses_terminal"], [])
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const viewed = calls[0]
|
||||
expect(viewed.method).toBe("POST")
|
||||
expect(new URL(viewed.url).pathname).toBe("/session/viewed")
|
||||
expect(new URL(viewed.url).searchParams.get("directory")).toBe("/tmp/project")
|
||||
expect(viewed.body).toEqual({
|
||||
viewer: { id: "11111111-1111-4111-8111-111111111111", active: false },
|
||||
attached: ["ses_selected", "ses_terminal"],
|
||||
visible: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -628,9 +628,14 @@ export async function removeProjectPty(input: Query, pty: string) {
|
||||
return demand("Remove terminal", result)
|
||||
}
|
||||
|
||||
export async function viewProjectSessions(input: ProjectQuery, focused: string[], open: string[]) {
|
||||
export async function viewProjectSessions(
|
||||
input: ProjectQuery,
|
||||
viewer: { id: string; active: boolean },
|
||||
attached: string[],
|
||||
visible: string[],
|
||||
) {
|
||||
const sdk = client(input)
|
||||
const result = await sdk.session.viewed({ directory: input.dir, focused, open })
|
||||
const result = await sdk.session.viewed({ directory: input.dir, viewer, attached, visible })
|
||||
return demand("Viewed sessions", result)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
normalizeConsoleDiffStyle,
|
||||
normalizeContextSidebarWidth,
|
||||
} from "../config/state/console"
|
||||
import { sender } from "./project-console-presence-sender"
|
||||
import { GhosttyTerminal } from "./terminal/GhosttyTerminal"
|
||||
|
||||
const ui = new Set(["3017", "3018"])
|
||||
@@ -147,6 +148,7 @@ function terminalKey(url: string, item: ProjectTerminalItem) {
|
||||
export function ProjectConsoleRoute() {
|
||||
const loc = useLocation()
|
||||
const params = useParams()
|
||||
const viewerId = crypto.randomUUID()
|
||||
const search = createMemo(() => new URLSearchParams(loc.search))
|
||||
const fallback = () => base(search())
|
||||
const [url, setUrl] = createSignal(fallback())
|
||||
@@ -660,19 +662,37 @@ export function ProjectConsoleRoute() {
|
||||
if (item) clearUnread(item)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
let lastInput: { url: string; dir: string } | undefined
|
||||
const queue = sender((err) => console.warn(`Viewed sessions: ${errMsg(err)}`))
|
||||
|
||||
function sendSnapshot(force = false) {
|
||||
const base = query()
|
||||
const data = snap()
|
||||
if (!base || !data) return
|
||||
const focused = activeSessionID()
|
||||
const open = terminals().flatMap((item) => {
|
||||
const selected = activeSessionID()
|
||||
const ids = new Set<string>()
|
||||
if (selected) ids.add(selected)
|
||||
for (const item of terminals()) {
|
||||
const id = sessionID(item)
|
||||
return id ? [id] : []
|
||||
})
|
||||
void viewProjectSessions({ url: base.url, dir: data.project.worktree }, focused ? [focused] : [], open).catch(
|
||||
() => {},
|
||||
if (id) ids.add(id)
|
||||
}
|
||||
const input = { url: base.url, dir: data.project.worktree }
|
||||
const key = input.url + "|" + input.dir + "|" + [...ids].sort().join(",")
|
||||
lastInput = input
|
||||
queue.push(
|
||||
{
|
||||
key,
|
||||
run: async () => {
|
||||
await viewProjectSessions(input, { id: viewerId, active: false }, [...ids], [])
|
||||
},
|
||||
},
|
||||
force,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => sendSnapshot())
|
||||
|
||||
const checkin = window.setInterval(() => sendSnapshot(true), 60_000)
|
||||
|
||||
createEffect(() => {
|
||||
const base = query()
|
||||
@@ -704,6 +724,19 @@ export function ProjectConsoleRoute() {
|
||||
onCleanup(() => {
|
||||
if (events.timer) window.clearTimeout(events.timer)
|
||||
if (resize.timer) window.clearTimeout(resize.timer)
|
||||
window.clearInterval(checkin)
|
||||
if (lastInput) {
|
||||
const input = lastInput
|
||||
queue.push(
|
||||
{
|
||||
key: input.url + "|" + input.dir + "|",
|
||||
run: async () => {
|
||||
await viewProjectSessions(input, { id: viewerId, active: false }, [], [])
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sender } from "./project-console-presence-sender"
|
||||
|
||||
function deferred() {
|
||||
const state: { resolve?: () => void } = {}
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
state.resolve = resolve
|
||||
})
|
||||
return { promise, resolve: () => state.resolve?.() }
|
||||
}
|
||||
|
||||
async function drain() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe("project console presence sender", () => {
|
||||
test("sends snapshots in order", async () => {
|
||||
const first = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
|
||||
queue.push({ key: "first", run: () => (calls.push("first"), first.promise) })
|
||||
queue.push({ key: "second", run: async () => void calls.push("second") })
|
||||
|
||||
expect(calls).toEqual(["first"])
|
||||
first.resolve()
|
||||
await drain()
|
||||
expect(calls).toEqual(["first", "second"])
|
||||
})
|
||||
|
||||
test("deduplicates the last successful snapshot unless forced", async () => {
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
const item = { key: "same", run: async () => void calls.push("same") }
|
||||
|
||||
queue.push(item)
|
||||
await drain()
|
||||
queue.push(item)
|
||||
await drain()
|
||||
expect(calls).toEqual(["same"])
|
||||
|
||||
queue.push(item, true)
|
||||
await drain()
|
||||
expect(calls).toEqual(["same", "same"])
|
||||
})
|
||||
|
||||
test("does not queue a reactive duplicate of an in-flight snapshot", async () => {
|
||||
const wait = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
const item = { key: "same", run: () => (calls.push("same"), wait.promise) }
|
||||
|
||||
queue.push(item)
|
||||
queue.push(item)
|
||||
wait.resolve()
|
||||
await drain()
|
||||
|
||||
expect(calls).toEqual(["same"])
|
||||
})
|
||||
|
||||
test("retains a forced renewal while the same snapshot is in flight", async () => {
|
||||
const wait = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
const item = { key: "same", run: () => (calls.push("same"), calls.length === 1 ? wait.promise : Promise.resolve()) }
|
||||
|
||||
queue.push(item)
|
||||
queue.push(item, true)
|
||||
expect(calls).toEqual(["same"])
|
||||
|
||||
wait.resolve()
|
||||
await drain()
|
||||
expect(calls).toEqual(["same", "same"])
|
||||
})
|
||||
|
||||
test("replaces an obsolete pending snapshot with the latest state", async () => {
|
||||
const first = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
|
||||
queue.push({ key: "first", run: () => (calls.push("first"), first.promise) })
|
||||
queue.push({ key: "second", run: async () => void calls.push("second") })
|
||||
queue.push({ key: "third", run: async () => void calls.push("third") })
|
||||
|
||||
first.resolve()
|
||||
await drain()
|
||||
expect(calls).toEqual(["first", "third"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
type Snapshot = {
|
||||
key: string
|
||||
run: () => Promise<void>
|
||||
}
|
||||
|
||||
export function sender(report: (err: unknown) => void) {
|
||||
let current: Snapshot | undefined
|
||||
let next: Snapshot | undefined
|
||||
let last: string | undefined
|
||||
|
||||
async function drain() {
|
||||
const item = next
|
||||
if (!item) return
|
||||
next = undefined
|
||||
current = item
|
||||
try {
|
||||
await item.run()
|
||||
last = item.key
|
||||
} catch (err) {
|
||||
report(err)
|
||||
}
|
||||
current = undefined
|
||||
if (next) void drain()
|
||||
}
|
||||
|
||||
return {
|
||||
push(item: Snapshot, force = false) {
|
||||
if (!force && item.key === last && !current) return
|
||||
if (!force && item.key === current?.key && !next) return
|
||||
next = item
|
||||
if (!current) void drain()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Contract test for the presence snapshot logic in ProjectConsoleRoute.tsx.
|
||||
*
|
||||
* The route is a large Solid component that cannot be mounted in a unit test,
|
||||
* so these source assertions pin the load-bearing presence behaviour instead:
|
||||
* the console is a dashboard viewer (always inactive, never reports visible
|
||||
* sessions), the attached union covers the selected session plus every terminal
|
||||
* session, the sender serializes snapshots and forced check-ins, and cleanup
|
||||
* reuses the exact url+dir the last regular snapshot used to queue a final empty
|
||||
* snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROUTE_FILE = path.resolve(import.meta.dir, "./ProjectConsoleRoute.tsx")
|
||||
|
||||
/** Collapse whitespace so multi-line expressions match regardless of formatting. */
|
||||
function flat(source: string) {
|
||||
return source.replace(/\s+/g, " ").replace(/\( /g, "(").replace(/ \)/g, ")").replace(/,\)/g, ")")
|
||||
}
|
||||
|
||||
describe("project console presence contract", () => {
|
||||
test("snapshots always report an inactive viewer with no visible sessions", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain("const viewerId = crypto.randomUUID()")
|
||||
expect(flat(content)).toContain(
|
||||
"run: async () => { await viewProjectSessions(input, { id: viewerId, active: false }, [...ids], []) }",
|
||||
)
|
||||
expect(content).not.toContain("active: true")
|
||||
})
|
||||
|
||||
test("attached union includes the selected session and every terminal session", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain("const selected = activeSessionID()")
|
||||
expect(content).toContain("const ids = new Set<string>()")
|
||||
expect(content).toContain("if (selected) ids.add(selected)")
|
||||
expect(flat(content)).toContain(
|
||||
"for (const item of terminals()) { const id = sessionID(item) if (id) ids.add(id) }",
|
||||
)
|
||||
})
|
||||
|
||||
test("snapshots record the url+dir they were sent with", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain("let lastInput: { url: string; dir: string } | undefined")
|
||||
expect(content).toContain("const input = { url: base.url, dir: data.project.worktree }")
|
||||
expect(content).toContain("lastInput = input")
|
||||
})
|
||||
|
||||
test("routes reactive snapshots and forced check-ins through the serialized sender", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain('import { sender } from "./project-console-presence-sender"')
|
||||
expect(content).toContain("const queue = sender")
|
||||
expect(content).toContain("function sendSnapshot(force = false)")
|
||||
expect(content).toContain("queue.push(")
|
||||
expect(content).toContain("createEffect(() => sendSnapshot())")
|
||||
expect(content).toContain("const checkin = window.setInterval(() => sendSnapshot(true), 60_000)")
|
||||
expect(content).toContain("window.clearInterval(checkin)")
|
||||
})
|
||||
|
||||
test("cleanup queues a final empty snapshot using the last snapshot's url+dir", () => {
|
||||
const content = flat(fs.readFileSync(ROUTE_FILE, "utf-8"))
|
||||
expect(content).toContain(
|
||||
'window.clearInterval(checkin) if (lastInput) { const input = lastInput queue.push({ key: input.url + "|" + input.dir + "|", run: async () => { await viewProjectSessions(input, { id: viewerId, active: false }, [], []) }, }, true) }',
|
||||
)
|
||||
expect(content).not.toContain("dir: base.dir")
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,8 @@ exclude = [
|
||||
'^https?://zod\.dev/v4/changelog',
|
||||
# Example punycode domain used in homograph attack documentation — does not exist
|
||||
'^https?://xn--pitest',
|
||||
# OpenAI docs return 404 to plain GET link checks but resolve in browsers
|
||||
'^https?://platform\.openai\.com/docs/api-reference/responses/create',
|
||||
# OpenAI docs return 404 to plain GET link checks but resolve in browsers
|
||||
'^https?://platform\.openai\.com/docs/api-reference/responses/create',
|
||||
# Telegram deep links reject automated requests from CI runners with connection failures
|
||||
'^https?://t\.me/',
|
||||
]
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d118bf005d5e87cf6e11dbf53cf24fe48bd05879d970946a8215ac4aa283739a
|
||||
size 1926
|
||||
@@ -50,7 +50,7 @@ export const INITIAL_MANAGER_RECOVERY_DELAY_MS = 500
|
||||
|
||||
/**Embedder Validation */
|
||||
export const REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS = 15_000
|
||||
export const REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES = 0
|
||||
export const REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES = 2
|
||||
export const OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS = 120_000
|
||||
|
||||
/**OpenAI Embedder */
|
||||
|
||||
+48
-4
@@ -57,6 +57,7 @@ import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.rpc.dto.ModelLimitDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import ai.kilocode.rpc.dto.SessionRevertDto
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ide.BrowserUtil
|
||||
@@ -127,6 +128,8 @@ class SessionUi(
|
||||
private var pending = false
|
||||
private var loaded: Boolean? = null
|
||||
private var revertPrompt: String? = null
|
||||
private var pendingRollback: String? = null
|
||||
private var pendingRedo: String? = null
|
||||
private val flushMs =
|
||||
Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt())
|
||||
.takeIf { it > 0 }
|
||||
@@ -362,8 +365,8 @@ class SessionUi(
|
||||
repo = workspace.directory,
|
||||
resize = { anchor, fn -> scroll.preserve(anchor, fn) },
|
||||
revert = ::revert,
|
||||
cancelRevert = controller::cancelRevert,
|
||||
banner = RevertBanner(controller.model, controller::redo, controller::redoAll, controller::cancelRevert, focus),
|
||||
cancelRevert = ::cancelRevert,
|
||||
banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus),
|
||||
).also {
|
||||
it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) }
|
||||
}
|
||||
@@ -537,7 +540,7 @@ class SessionUi(
|
||||
|
||||
is SessionModelEvent.SessionUpdated -> onSessionUpdated()
|
||||
|
||||
is SessionModelEvent.RevertChanged -> syncPromptRevert()
|
||||
is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert)
|
||||
|
||||
is SessionModelEvent.TurnAdded,
|
||||
is SessionModelEvent.TurnUpdated,
|
||||
@@ -688,10 +691,47 @@ class SessionUi(
|
||||
|
||||
@RequiresEdt
|
||||
private fun revert(id: String) {
|
||||
scroll.followBottom(true)
|
||||
pendingRollback = id
|
||||
pendingRedo = null
|
||||
controller.revert(id)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun redo() {
|
||||
pendingRedo = controller.model.revert()?.messageID
|
||||
pendingRollback = null
|
||||
controller.redo()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun cancelRevert() {
|
||||
pendingRollback = null
|
||||
pendingRedo = null
|
||||
controller.cancelRevert()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun onRevertChanged(revert: SessionRevertDto?) {
|
||||
syncPromptRevert()
|
||||
val rollback = pendingRollback
|
||||
if (rollback != null) {
|
||||
if (revert?.messageID == rollback) {
|
||||
pendingRollback = null
|
||||
scroll.followBottom(true)
|
||||
return
|
||||
}
|
||||
pendingRollback = null
|
||||
}
|
||||
val redo = pendingRedo
|
||||
if (redo == null) return
|
||||
if (!controller.model.isRevertedMessage(redo)) {
|
||||
pendingRedo = null
|
||||
scroll.scrollMessageBottom(redo)
|
||||
return
|
||||
}
|
||||
if (revert != null) pendingRedo = null
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncPromptRevert() {
|
||||
val saved = revertPrompt
|
||||
@@ -811,6 +851,10 @@ class SessionUi(
|
||||
private fun onStateChanged(state: SessionState) {
|
||||
if (disposed) return
|
||||
if (state is SessionState.Reverting) overlay.clear()
|
||||
if (state is SessionState.Error) {
|
||||
pendingRollback = null
|
||||
pendingRedo = null
|
||||
}
|
||||
prompt.setBusy(state.isBusy())
|
||||
load.setState(state)
|
||||
scroll.setQuestionPending(questionPending(state))
|
||||
|
||||
+61
@@ -134,6 +134,27 @@ internal class SessionScroll(
|
||||
followBottom(component.viewport.view === messages && tail)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun scrollMessageBottom(id: String): Boolean {
|
||||
val target = messages.findMessage(id) ?: return false
|
||||
if (!target.isVisible) return false
|
||||
user = false
|
||||
pause = false
|
||||
stable = -1
|
||||
auto = true
|
||||
show(messages)
|
||||
auto = false
|
||||
val gen = ++seq
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
messagePass(gen, id, FOLLOW_PASSES)
|
||||
return true
|
||||
}
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
messagePass(gen, id, FOLLOW_PASSES)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun following(): Boolean {
|
||||
return component.viewport.view === messages && tail
|
||||
@@ -290,6 +311,39 @@ internal class SessionScroll(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun messagePass(id: Int, message: String, remaining: Int) {
|
||||
if (id != seq) return
|
||||
val target = messages.findMessage(message)
|
||||
if (target == null || !target.isVisible) {
|
||||
stable = -1
|
||||
updateJump()
|
||||
return
|
||||
}
|
||||
auto = true
|
||||
try {
|
||||
layoutScroll()
|
||||
val y = messageBottom(target)
|
||||
component.viewport.viewPosition = Point(0, y)
|
||||
bar.value = y
|
||||
tail = near()
|
||||
updateJump()
|
||||
} finally {
|
||||
auto = false
|
||||
}
|
||||
syncValue()
|
||||
if (remaining <= 0) {
|
||||
stable = -1
|
||||
return
|
||||
}
|
||||
val next = messageBottom(target)
|
||||
val left = if (next == stable) remaining - 1 else FOLLOW_PASSES
|
||||
stable = next
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
messagePass(id, message, left)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun layoutScroll() {
|
||||
root.validate()
|
||||
@@ -305,6 +359,13 @@ internal class SessionScroll(
|
||||
bar.value = bottom()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun messageBottom(target: JComponent): Int {
|
||||
val point = SwingUtilities.convertPoint(target, Point(0, target.height.coerceAtLeast(1)), messages)
|
||||
val extent = component.viewport.extentSize.height
|
||||
return (point.y - extent).coerceIn(0, bottom())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun bottom(): Int {
|
||||
val bar = component.verticalScrollBar
|
||||
|
||||
+58
-9
@@ -6,14 +6,17 @@ import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.StackAxis
|
||||
import ai.kilocode.client.util.UiTimerSource
|
||||
import ai.kilocode.client.util.UiTimers
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
|
||||
/**
|
||||
* Progress footer rendered at the bottom of the session transcript while the
|
||||
@@ -31,28 +34,39 @@ import com.intellij.util.ui.JBUI
|
||||
class ProgressPanel(
|
||||
model: SessionModel,
|
||||
parent: Disposable,
|
||||
) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()), SessionEditorStyleTarget {
|
||||
private val clock: UiTimerSource = UiTimers,
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget {
|
||||
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var state: SessionState = SessionState.Idle
|
||||
private var began = 0L
|
||||
private val label = JBLabel().apply {
|
||||
foreground = style.editorForeground
|
||||
}
|
||||
private val elapsed = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
}
|
||||
private val spinner = JBLabel(AnimatedIcon.Default())
|
||||
private val tick = clock.timer(1000) { syncElapsed() }
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
isVisible = false
|
||||
border = JBUI.Borders.empty(
|
||||
UiStyle.Gap.sm(),
|
||||
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
applyStyle(SessionEditorStyle.current())
|
||||
|
||||
next(spinner)
|
||||
next(label)
|
||||
addToLeft(
|
||||
Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md())
|
||||
.next(spinner)
|
||||
.next(label),
|
||||
)
|
||||
addToRight(elapsed)
|
||||
Disposer.register(parent) { tick.stop() }
|
||||
|
||||
model.addListener(parent) { event ->
|
||||
if (event is SessionModelEvent.StateChanged) onState(event.state)
|
||||
@@ -62,6 +76,9 @@ class ProgressPanel(
|
||||
/** Exposed for test assertions. */
|
||||
fun labelText(): String = label.text
|
||||
|
||||
/** Exposed for test assertions. */
|
||||
fun elapsedText(): String = elapsed.text
|
||||
|
||||
/** Exposed for test assertions. */
|
||||
fun labelForeground() = label.foreground
|
||||
|
||||
@@ -72,26 +89,46 @@ class ProgressPanel(
|
||||
spinner.isVisible = true
|
||||
label.text = state.text
|
||||
label.foreground = style.editorForeground
|
||||
isVisible = true
|
||||
showProgress()
|
||||
}
|
||||
is SessionState.Retry -> {
|
||||
spinner.isVisible = true
|
||||
label.text = retryText(state)
|
||||
label.foreground = UiStyle.Colors.warningLabelForeground()
|
||||
isVisible = true
|
||||
showProgress()
|
||||
}
|
||||
is SessionState.Offline -> {
|
||||
spinner.isVisible = false
|
||||
label.text = state.message.ifBlank { KiloBundle.message("session.status.offline") }
|
||||
label.foreground = UiStyle.Colors.errorLabelForeground()
|
||||
isVisible = true
|
||||
showProgress()
|
||||
}
|
||||
else -> isVisible = false
|
||||
else -> hideProgress()
|
||||
}
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun showProgress() {
|
||||
if (!isVisible) {
|
||||
began = clock.now()
|
||||
syncElapsed()
|
||||
}
|
||||
if (!tick.isRunning()) tick.start()
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
private fun hideProgress() {
|
||||
tick.stop()
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private fun syncElapsed() {
|
||||
elapsed.text = elapsedText((clock.now() - began).coerceAtLeast(0))
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun retryText(state: SessionState.Retry): String {
|
||||
val base = state.message.ifBlank { KiloBundle.message("session.status.retry") }
|
||||
return if (state.attempt > 0) {
|
||||
@@ -102,8 +139,20 @@ class ProgressPanel(
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
label.font = style.regularFont
|
||||
elapsed.font = style.regularFont
|
||||
elapsed.foreground = UiStyle.Colors.weak()
|
||||
if (state is SessionState.Busy) label.foreground = style.editorForeground
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun elapsedText(ms: Long): String {
|
||||
val total = ms / 1000
|
||||
val sec = total % 60
|
||||
val min = (total / 60) % 60
|
||||
val hour = total / 3600
|
||||
if (hour > 0) return "${hour}h ${min}m ${sec}s"
|
||||
if (min > 0) return "${min}m ${sec}s"
|
||||
return "${sec}s"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-25
@@ -76,6 +76,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BasicStroke
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Cursor
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
@@ -116,7 +117,7 @@ class PromptPanel(
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(PromptPanel::class.java)
|
||||
private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java)
|
||||
private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java)
|
||||
private val STOP_ICON: Icon = AllIcons.Actions.Suspend
|
||||
private val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PromptPanel::class.java)
|
||||
private val SHIELD_FILLED_ICON: Icon = IconLoader.getIcon("/icons/shield-filled.svg", PromptPanel::class.java)
|
||||
private val WAND_ICON: Icon = IconLoader.getIcon("/icons/wand-sparkles.svg", PromptPanel::class.java)
|
||||
@@ -142,7 +143,7 @@ class PromptPanel(
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
)
|
||||
}
|
||||
private val attachments = mutableListOf<PromptAttachment>()
|
||||
@@ -177,12 +178,9 @@ class PromptPanel(
|
||||
ed.settings.setBlockCursor(false)
|
||||
SpellCheckingEditorCustomizationProvider.getInstance().getDisabledCustomization()?.customize(ed)
|
||||
ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) })
|
||||
ed.setVerticalScrollbarVisible(false)
|
||||
ed.setHorizontalScrollbarVisible(false)
|
||||
ed.scrollPane.verticalScrollBarPolicy =
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
ed.scrollPane.horizontalScrollBarPolicy =
|
||||
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
installCompletionShortcut(ed)
|
||||
completion?.let { MentionNavigator(ed, it).install() }
|
||||
installFileDrop(ed.contentComponent, "editor")
|
||||
@@ -244,6 +242,14 @@ class PromptPanel(
|
||||
accessibleContext.accessibleName = KiloBundle.message("prompt.action.enhance")
|
||||
addActionListener { enhance() }
|
||||
}
|
||||
private val separator = object : JComponent() {
|
||||
override fun getPreferredSize() = JBUI.size(1, JBUI.scale(16))
|
||||
override fun getMinimumSize() = preferredSize
|
||||
override fun getMaximumSize() = preferredSize
|
||||
}.apply {
|
||||
alignmentY = Component.CENTER_ALIGNMENT
|
||||
border = JBUI.Borders.customLineLeft(SessionUiStyle.View.Prompt.separator())
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var busy = false
|
||||
@@ -290,6 +296,8 @@ class PromptPanel(
|
||||
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
|
||||
bar.add(enhance)
|
||||
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
|
||||
bar.add(separator)
|
||||
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
|
||||
bar.add(button)
|
||||
shell.add(bar, BorderLayout.SOUTH)
|
||||
add(shell, BorderLayout.CENTER)
|
||||
@@ -378,24 +386,8 @@ class PromptPanel(
|
||||
@RequiresEdt
|
||||
private fun chrome(ed: EditorEx) {
|
||||
if (ed.isDisposed) return
|
||||
style.applyTranscriptToEditor(ed)
|
||||
style.applyPromptToEditor(ed)
|
||||
if (ed.isDisposed) return
|
||||
val bg = style.editorBackground
|
||||
ed.setBorder(JBUI.Borders.empty())
|
||||
ed.scrollPane.border = JBUI.Borders.empty()
|
||||
ed.scrollPane.viewportBorder = JBUI.Borders.empty(
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
)
|
||||
ed.backgroundColor = bg
|
||||
ed.component.background = bg
|
||||
ed.contentComponent.background = bg
|
||||
ed.scrollPane.background = bg
|
||||
ed.scrollPane.viewport.background = bg
|
||||
ed.scrollPane.revalidate()
|
||||
ed.scrollPane.repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
@@ -482,7 +474,7 @@ class PromptPanel(
|
||||
this.style = style
|
||||
background = style.editorScheme.defaultBackground
|
||||
shell.background = style.editorScheme.defaultBackground
|
||||
editor.font = style.transcriptFont
|
||||
style.applyTranscriptToField(editor)
|
||||
editor.getEditor(false)?.let(::chrome)
|
||||
editor.background = style.editorBackground
|
||||
syncEditorHeight()
|
||||
@@ -927,6 +919,7 @@ class PromptPanel(
|
||||
val content = editor.preferredSize.height
|
||||
val sessionCap = rootCap(min)
|
||||
val height = minOf(content, sessionCap ?: content).coerceAtLeast(min)
|
||||
syncEditorScroll(view, content > height)
|
||||
if (before == height && lower == height) {
|
||||
editor.preferredSize = JBDimension(0, height)
|
||||
editor.minimumSize = JBDimension(0, height)
|
||||
@@ -938,6 +931,18 @@ class PromptPanel(
|
||||
repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) {
|
||||
// AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on
|
||||
// scroll/hover, fades on inactivity); NEVER hides it entirely when the
|
||||
// content fits so no bar is shown at all.
|
||||
ed?.scrollPane?.verticalScrollBarPolicy = if (overflow) {
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
} else {
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun rootCap(min: Int): Int? {
|
||||
val root = root ?: return null
|
||||
|
||||
+32
@@ -4,9 +4,12 @@ import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.openapi.editor.colors.EditorColorsManager
|
||||
import com.intellij.openapi.editor.colors.EditorColorsScheme
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Color
|
||||
import java.awt.Font
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -62,6 +65,35 @@ data class SessionEditorStyle(
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply standard transcript typography to an editor text field and its embedded editor when available. */
|
||||
fun applyTranscriptToField(field: EditorTextField) {
|
||||
field.font = transcriptFont
|
||||
field.getEditor(false)?.let(::applyTranscriptToEditor)
|
||||
}
|
||||
|
||||
/** Apply the visible prompt-input text styling to embedded session editor components. */
|
||||
fun applyPromptToEditor(editor: EditorEx) {
|
||||
if (editor.isDisposed) return
|
||||
applyTranscriptToEditor(editor)
|
||||
if (editor.isDisposed) return
|
||||
editor.setBorder(JBUI.Borders.empty())
|
||||
editor.scrollPane.border = JBUI.Borders.empty()
|
||||
editor.scrollPane.viewportBorder = JBUI.Borders.empty(
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
)
|
||||
editor.backgroundColor = editorBackground
|
||||
editor.component.background = editorBackground
|
||||
editor.contentComponent.background = editorBackground
|
||||
editor.scrollPane.background = editorBackground
|
||||
editor.scrollPane.viewport.background = editorBackground
|
||||
editor.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
editor.scrollPane.revalidate()
|
||||
editor.scrollPane.repaint()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Builds a style snapshot from the current global editor color scheme. */
|
||||
fun current(): SessionEditorStyle {
|
||||
|
||||
+65
-20
@@ -4,6 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Question
|
||||
import ai.kilocode.client.session.model.QuestionItem
|
||||
import ai.kilocode.client.session.model.QuestionOption
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.ui.SessionView
|
||||
import ai.kilocode.client.session.ui.editor.SessionEditorTextField
|
||||
import ai.kilocode.client.session.views.SessionViewIcons
|
||||
@@ -11,11 +12,15 @@ import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
@@ -23,15 +28,18 @@ import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBRadioButton
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBDimension
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.GridBagLayout
|
||||
import java.awt.Rectangle
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
@@ -41,9 +49,8 @@ import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.ButtonGroup
|
||||
import javax.swing.JPanel
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
/** Question tool form rendered inside the session transcript. */
|
||||
class QuestionView(
|
||||
@@ -71,6 +78,12 @@ class QuestionView(
|
||||
// The custom editor for the currently shown question; null when not shown.
|
||||
private var customEditor: SessionEditorTextField? = null
|
||||
private var customFocus: FocusAdapter? = null
|
||||
private val resize = object : ComponentAdapter() {
|
||||
@RequiresEdt
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
customEditor?.let(::syncEditorHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private val card = BaseQuestionView(selection, focus)
|
||||
|
||||
@@ -112,6 +125,7 @@ class QuestionView(
|
||||
init {
|
||||
isOpaque = false
|
||||
isVisible = false
|
||||
addComponentListener(resize)
|
||||
|
||||
nav.add(back)
|
||||
nav.add(fwd)
|
||||
@@ -123,6 +137,12 @@ class QuestionView(
|
||||
add(card, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
customEditor?.let(::syncEditorHeight)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun show(q: Question) {
|
||||
if (q.items.isEmpty()) {
|
||||
@@ -165,9 +185,9 @@ class QuestionView(
|
||||
this.style = style
|
||||
card.applyStyle(style)
|
||||
customEditor?.let { ed ->
|
||||
ed.font = style.transcriptFont
|
||||
ed.getEditor(false)?.let(style::applyTranscriptToEditor)
|
||||
ed.background = style.editorScheme.defaultBackground
|
||||
style.applyTranscriptToField(ed)
|
||||
ed.background = style.editorBackground
|
||||
syncEditorHeight(ed)
|
||||
}
|
||||
val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
|
||||
if (!changed) return
|
||||
@@ -496,21 +516,18 @@ class QuestionView(
|
||||
ed.setShowPlaceholderWhenFocused(true)
|
||||
ed.setOneLineMode(false)
|
||||
ed.addSettingsProvider { ex ->
|
||||
style.applyTranscriptToEditor(ex)
|
||||
ex.setBorder(JBUI.Borders.empty())
|
||||
ex.scrollPane.border = JBUI.Borders.empty()
|
||||
ex.scrollPane.viewportBorder = JBUI.Borders.empty()
|
||||
ex.backgroundColor = style.editorScheme.defaultBackground
|
||||
ex.scrollPane.background = style.editorScheme.defaultBackground
|
||||
ex.scrollPane.viewport.background = style.editorScheme.defaultBackground
|
||||
style.applyPromptToEditor(ex)
|
||||
ex.settings.isUseSoftWraps = true
|
||||
ex.settings.isPaintSoftWraps = false
|
||||
ex.settings.isAdditionalPageAtBottom = false
|
||||
ex.setHorizontalScrollbarVisible(false)
|
||||
ex.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
syncEditorHeight(ed, ex)
|
||||
}
|
||||
selection?.register(ed)?.let(regs::add)
|
||||
ed.font = style.transcriptFont
|
||||
ed.background = style.editorScheme.defaultBackground
|
||||
style.applyTranscriptToField(ed)
|
||||
ed.background = style.editorBackground
|
||||
|
||||
// Pre-fill with saved text. This call also forces lazy document creation so
|
||||
// that addDocumentListener can install on a non-null document immediately.
|
||||
@@ -546,13 +563,41 @@ class QuestionView(
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncEditorHeight(ed: SessionEditorTextField) {
|
||||
val editor = ed.getEditor(false)
|
||||
syncEditorHeight(ed, ed.getEditor(false))
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncEditorHeight(ed: SessionEditorTextField, editor: EditorEx?) {
|
||||
val estimated = estimatedLines(ed)
|
||||
val lines = maxOf(editor?.offsetToVisualPosition(editor.document.textLength)?.line?.plus(1) ?: estimated, estimated)
|
||||
val line = editor?.lineHeight ?: ed.getFontMetrics(ed.font).height
|
||||
val height = line * lines.coerceAtLeast(1) + JBUI.scale(16)
|
||||
ed.preferredSize = Dimension(0, height)
|
||||
ed.minimumSize = Dimension(0, height)
|
||||
val min = line + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME)
|
||||
val content = line * lines.coerceAtLeast(1) + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME)
|
||||
val cap = rootCap(min)
|
||||
val height = minOf(content, cap ?: content).coerceAtLeast(min)
|
||||
syncEditorScroll(editor, content > height)
|
||||
ed.preferredSize = JBDimension(0, height)
|
||||
ed.minimumSize = JBDimension(0, height)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) {
|
||||
// AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on
|
||||
// scroll/hover, fades on inactivity); NEVER hides it entirely when the
|
||||
// content fits so no bar is shown at all.
|
||||
ed?.scrollPane?.verticalScrollBarPolicy = if (overflow) {
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
} else {
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun rootCap(min: Int): Int? {
|
||||
val root = SwingUtilities.getAncestorOfClass(SessionRootPanel::class.java, this) as? SessionRootPanel
|
||||
?: return null
|
||||
if (root.height <= 0) return null
|
||||
return (root.height / 3).coerceAtLeast(min)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="#6C707E" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="#0066B8" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.5 1.5L14.5 8L1.5 14.5V9L10 8L1.5 7V1.5Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 163 B After Width: | Height: | Size: 163 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="#CED0D6" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="#0A7BD8" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.5 1.5L14.5 8L1.5 14.5V9L10 8L1.5 7V1.5Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 163 B After Width: | Height: | Size: 163 B |
+43
-1
@@ -1061,13 +1061,28 @@ class SessionScrollTest : SessionUiTestBase() {
|
||||
assertTrue(jumpButton().isVisible)
|
||||
}
|
||||
|
||||
fun `test rollback click follows bottom and hides jump button`() {
|
||||
fun `test rollback click does not scroll before marker update`() {
|
||||
showMessages()
|
||||
fillTranscript(48)
|
||||
val bar = scrollBar()
|
||||
setValue(bar, bottom(bar) / 2)
|
||||
val value = bar.value
|
||||
assertTrue(jumpButton().isVisible)
|
||||
|
||||
rollback("msg_36").doClick()
|
||||
settle()
|
||||
drainScroll()
|
||||
|
||||
assertEquals(value, bar.value)
|
||||
assertTrue(jumpButton().isVisible)
|
||||
}
|
||||
|
||||
fun `test rollback scrolls after marker shows banner`() {
|
||||
showMessages()
|
||||
fillTranscript(48)
|
||||
val bar = scrollBar()
|
||||
setValue(bar, bottom(bar) / 2)
|
||||
|
||||
rollback("msg_36").doClick()
|
||||
settle()
|
||||
drainScroll()
|
||||
@@ -1079,6 +1094,25 @@ class SessionScrollTest : SessionUiTestBase() {
|
||||
assertFalse(jumpButton().isVisible)
|
||||
}
|
||||
|
||||
fun `test redo scrolls to restored message bottom`() {
|
||||
showMessages()
|
||||
fillTranscript(48)
|
||||
val bar = scrollBar()
|
||||
emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test").copy(revert = SessionRevertDto("msg_36"))))
|
||||
drainScroll()
|
||||
setValue(bar, 0)
|
||||
|
||||
button(KiloBundle.message("revert.banner.redo")).doClick()
|
||||
settle()
|
||||
drainScroll()
|
||||
emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test").copy(revert = SessionRevertDto("msg_37"))))
|
||||
drainScroll()
|
||||
|
||||
val expected = messageBottomValue("msg_36")
|
||||
assertTrue("expected=$expected bottom=${bottom(bar)}", expected < bottom(bar))
|
||||
assertTrue("value=${bar.value} expected=$expected", kotlin.math.abs(bar.value - expected) <= 1)
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun button(text: String): JButton = findAll<JButton>(ui).first { it.text == text }
|
||||
@@ -1093,6 +1127,14 @@ class SessionScrollTest : SessionUiTestBase() {
|
||||
.first { it.toolTipText == KiloBundle.message("revert.message.rollback") }
|
||||
}
|
||||
|
||||
private fun messageBottomValue(id: String): Int {
|
||||
val pane = scrollComponent() as JBScrollPane
|
||||
val messages = find<SessionMessageListPanel>(ui)
|
||||
val message = messages.findMessage(id) ?: error("missing message $id")
|
||||
val point = SwingUtilities.convertPoint(message, Point(0, message.height.coerceAtLeast(1)), messages)
|
||||
return (point.y - pane.viewport.extentSize.height).coerceIn(0, bottom(scrollBar()))
|
||||
}
|
||||
|
||||
private inline fun <reified T> option(label: String): T where T : AbstractButton =
|
||||
findAll<T>(ui).first { it.actionCommand == label }
|
||||
|
||||
|
||||
+129
-11
@@ -5,12 +5,15 @@ import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionMeta
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.util.UiTimer
|
||||
import ai.kilocode.client.util.UiTimerSource
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
|
||||
/**
|
||||
* Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel]
|
||||
@@ -47,13 +50,14 @@ class ProgressPanelTest : BasePlatformTestCase() {
|
||||
|
||||
assertTrue(panel.isVisible)
|
||||
assertEquals("Thinking\u2026", panel.labelText())
|
||||
assertEquals("0s", panel.elapsedText())
|
||||
}
|
||||
|
||||
fun `test panel uses transcript row padding`() {
|
||||
fun `test panel relies on transcript inset for left padding`() {
|
||||
val ins = panel.insets
|
||||
|
||||
assertEquals(UiStyle.Gap.sm(), ins.top)
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left)
|
||||
assertEquals(0, ins.left)
|
||||
assertEquals(0, ins.bottom)
|
||||
assertEquals(0, ins.right)
|
||||
}
|
||||
@@ -103,14 +107,66 @@ class ProgressPanelTest : BasePlatformTestCase() {
|
||||
assertEquals("Rate limited", panel.labelText())
|
||||
}
|
||||
|
||||
fun `test elapsed time ticks while progress is visible`() {
|
||||
val clock = FakeClock()
|
||||
replace(clock)
|
||||
|
||||
model.setState(SessionState.Busy("Thinking"))
|
||||
|
||||
assertEquals("0s", panel.elapsedText())
|
||||
assertTrue(clock.timer.isRunning())
|
||||
|
||||
clock.advance(59_000)
|
||||
assertEquals("59s", panel.elapsedText())
|
||||
|
||||
clock.advance(23_000)
|
||||
assertEquals("1m 22s", panel.elapsedText())
|
||||
|
||||
clock.advance(3_600_000)
|
||||
assertEquals("1h 1m 22s", panel.elapsedText())
|
||||
}
|
||||
|
||||
fun `test elapsed time is right aligned`() {
|
||||
val clock = FakeClock()
|
||||
replace(clock)
|
||||
|
||||
model.setState(SessionState.Busy("Thinking"))
|
||||
panel.setSize(300, panel.preferredSize.height)
|
||||
panel.doLayout()
|
||||
|
||||
val time = labels(panel).first { it.text == "0s" }
|
||||
|
||||
assertEquals(panel.width - panel.insets.right, time.x + time.width)
|
||||
}
|
||||
|
||||
fun `test elapsed time continues across visible progress states and stops when hidden`() {
|
||||
val clock = FakeClock()
|
||||
replace(clock)
|
||||
|
||||
model.setState(SessionState.Busy("Thinking"))
|
||||
clock.advance(61_000)
|
||||
model.setState(SessionState.Retry("Rate limited", attempt = 1, next = 0L))
|
||||
|
||||
assertEquals("1m 1s", panel.elapsedText())
|
||||
|
||||
model.setState(SessionState.Idle)
|
||||
assertFalse(clock.timer.isRunning())
|
||||
|
||||
clock.advance(1_000)
|
||||
assertEquals("1m 1s", panel.elapsedText())
|
||||
|
||||
model.setState(SessionState.Busy("Thinking again"))
|
||||
assertEquals("0s", panel.elapsedText())
|
||||
}
|
||||
|
||||
fun `test reverting state is busy`() {
|
||||
assertTrue(SessionState.Reverting("x", SessionState.Reverting.Kind.ROLLBACK).isBusy())
|
||||
}
|
||||
|
||||
fun `test state churn retains footer components`() {
|
||||
val count = panel.componentCount
|
||||
val icon = panel.components[0]
|
||||
val text = panel.components[1]
|
||||
val clock = FakeClock()
|
||||
replace(clock)
|
||||
val comps = components(panel)
|
||||
|
||||
repeat(500) { i ->
|
||||
model.setState(SessionState.Busy("Thinking $i"))
|
||||
@@ -118,9 +174,7 @@ class ProgressPanelTest : BasePlatformTestCase() {
|
||||
model.setState(SessionState.Offline("Computer appears offline", requestId = "req$i"))
|
||||
model.setState(SessionState.Idle)
|
||||
|
||||
assertEquals(count, panel.componentCount)
|
||||
assertSame(icon, panel.components[0])
|
||||
assertSame(text, panel.components[1])
|
||||
assertEquals(comps, components(panel))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +204,13 @@ class ProgressPanelTest : BasePlatformTestCase() {
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun replace(clock: FakeClock) {
|
||||
Disposer.dispose(parent)
|
||||
parent = Disposer.newDisposable("test replacement")
|
||||
model = SessionModel()
|
||||
panel = ProgressPanel(model, parent, clock)
|
||||
}
|
||||
|
||||
private fun stub() = Permission(
|
||||
id = "perm1",
|
||||
sessionId = "ses",
|
||||
@@ -159,5 +220,62 @@ class ProgressPanelTest : BasePlatformTestCase() {
|
||||
meta = PermissionMeta(raw = emptyMap()),
|
||||
)
|
||||
|
||||
private fun spinner() = panel.components[0]
|
||||
private fun spinner() = labels(panel).first { it.icon != null }
|
||||
|
||||
private fun labels(root: Container): List<JBLabel> {
|
||||
val items = mutableListOf<JBLabel>()
|
||||
for (child in root.components) {
|
||||
if (child is JBLabel) items.add(child)
|
||||
if (child is Container) items.addAll(labels(child))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private fun components(root: Container): List<Component> {
|
||||
val items = mutableListOf<Component>()
|
||||
for (child in root.components) {
|
||||
items.add(child)
|
||||
if (child is Container) items.addAll(components(child))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private class FakeClock : UiTimerSource {
|
||||
var time = 0L
|
||||
lateinit var timer: FakeTimer
|
||||
|
||||
override fun now(): Long = time
|
||||
|
||||
override fun timer(ms: Int, repeats: Boolean, action: () -> Unit): UiTimer {
|
||||
timer = FakeTimer(action)
|
||||
return timer
|
||||
}
|
||||
|
||||
fun advance(ms: Long) {
|
||||
time += ms
|
||||
timer.fire()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeTimer(private val action: () -> Unit) : UiTimer {
|
||||
private var running = false
|
||||
|
||||
override fun start() {
|
||||
running = true
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
}
|
||||
|
||||
override fun restart() {
|
||||
running = true
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean = running
|
||||
|
||||
fun fire() {
|
||||
if (running) action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-4
@@ -97,6 +97,7 @@ import java.io.File
|
||||
import java.util.Base64
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.ScrollPaneConstants
|
||||
@@ -192,6 +193,16 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertEquals(pad, ins.right)
|
||||
}
|
||||
|
||||
fun `test prompt shell right padding matches bottom padding`() {
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val shell = panel.shellForTest()
|
||||
val ins = shell.border.getBorderInsets(shell)
|
||||
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ins.left)
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ins.bottom)
|
||||
assertEquals(ins.bottom, ins.right)
|
||||
}
|
||||
|
||||
fun `test prompt focus outline follows editor focus`() {
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
realize(panel, 260, 400)
|
||||
@@ -332,6 +343,10 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
|
||||
val chrome = (panel.preferredSize.height - editor.preferredSize.height).coerceAtLeast(0)
|
||||
assertTrue(editor.preferredSize.height <= root.height / 3 - chrome + 1)
|
||||
assertEquals(
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
editor.getEditor(false)!!.scrollPane.verticalScrollBarPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test attachment strip is included in session root cap`() {
|
||||
@@ -350,17 +365,29 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertTrue(attachedEditor.preferredSize.height < plainEditor.preferredSize.height)
|
||||
}
|
||||
|
||||
fun `test prompt editor hides scrollbars and keeps soft wraps`() {
|
||||
fun `test prompt editor hides scrollbars until content overflows cap`() {
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
realize(panel, 180, 400)
|
||||
|
||||
val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!!
|
||||
val field = panel.defaultFocusedComponent as EditorTextField
|
||||
val editor = field.getEditor(false)!!
|
||||
|
||||
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy)
|
||||
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy)
|
||||
assertTrue(editor.settings.isUseSoftWraps)
|
||||
assertFalse(editor.settings.isPaintSoftWraps)
|
||||
assertFalse(editor.settings.isBlockCursor)
|
||||
|
||||
field.text = (1..40).joinToString("\n") { "line $it" }
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editor.scrollPane.verticalScrollBarPolicy)
|
||||
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy)
|
||||
|
||||
field.text = "short"
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy)
|
||||
}
|
||||
|
||||
fun `test prompt editor highlights validated commands and mentions`() {
|
||||
@@ -985,9 +1012,15 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
panel.setBusy(true)
|
||||
|
||||
assertEquals("Stop", panel.buttonForTest().toolTipText)
|
||||
assertSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon)
|
||||
assertTrue(panel.isStopEnabled)
|
||||
}
|
||||
|
||||
fun `test send icon matches scroll button theme colors`() {
|
||||
assertTrue(resource("/icons/send.svg").contains("fill=\"#0066B8\""))
|
||||
assertTrue(resource("/icons/send_dark.svg").contains("fill=\"#0A7BD8\""))
|
||||
}
|
||||
|
||||
fun `test busy disables send button`() {
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
panel.setReady(true)
|
||||
@@ -1032,18 +1065,21 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertSame(icon, button.icon)
|
||||
}
|
||||
|
||||
fun `test auto approve and enhance buttons sit next to send button`() {
|
||||
fun `test auto approve enhance separator and send buttons sit in order`() {
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val auto = autoApproveButton(panel)
|
||||
val enhance = enhanceButton(panel)
|
||||
val send = panel.buttonForTest()
|
||||
val items = auto.parent.components.toList()
|
||||
val sep = items[items.indexOf(enhance) + 2] as JComponent
|
||||
|
||||
assertTrue(SwingUtilities.isDescendingFrom(auto, panel.shellForTest()))
|
||||
assertSame(auto.parent, enhance.parent)
|
||||
assertSame(auto.parent, send.parent)
|
||||
assertEquals(2, items.indexOf(enhance) - items.indexOf(auto))
|
||||
assertEquals(2, items.indexOf(send) - items.indexOf(enhance))
|
||||
assertEquals(4, items.indexOf(send) - items.indexOf(enhance))
|
||||
assertEquals(JBUI.scale(1), sep.preferredSize.width)
|
||||
assertNotNull(sep.border)
|
||||
}
|
||||
|
||||
fun `test enhance button follows connection and busy state`() {
|
||||
@@ -1363,6 +1399,11 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resource(path: String): String {
|
||||
val stream = PromptPanel::class.java.getResourceAsStream(path) ?: error("missing resource $path")
|
||||
return stream.use { it.readBytes().decodeToString() }
|
||||
}
|
||||
|
||||
private class FileListTransferable(private val files: List<File>) : Transferable {
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.javaFileListFlavor)
|
||||
|
||||
|
||||
+77
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views
|
||||
import ai.kilocode.client.session.model.Question
|
||||
import ai.kilocode.client.session.model.QuestionItem
|
||||
import ai.kilocode.client.session.model.QuestionOption
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
@@ -17,6 +18,7 @@ import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBRadioButton
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
@@ -33,6 +35,7 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
|
||||
private val replies = mutableListOf<Triple<String, QuestionReplyDto, List<List<String>>>>()
|
||||
private val rejects = mutableListOf<String>()
|
||||
private val roots = mutableListOf<SessionRootPanel>()
|
||||
private var scrolls = 0
|
||||
private lateinit var view: QuestionView
|
||||
|
||||
@@ -46,6 +49,15 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
roots.asReversed().forEach { it.removeNotify() }
|
||||
roots.clear()
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
// ------ empty question ------
|
||||
|
||||
fun `test empty question hides view and clears stale request id`() {
|
||||
@@ -287,6 +299,10 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName)
|
||||
assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize)
|
||||
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy)
|
||||
val ins = editor.scrollPane.viewportBorder.getBorderInsets(editor.scrollPane)
|
||||
val pad = JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET)
|
||||
assertEquals(pad, ins.left)
|
||||
assertEquals(pad, ins.right)
|
||||
assertTrue(editor.settings.isUseSoftWraps)
|
||||
assertFalse(editor.settings.isPaintSoftWraps)
|
||||
} finally {
|
||||
@@ -295,6 +311,29 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun `test custom answer editor style updates use transcript font`() {
|
||||
view.show(customSingleQuestion("q_custom_style_update"))
|
||||
|
||||
findAll<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
|
||||
val field = findAll<EditorTextField>(view).first()
|
||||
view.addNotify()
|
||||
try {
|
||||
layout(view)
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
val editor = field.getEditor(true) ?: error("missing editor")
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 26)
|
||||
|
||||
view.applyStyle(style)
|
||||
|
||||
assertEquals(style.transcriptFont, field.font)
|
||||
assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName)
|
||||
assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize)
|
||||
} finally {
|
||||
view.hideView()
|
||||
view.removeNotify()
|
||||
}
|
||||
}
|
||||
|
||||
// ------ multi-question navigation ------
|
||||
|
||||
fun `test multi question shows one question at a time and navigates`() {
|
||||
@@ -708,6 +747,33 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertTrue("custom editor should grow when soft-wrapped text needs more lines", ed.preferredSize.height > initial)
|
||||
}
|
||||
|
||||
fun `test custom editor enables vertical scrollbar only after cap`() {
|
||||
view.show(customSingleQuestion("q_custom_cap"))
|
||||
val root = realize(view, 240, 600)
|
||||
|
||||
val customRadio = findAll<JBRadioButton>(view).first { it.actionCommand == "" }
|
||||
customRadio.doClick()
|
||||
layoutTree(root)
|
||||
|
||||
val ed = findAll<EditorTextField>(view).first()
|
||||
val editor = ed.getEditor(false)!!
|
||||
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy)
|
||||
|
||||
ed.text = (1..40).joinToString("\n") { "line $it" }
|
||||
layoutTree(root)
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertTrue(ed.preferredSize.height <= root.height / 3)
|
||||
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editor.scrollPane.verticalScrollBarPolicy)
|
||||
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy)
|
||||
|
||||
ed.text = "short"
|
||||
layoutTree(root)
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy)
|
||||
}
|
||||
|
||||
fun `test blank custom input does not enable submit`() {
|
||||
view.show(customSingleQuestion("q_custom_blank"))
|
||||
|
||||
@@ -998,6 +1064,17 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
layoutTree(root)
|
||||
}
|
||||
|
||||
private fun realize(child: Component, width: Int, height: Int): SessionRootPanel {
|
||||
val root = SessionRootPanel()
|
||||
root.setSize(width, height)
|
||||
root.content.add(child, BorderLayout.CENTER)
|
||||
root.addNotify()
|
||||
layoutTree(root)
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
roots.add(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private fun layoutTree(root: Container) {
|
||||
root.doLayout()
|
||||
for (child in root.components) {
|
||||
|
||||
@@ -347,7 +347,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private modelUsageSessionIds: Set<string> = new Set()
|
||||
private syncedChildSessions: Set<string> = new Set()
|
||||
private readonly checkpoints = new Map<string, Promise<void>>()
|
||||
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string }>>()
|
||||
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string } | undefined>>()
|
||||
private readonly draftSessions = new Map<string, { sid: string; dir: string; expires: number }>()
|
||||
private readonly sandboxTransitions = new Map<string, Promise<void>>()
|
||||
private readonly revisions = new Map<string, { id: string; seq: number }>()
|
||||
@@ -502,8 +502,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
private focusSession(id?: string): void {
|
||||
this.streams.focus(id)
|
||||
if (id) this.connectionService.registerFocused(this.instanceId, id)
|
||||
else this.connectionService.unregisterFocused(this.instanceId)
|
||||
this.registerPresence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report presence for this provider: the focused session is visible, and
|
||||
* open local tab sessions (plus the focused one) stay attached even while
|
||||
* the view is hidden.
|
||||
*/
|
||||
private registerPresence(): void {
|
||||
if (this.opts.disableViewedRegistration) return
|
||||
const focused = this.streams.focused
|
||||
this.connectionService.registerVisible(this.instanceId, focused ? [focused] : [])
|
||||
const attached = new Set(this.openSessionIds)
|
||||
if (focused) attached.add(focused)
|
||||
this.connectionService.registerAttached(this.instanceId, [...attached])
|
||||
}
|
||||
|
||||
public setStreamVisibility(active: boolean): void {
|
||||
@@ -735,9 +748,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
this.setupWebviewMessageHandler(panel.webview)
|
||||
this.viewStateDisposable?.dispose()
|
||||
this.viewStateDisposable = this.visibleTaskStreams.bindPanel(panel, () =>
|
||||
this.focusSession(panel.active ? this.currentSession?.id : undefined),
|
||||
)
|
||||
this.viewStateDisposable = this.visibleTaskStreams.bindPanel(panel, () => {
|
||||
if (this.opts.disableViewedRegistration) return
|
||||
const id = this.contextSessionID
|
||||
this.streams.focus(panel.visible ? id : undefined)
|
||||
this.connectionService.registerVisible(this.instanceId, panel.visible && id ? [id] : [])
|
||||
})
|
||||
this.initializeConnection()
|
||||
}
|
||||
|
||||
@@ -787,6 +803,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return this.sessionDirectories
|
||||
}
|
||||
|
||||
public async getSessionInfo(sessionId: string): Promise<Session | undefined> {
|
||||
await this.initializeConnection()
|
||||
const client = this.client
|
||||
if (!client) return
|
||||
const directory = this.getWorkspaceDirectory(sessionId)
|
||||
return retry(() => client.session.get({ sessionID: sessionId, directory }, { throwOnError: true }))
|
||||
.then((result) => result.data)
|
||||
.catch((error: unknown) => {
|
||||
console.warn("[Kilo New] KiloProvider: Failed to resolve managed session:", error)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
/** Return the currently active session ID, if any. */
|
||||
public getCurrentSessionId(): string | undefined {
|
||||
return this.currentSession?.id ?? undefined
|
||||
@@ -832,7 +861,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
for (const [key, session] of this.draftSessions) {
|
||||
if (next.has(session.sid) || session.expires <= now) this.draftSessions.delete(key)
|
||||
}
|
||||
this.connectionService.registerOpen(this.instanceId, ids)
|
||||
this.registerPresence()
|
||||
this.recoverPendingPrompts()
|
||||
}
|
||||
|
||||
@@ -1917,11 +1946,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory(sessionID)
|
||||
const { data: messagesData } = await retry(() =>
|
||||
this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true }),
|
||||
)
|
||||
const [info, history] = await Promise.all([
|
||||
retry(() => this.client!.session.get({ sessionID, directory: workspaceDir }, { throwOnError: true })),
|
||||
retry(() => this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true })),
|
||||
])
|
||||
this.postMessage({ type: "sessionUpdated", session: this.sessionToWebview(info.data) })
|
||||
|
||||
const messages = messagesData.map((m) => ({
|
||||
const messages = history.data.map((m) => ({
|
||||
...this.slimInfo(m.info),
|
||||
parts: this.slimParts(m.parts),
|
||||
createdAt: new Date(m.info.time.created).toISOString(),
|
||||
@@ -3038,12 +3069,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (!sessionID && (draftID || !this.currentSession)) {
|
||||
const pending = this.sessionCreations.get(key)
|
||||
if (pending) return pending
|
||||
if (draftID) this.creatingDrafts.add(draftID)
|
||||
const creation = (async () => {
|
||||
const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, this.client!, dir)
|
||||
const { data: session } = await this.client!.session.create(
|
||||
{ directory: dir, platform: this.opts.platform, metadata },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
if (draftID && this.closedDrafts.delete(draftID)) {
|
||||
await this.client!.session.delete({ sessionID: session.id, directory: dir }, { throwOnError: true })
|
||||
return undefined
|
||||
}
|
||||
this.stopCurrentSessionProcesses(session.id)
|
||||
this.setCurrentSession(session)
|
||||
this.contextSessionID = session.id
|
||||
@@ -3058,7 +3094,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const resolved = { sid: session.id, dir }
|
||||
if (draftID) this.draftSessions.set(key, { ...resolved, expires: Date.now() + 60_000 })
|
||||
return resolved
|
||||
})().finally(() => this.sessionCreations.delete(key))
|
||||
})().finally(() => {
|
||||
this.sessionCreations.delete(key)
|
||||
if (draftID) this.creatingDrafts.delete(draftID)
|
||||
})
|
||||
this.sessionCreations.set(key, creation)
|
||||
return creation
|
||||
}
|
||||
@@ -3069,6 +3108,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return { sid, dir }
|
||||
}
|
||||
|
||||
/** Drafts closed while their backend session is being created or submitted. */
|
||||
private closedDrafts = new Set<string>()
|
||||
private creatingDrafts = new Set<string>()
|
||||
|
||||
/** Abort controllers for active retry loops, keyed by session ID */
|
||||
private retryAbortControllers = new Map<string, AbortController>()
|
||||
|
||||
@@ -3243,7 +3286,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }),
|
||||
)
|
||||
resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory)
|
||||
if (!resolved) throw new Error("Failed to resolve session")
|
||||
if (!resolved) return
|
||||
if (sandbox) await sandbox
|
||||
const sid = resolved.sid
|
||||
const dir = resolved.dir
|
||||
@@ -3258,6 +3301,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
await this.requirements.assertAgentRequirements(agent, dir)
|
||||
const editorContext = await this.gatherEditorContext(dir)
|
||||
if (draftID && this.closedDrafts.delete(draftID)) {
|
||||
for (const [k, v] of this.draftSessions) if (v.sid === sid) this.draftSessions.delete(k)
|
||||
return
|
||||
}
|
||||
|
||||
if (messageID) {
|
||||
this.connectionService.recordMessageSessionId(messageID, sid)
|
||||
@@ -3330,7 +3377,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }),
|
||||
)
|
||||
resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory)
|
||||
if (!resolved) throw new Error("Failed to resolve session")
|
||||
if (!resolved) return
|
||||
if (sandbox) await sandbox
|
||||
const sid = resolved.sid
|
||||
const dir = resolved.dir
|
||||
@@ -3382,9 +3429,44 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
public acknowledgeDraft(draftID: string, sessionID: string): void {
|
||||
for (const [k, v] of this.draftSessions) {
|
||||
if (v.sid === sessionID) {
|
||||
this.draftSessions.delete(k)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.closedDrafts.delete(draftID)
|
||||
}
|
||||
|
||||
public async abortSessions(ids: readonly string[]): Promise<void> {
|
||||
const sessions = [...new Set(ids)]
|
||||
const targets = new Set(sessions.filter((sid) => !sid.startsWith("pending:")))
|
||||
for (const draft of sessions.filter((sid) => sid.startsWith("pending:"))) {
|
||||
let sid: string | undefined
|
||||
for (const [k, v] of this.draftSessions) {
|
||||
if (k.startsWith(`${draft}\0`)) {
|
||||
sid = v.sid
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!sid && !this.creatingDrafts.has(draft)) continue
|
||||
this.closedDrafts.add(draft)
|
||||
if (sid) targets.add(sid)
|
||||
}
|
||||
await Promise.all([...targets].map((sid) => this.stopSession(sid)))
|
||||
}
|
||||
|
||||
private stopSession(sid: string): Promise<boolean> {
|
||||
this.cancelRetry(sid)
|
||||
const client = this.client
|
||||
if (!client) return Promise.resolve(false)
|
||||
return this.aborts.stop(client, sid, this.getWorkspaceDirectory(sid))
|
||||
}
|
||||
|
||||
private async handleAbort(sessionID?: string): Promise<void> {
|
||||
const sid = sessionID || this.currentSession?.id
|
||||
if (!this.client || !sid || !(await this.aborts.stop(this.client, sid, this.getWorkspaceDirectory(sid)))) return
|
||||
if (!sid || !(await this.stopSession(sid))) return
|
||||
this.sessionStatusMap.set(sid, "idle")
|
||||
this.streams.flush(sid)
|
||||
this.postMessage({ type: "sessionTurnClosed", sessionID: sid, reason: "interrupted" })
|
||||
@@ -4302,7 +4384,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
private matchesPendingFollowup(session: Session) {
|
||||
return matchFollowup({ pending: this.pendingFollowup, dir: session.directory, now: Date.now() })
|
||||
return matchFollowup({
|
||||
pending: this.pendingFollowup,
|
||||
dir: session.directory,
|
||||
now: Date.now(),
|
||||
parentID: session.parentID,
|
||||
})
|
||||
}
|
||||
|
||||
private adoptPendingFollowup(session: Session) {
|
||||
@@ -4401,8 +4488,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
*/
|
||||
dispose(): void {
|
||||
this.unsubscribeRemote?.()
|
||||
this.focusSession()
|
||||
this.connectionService.registerOpen(this.instanceId, [])
|
||||
this.streams.focus(undefined)
|
||||
this.connectionService.unregisterVisible(this.instanceId)
|
||||
this.connectionService.unregisterAttached(this.instanceId)
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
this.unsubscribeEvent?.()
|
||||
|
||||
@@ -9,13 +9,12 @@ import { isAbsolutePath } from "../path-utils"
|
||||
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
|
||||
import { handleSection } from "./section-handler"
|
||||
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
|
||||
import { normalizeBaseBranch } from "./base-branch"
|
||||
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
|
||||
import { PRStatusBridge } from "./pr-status-bridge"
|
||||
import { GitOps } from "./GitOps"
|
||||
import { versionedName } from "./branch-name"
|
||||
import { BranchNamingController } from "./branch-naming"
|
||||
import { classifyWorktreeError } from "./git-import"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
import { SetupScriptRunner } from "./SetupScriptRunner"
|
||||
import { copyEnvFiles } from "./env-copy"
|
||||
@@ -27,15 +26,22 @@ import { startVscodeRunTask } from "./run/task"
|
||||
import { RunController } from "./run/controller"
|
||||
import { handleRunMessage } from "./run/message"
|
||||
import { forkSession } from "./fork-session"
|
||||
import { AgentManagerVisiblePresence } from "./am-visible-presence"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { WorktreeDiffController } from "./worktree-diff-controller"
|
||||
import { WorktreeImporter } from "./worktree-importer"
|
||||
import {
|
||||
createWorktreeOnDisk,
|
||||
type CreateWorktreeOnDiskOptions,
|
||||
type CreateWorktreeOnDiskResult,
|
||||
} from "./worktree-create"
|
||||
import { recordPromotionHandoff } from "./promotion-handoff"
|
||||
import { restoreWorktrees } from "./state-recovery"
|
||||
import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff"
|
||||
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
|
||||
import { stopSessionProcesses } from "../kilo-provider/background-process"
|
||||
import { sandboxSessionMetadata } from "../shared/sandbox-session"
|
||||
import { pruneSubagents } from "./prune-subagents"
|
||||
|
||||
import { startSession } from "./mcp-warmup"
|
||||
import { readTerminalFont, watchTerminalFont } from "./terminal-font"
|
||||
@@ -82,11 +88,16 @@ export class AgentManagerProvider implements Disposable {
|
||||
private unsubFont: (() => void) | undefined
|
||||
private closing: Promise<void> | undefined
|
||||
private onVisibilityChange: ((visible: boolean) => void) | undefined
|
||||
// Tracks sessions owned by this panel until they are explicitly closed.
|
||||
private panelSessions = new Set<string>()
|
||||
|
||||
/** Session ID most recently loaded via a `loadMessages` message from the webview.
|
||||
* Updated synchronously — unlike the session provider's currentSession which depends on
|
||||
* an async `session.get` round-trip and can be stale during rapid tab switches. */
|
||||
/** Session ID most recently loaded via `loadMessages`; updated synchronously. */
|
||||
private activeSessionId: string | undefined
|
||||
private visiblePresence = new AgentManagerVisiblePresence(
|
||||
(ids) => this.connectionService.registerVisible("agent-manager", ids),
|
||||
() => this.panel?.visible ?? false,
|
||||
(ids) => this.connectionService.registerAttached("agent-manager", ids),
|
||||
)
|
||||
constructor(
|
||||
private readonly host: Host,
|
||||
private readonly connectionService: KiloConnectionService,
|
||||
@@ -248,8 +259,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
private attachPanel(ctx: PanelContext): void {
|
||||
if (this.panel) {
|
||||
this.log("Disposing previous panel before attaching new one")
|
||||
this.panel.dispose()
|
||||
const panel = this.panel
|
||||
this.panel = undefined
|
||||
panel.dispose()
|
||||
}
|
||||
this.panel = ctx
|
||||
|
||||
@@ -257,6 +269,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.onVisibilityChange?.(ctx.visible)
|
||||
ctx.onDidChangeVisibility((visible) => {
|
||||
this.statsPoller.setVisible(visible)
|
||||
this.visiblePresence.flush()
|
||||
})
|
||||
|
||||
ctx.sessions.onFollowupAdopted((session, directory) => {
|
||||
@@ -272,12 +285,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
// have already replaced us via attachPanel.
|
||||
if (this.panel === ctx) {
|
||||
this.log("Panel disposed")
|
||||
const ids = [...this.panelSessions]
|
||||
if (this.activeSessionId) ids.push(this.activeSessionId)
|
||||
this.panelSessions.clear()
|
||||
void ctx.sessions.abortSessions(ids).catch((err) => this.log("Failed to abort sessions on panel close:", err))
|
||||
this.statsPoller.stop()
|
||||
this.prBridge.poller.stop()
|
||||
this.diffs.stop()
|
||||
this.activeSessionId = undefined
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.connectionService.registerOpen("agent-manager", [])
|
||||
this.visiblePresence.clear()
|
||||
this.panel = undefined
|
||||
this.onVisibilityChange?.(false)
|
||||
}
|
||||
@@ -285,9 +301,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async initializeState(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
@@ -318,12 +332,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
for (const wt of state.getWorktrees()) {
|
||||
for (const s of state.getSessions(wt.id)) {
|
||||
this.panel?.sessions.setSessionDirectory(s.id, wt.path)
|
||||
this.panel?.sessions.trackSession(s.id)
|
||||
}
|
||||
for (const s of state.getSessions(wt.id)) this.panel?.sessions.setSessionDirectory(s.id, wt.path)
|
||||
}
|
||||
for (const s of state.getSessions()) if (!s.worktreeId) this.panel?.sessions.trackSession(s.id)
|
||||
await pruneSubagents(state, this.panel?.sessions, (message) => this.log(message))
|
||||
for (const s of state.getSessions()) this.panel?.sessions.trackSession(s.id)
|
||||
this.pushState()
|
||||
|
||||
// Refresh sessions so worktree sessions appear in the list
|
||||
@@ -358,9 +370,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
await state.flush()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message interceptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
if (this.prBridge.handleMessage(msg)) return null
|
||||
@@ -463,6 +473,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
if (m.type === "agentManager.persistSession" || m.type === "agentManager.forgetSession") {
|
||||
const persist = m.type === "agentManager.persistSession"
|
||||
if (persist && m.draftID) {
|
||||
this.panel?.sessions.acknowledgeDraft(m.draftID, m.sessionId)
|
||||
this.panelSessions.delete(m.draftID)
|
||||
this.panelSessions.add(m.sessionId)
|
||||
}
|
||||
void this.stateReady?.then(() => {
|
||||
const state = this.getStateManager()
|
||||
if (!state) return
|
||||
@@ -480,6 +495,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
m.type === "setSandboxDefault" ||
|
||||
((m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && !m.sessionID)
|
||||
) {
|
||||
if (m.type === "sendMessage" || m.type === "sendCommand") {
|
||||
if (m.draftID) this.panelSessions.add(m.draftID)
|
||||
}
|
||||
const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined
|
||||
const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined
|
||||
if (worktree) {
|
||||
@@ -509,7 +527,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
if (m.type === "loadMessages") {
|
||||
this.activeSessionId = m.sessionID
|
||||
this.connectionService.registerFocused("agent-manager", m.sessionID)
|
||||
this.terminalManager.syncOnSessionSwitch(m.sessionID)
|
||||
this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined)
|
||||
return msg
|
||||
@@ -517,7 +534,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
if (m.type === "clearSession") {
|
||||
this.activeSessionId = undefined
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.visiblePresence.setDisplayed(null)
|
||||
void Promise.resolve().then(() => {
|
||||
if (!this.panel || !this.state) return
|
||||
for (const id of this.state.worktreeSessionIds()) {
|
||||
@@ -536,7 +553,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
if (m.type === "agentManager.openSessions") {
|
||||
this.connectionService.registerOpen("agent-manager", m.sessionIDs)
|
||||
for (const id of m.sessionIDs) this.panelSessions.add(id)
|
||||
}
|
||||
if (m.type === "agentManager.openSessions" || m.type === "agentManager.visibleSession") {
|
||||
this.visiblePresence.handle(m)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -731,111 +751,21 @@ export class AgentManagerProvider implements Disposable {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve the effective base branch using the configured default, explicit override, and existence check. */
|
||||
private async resolveBaseBranch(
|
||||
manager: WorktreeManager,
|
||||
state: WorktreeStateManager,
|
||||
explicit?: string,
|
||||
): Promise<string | undefined> {
|
||||
const configured = state.getDefaultBaseBranch()
|
||||
if (!configured && !explicit) return undefined
|
||||
|
||||
const configuredExists = configured ? await manager.branchExists(configured) : false
|
||||
const result = chooseBaseBranch({ explicit, configured, configuredExists })
|
||||
|
||||
if (result.stale) {
|
||||
this.clearStaleDefaultBaseBranch(state, result.stale)
|
||||
}
|
||||
return result.branch
|
||||
}
|
||||
|
||||
/** Reset a stale default base branch and notify the webview. */
|
||||
private clearStaleDefaultBaseBranch(state: WorktreeStateManager, stale: string): void {
|
||||
this.log(`Default base branch "${stale}" no longer exists, clearing`)
|
||||
state.setDefaultBaseBranch(undefined)
|
||||
this.pushState()
|
||||
}
|
||||
|
||||
/** Create a git worktree on disk and register it in state. Returns null on failure. */
|
||||
private async createWorktreeOnDisk(opts?: {
|
||||
groupId?: string
|
||||
baseBranch?: string
|
||||
branchName?: string
|
||||
existingBranch?: string
|
||||
name?: string
|
||||
label?: string
|
||||
}): Promise<{
|
||||
worktree: ReturnType<WorktreeStateManager["addWorktree"]>
|
||||
result: CreateWorktreeResult
|
||||
} | null> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: "Open a folder that contains a git repository to use worktrees",
|
||||
errorCode: "not_git_repo",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Creating git worktree..." })
|
||||
|
||||
// Resolve effective base branch using configured default
|
||||
const effectiveBase = opts?.existingBranch
|
||||
? undefined
|
||||
: await this.resolveBaseBranch(manager, state, opts?.baseBranch)
|
||||
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
result = await manager.createWorktree({
|
||||
prompt: opts?.name || "kilo",
|
||||
baseBranch: effectiveBase ?? opts?.baseBranch,
|
||||
branchName: opts?.branchName,
|
||||
existingBranch: opts?.existingBranch,
|
||||
})
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: msg,
|
||||
errorCode: classifyWorktreeError(msg),
|
||||
})
|
||||
this.host.capture("Agent Manager Session Error", {
|
||||
source: PLATFORM,
|
||||
error: msg,
|
||||
context: "createWorktree",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
groupId: opts?.groupId,
|
||||
label: opts?.label,
|
||||
branchOwned: !opts?.existingBranch,
|
||||
})
|
||||
|
||||
// Push state immediately so the sidebar shows the new worktree with a loading indicator
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Setting up worktree...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
|
||||
return { worktree, result }
|
||||
private async createWorktreeOnDisk(opts?: CreateWorktreeOnDiskOptions): Promise<CreateWorktreeOnDiskResult | null> {
|
||||
return createWorktreeOnDisk(
|
||||
{
|
||||
getWorktreeManager: () => this.getWorktreeManager(),
|
||||
getStateManager: () => this.getStateManager(),
|
||||
postToWebview: (message) => this.postToWebview(message),
|
||||
capture: (event, properties) => this.host.capture(event, properties),
|
||||
pushState: () => this.pushState(),
|
||||
log: (...args) => this.log(...args),
|
||||
},
|
||||
opts,
|
||||
)
|
||||
}
|
||||
|
||||
/** Create a CLI session in a worktree directory. Returns null on failure. */
|
||||
@@ -843,6 +773,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
worktreeId?: string,
|
||||
source?: { sandboxInheritanceToken?: string },
|
||||
): Promise<Session | null> {
|
||||
let client: KiloClient
|
||||
try {
|
||||
@@ -876,7 +807,16 @@ export class AgentManagerProvider implements Disposable {
|
||||
const { data: session } = await startSession(
|
||||
client,
|
||||
worktreePath,
|
||||
() => client.session.create({ directory: worktreePath, platform: PLATFORM, metadata }, { throwOnError: true }),
|
||||
() =>
|
||||
client.session.create(
|
||||
{
|
||||
directory: worktreePath,
|
||||
platform: PLATFORM,
|
||||
metadata,
|
||||
...(source?.sandboxInheritanceToken ? { sandboxInheritanceToken: source.sandboxInheritanceToken } : {}),
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
(...args) => this.log(...args),
|
||||
)
|
||||
return session
|
||||
@@ -983,7 +923,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
const properties = (event as { properties?: unknown }).properties
|
||||
const req = parseToolRequest(properties)
|
||||
if (!req) return
|
||||
if (directory) req.directory = directory
|
||||
if (directory) {
|
||||
req.directory = directory
|
||||
}
|
||||
void this.startToolRequest(req)
|
||||
}
|
||||
|
||||
@@ -1010,7 +952,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.pushState()
|
||||
},
|
||||
setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
|
||||
createSessionInWorktree: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id),
|
||||
createSessionInWorktree: (dir, branch, id, source) => this.createSessionInWorktree(dir, branch, id, source),
|
||||
sessionMetadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir),
|
||||
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
||||
notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid),
|
||||
@@ -1024,9 +966,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worktree actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a new worktree with an auto-created first session. */
|
||||
private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise<null> {
|
||||
@@ -1272,29 +1212,27 @@ export class AgentManagerProvider implements Disposable {
|
||||
)
|
||||
}
|
||||
|
||||
/** Close (remove) a session from its worktree. */
|
||||
/** Stop a session and remove it from Agent Manager. */
|
||||
private async onCloseSession(sessionId: string): Promise<null> {
|
||||
const state = this.getStateManager()
|
||||
if (!state) return null
|
||||
|
||||
const dirs = this.panel?.sessions.getSessionDirectories()
|
||||
const dir = state.directoryFor(sessionId) ?? dirs?.get(sessionId) ?? this.getRoot() ?? process.cwd()
|
||||
const dir = state?.directoryFor(sessionId) ?? dirs?.get(sessionId) ?? this.getRoot() ?? process.cwd()
|
||||
await this.panel?.sessions.abortSessions([sessionId])
|
||||
this.panelSessions.delete(sessionId)
|
||||
try {
|
||||
await stopSessionProcesses(this.connectionService.getClient(), sessionId, dir)
|
||||
} catch (err) {
|
||||
this.log("onCloseSession: client not available:", err)
|
||||
}
|
||||
|
||||
state.removeSession(sessionId)
|
||||
state?.removeSession(sessionId)
|
||||
this.panel?.sessions.clearSessionDirectory(sessionId)
|
||||
this.pushState()
|
||||
if (state) this.pushState()
|
||||
this.log(`Closed session ${sessionId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-version worktree creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create N worktree sessions for the same prompt (multi-version mode). */
|
||||
private async onCreateMultiVersion(
|
||||
@@ -1475,19 +1413,13 @@ export class AgentManagerProvider implements Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keybindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private sendKeybindings(): void {
|
||||
const keybindings = this.host.extensionKeybindings()
|
||||
const bindings = buildKeybindingMap(keybindings, process.platform === "darwin")
|
||||
this.postToWebview({ type: "agentManager.keybindings", bindings })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup script
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Open the worktree setup script in the editor for user configuration. */
|
||||
private async configureSetupScript(): Promise<void> {
|
||||
@@ -1542,9 +1474,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async sendRepoInfo(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
@@ -1558,9 +1488,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private registerWorktreeSession(sessionId: string, directory: string): void {
|
||||
const worktree = this.state?.findWorktreeByPath(directory)
|
||||
@@ -1714,9 +1642,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manager accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private getRoot(): string | undefined {
|
||||
return this.host.workspacePath()
|
||||
@@ -1759,9 +1685,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
return this.setupScript
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worktree file helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Open a worktree directory directly in VS Code. */
|
||||
private openWorktreeDirectory(worktreeId: string): void {
|
||||
@@ -1982,8 +1906,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.unsubTool?.()
|
||||
this.unsubStatus?.()
|
||||
this.unsubFont?.()
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.connectionService.registerOpen("agent-manager", [])
|
||||
this.visiblePresence.clear()
|
||||
this.diffs.stop()
|
||||
this.naming.dispose()
|
||||
this.statsPoller.stop()
|
||||
@@ -1992,7 +1915,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.run.dispose()
|
||||
this.terminalManager.dispose()
|
||||
await this.terminalRouter.dispose()
|
||||
this.panel?.dispose()
|
||||
const panel = this.panel
|
||||
this.panel = undefined
|
||||
panel?.dispose()
|
||||
this.outputChannel.dispose()
|
||||
this.host.dispose()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AgentManagerVisiblePresence } from "./am-visible-presence"
|
||||
|
||||
function setup(initialVisible = true) {
|
||||
const calls: string[][] = []
|
||||
const attached: string[][] = []
|
||||
let visible = initialVisible
|
||||
const presence = new AgentManagerVisiblePresence(
|
||||
(ids) => calls.push(ids),
|
||||
() => visible,
|
||||
(ids) => attached.push(ids),
|
||||
)
|
||||
return {
|
||||
calls,
|
||||
attached,
|
||||
presence,
|
||||
setVisible(value: boolean) {
|
||||
visible = value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("AgentManagerVisiblePresence", () => {
|
||||
test("registers the displayed id while the panel is visible", () => {
|
||||
const { calls, presence } = setup(true)
|
||||
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("flush registers empty when the panel is hidden", () => {
|
||||
const { calls, presence, setVisible } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
setVisible(false)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("flush clears attached when the panel is hidden", () => {
|
||||
const { attached, presence, setVisible } = setup(true)
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1", "ses_2"] })
|
||||
|
||||
setVisible(false)
|
||||
presence.flush()
|
||||
|
||||
expect(attached.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("flush re-registers attached when the panel becomes visible again", () => {
|
||||
const { attached, presence, setVisible } = setup(true)
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1", "ses_2"] })
|
||||
|
||||
setVisible(false)
|
||||
presence.flush()
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(attached.at(-1)).toEqual(["ses_1", "ses_2"])
|
||||
})
|
||||
|
||||
test("setDisplayed(null) registers empty even while visible", () => {
|
||||
const { calls, presence } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
presence.setDisplayed(null)
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("flush after visibility returns re-registers the retained id", () => {
|
||||
const { calls, presence, setVisible } = setup(false)
|
||||
presence.setDisplayed("ses_1")
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("setDisplayed(null) prevents a stale id from re-registering on a later flush", () => {
|
||||
const { calls, presence, setVisible } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
setVisible(false)
|
||||
presence.setDisplayed(null)
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("handle routes openSessions to attached and visibleSession to visible", () => {
|
||||
const { calls, attached, presence } = setup(true)
|
||||
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1", "ses_2"] })
|
||||
presence.handle({ type: "agentManager.visibleSession", sessionID: "ses_1" })
|
||||
|
||||
expect(attached.at(-1)).toEqual(["ses_1", "ses_2"])
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("handle while hidden stores state but registers empty", () => {
|
||||
const { calls, attached, presence, setVisible } = setup(false)
|
||||
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1"] })
|
||||
presence.handle({ type: "agentManager.visibleSession", sessionID: "ses_1" })
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
expect(attached.at(-1)).toEqual([])
|
||||
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
expect(attached.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("clear empties both the visible and attached registrations", () => {
|
||||
const { calls, attached, presence } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1"] })
|
||||
|
||||
presence.clear()
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
expect(attached.at(-1)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Vscode-free presence state for the Agent Manager.
|
||||
*
|
||||
* Owns the displayed session id and the open-tab session set. Both are gated
|
||||
* on panel visibility: when the panel is hidden (retainContextWhenHidden
|
||||
* keeps the webview alive), flush() clears both registrations so the retained
|
||||
* webview's reactive updates cannot keep stale sessions attached or visible.
|
||||
* When the panel returns, flush() re-registers from stored state. */
|
||||
|
||||
type Register = (ids: string[]) => void
|
||||
|
||||
type PresenceMessage =
|
||||
| { type: "agentManager.openSessions"; sessionIDs: string[] }
|
||||
| { type: "agentManager.visibleSession"; sessionID: string | null }
|
||||
|
||||
export class AgentManagerVisiblePresence {
|
||||
private id: string | null = null
|
||||
private open: string[] = []
|
||||
constructor(
|
||||
private readonly register: Register,
|
||||
private readonly panelVisible: () => boolean,
|
||||
private readonly registerAttached: Register,
|
||||
) {}
|
||||
|
||||
setDisplayed(id: string | null): void {
|
||||
this.id = id
|
||||
this.flush()
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (this.panelVisible()) {
|
||||
this.register(this.id ? [this.id] : [])
|
||||
this.registerAttached(this.open)
|
||||
} else {
|
||||
this.register([])
|
||||
this.registerAttached([])
|
||||
}
|
||||
}
|
||||
|
||||
handle(m: PresenceMessage): void {
|
||||
if (m.type === "agentManager.openSessions") this.open = m.sessionIDs
|
||||
else this.id = m.sessionID
|
||||
this.flush()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.id = null
|
||||
this.open = []
|
||||
this.register([])
|
||||
this.registerAttached([])
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export interface SessionProvider {
|
||||
setSessionDirectory(id: string, directory: string): void
|
||||
clearSessionDirectory(id: string): void
|
||||
getSessionDirectories(): ReadonlyMap<string, string>
|
||||
getSessionInfo?(id: string): Promise<Session | undefined>
|
||||
trackSession(id: string): void
|
||||
refreshSessions(): void
|
||||
registerSession(session: Session): void
|
||||
@@ -44,6 +45,8 @@ export interface SessionProvider {
|
||||
* The callback receives the new session and its directory so the Agent Manager
|
||||
* can route it to the correct worktree instead of LOCAL. */
|
||||
onFollowupAdopted(cb: (session: Session, directory: string) => void): void
|
||||
acknowledgeDraft(draftID: string, sessionID: string): void
|
||||
abortSessions(ids: readonly string[]): Promise<void>
|
||||
showMemory(sessionID?: string): Promise<void>
|
||||
toggleMemory(sessionID?: string): Promise<void>
|
||||
dispose(): void
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SessionProvider } from "./host"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
|
||||
export async function pruneSubagents(
|
||||
state: WorktreeStateManager,
|
||||
sessions: SessionProvider | undefined,
|
||||
log: (message: string) => void,
|
||||
): Promise<void> {
|
||||
const get = sessions?.getSessionInfo
|
||||
if (!sessions || !get) return
|
||||
const managed = state.getSessions()
|
||||
const infos = await Promise.all(managed.map(async (item) => ({ item, info: await get(item.id) })))
|
||||
for (const result of infos) {
|
||||
const parent = result.info?.parentID
|
||||
if (parent === undefined || parent === null) continue
|
||||
state.removeSession(result.item.id)
|
||||
sessions.clearSessionDirectory(result.item.id)
|
||||
log(`Removed subagent session ${result.item.id} from managed state`)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,16 @@ export interface ToolRequest {
|
||||
requestID: string
|
||||
sessionID?: string
|
||||
directory?: string
|
||||
sandboxInheritanceToken?: string
|
||||
mode: "worktree" | "local"
|
||||
versions?: boolean
|
||||
tasks: ToolTask[]
|
||||
}
|
||||
|
||||
export interface ToolSource {
|
||||
sandboxInheritanceToken?: string
|
||||
}
|
||||
|
||||
interface WorktreeCreated {
|
||||
worktree: ReturnType<WorktreeStateManager["addWorktree"]>
|
||||
result: CreateWorktreeResult
|
||||
@@ -47,7 +52,7 @@ export interface ToolDeps {
|
||||
claimRequest?: (requestID: string) => boolean
|
||||
cleanupWorktree: (wid: string, dir: string) => Promise<void>
|
||||
setup: (dir: string, branch?: string, id?: string) => Promise<void>
|
||||
createSessionInWorktree: (dir: string, branch: string, id?: string) => Promise<Session | null>
|
||||
createSessionInWorktree: (dir: string, branch: string, id?: string, source?: ToolSource) => Promise<Session | null>
|
||||
sessionMetadata: (client: KiloClient, dir: string) => Promise<Record<string, unknown>>
|
||||
registerWorktreeSession: (sid: string, dir: string) => void
|
||||
notifyReady: (sid: string, result: CreateWorktreeResult, wid?: string) => void
|
||||
@@ -116,7 +121,7 @@ async function prompt(client: KiloClient, sid: string, dir: string, task: ToolTa
|
||||
)
|
||||
}
|
||||
|
||||
async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directory?: string) {
|
||||
async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directory?: string, source?: ToolSource) {
|
||||
const root = deps.getRoot()
|
||||
const state = deps.getState()
|
||||
if (!root || !state) return false
|
||||
@@ -135,7 +140,12 @@ async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directo
|
||||
const target = wt?.path ?? root
|
||||
const metadata = await deps.sessionMetadata(client, target)
|
||||
const { data } = await client.session.create(
|
||||
{ directory: target, platform: PLATFORM, metadata },
|
||||
{
|
||||
directory: target,
|
||||
platform: PLATFORM,
|
||||
metadata,
|
||||
...(source?.sandboxInheritanceToken ? { sandboxInheritanceToken: source.sandboxInheritanceToken } : {}),
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const session = data
|
||||
@@ -163,6 +173,7 @@ async function worktree(
|
||||
total: number,
|
||||
groupId?: string,
|
||||
versions?: boolean,
|
||||
source?: ToolSource,
|
||||
) {
|
||||
const baseBranch = branch(task.branchName) ?? branch(task.name)
|
||||
const baseLabel = label(task.name) ?? label(task.branchName) ?? label(task.prompt)
|
||||
@@ -176,7 +187,12 @@ async function worktree(
|
||||
if (!created) return false
|
||||
|
||||
await deps.setup(created.result.path, created.result.branch, created.worktree.id)
|
||||
const session = await deps.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id)
|
||||
const session = await deps.createSessionInWorktree(
|
||||
created.result.path,
|
||||
created.result.branch,
|
||||
created.worktree.id,
|
||||
source,
|
||||
)
|
||||
if (!session) {
|
||||
await deps.cleanupWorktree(created.worktree.id, created.result.path)
|
||||
return false
|
||||
@@ -216,6 +232,7 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
|
||||
const versions = req.mode === "worktree" && req.versions === true && total > 1
|
||||
const groupId = versions ? `grp-${Date.now()}` : undefined
|
||||
const state = { ok: 0 }
|
||||
const source = { sandboxInheritanceToken: req.sandboxInheritanceToken }
|
||||
|
||||
deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: 0, groupId })
|
||||
for (let i = 0; i < req.tasks.length; i++) {
|
||||
@@ -223,8 +240,8 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
|
||||
try {
|
||||
const done =
|
||||
req.mode === "local"
|
||||
? await local(deps, client, task, req.directory)
|
||||
: await worktree(deps, client, task, i, total, groupId, versions)
|
||||
? await local(deps, client, task, req.directory, source)
|
||||
: await worktree(deps, client, task, i, total, groupId, versions, source)
|
||||
if (done) state.ok++
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
@@ -284,6 +301,8 @@ export function parseToolRequest(value: unknown): ToolRequest | undefined {
|
||||
requestID: typeof value.requestID === "string" ? value.requestID : `am-${Date.now()}`,
|
||||
sessionID: typeof value.sessionID === "string" ? value.sessionID : undefined,
|
||||
directory: typeof value.directory === "string" ? value.directory : undefined,
|
||||
sandboxInheritanceToken:
|
||||
typeof value.sandboxInheritanceToken === "string" ? value.sandboxInheritanceToken : undefined,
|
||||
mode,
|
||||
versions: typeof value.versions === "boolean" ? value.versions : undefined,
|
||||
tasks: parsed,
|
||||
|
||||
@@ -373,6 +373,7 @@ interface CloseSessionIn {
|
||||
interface PersistSessionIn {
|
||||
type: "agentManager.persistSession"
|
||||
sessionId: string
|
||||
draftID?: string
|
||||
}
|
||||
|
||||
/** Remove a non-worktree session from agent-manager.json. */
|
||||
@@ -564,6 +565,11 @@ interface OpenSessionsIn {
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
interface VisibleSessionIn {
|
||||
type: "agentManager.visibleSession"
|
||||
sessionID: string | null
|
||||
}
|
||||
|
||||
interface OpenFileIn {
|
||||
type: "agentManager.openFile"
|
||||
sessionId: string
|
||||
@@ -809,6 +815,7 @@ export type AgentManagerInMessage =
|
||||
| RefreshPRIn
|
||||
| OpenPRIn
|
||||
| OpenSessionsIn
|
||||
| VisibleSessionIn
|
||||
| OpenFileIn
|
||||
| GenericOpenFileIn
|
||||
| PreviewImageIn
|
||||
|
||||
@@ -96,6 +96,7 @@ export class VscodeHost implements Host {
|
||||
snapshotInitialization: SNAPSHOT_INITIALIZATION,
|
||||
slimEditMetadata: true,
|
||||
worktreeDirectories: () => opts.worktreeDirectories?.() ?? [],
|
||||
disableViewedRegistration: true,
|
||||
})
|
||||
if (this.diffVirtual) {
|
||||
provider.setDiffVirtualProvider(this.diffVirtual)
|
||||
@@ -114,11 +115,14 @@ export class VscodeHost implements Host {
|
||||
setSessionDirectory: (id, dir) => provider.setSessionDirectory(id, dir),
|
||||
clearSessionDirectory: (id) => provider.clearSessionDirectory(id),
|
||||
getSessionDirectories: () => provider.getSessionDirectories(),
|
||||
getSessionInfo: (id) => provider.getSessionInfo(id),
|
||||
trackSession: (id) => provider.trackSession(id),
|
||||
refreshSessions: () => provider.refreshSessions(),
|
||||
registerSession: (s) => provider.registerSession(s),
|
||||
recoverPendingPrompts: () => provider.recoverPendingPrompts(),
|
||||
onFollowupAdopted: (cb) => provider.onFollowupAdopted(cb),
|
||||
acknowledgeDraft: (draftID, sessionID) => provider.acknowledgeDraft(draftID, sessionID),
|
||||
abortSessions: (ids) => provider.abortSessions(ids),
|
||||
showMemory: (id) => provider.showMemory(id),
|
||||
toggleMemory: (id) => provider.toggleMemory(id),
|
||||
dispose: () => provider.dispose(),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Worktree, WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import type { WorktreeManager, CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { chooseBaseBranch } from "./base-branch"
|
||||
import { classifyWorktreeError } from "./git-import"
|
||||
import { PLATFORM } from "./constants"
|
||||
import type { AgentManagerOutMessage } from "./types"
|
||||
|
||||
export type CreateWorktreeOnDiskOptions = {
|
||||
groupId?: string
|
||||
baseBranch?: string
|
||||
branchName?: string
|
||||
existingBranch?: string
|
||||
name?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type CreateWorktreeOnDiskResult = {
|
||||
worktree: Worktree
|
||||
result: CreateWorktreeResult
|
||||
}
|
||||
|
||||
export interface CreateWorktreeOnDiskContext {
|
||||
getWorktreeManager: () => WorktreeManager | undefined
|
||||
getStateManager: () => WorktreeStateManager | undefined
|
||||
postToWebview: (message: AgentManagerOutMessage) => void
|
||||
capture: (event: string, properties?: Record<string, unknown>) => void
|
||||
pushState: () => void
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git worktree on disk and register it in state. Returns null on failure.
|
||||
*
|
||||
* Pure orchestration — no vscode imports.
|
||||
*/
|
||||
export async function createWorktreeOnDisk(
|
||||
ctx: CreateWorktreeOnDiskContext,
|
||||
opts?: CreateWorktreeOnDiskOptions,
|
||||
): Promise<CreateWorktreeOnDiskResult | null> {
|
||||
const manager = ctx.getWorktreeManager()
|
||||
const state = ctx.getStateManager()
|
||||
if (!manager || !state) {
|
||||
ctx.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: "Open a folder that contains a git repository to use worktrees",
|
||||
errorCode: "not_git_repo",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
ctx.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Creating git worktree..." })
|
||||
|
||||
// Resolve effective base branch using configured default
|
||||
const effectiveBase = opts?.existingBranch
|
||||
? undefined
|
||||
: await resolveBaseBranch(ctx, manager, state, opts?.baseBranch)
|
||||
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
result = await manager.createWorktree({
|
||||
prompt: opts?.name || "kilo",
|
||||
baseBranch: effectiveBase ?? opts?.baseBranch,
|
||||
branchName: opts?.branchName,
|
||||
existingBranch: opts?.existingBranch,
|
||||
})
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
ctx.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: msg,
|
||||
errorCode: classifyWorktreeError(msg),
|
||||
})
|
||||
ctx.capture("Agent Manager Session Error", {
|
||||
source: PLATFORM,
|
||||
error: msg,
|
||||
context: "createWorktree",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
remote: result.remote,
|
||||
groupId: opts?.groupId,
|
||||
label: opts?.label,
|
||||
branchOwned: !opts?.existingBranch,
|
||||
})
|
||||
|
||||
// Push state immediately so the sidebar shows the new worktree with a loading indicator
|
||||
ctx.pushState()
|
||||
ctx.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Setting up worktree...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
|
||||
return { worktree, result }
|
||||
}
|
||||
|
||||
/** Resolve the effective base branch using the configured default, explicit override, and existence check. */
|
||||
async function resolveBaseBranch(
|
||||
ctx: CreateWorktreeOnDiskContext,
|
||||
manager: WorktreeManager,
|
||||
state: WorktreeStateManager,
|
||||
explicit?: string,
|
||||
): Promise<string | undefined> {
|
||||
const configured = state.getDefaultBaseBranch()
|
||||
if (!configured && !explicit) return undefined
|
||||
|
||||
const configuredExists = configured ? await manager.branchExists(configured) : false
|
||||
const result = chooseBaseBranch({ explicit, configured, configuredExists })
|
||||
|
||||
if (result.stale) clearStaleDefaultBaseBranch(ctx, state, result.stale)
|
||||
return result.branch
|
||||
}
|
||||
|
||||
/** Reset a stale default base branch and notify the webview. */
|
||||
function clearStaleDefaultBaseBranch(
|
||||
ctx: CreateWorktreeOnDiskContext,
|
||||
state: WorktreeStateManager,
|
||||
stale: string,
|
||||
): void {
|
||||
ctx.log(`Default base branch "${stale}" no longer exists, clearing`)
|
||||
state.setDefaultBaseBranch(undefined)
|
||||
ctx.pushState()
|
||||
}
|
||||
@@ -14,7 +14,13 @@ export function recordFollowup(input: { answers: string[][]; dir: string; now: n
|
||||
return { dir: input.dir, time: input.now }
|
||||
}
|
||||
|
||||
export function matchFollowup(input: { pending: Followup | null; dir: string; now: number }): boolean {
|
||||
export function matchFollowup(input: {
|
||||
pending: Followup | null
|
||||
dir: string
|
||||
now: number
|
||||
parentID?: string | null
|
||||
}): boolean {
|
||||
if (input.parentID !== undefined && input.parentID !== null) return false
|
||||
const item = input.pending
|
||||
if (!item) return false
|
||||
if (input.now - item.time > TTL) return false
|
||||
|
||||
@@ -5,4 +5,6 @@ export type KiloProviderOptions = {
|
||||
slimEditMetadata?: boolean
|
||||
tabTitle?: (title: string) => void
|
||||
worktreeDirectories?: () => string[]
|
||||
/** Composite hosts (Agent Manager) own viewed/presence registration themselves. */
|
||||
disableViewedRegistration?: boolean
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ describe("KiloConnectionService clients", () => {
|
||||
})
|
||||
|
||||
describe("KiloConnectionService viewed sessions", () => {
|
||||
test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => {
|
||||
test("keeps Agent Manager sessions when sidebar visibility changes during a flush", async () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
const calls: Array<{ focused: string[]; open?: string[] }> = []
|
||||
const calls: Array<{ viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }> = []
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
@@ -50,10 +50,9 @@ describe("KiloConnectionService viewed sessions", () => {
|
||||
let active = 0
|
||||
let max = 0
|
||||
|
||||
;(service as any).remoteService = { getState: () => ({ enabled: true }) }
|
||||
;(service as any).client = {
|
||||
session: {
|
||||
viewed: async (input: { focused: string[]; open?: string[] }) => {
|
||||
viewed: async (input: { viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }) => {
|
||||
calls.push(input)
|
||||
active += 1
|
||||
max = Math.max(max, active)
|
||||
@@ -63,23 +62,91 @@ describe("KiloConnectionService viewed sessions", () => {
|
||||
},
|
||||
}
|
||||
|
||||
service.registerFocused("agent-manager", "am-1")
|
||||
service.registerOpen("agent-manager", ["am-1", "am-2"])
|
||||
service.registerVisible("agent-manager", ["am-1"])
|
||||
service.registerAttached("agent-manager", ["am-1", "am-2"])
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toEqual([{ focused: ["am-1"], open: ["am-2"] }])
|
||||
expect(calls).toHaveLength(1)
|
||||
expect([...calls[0].visible].sort()).toEqual(["am-1"])
|
||||
expect([...calls[0].attached].sort()).toEqual(["am-1", "am-2"])
|
||||
|
||||
service.registerFocused("sidebar", "side-1")
|
||||
service.registerVisible("sidebar", ["side-1"])
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
release()
|
||||
await Bun.sleep(10)
|
||||
expect(max).toBe(1)
|
||||
expect(calls[1]).toEqual({ focused: ["am-1", "side-1"], open: ["am-2"] })
|
||||
expect([...calls[1].visible].sort()).toEqual(["am-1", "side-1"])
|
||||
expect([...calls[1].attached].sort()).toEqual(["am-1", "am-2", "side-1"])
|
||||
|
||||
service.unregisterFocused("sidebar")
|
||||
service.registerVisible("sidebar", [])
|
||||
await Bun.sleep(175)
|
||||
expect(calls[2]).toEqual({ focused: ["am-1"], open: ["am-2"] })
|
||||
expect([...calls[2].visible].sort()).toEqual(["am-1"])
|
||||
expect([...calls[2].attached].sort()).toEqual(["am-1", "am-2"])
|
||||
})
|
||||
|
||||
test("window focus gates viewer.active but not attachment", async () => {
|
||||
const window = vscode.window as unknown as {
|
||||
state: { focused: boolean }
|
||||
onDidChangeWindowState: (listener: (ws: { focused: boolean }) => void) => { dispose(): void }
|
||||
}
|
||||
const original = window.onDidChangeWindowState
|
||||
let listener: ((ws: { focused: boolean }) => void) | undefined
|
||||
window.onDidChangeWindowState = (cb) => {
|
||||
listener = cb
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
const calls: Array<{ viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }> = []
|
||||
;(service as any).client = {
|
||||
session: {
|
||||
viewed: async (input: (typeof calls)[number]) => {
|
||||
calls.push(input)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service.registerVisible("sidebar", ["ses-1"])
|
||||
service.registerAttached("sidebar", ["ses-1", "ses-2"])
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].viewer.active).toBe(true)
|
||||
|
||||
listener!({ focused: false })
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1].viewer.active).toBe(false)
|
||||
expect([...calls[1].visible].sort()).toEqual(["ses-1"])
|
||||
expect([...calls[1].attached].sort()).toEqual(["ses-1", "ses-2"])
|
||||
} finally {
|
||||
window.onDidChangeWindowState = original
|
||||
}
|
||||
})
|
||||
|
||||
test("sends snapshots while remote control is disabled", async () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
const calls: Array<{ viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }> = []
|
||||
;(service as any).client = {
|
||||
session: {
|
||||
viewed: async (input: (typeof calls)[number]) => {
|
||||
calls.push(input)
|
||||
},
|
||||
},
|
||||
}
|
||||
service.setRemoteService({
|
||||
getState: () => ({ enabled: false, connected: false }),
|
||||
onChange: () => () => {},
|
||||
} as any)
|
||||
|
||||
service.registerVisible("sidebar", ["ses-1"])
|
||||
service.registerAttached("agent-manager", ["ses-2"])
|
||||
await Bun.sleep(175)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect([...calls[0].visible].sort()).toEqual(["ses-1"])
|
||||
expect([...calls[0].attached].sort()).toEqual(["ses-1", "ses-2"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ function isNotFound(err: unknown) {
|
||||
return false
|
||||
}
|
||||
|
||||
function sameSet(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const id of a) if (!b.has(id)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Poll /global/health every 10 seconds.
|
||||
// This provides a second detection channel for server death independent of the SSE heartbeat.
|
||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||
@@ -111,10 +117,14 @@ export class KiloConnectionService {
|
||||
*/
|
||||
private readonly messageSessionIdsByMessageId: Map<string, string> = new Map()
|
||||
|
||||
/** Provider key → single focused session ID. */
|
||||
private readonly focused: Map<string, string> = new Map()
|
||||
/** Provider key → all open (background) session IDs. */
|
||||
private readonly opened: Map<string, string[]> = new Map()
|
||||
private readonly viewerId = crypto.randomUUID()
|
||||
private active = true
|
||||
private windowStateDisposable: vscode.Disposable | null = null
|
||||
private checkinTimer: ReturnType<typeof setInterval> | null = null
|
||||
/** Provider key → attached (retained for remote control) session IDs. */
|
||||
private readonly attached: Map<string, Set<string>> = new Map()
|
||||
/** Provider key → visibly rendered session IDs. */
|
||||
private readonly visible: Map<string, Set<string>> = new Map()
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private viewedSending = false
|
||||
private viewedDirty = false
|
||||
@@ -129,6 +139,11 @@ export class KiloConnectionService {
|
||||
} satisfies Pick<vscode.Memento, "get" | "update">)
|
||||
this.sandboxPreference = new SandboxPreference(state)
|
||||
this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code))
|
||||
this.active = vscode.window.state.focused
|
||||
this.windowStateDisposable = vscode.window.onDidChangeWindowState((ws) => {
|
||||
this.active = ws.focused
|
||||
this.flushViewed()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,14 +308,15 @@ export class KiloConnectionService {
|
||||
for (const [mid, sid] of this.messageSessionIdsByMessageId) {
|
||||
if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid)
|
||||
}
|
||||
for (const [key, sid] of this.focused) {
|
||||
if (sid === sessionId) this.focused.delete(key)
|
||||
for (const [key, ids] of this.attached) {
|
||||
if (!ids.has(sessionId)) continue
|
||||
ids.delete(sessionId)
|
||||
if (ids.size === 0) this.attached.delete(key)
|
||||
}
|
||||
for (const [key, ids] of this.opened) {
|
||||
if (!ids.includes(sessionId)) continue
|
||||
const next = ids.filter((id) => id !== sessionId)
|
||||
if (next.length === 0) this.opened.delete(key)
|
||||
else this.opened.set(key, next)
|
||||
for (const [key, ids] of this.visible) {
|
||||
if (!ids.has(sessionId)) continue
|
||||
ids.delete(sessionId)
|
||||
if (ids.size === 0) this.visible.delete(key)
|
||||
}
|
||||
this.flushViewed()
|
||||
}
|
||||
@@ -584,38 +600,49 @@ export class KiloConnectionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the session a provider is actively viewing (focused).
|
||||
* After any change the aggregated set is sent to the server (debounced).
|
||||
* Register the sessions a provider retains for remote control (attached).
|
||||
* Sent to the server (debounced) regardless of remote-control enablement.
|
||||
*/
|
||||
registerFocused(key: string, sessionID: string): void {
|
||||
if (this.focused.get(key) === sessionID) return
|
||||
this.focused.set(key, sessionID)
|
||||
registerAttached(key: string, ids: string[]): void {
|
||||
const next = new Set(ids)
|
||||
const prev = this.attached.get(key)
|
||||
if (prev && sameSet(prev, next)) return
|
||||
this.attached.set(key, next)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a provider's focused session (e.g. on dispose, hidden, or clearSession).
|
||||
* Unregister a provider's attached sessions (e.g. on dispose or clear).
|
||||
*/
|
||||
unregisterFocused(key: string): void {
|
||||
if (!this.focused.has(key)) return
|
||||
this.focused.delete(key)
|
||||
unregisterAttached(key: string): void {
|
||||
if (!this.attached.has(key)) return
|
||||
this.attached.delete(key)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the open (background tab) session IDs for a provider.
|
||||
* Sessions that appear in both focused and open are reported as focused only.
|
||||
* Register the sessions a provider visibly renders (visible).
|
||||
* Visible sessions are also reported as attached.
|
||||
*/
|
||||
registerOpen(key: string, ids: string[]): void {
|
||||
const prev = this.opened.get(key)
|
||||
if (prev && prev.length === ids.length && prev.every((v, i) => v === ids[i])) return
|
||||
this.opened.set(key, ids)
|
||||
registerVisible(key: string, ids: string[]): void {
|
||||
const next = new Set(ids)
|
||||
const prev = this.visible.get(key)
|
||||
if (prev && sameSet(prev, next)) return
|
||||
this.visible.set(key, next)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/** Debounced: send the aggregated focused + open session IDs to the server. */
|
||||
/**
|
||||
* Unregister a provider's visible sessions (e.g. on hide, clear, or dispose).
|
||||
*/
|
||||
unregisterVisible(key: string): void {
|
||||
if (!this.visible.has(key)) return
|
||||
this.visible.delete(key)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/** Debounced: send the aggregated attached + visible snapshot to the server. Works even when remote control is disabled. */
|
||||
flushViewed(): void {
|
||||
if (!this.isRemoteEnabled()) return
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null
|
||||
@@ -624,28 +651,21 @@ export class KiloConnectionService {
|
||||
}
|
||||
|
||||
private sendViewed(): void {
|
||||
if (!this.isRemoteEnabled()) {
|
||||
this.viewedDirty = false
|
||||
return
|
||||
}
|
||||
if (this.viewedSending) {
|
||||
this.viewedDirty = true
|
||||
return
|
||||
}
|
||||
if (!this.client) return
|
||||
|
||||
const focus = new Set(this.focused.values())
|
||||
const open = new Set<string>()
|
||||
for (const ids of this.opened.values()) {
|
||||
for (const id of ids) {
|
||||
if (!focus.has(id)) open.add(id)
|
||||
}
|
||||
}
|
||||
const visible = new Set<string>()
|
||||
for (const ids of this.visible.values()) for (const id of ids) visible.add(id)
|
||||
const attached = new Set<string>(visible)
|
||||
for (const ids of this.attached.values()) for (const id of ids) attached.add(id)
|
||||
|
||||
this.viewedSending = true
|
||||
this.viewedDirty = false
|
||||
void this.client.session
|
||||
.viewed({ focused: [...focus], open: [...open] })
|
||||
.viewed({ viewer: { id: this.viewerId, active: this.active }, attached: [...attached], visible: [...visible] })
|
||||
.catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err))
|
||||
.finally(() => {
|
||||
this.viewedSending = false
|
||||
@@ -674,12 +694,23 @@ export class KiloConnectionService {
|
||||
this.permissionDirectories.clear()
|
||||
this.questionDirectories.clear()
|
||||
this.questionRevision += 1
|
||||
this.focused.clear()
|
||||
this.opened.clear()
|
||||
if (this.client?.session?.viewed) {
|
||||
void this.client.session
|
||||
.viewed({ viewer: { id: this.viewerId, active: false }, attached: [], visible: [] })
|
||||
.catch(() => {})
|
||||
}
|
||||
this.attached.clear()
|
||||
this.visible.clear()
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
if (this.checkinTimer) {
|
||||
clearInterval(this.checkinTimer)
|
||||
this.checkinTimer = null
|
||||
}
|
||||
this.windowStateDisposable?.dispose()
|
||||
this.windowStateDisposable = null
|
||||
this.viewedDirty = false
|
||||
this.unsubRemote?.()
|
||||
this.unsubRemote = null
|
||||
@@ -747,6 +778,7 @@ export class KiloConnectionService {
|
||||
|
||||
private resetConnection(): void {
|
||||
this.stopHealthPoll()
|
||||
this.stopCheckin()
|
||||
const sse = this.sseClient
|
||||
this.sseClient = null
|
||||
sse?.disconnect()
|
||||
@@ -837,6 +869,7 @@ export class KiloConnectionService {
|
||||
resolveConnected?.()
|
||||
resolveConnected = null
|
||||
rejectConnected = null
|
||||
this.flushViewed()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -851,10 +884,24 @@ export class KiloConnectionService {
|
||||
|
||||
await connectedPromise
|
||||
|
||||
this.startCheckin()
|
||||
// Start the independent health poll once we are confirmed connected.
|
||||
this.startHealthPoll(config.baseUrl, config.password)
|
||||
}
|
||||
|
||||
private startCheckin(): void {
|
||||
this.stopCheckin()
|
||||
this.checkinTimer = setInterval(() => this.flushViewed(), 60_000)
|
||||
this.checkinTimer.unref?.()
|
||||
}
|
||||
|
||||
private stopCheckin(): void {
|
||||
if (this.checkinTimer) {
|
||||
clearInterval(this.checkinTimer)
|
||||
this.checkinTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private handlePermissionEvent(event: SSEPayload, directory?: string): void {
|
||||
if (event.type === "permission.asked" && directory) {
|
||||
this.recordPermissionDirectory(event.properties.id, directory)
|
||||
|
||||
@@ -80,6 +80,8 @@ const mockVscode = {
|
||||
},
|
||||
window: {
|
||||
activeTextEditor: undefined,
|
||||
state: { focused: true },
|
||||
onDidChangeWindowState: () => ({ dispose: noop }),
|
||||
activeNotebookEditor: undefined,
|
||||
visibleTextEditors: [],
|
||||
visibleNotebookEditors: [],
|
||||
|
||||
@@ -214,11 +214,44 @@ describe("Agent Manager Provider Messages", () => {
|
||||
expect(body).not.toContain("void this.terminalRouter.dispose()")
|
||||
})
|
||||
|
||||
it("clears remote session registrations when the panel closes", () => {
|
||||
it("stops both Local and worktree agents when their session tabs close", () => {
|
||||
const text = fs.readFileSync(TSX_FILE, "utf-8")
|
||||
const start = text.indexOf("const handleCloseTab =")
|
||||
const end = text.indexOf("const handleTabMouseDown =", start)
|
||||
const body = text.slice(start, end)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
expect(body).toContain("closedDrafts.add(sessionId)")
|
||||
expect(body).toContain('vscode.postMessage({ type: "agentManager.closeSession", sessionId })')
|
||||
expect(body).not.toContain('type: "agentManager.forgetSession"')
|
||||
expect(getMethodBody("onCloseSession")).toContain("await this.panel?.sessions.abortSessions([sessionId])")
|
||||
expect(text).toContain("if (created.draftID && closedDrafts.delete(created.draftID)) return")
|
||||
})
|
||||
|
||||
it("stops open sessions and clears remote registrations when the panel closes", () => {
|
||||
const body = getMethodBody("attachPanel")
|
||||
expect(body).toContain('this.connectionService.unregisterFocused("agent-manager")')
|
||||
expect(body).toContain('this.connectionService.registerOpen("agent-manager", [])')
|
||||
const abort = body.indexOf("ctx.sessions.abortSessions(ids)")
|
||||
const dispose = body.indexOf("ctx.sessions.dispose()")
|
||||
expect(abort).toBeGreaterThanOrEqual(0)
|
||||
expect(dispose).toBeGreaterThan(abort)
|
||||
expect(body).toContain("const ids = [...this.panelSessions]")
|
||||
expect(body).toContain("if (this.activeSessionId) ids.push(this.activeSessionId)")
|
||||
// Presence must be cleared via visiblePresence.clear() — a direct
|
||||
// registerVisible("agent-manager", []) would leave a stale displayed id
|
||||
// that re-registers on the next flush after the panel reopens.
|
||||
expect(body).toContain("this.visiblePresence.clear()")
|
||||
expect(body).not.toContain('this.connectionService.registerVisible("agent-manager"')
|
||||
expect(body).not.toContain('this.connectionService.registerAttached("agent-manager"')
|
||||
expect(body).toContain("this.activeSessionId = undefined")
|
||||
const messages = getMethodBody("onSessionMessage")
|
||||
expect(messages).toContain("if (m.draftID) this.panelSessions.add(m.draftID)")
|
||||
expect(messages).toContain("this.panel?.sessions.acknowledgeDraft(m.draftID, m.sessionId)")
|
||||
expect(messages).toContain("for (const id of m.sessionIDs) this.panelSessions.add(id)")
|
||||
})
|
||||
|
||||
it("does not treat extension shutdown as a user panel close", () => {
|
||||
const body = getMethodBody("disposeAsync")
|
||||
expect(body.indexOf("this.panel = undefined")).toBeLessThan(body.indexOf("panel?.dispose()"))
|
||||
})
|
||||
|
||||
it("reports all open Agent Manager sessions for remote control", () => {
|
||||
|
||||
@@ -8,8 +8,10 @@ type Manager = {
|
||||
sessions: {
|
||||
getSessionDirectories: () => ReadonlyMap<string, string>
|
||||
clearSessionDirectory: (id: string) => void
|
||||
abortSessions: (ids: readonly string[]) => Promise<void>
|
||||
}
|
||||
}
|
||||
panelSessions: Set<string>
|
||||
getStateManager: () => unknown
|
||||
getRoot: () => string
|
||||
pushState: () => void
|
||||
@@ -17,14 +19,17 @@ type Manager = {
|
||||
onCloseSession: (sessionId: string) => Promise<null>
|
||||
}
|
||||
|
||||
function createManager(options?: { dir?: string; panelDir?: string }) {
|
||||
function createManager(options?: { dir?: string; panelDir?: string; state?: boolean }) {
|
||||
const stopped: unknown[] = []
|
||||
const aborted: string[][] = []
|
||||
const cleared: string[] = []
|
||||
const removed: string[] = []
|
||||
const events: string[] = []
|
||||
const client = {
|
||||
backgroundProcess: {
|
||||
stopSession: mock(async (params: unknown) => {
|
||||
stopped.push(params)
|
||||
events.push("processes")
|
||||
return { data: {} }
|
||||
}),
|
||||
},
|
||||
@@ -33,6 +38,7 @@ function createManager(options?: { dir?: string; panelDir?: string }) {
|
||||
directoryFor: mock((sessionId: string) => (sessionId === "s1" ? options?.dir : undefined)),
|
||||
removeSession: mock((sessionId: string) => {
|
||||
removed.push(sessionId)
|
||||
events.push("remove")
|
||||
}),
|
||||
}
|
||||
const manager = Object.create(AgentManagerProvider.prototype) as Manager
|
||||
@@ -41,25 +47,33 @@ function createManager(options?: { dir?: string; panelDir?: string }) {
|
||||
sessions: {
|
||||
getSessionDirectories: () => new Map(options?.panelDir ? [["s1", options.panelDir]] : []),
|
||||
clearSessionDirectory: (id) => cleared.push(id),
|
||||
abortSessions: async (ids) => {
|
||||
aborted.push([...ids])
|
||||
events.push("abort")
|
||||
},
|
||||
},
|
||||
}
|
||||
manager.getStateManager = () => state
|
||||
manager.panelSessions = new Set(["s1"])
|
||||
manager.getStateManager = () => (options?.state === false ? undefined : state)
|
||||
manager.getRoot = () => "/repo"
|
||||
manager.pushState = mock(() => undefined)
|
||||
manager.log = mock(() => undefined)
|
||||
|
||||
return { manager, stopped, cleared, removed }
|
||||
return { manager, stopped, aborted, cleared, removed, events }
|
||||
}
|
||||
|
||||
describe("AgentManagerProvider closeSession", () => {
|
||||
it("stops background processes in the worktree directory before closing", async () => {
|
||||
const { manager, stopped, cleared, removed } = createManager({ dir: "/repo/worktree" })
|
||||
it("aborts the agent before stopping processes and removing its tab", async () => {
|
||||
const { manager, stopped, aborted, cleared, removed, events } = createManager({ dir: "/repo/worktree" })
|
||||
|
||||
await manager.onCloseSession("s1")
|
||||
|
||||
expect(aborted).toEqual([["s1"]])
|
||||
expect(stopped).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }])
|
||||
expect(events).toEqual(["abort", "processes", "remove"])
|
||||
expect(removed).toEqual(["s1"])
|
||||
expect(cleared).toEqual(["s1"])
|
||||
expect(manager.panelSessions.has("s1")).toBe(false)
|
||||
})
|
||||
|
||||
it("falls back to session provider directory mappings", async () => {
|
||||
@@ -69,4 +83,13 @@ describe("AgentManagerProvider closeSession", () => {
|
||||
|
||||
expect(stopped).toEqual([{ sessionID: "s1", directory: "/repo/panel-worktree" }])
|
||||
})
|
||||
|
||||
it("still aborts when Agent Manager has no workspace state", async () => {
|
||||
const { manager, aborted, removed } = createManager({ state: false })
|
||||
|
||||
await manager.onCloseSession("s1")
|
||||
|
||||
expect(aborted).toEqual([["s1"]])
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { visible } from "../../webview-ui/agent-manager/remote-sessions"
|
||||
|
||||
const APP = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx")
|
||||
|
||||
function flat(source: string) {
|
||||
return source.replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
test("reports a real session only while its chat surface is displayed", () => {
|
||||
expect(visible("ses_1", false)).toBe("ses_1")
|
||||
expect(visible("ses_1", true)).toBeNull()
|
||||
})
|
||||
|
||||
test("does not report synthetic pending or cloud preview IDs", () => {
|
||||
expect(visible("pending:1", false)).toBeNull()
|
||||
expect(visible("cloud:1", false)).toBeNull()
|
||||
})
|
||||
|
||||
test("blocks visible presence while setup or an empty pane covers chat", () => {
|
||||
const source = flat(fs.readFileSync(APP, "utf-8"))
|
||||
expect(source).toContain(
|
||||
"visible( session.currentSessionID(), !!terms.activeId() || reviewActive() || history() || !!overlay() || contextEmpty(), )",
|
||||
)
|
||||
expect(source).toContain("<Show when={overlay()}>")
|
||||
})
|
||||
@@ -144,6 +144,29 @@ describe("agent manager tool start", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("passes sandbox inheritance token to local sessions", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
create: mock(async () => ({ data: session("s-local") })),
|
||||
promptAsync: mock(async () => ({})),
|
||||
},
|
||||
}
|
||||
const c = deps({ getClient: () => client as never })
|
||||
|
||||
await startFromTool(c, {
|
||||
requestID: "am-local-source",
|
||||
sessionID: "s-parent",
|
||||
sandboxInheritanceToken: "si-token",
|
||||
mode: "local",
|
||||
tasks: [{ prompt: "Do work" }],
|
||||
})
|
||||
|
||||
expect(client.session.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sandboxInheritanceToken: "si-token" }),
|
||||
{ throwOnError: true },
|
||||
)
|
||||
})
|
||||
|
||||
it("starts local sessions when Windows drive-letter casing differs", async () => {
|
||||
setPlatform("win32")
|
||||
const client = {
|
||||
@@ -191,6 +214,8 @@ describe("agent manager tool start", () => {
|
||||
const c = deps({ getClient: () => client as never })
|
||||
await startFromTool(c, {
|
||||
requestID: "am-2",
|
||||
sessionID: "s-parent",
|
||||
sandboxInheritanceToken: "si-token",
|
||||
mode: "worktree",
|
||||
tasks: [
|
||||
{
|
||||
@@ -206,7 +231,9 @@ describe("agent manager tool start", () => {
|
||||
expect.objectContaining({ branchName: "fix-one", name: "fix-one", label: "one" }),
|
||||
)
|
||||
expect(c.setup).toHaveBeenCalled()
|
||||
expect(c.createSessionInWorktree).toHaveBeenCalled()
|
||||
expect(c.createSessionInWorktree).toHaveBeenCalledWith("/repo/.kilo/worktrees/wt-1", "kilo/test", "wt-1", {
|
||||
sandboxInheritanceToken: "si-token",
|
||||
})
|
||||
expect(c.registerWorktreeSession).toHaveBeenCalledWith("s-wt", "/repo/.kilo/worktrees/wt-1")
|
||||
expect(c.notifyReady).toHaveBeenCalled()
|
||||
expect(client.session.promptAsync).toHaveBeenCalledWith(
|
||||
|
||||
@@ -35,4 +35,11 @@ describe("followup-session", () => {
|
||||
expect(matchFollowup({ pending, dir: "c:/repo/.kilo/worktrees/other", now: 2 })).toBe(false)
|
||||
expect(matchFollowup({ pending, dir: "c:/repo/.kilo/worktrees/feature", now: 30_002 })).toBe(false)
|
||||
})
|
||||
|
||||
it("never matches a subagent session", () => {
|
||||
const pending = { dir: "/repo", time: 1 }
|
||||
|
||||
expect(matchFollowup({ pending, dir: "/repo", now: 2, parentID: "root" })).toBe(false)
|
||||
expect(matchFollowup({ pending, dir: "/repo", now: 2, parentID: "" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,10 +24,11 @@ type Internals = {
|
||||
startStatsPolling: () => void
|
||||
}
|
||||
|
||||
function created(input: { id: string; directory: string }): Event {
|
||||
function created(input: { id: string; directory: string; parentID?: string }): Event {
|
||||
return {
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID: input.id,
|
||||
info: {
|
||||
id: input.id,
|
||||
slug: `${input.id}-slug`,
|
||||
@@ -36,6 +37,7 @@ function created(input: { id: string; directory: string }): Event {
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
parentID: input.parentID,
|
||||
},
|
||||
},
|
||||
} as Event
|
||||
@@ -78,7 +80,7 @@ function connection() {
|
||||
}
|
||||
|
||||
describe("KiloProvider follow-up sessions", () => {
|
||||
it("adopts pending follow-up sessions for single-session views", async () => {
|
||||
it("ignores subagents before adopting pending follow-up sessions", async () => {
|
||||
const service = connection()
|
||||
const provider = new KiloProvider({} as never, service as never)
|
||||
const internal = provider as unknown as Internals
|
||||
@@ -111,6 +113,15 @@ describe("KiloProvider follow-up sessions", () => {
|
||||
loaded.push(sessionID)
|
||||
}
|
||||
|
||||
service.emit(created({ id: "ses-child", directory: "/repo", parentID: "ses-parent" }))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(internal.currentSession).toBeNull()
|
||||
expect(internal.trackedSessionIds.has("ses-child")).toBe(false)
|
||||
expect(internal.pendingFollowup).not.toBeNull()
|
||||
expect(loaded).toEqual([])
|
||||
expect(sent).toEqual([])
|
||||
|
||||
service.emit(created({ id: "ses-followup", directory: "/repo" }))
|
||||
await Promise.resolve()
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ function mkResult(items: unknown[]) {
|
||||
return { data: items, response: { headers: new Headers() } }
|
||||
}
|
||||
|
||||
function mkCreatedSession(id = "created") {
|
||||
return { id, title: "Created", time: { created: 0, updated: 0 } }
|
||||
}
|
||||
|
||||
function createClient(options?: {
|
||||
messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }>
|
||||
messagesData?: unknown[]
|
||||
@@ -61,8 +65,9 @@ function createClient(options?: {
|
||||
revertDeferred?: Deferred<{ data?: unknown; error?: unknown }>
|
||||
sessionData?: unknown
|
||||
sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }>
|
||||
createDeferred?: Deferred<{ data: ReturnType<typeof mkCreatedSession> }>
|
||||
abortFailures?: string[]
|
||||
createDeferred?: Deferred<{ data: unknown }>
|
||||
abortDeferred?: Deferred<void>
|
||||
supportDeferred?: Deferred<{ data: { available: boolean; reason?: string } }>
|
||||
sandboxDeferred?: Deferred<{ data: unknown }>
|
||||
sandboxStarted?: Deferred<void>
|
||||
@@ -71,6 +76,7 @@ function createClient(options?: {
|
||||
const calls: { before?: string; limit?: number }[] = []
|
||||
const stopped: { sessionID: string; directory?: string }[] = []
|
||||
const aborted: { sessionID: string; directory?: string }[] = []
|
||||
const deleted: { sessionID: string; directory?: string }[] = []
|
||||
const prompted: Array<Record<string, unknown>> = []
|
||||
const reverted: Array<Record<string, unknown>> = []
|
||||
const created: Array<Record<string, unknown>> = []
|
||||
@@ -81,6 +87,7 @@ function createClient(options?: {
|
||||
calls,
|
||||
stopped,
|
||||
aborted,
|
||||
deleted,
|
||||
prompted,
|
||||
reverted,
|
||||
created,
|
||||
@@ -92,7 +99,7 @@ function createClient(options?: {
|
||||
create: async (params: Record<string, unknown>) => {
|
||||
created.push(params)
|
||||
if (options?.createSession) return options.createSession(params, created.length - 1)
|
||||
return options?.createDeferred?.promise ?? { data: mkSession() }
|
||||
return options?.createDeferred?.promise ?? { data: mkCreatedSession() }
|
||||
},
|
||||
get: async (params: { sessionID: string; directory?: string }) => {
|
||||
if (options?.sessionGet) return options.sessionGet(params)
|
||||
@@ -111,6 +118,7 @@ function createClient(options?: {
|
||||
abort: async (params: { sessionID: string; directory?: string }) => {
|
||||
aborted.push(params)
|
||||
if (params.directory && options?.abortFailures?.includes(params.directory)) throw new Error("abort failed")
|
||||
await options?.abortDeferred?.promise
|
||||
return { data: true }
|
||||
},
|
||||
messages: async (params: { before?: string; limit?: number }) => {
|
||||
@@ -118,7 +126,8 @@ function createClient(options?: {
|
||||
if (options?.messagesDeferred) return options.messagesDeferred.promise
|
||||
return mkResult(options?.messagesData ?? [])
|
||||
},
|
||||
delete: async () => {
|
||||
delete: async (params: { sessionID: string; directory?: string }) => {
|
||||
deleted.push(params)
|
||||
if (options?.deleteDeferred) return options.deleteDeferred.promise
|
||||
return { data: {} }
|
||||
},
|
||||
@@ -199,9 +208,10 @@ function createConnection(client: ReturnType<typeof createClient>) {
|
||||
recordMessageSessionId: () => undefined,
|
||||
notifyNotificationDismissed: () => undefined,
|
||||
pruneSession: () => undefined,
|
||||
registerFocused: () => undefined,
|
||||
unregisterFocused: () => undefined,
|
||||
registerOpen: () => undefined,
|
||||
registerVisible: () => undefined,
|
||||
unregisterVisible: () => undefined,
|
||||
registerAttached: () => undefined,
|
||||
unregisterAttached: () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +233,7 @@ type ProviderInternals = {
|
||||
stopCurrentSessionProcesses: (next?: string) => void
|
||||
handleEvent: (event: unknown, directory?: string) => void
|
||||
handleAbort: (sid?: string) => Promise<void>
|
||||
resolveSession: (sid?: string, draft?: string, context?: string, dir?: string) => Promise<unknown>
|
||||
handleCostAlertResponse: (sid: string, limit: number, response: "continue" | "stop") => Promise<void>
|
||||
setMaxCost: (value: unknown) => void
|
||||
handleRevertSession: (sid: string, messageID: string) => Promise<void>
|
||||
@@ -315,6 +326,87 @@ describe("KiloProvider.handleAbort", () => {
|
||||
expect(error).toHaveBeenCalledTimes(1)
|
||||
error.mockRestore()
|
||||
})
|
||||
|
||||
it("snapshots every session owner before provider disposal", async () => {
|
||||
const pending = defer<void>()
|
||||
const client = createClient({ abortDeferred: pending })
|
||||
const { provider, internal } = makeProvider(client)
|
||||
internal.handleEvent(
|
||||
{
|
||||
type: "session.status",
|
||||
properties: { sessionID: "s1", status: { type: "busy" } },
|
||||
},
|
||||
"/repo",
|
||||
)
|
||||
provider.setSessionDirectory("s1", "/repo/worktree")
|
||||
provider.setSessionDirectory("s2", "/repo/other")
|
||||
|
||||
const stopped = provider.abortSessions(["s1", "s2", "s2"])
|
||||
provider.dispose()
|
||||
|
||||
expect(client.aborted).toEqual([
|
||||
{ sessionID: "s1", directory: "/repo" },
|
||||
{ sessionID: "s1", directory: "/repo/worktree" },
|
||||
{ sessionID: "s2", directory: "/repo/other" },
|
||||
])
|
||||
pending.resolve(undefined)
|
||||
await stopped
|
||||
})
|
||||
|
||||
it("discards a session created after its pending tab closes", async () => {
|
||||
const created = defer<{ data: ReturnType<typeof mkCreatedSession> }>()
|
||||
const client = createClient({ createDeferred: created })
|
||||
const { provider, internal, sent } = makeProvider(client)
|
||||
|
||||
const resolving = internal.resolveSession(undefined, "pending:1", "local")
|
||||
await provider.abortSessions(["pending:1"])
|
||||
created.resolve({ data: mkCreatedSession() })
|
||||
|
||||
expect(await resolving).toBeUndefined()
|
||||
expect(client.deleted).toEqual([{ sessionID: "created", directory: "/repo" }])
|
||||
expect(sent).not.toContainEqual(expect.objectContaining({ type: "sessionCreated" }))
|
||||
})
|
||||
|
||||
it("does not tombstone a pending tab that never started creating", async () => {
|
||||
const client = createClient()
|
||||
const { provider, internal } = makeProvider(client)
|
||||
|
||||
await provider.abortSessions(["pending:1"])
|
||||
expect(await internal.resolveSession(undefined, "pending:1", "local")).toBeDefined()
|
||||
expect(client.deleted).toEqual([])
|
||||
})
|
||||
|
||||
it("does not submit a prompt when its pending tab closes after creation", async () => {
|
||||
const context = defer<Record<string, never>>()
|
||||
const client = createClient()
|
||||
const { provider, internal, sent } = makeProvider(client)
|
||||
internal.gatherEditorContext = () => context.promise
|
||||
|
||||
const sending = internal.handleSendMessage("hello", "msg-1", undefined, "pending:1")
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(sent).toContainEqual(expect.objectContaining({ type: "sessionCreated" }))
|
||||
|
||||
await provider.abortSessions(["pending:1"])
|
||||
context.resolve({})
|
||||
await sending
|
||||
|
||||
expect(client.aborted).toEqual([{ sessionID: "created", directory: "/repo" }])
|
||||
expect(client.prompted).toEqual([])
|
||||
|
||||
await provider.abortSessions(["pending:1"])
|
||||
expect(client.aborted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("releases draft routing after the webview adopts the created session", async () => {
|
||||
const client = createClient()
|
||||
const { provider, internal } = makeProvider(client)
|
||||
|
||||
expect(await internal.resolveSession(undefined, "pending:1", "local")).toBeDefined()
|
||||
provider.acknowledgeDraft("pending:1", "created")
|
||||
await provider.abortSessions(["pending:1"])
|
||||
|
||||
expect(client.aborted).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider sandbox status", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ const ASSISTANT_MESSAGE_FILE = path.join(
|
||||
MONOREPO_ROOT,
|
||||
"packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx",
|
||||
)
|
||||
const TRANSCRIPT_PARTS_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts")
|
||||
const CHAT_LAYOUT_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/styles/chat-layout.css")
|
||||
|
||||
function check(code: string): { ok: boolean; output: string } {
|
||||
@@ -308,9 +309,10 @@ describe("HighlightedText @mention regex fallback and click handler (source)", (
|
||||
|
||||
describe("AssistantMessage visible row contract (source)", () => {
|
||||
const src = fs.readFileSync(ASSISTANT_MESSAGE_FILE, "utf-8")
|
||||
const parts = fs.readFileSync(TRANSCRIPT_PARTS_FILE, "utf-8")
|
||||
|
||||
it("filters suppressed tools that have no visible renderer", () => {
|
||||
expect(src).toContain('state.status === "completed" && !!ToolRegistry.render(tool)')
|
||||
expect(parts).toContain('part.state.status === "completed" && !!ToolRegistry.render(part.tool)')
|
||||
})
|
||||
|
||||
it("filters pending questions until their dock request exists", () => {
|
||||
@@ -319,8 +321,8 @@ describe("AssistantMessage visible row contract (source)", () => {
|
||||
})
|
||||
|
||||
it("filters completed synthetic text and redaction-only reasoning", () => {
|
||||
expect(src).toContain('part.type === "text" && part.synthetic && props.message.time.completed')
|
||||
expect(src).toContain('.text?.replace("[REDACTED]", "").trim()')
|
||||
expect(parts).toContain("part.synthetic && message?.time.completed")
|
||||
expect(parts).toContain('.text?.replace("[REDACTED]", "").trim()')
|
||||
})
|
||||
|
||||
it("uses the plan exit card only when plan metadata is renderable", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
restoreTabs,
|
||||
restoreTrackedTabs,
|
||||
showTabStrip,
|
||||
trackedSessionInventory,
|
||||
type LocalTabState,
|
||||
} from "../../webview-ui/src/utils/local-tabs"
|
||||
import { reorderTabs } from "../../webview-ui/src/utils/tab-order"
|
||||
@@ -43,6 +44,21 @@ const reorder = (items: { id: string }[], order: string[]) => {
|
||||
return result
|
||||
}
|
||||
const inventory = (local: string[], external: string[] = []) => ({ local, external: new Set(external) })
|
||||
const tracked = () =>
|
||||
trackedSessionInventory(
|
||||
[
|
||||
{ id: "local", worktreeId: null },
|
||||
{ id: "worktree", worktreeId: "wt-1" },
|
||||
{ id: "sparse", worktreeId: null },
|
||||
{ id: "child", worktreeId: "wt-1" },
|
||||
],
|
||||
[
|
||||
{ id: "local", parentID: null },
|
||||
{ id: "worktree", parentID: null },
|
||||
{ id: "sparse" },
|
||||
{ id: "child", parentID: "root" },
|
||||
],
|
||||
)
|
||||
|
||||
describe("local session tabs", () => {
|
||||
it("hides the tab strip when only one tab remains", () => {
|
||||
@@ -167,6 +183,16 @@ describe("shared close selection", () => {
|
||||
})
|
||||
|
||||
describe("tracked tab restore", () => {
|
||||
it("restores only sessions with known root ancestry", () => {
|
||||
expect(restoreTrackedTabs(tracked(), [], undefined, trackedPending, identity)).toEqual(["local"])
|
||||
})
|
||||
|
||||
it("evicts sparse and child sessions from restored tabs", () => {
|
||||
expect(restoreTrackedTabs(tracked(), ["local", "sparse", "child"], undefined, trackedPending, identity)).toEqual([
|
||||
"local",
|
||||
])
|
||||
})
|
||||
|
||||
it("restores durable local sessions when the current list has no real tabs", () => {
|
||||
expect(restoreTrackedTabs(inventory(["s1", "s2"]), [], undefined, trackedPending, identity)).toEqual(["s1", "s2"])
|
||||
})
|
||||
@@ -202,6 +228,27 @@ describe("tracked tab restore", () => {
|
||||
})
|
||||
|
||||
describe("tracked tab reconcile", () => {
|
||||
it("evicts sparse sessions without forgetting them", () => {
|
||||
const data = trackedSessionInventory(
|
||||
[
|
||||
{ id: "local", worktreeId: null },
|
||||
{ id: "sparse", worktreeId: null },
|
||||
],
|
||||
[{ id: "local", parentID: null }, { id: "sparse" }],
|
||||
)
|
||||
expect(reconcileTrackedTabs(["local", "sparse"], ["local"], data, trackedPending)).toEqual({
|
||||
ids: ["local"],
|
||||
forget: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("forgets explicit child sessions even when they only exist in managed state", () => {
|
||||
expect(reconcileTrackedTabs(["local"], ["local"], tracked(), trackedPending)).toEqual({
|
||||
ids: ["local"],
|
||||
forget: ["child"],
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves durable local sessions before loaded sessions include them", () => {
|
||||
expect(reconcileTrackedTabs(["s1", "s2"], [], inventory(["s1", "s2"]), trackedPending)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveNavigation,
|
||||
validateLocalSession,
|
||||
adjacentHint,
|
||||
canOpenRootSession,
|
||||
filterUnassignedSessions,
|
||||
remoteSessions,
|
||||
LOCAL,
|
||||
@@ -190,16 +191,16 @@ describe("adjacentHint", () => {
|
||||
|
||||
describe("filterUnassignedSessions", () => {
|
||||
const at = (day: number) => `2026-01-${String(day).padStart(2, "0")}T00:00:00.000Z`
|
||||
const info = (id: string, day: number, parentID?: string | null) => ({
|
||||
const info = (id: string, day: number, parentID: string | null = null) => ({
|
||||
id,
|
||||
createdAt: at(day),
|
||||
...(parentID === undefined ? {} : { parentID }),
|
||||
parentID,
|
||||
})
|
||||
|
||||
it("keeps root sessions with undefined parent IDs", () => {
|
||||
const result = filterUnassignedSessions([info("old", 1), info("new", 3)], new Set(), new Set())
|
||||
it("filters sparse session updates until ancestry is known", () => {
|
||||
const result = filterUnassignedSessions([{ id: "unknown", createdAt: at(1) }], new Set(), new Set())
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["new", "old"])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps root sessions with null parent IDs", () => {
|
||||
@@ -287,6 +288,17 @@ describe("filterUnassignedSessions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("canOpenRootSession", () => {
|
||||
const sessions = [{ id: "root", parentID: null }, { id: "child", parentID: "root" }, { id: "sparse" }]
|
||||
|
||||
it("only opens sessions with known root ancestry", () => {
|
||||
expect(canOpenRootSession("root", sessions)).toBe(true)
|
||||
expect(canOpenRootSession("child", sessions)).toBe(false)
|
||||
expect(canOpenRootSession("sparse", sessions)).toBe(false)
|
||||
expect(canOpenRootSession("missing", sessions)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("remoteSessions", () => {
|
||||
const pending = (id: string) => id.startsWith("pending:")
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Source contract tests for session-presence registration.
|
||||
*
|
||||
* Static analysis — reads KiloProvider.ts, AgentManagerProvider.ts,
|
||||
* vscode-host.ts, and connection-service.ts and verifies the locked
|
||||
* viewed/presence behavior from the presence plan:
|
||||
*
|
||||
* - Editor panels register visible keyed on `panel.visible` and the
|
||||
* synchronous `contextSessionID`; attachment persists while hidden.
|
||||
* - Embedded Agent Manager providers skip generic viewed registration
|
||||
* (`disableViewedRegistration`) so sessions are not double-reported.
|
||||
* - The connection service resends the full snapshot on backend reconnect.
|
||||
* - Agent Manager visible presence is routed through
|
||||
* AgentManagerVisiblePresence so cleanup cannot leave a stale displayed id.
|
||||
*
|
||||
* Protects against accidental removal during Kilo development.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
|
||||
const AGENT_MANAGER_PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
|
||||
const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts")
|
||||
const CONNECTION_SERVICE_FILE = path.join(ROOT, "src/services/cli-backend/connection-service.ts")
|
||||
|
||||
function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
}
|
||||
|
||||
describe("KiloProvider editor-panel visible registration contract", () => {
|
||||
const source = readFile(KILOPROVIDER_FILE)
|
||||
// The bindPanel callback installed in resolveWebviewPanel.
|
||||
const match = source.match(
|
||||
/this\.viewStateDisposable = this\.visibleTaskStreams\.bindPanel\(panel, \(\) => \{([\s\S]*?)\n {4}\}\)/,
|
||||
)
|
||||
|
||||
it("binds a view-state callback on the panel", () => {
|
||||
expect(match).not.toBeNull()
|
||||
})
|
||||
|
||||
it("registers visible keyed on panel.visible and the synchronous contextSessionID", () => {
|
||||
// `panel.active` would drop visible-but-inactive split editors, and
|
||||
// `this.currentSession?.id` is populated asynchronously — rapid A→B
|
||||
// navigation must report B without awaiting B's metadata fetch.
|
||||
const body = match![1]
|
||||
expect(body).toContain("this.contextSessionID")
|
||||
expect(body).toContain("panel.visible")
|
||||
expect(body).toContain("this.connectionService.registerVisible(this.instanceId,")
|
||||
expect(body).not.toContain("panel.active")
|
||||
expect(body).not.toContain("this.currentSession")
|
||||
})
|
||||
|
||||
it("does not clear attachment when the panel is hidden", () => {
|
||||
// Hidden editor tabs stay reachable for remote control: the panel
|
||||
// view-state callback must never touch the attached registration
|
||||
// (directly or via focusSession) — attachment is cleared only by the
|
||||
// dispose/clear/delete paths.
|
||||
const body = match![1]
|
||||
expect(body).toContain("this.streams.focus(panel.visible ? id : undefined)")
|
||||
expect(body).not.toContain("registerAttached")
|
||||
expect(body).not.toContain("focusSession")
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider disableViewedRegistration contract", () => {
|
||||
const kiloProvider = readFile(KILOPROVIDER_FILE)
|
||||
const vscodeHost = readFile(VSCODE_HOST_FILE)
|
||||
|
||||
it("registerPresence skips viewed registration when the option is set", () => {
|
||||
const match = kiloProvider.match(/private registerPresence\(\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
const body = match![1]
|
||||
const guard = body.indexOf("if (this.opts.disableViewedRegistration) return")
|
||||
const visible = body.indexOf("this.connectionService.registerVisible(this.instanceId,")
|
||||
const attached = body.indexOf("this.connectionService.registerAttached(this.instanceId,")
|
||||
expect(guard).toBeGreaterThanOrEqual(0)
|
||||
expect(visible).toBeGreaterThan(guard)
|
||||
expect(attached).toBeGreaterThan(guard)
|
||||
})
|
||||
|
||||
it("focusSession and trackOpenSessions report through registerPresence", () => {
|
||||
// Both the focused session (visible) and the open local tabs (attached)
|
||||
// funnel into one snapshot so neither write can clobber the other.
|
||||
const focus = kiloProvider.match(/private focusSession\(id\?: string\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(focus).not.toBeNull()
|
||||
expect(focus![1]).toContain("this.registerPresence()")
|
||||
const track = kiloProvider.match(/private trackOpenSessions\(ids: string\[\]\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(track).not.toBeNull()
|
||||
expect(track![1]).toContain("this.registerPresence()")
|
||||
})
|
||||
|
||||
it("registerPresence attaches the open local tabs plus the focused session", () => {
|
||||
const match = kiloProvider.match(/private registerPresence\(\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
const body = match![1]
|
||||
expect(body).toContain("const attached = new Set(this.openSessionIds)")
|
||||
expect(body).toContain("if (focused) attached.add(focused)")
|
||||
})
|
||||
|
||||
it("the editor-panel view-state callback honors the same option", () => {
|
||||
const match = kiloProvider.match(
|
||||
/this\.viewStateDisposable = this\.visibleTaskStreams\.bindPanel\(panel, \(\) => \{([\s\S]*?)\n {4}\}\)/,
|
||||
)
|
||||
expect(match).not.toBeNull()
|
||||
const guard = match![1].indexOf("if (this.opts.disableViewedRegistration) return")
|
||||
const visible = match![1].indexOf("registerVisible")
|
||||
expect(guard).toBeGreaterThanOrEqual(0)
|
||||
expect(visible).toBeGreaterThan(guard)
|
||||
})
|
||||
|
||||
it("embedded Agent Manager providers disable generic viewed registration", () => {
|
||||
// Each Agent Manager panel hosts a full KiloProvider; the "agent-manager"
|
||||
// keys own presence there, so the embedded provider must not
|
||||
// double-register under its own instanceId.
|
||||
expect(vscodeHost).toContain("disableViewedRegistration: true")
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService connection snapshot contract", () => {
|
||||
const source = readFile(CONNECTION_SERVICE_FILE)
|
||||
|
||||
it("sends the accumulated snapshot on initial connection and reconnect", () => {
|
||||
const start = source.indexOf('if (sseState === "connected")')
|
||||
const end = source.indexOf('if (!didConnect && sseState === "disconnected")', start)
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const body = source.slice(start, end)
|
||||
expect(body).toContain("this.flushViewed()")
|
||||
expect(body).not.toContain("if (isReconnect)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AgentManagerProvider visible-presence contract", () => {
|
||||
const source = readFile(AGENT_MANAGER_PROVIDER_FILE)
|
||||
|
||||
it("routes all agent-manager visible registration through AgentManagerVisiblePresence", () => {
|
||||
// Exactly one direct registerVisible("agent-manager", ...) call site — the
|
||||
// presence callback. Cleanup paths that bypassed it (registering [] without
|
||||
// clearing the displayed id) let a stale id re-register on the next flush.
|
||||
const sites = source.match(/registerVisible\("agent-manager"/g) ?? []
|
||||
expect(sites).toHaveLength(1)
|
||||
expect(source).toMatch(
|
||||
/new AgentManagerVisiblePresence\(\s*\(ids\) => this\.connectionService\.registerVisible\("agent-manager", ids\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("async shutdown clears both the visible and attached registrations", () => {
|
||||
// clear() resets the displayed id and empties the attached set, so a
|
||||
// stale id cannot re-register on a later flush.
|
||||
const match = source.match(/private async disposeAsync\(\): Promise<void> \{([\s\S]*?)\n {2}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toContain("this.visiblePresence.clear()")
|
||||
})
|
||||
|
||||
it("routes the webview presence messages to visiblePresence.handle", () => {
|
||||
// The webview reports the open tab set (→ attached) and the actually
|
||||
// displayed real session id (null for terminal/review/pending/empty
|
||||
// tabs, → visible); both flow through the presence helper.
|
||||
expect(source).toMatch(
|
||||
/if \(m\.type === "agentManager\.openSessions" \|\| m\.type === "agentManager\.visibleSession"\) \{\s*this\.visiblePresence\.handle\(m\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not let background message loads override webview visibility", () => {
|
||||
const match = source.match(/if \(m\.type === "loadMessages"\) \{([\s\S]*?)\n {4}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).not.toContain("visiblePresence.setDisplayed")
|
||||
})
|
||||
|
||||
it("recomputes visible presence when panel visibility changes", () => {
|
||||
// A hidden Agent Manager panel must drop its session from visible (while
|
||||
// keeping it attached); reappearing must re-register the retained id.
|
||||
const match = source.match(/ctx\.onDidChangeVisibility\(\(visible\) => \{([\s\S]*?)\n {4}\}\)/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toContain("this.visiblePresence.flush()")
|
||||
})
|
||||
})
|
||||
@@ -194,9 +194,9 @@ describe("KiloProvider pruneDeletedSession contract", () => {
|
||||
})
|
||||
|
||||
it("unfocuses the streams when the deleted id matches the focused session", () => {
|
||||
// Without this, connectionService.focused still reports the deleted id to
|
||||
// the backend (viewed.focused), and focusSession() never calls
|
||||
// unregisterFocused for this instance.
|
||||
// Without this, connectionService still reports the deleted id to the
|
||||
// backend as visible, and focusSession() never clears the visible
|
||||
// registration for this instance.
|
||||
const match = source.match(/pruneDeletedSession\(sessionID: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toMatch(/if \(this\.streams\.focused === sessionID\) this\.focusSession\(undefined\)/)
|
||||
@@ -310,6 +310,20 @@ describe("PromptInput send origin contract", () => {
|
||||
expect(source).toMatch(/session\.sendMessage\([\s\S]*origin \?\? null\)/)
|
||||
expect(source).toMatch(/session\.sendCommand\([\s\S]*origin \?\? null\)/)
|
||||
})
|
||||
|
||||
it("records sent prompts before a pending session key change can return", () => {
|
||||
const start = source.indexOf("const handleSend = async () =>")
|
||||
const end = source.indexOf("\n return (", start)
|
||||
const body = source.slice(start, end)
|
||||
const send = Math.max(body.indexOf("session.sendMessage("), body.indexOf("session.sendCommand("))
|
||||
const append = body.lastIndexOf("history.append(draft)")
|
||||
const guard = body.indexOf("if (draftKey() !== key) return")
|
||||
|
||||
expect(send).toBeGreaterThan(-1)
|
||||
expect(append).toBeGreaterThan(send)
|
||||
expect(append).toBeLessThan(guard)
|
||||
expect(body.indexOf('setText("")', guard)).toBeGreaterThan(guard)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionContext userClearedSession contract", () => {
|
||||
@@ -542,15 +556,15 @@ describe("Cloud import parts cleanup contract", () => {
|
||||
describe("KiloConnectionService pruneSession contract", () => {
|
||||
const source = readFile(CONNECTION_SERVICE_FILE)
|
||||
|
||||
it("drops the deleted session from focused and opened Maps", () => {
|
||||
it("drops the deleted session from attached and visible Maps", () => {
|
||||
// KiloProvider's pruneDeletedSession calls connectionService.pruneSession.
|
||||
// Without clearing focused/opened entries whose value is the deleted id,
|
||||
// the backend keeps receiving viewed.focused with the dead session id and
|
||||
// any background tab opener stays registered for it.
|
||||
// Without clearing attached/visible entries whose value is the deleted id,
|
||||
// the backend keeps receiving the dead session id and any background tab
|
||||
// opener stays registered for it.
|
||||
const match = source.match(/pruneSession\(sessionId: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toMatch(/this\.focused\.delete\(key\)/)
|
||||
expect(match![1]).toMatch(/this\.opened\.(?:set|delete)/)
|
||||
expect(match![1]).toMatch(/this\.attached\.(?:set|delete)/)
|
||||
expect(match![1]).toMatch(/this\.visible\.(?:set|delete)/)
|
||||
expect(match![1]).toMatch(/this\.flushViewed\(\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,9 +30,28 @@ describe("TaskTimeline delegated tooltip contract", () => {
|
||||
it("keeps accessibility and bar overlays bounded", () => {
|
||||
expect(src).toMatch(/data-timeline-count=\{bars\(\)\.length\}/)
|
||||
expect(src).toMatch(/tabIndex=\{0\}/)
|
||||
expect(src).toMatch(/aria-label=\{aria\(\)\}/)
|
||||
expect(src).toMatch(/role="slider"/)
|
||||
expect(src).toMatch(/aria-valuenow=\{value\(\)\}/)
|
||||
expect(src).toMatch(/aria-valuetext=\{aria\(\)\}/)
|
||||
expect(src).toMatch(/<For each=\{layout\(\)\.paths\}>/)
|
||||
expect(src).not.toMatch(/<Index\b/)
|
||||
expect(src).not.toMatch(/data-tip=/)
|
||||
})
|
||||
|
||||
it("only collects parts with matching transcript content", () => {
|
||||
expect(src).toMatch(/const revert = session\.revert\(\) \?\? undefined/)
|
||||
expect(src).toMatch(/visibleParts\(m\.id, session\.getParts\(m\.id\), revert\)/)
|
||||
expect(src).toMatch(/isRenderable\(part as SDKPart, m as SDKAssistantMessage\)/)
|
||||
expect(src).toMatch(/item\.tool\?\.callID === call && item\.tool\?\.messageID === m\.id/)
|
||||
})
|
||||
|
||||
it("keeps selected bars highlighted after click and keyboard activation", () => {
|
||||
expect(src).toMatch(/const select = \(idx: number\) => \{[\s\S]*showTip\(idx\)/)
|
||||
expect(src).toMatch(/select\(selected\(\)\)/)
|
||||
})
|
||||
|
||||
it("preserves a hovered part across streaming updates", () => {
|
||||
expect(src).toMatch(/if \(idx < 0 \|\| same\(previous\?\.\[idx\], next\[idx\]\)\) return/)
|
||||
expect(src).toMatch(/if \(same\(previous, next\)\) return previous/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui")
|
||||
const PASS = "TIMELINE_HIGHLIGHT_EVENTS_PASS"
|
||||
const FAIL = "TIMELINE_HIGHLIGHT_EVENTS_FAIL:"
|
||||
|
||||
const SCRIPT = `
|
||||
import { Window } from "happy-dom"
|
||||
|
||||
const window = new Window()
|
||||
globalThis.window = window
|
||||
globalThis.CustomEvent = window.CustomEvent
|
||||
|
||||
const { dispatchTimelineHighlight, onTimelineHighlight, same } = await import("./src/utils/timeline/highlight.ts")
|
||||
const values = []
|
||||
const dispose = onTimelineHighlight((value) => values.push(value))
|
||||
const value = { msgId: "message-1", partId: "part-1" }
|
||||
dispatchTimelineHighlight(value)
|
||||
dispose()
|
||||
dispatchTimelineHighlight(undefined)
|
||||
|
||||
const fail = (reason) => {
|
||||
console.log("${FAIL}" + reason)
|
||||
process.exit(2)
|
||||
}
|
||||
if (values.length !== 1) fail("listener was not cleaned up")
|
||||
if (values[0]?.msgId !== value.msgId || values[0]?.partId !== value.partId) {
|
||||
fail("listener received the wrong highlight")
|
||||
}
|
||||
if (!same(value, { ...value }) || same(value, { ...value, partId: "part-2" })) {
|
||||
fail("highlight identity comparison is incorrect")
|
||||
}
|
||||
console.log("${PASS}")
|
||||
`
|
||||
|
||||
describe("timeline highlight events", () => {
|
||||
it("delivers a highlight once and removes its listener", () => {
|
||||
const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], {
|
||||
cwd: WEBVIEW,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const output = result.stdout.toString() + result.stderr.toString()
|
||||
|
||||
if (output.includes(PASS)) return
|
||||
const index = output.indexOf(FAIL)
|
||||
if (index !== -1) {
|
||||
expect.unreachable(
|
||||
output
|
||||
.slice(index + FAIL.length)
|
||||
.split("\n")[0]
|
||||
?.trim(),
|
||||
)
|
||||
}
|
||||
expect.unreachable(`timeline highlight events test exited ${result.exitCode}: ${output.trim()}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui")
|
||||
const PASS = "TRANSCRIPT_PARTS_PASS"
|
||||
const FAIL = "TRANSCRIPT_PARTS_FAIL:"
|
||||
|
||||
const SCRIPT = `
|
||||
import { Window } from "happy-dom"
|
||||
|
||||
const window = new Window()
|
||||
globalThis.window = window
|
||||
globalThis.document = window.document
|
||||
globalThis.Node = window.Node
|
||||
globalThis.CustomEvent = window.CustomEvent
|
||||
|
||||
const { isRenderable } = await import("./src/utils/transcript-parts.ts")
|
||||
const message = { id: "message-1", role: "assistant", time: { created: 1, completed: 2 } }
|
||||
const parts = [
|
||||
{ id: "step-finish", type: "step-finish", reason: "stop" },
|
||||
{ id: "empty-text", type: "text", text: " " },
|
||||
{ id: "synthetic-text", type: "text", text: "Synthetic", synthetic: true },
|
||||
{ id: "visible-text", type: "text", text: "Visible transcript text" },
|
||||
{ id: "redacted-reasoning", type: "reasoning", text: "[REDACTED]" },
|
||||
{ id: "visible-reasoning", type: "reasoning", text: "Inspect the implementation" },
|
||||
{ id: "todo-pending", type: "tool", tool: "todowrite", state: { status: "pending", input: {} } },
|
||||
{
|
||||
id: "todo-completed",
|
||||
type: "tool",
|
||||
tool: "todowrite",
|
||||
state: { status: "completed", input: {}, output: "done", title: "Updated todos" },
|
||||
},
|
||||
{ id: "read-running", type: "tool", tool: "read", state: { status: "running", input: {} } },
|
||||
]
|
||||
const visible = parts.filter((part) => isRenderable(part, message)).map((part) => part.id)
|
||||
|
||||
const fail = (reason) => {
|
||||
console.log("${FAIL}" + reason)
|
||||
process.exit(2)
|
||||
}
|
||||
const expected = ["visible-text", "visible-reasoning", "todo-completed", "read-running"]
|
||||
if (visible.length !== expected.length || visible.some((id, index) => id !== expected[index])) {
|
||||
fail("did not exclude transcript-invisible parts")
|
||||
}
|
||||
console.log("${PASS}")
|
||||
`
|
||||
|
||||
describe("transcript parts", () => {
|
||||
it("keeps timeline candidates aligned with visible transcript parts", () => {
|
||||
const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], {
|
||||
cwd: WEBVIEW,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const output = result.stdout.toString() + result.stderr.toString()
|
||||
|
||||
if (output.includes(PASS)) return
|
||||
const index = output.indexOf(FAIL)
|
||||
if (index !== -1) {
|
||||
expect.unreachable(
|
||||
output
|
||||
.slice(index + FAIL.length)
|
||||
.split("\n")[0]
|
||||
?.trim(),
|
||||
)
|
||||
}
|
||||
expect.unreachable(`transcript parts test exited ${result.exitCode}: ${output.trim()}`)
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,14 @@ import { LanguageBridge } from "../src/context/language-bridge"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import { createTabFocus } from "../src/utils/tab-navigation"
|
||||
import { nextSelectionAfterDelete, adjacentHint, filterUnassignedSessions, LOCAL } from "./navigate"
|
||||
import {
|
||||
canOpenRootSession,
|
||||
isKnownRootSession,
|
||||
nextSelectionAfterDelete,
|
||||
adjacentHint,
|
||||
filterUnassignedSessions,
|
||||
LOCAL,
|
||||
} from "./navigate"
|
||||
import {
|
||||
addPendingTab as addLocalPendingTab,
|
||||
nextTabAfterClose,
|
||||
@@ -102,6 +109,7 @@ import {
|
||||
reconcileTrackedTabs,
|
||||
replacePendingTab,
|
||||
restoreTrackedTabs,
|
||||
trackedSessionInventory,
|
||||
} from "../src/utils/local-tabs"
|
||||
import {
|
||||
deletePendingDraft,
|
||||
@@ -111,7 +119,7 @@ import {
|
||||
} from "../src/utils/draft-store"
|
||||
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
|
||||
import { createTabOrderSync } from "./tab-order-sync"
|
||||
import { reportRemoteSessions } from "./remote-sessions"
|
||||
import { reportRemoteSessions, reportVisibleSession, visible } from "./remote-sessions"
|
||||
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
|
||||
import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal"
|
||||
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
|
||||
@@ -245,10 +253,6 @@ const AgentManagerContent: Component = () => {
|
||||
/** Remove a session ID from the local tab (no-op if absent). */
|
||||
const evictLocal = (sid: string) =>
|
||||
setLocalSessionIDs((prev) => (prev.includes(sid) ? prev.filter((id) => id !== sid) : prev))
|
||||
const inventory = (items: ManagedSessionState[]) => ({
|
||||
local: items.filter((item) => !item.worktreeId).map((item) => item.id),
|
||||
external: new Set(items.filter((item) => item.worktreeId).map((item) => item.id)),
|
||||
})
|
||||
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
|
||||
const [sessionsCollapsed, setSessionsCollapsed] = createSignal(true)
|
||||
const sidebar = createSidebarCollapse(vscode)
|
||||
@@ -295,8 +299,8 @@ const AgentManagerContent: Component = () => {
|
||||
const [applySelectedFiles, setApplySelectedFiles] = createSignal<string[]>([])
|
||||
const [applySelectionTouched, setApplySelectionTouched] = createSignal(false)
|
||||
|
||||
// Pending local tab counter for generating unique IDs
|
||||
const PENDING_PREFIX = "pending:"
|
||||
const closedDrafts = new Set<string>()
|
||||
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
|
||||
|
||||
// Per-sidebar-context terminal state. `terms.activeId` holds the id
|
||||
@@ -615,8 +619,7 @@ const AgentManagerContent: Component = () => {
|
||||
const next = addLocalPendingTab({ ids: localSessionIDs(), active: activePendingId() }, id)
|
||||
setLocalSessionIDs(next.ids)
|
||||
appendToTabOrder(LOCAL, id)
|
||||
// Deactivate any focused terminal so the new pending session is
|
||||
// actually visible — visibleTabId prioritises terms.activeId().
|
||||
// Deactivate any focused terminal so the new pending session is visible.
|
||||
terms.setActiveId(undefined)
|
||||
setActivePendingId(id)
|
||||
session.clearCurrentSession()
|
||||
@@ -666,8 +669,8 @@ const AgentManagerContent: Component = () => {
|
||||
if (all.length === 0) return // sessions not loaded yet
|
||||
const next = reconcileTrackedTabs(
|
||||
localSessionIDs(),
|
||||
all.map((s) => s.id),
|
||||
inventory(managedSessions()),
|
||||
all.filter(isKnownRootSession).map((s) => s.id),
|
||||
trackedSessionInventory(managedSessions(), all),
|
||||
isPending,
|
||||
)
|
||||
if (!next) return
|
||||
@@ -722,7 +725,7 @@ const AgentManagerContent: Component = () => {
|
||||
const now = new Date().toISOString()
|
||||
for (const id of ids) {
|
||||
const real = lookup.get(id)
|
||||
if (real) {
|
||||
if (real && isKnownRootSession(real)) {
|
||||
result.push(real)
|
||||
} else if (isPending(id)) {
|
||||
result.push({ id, title: t("agentManager.session.newSession"), createdAt: now, updatedAt: now })
|
||||
@@ -741,7 +744,7 @@ const AgentManagerContent: Component = () => {
|
||||
return applyTabOrder(
|
||||
session
|
||||
.sessions()
|
||||
.filter((s) => ids.has(s.id))
|
||||
.filter((s) => isKnownRootSession(s) && ids.has(s.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
||||
worktreeTabOrder()[worktreeId],
|
||||
)
|
||||
@@ -768,6 +771,21 @@ const AgentManagerContent: Component = () => {
|
||||
return false
|
||||
})
|
||||
|
||||
const overlay = createMemo((): SetupState | null => {
|
||||
const state = setup()
|
||||
const sel = selection()
|
||||
if (state.active && (!state.worktreeId || sel === state.worktreeId)) return state
|
||||
if (typeof sel !== "string" || sel === LOCAL) return null
|
||||
const busy = busyWorktrees().get(sel)
|
||||
if (busy?.reason !== "setting-up") return null
|
||||
const tree = worktrees().find((item) => item.id === sel)
|
||||
return {
|
||||
active: true,
|
||||
message: busy.message ?? "",
|
||||
branch: busy.branch ?? tree?.branch,
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sel = selection()
|
||||
if (sel === null) {
|
||||
@@ -796,7 +814,13 @@ const AgentManagerContent: Component = () => {
|
||||
if (reviewActive()) return REVIEW_TAB_ID
|
||||
return session.currentSessionID() ?? activePendingId()
|
||||
})
|
||||
|
||||
const visibleSession = createMemo(() =>
|
||||
visible(
|
||||
session.currentSessionID(),
|
||||
!!terms.activeId() || reviewActive() || history() || !!overlay() || contextEmpty(),
|
||||
),
|
||||
)
|
||||
reportVisibleSession(vscode, visibleSession)
|
||||
const worktreeLabel = (wt: WorktreeState): string => {
|
||||
if (wt.label) return wt.label
|
||||
return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch)
|
||||
@@ -969,14 +993,9 @@ const AgentManagerContent: Component = () => {
|
||||
const remembered = tabMemory()[worktreeId]
|
||||
if (terms.hasRemembered(worktreeId, remembered)) return termHandlers.activate(remembered!)
|
||||
terms.setActiveId(undefined)
|
||||
// Try rich session list first, fall back to managed session IDs when
|
||||
// session.sessions() hasn't been populated yet for this worktree.
|
||||
const rich = sessionsForWorktree(worktreeId)
|
||||
const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
|
||||
const target = remembered
|
||||
? (rich.find((s) => s.id === remembered) ?? managed.find((ms) => ms.id === remembered))
|
||||
: undefined
|
||||
const fallback = target ?? rich[0] ?? managed[0]
|
||||
const target = remembered ? rich.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? rich[0]
|
||||
if (fallback) session.selectSession(fallback.id)
|
||||
else session.setCurrentSessionID(undefined)
|
||||
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true)
|
||||
@@ -984,7 +1003,7 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const addSessionToCurrentWorktree = (sid: string) => {
|
||||
const sel = selection()
|
||||
if (!sel || sel === LOCAL) return false
|
||||
if (!sel || sel === LOCAL || !canOpenRootSession(sid, session.sessions())) return false
|
||||
const current = managedSessions().find((entry) => entry.id === sid)
|
||||
if (current?.worktreeId) return focusManagedSession(current.worktreeId, sid)
|
||||
saveTabMemory()
|
||||
@@ -1169,8 +1188,10 @@ const AgentManagerContent: Component = () => {
|
||||
const unsubCreate = vscode.onMessage((msg) => {
|
||||
if (msg.type !== "sessionCreated") return
|
||||
const created = msg as SessionCreatedMessage
|
||||
if (!isKnownRootSession(created.session)) return
|
||||
if (!created.draftID && createdSessions.delete(created.session.id)) return
|
||||
if (created.draftID) createdSessions.add(created.session.id)
|
||||
if (created.draftID && closedDrafts.delete(created.draftID)) return
|
||||
if (created.draftID && promotePendingDraftDiscard(created.draftID, created.session.id)) return
|
||||
const pending = created.draftID && localSessionIDs().includes(created.draftID) ? created.draftID : undefined
|
||||
if (!pending && localSessionIDs().includes(created.session.id)) return
|
||||
@@ -1180,7 +1201,11 @@ const AgentManagerContent: Component = () => {
|
||||
if (!pending) saveTabMemory()
|
||||
placeLocal(created.session.id, pending, active)
|
||||
if (!pending) setSelection(LOCAL)
|
||||
vscode.postMessage({ type: "agentManager.persistSession", sessionId: created.session.id })
|
||||
vscode.postMessage({
|
||||
type: "agentManager.persistSession",
|
||||
sessionId: created.session.id,
|
||||
draftID: created.draftID,
|
||||
})
|
||||
if (focus) session.selectSession(created.session.id)
|
||||
})
|
||||
|
||||
@@ -1320,7 +1345,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
// Restore local session IDs from persisted state (sessions with no worktreeId)
|
||||
const restored = restoreTrackedTabs(
|
||||
inventory(state.sessions),
|
||||
trackedSessionInventory(state.sessions, session.sessions()),
|
||||
localSessionIDs(),
|
||||
state.tabOrder?.[LOCAL],
|
||||
isPending,
|
||||
@@ -1914,6 +1939,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
|
||||
const openLocally = (sid: string) => {
|
||||
if (!canOpenRootSession(sid, session.sessions())) return
|
||||
saveTabMemory()
|
||||
expandSidebar()
|
||||
const pending = activePendingId()
|
||||
@@ -1964,14 +1990,13 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
if (pending || localSet().has(sessionId)) {
|
||||
setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId))
|
||||
if (!pending) vscode.postMessage({ type: "agentManager.forgetSession", sessionId })
|
||||
} else {
|
||||
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
|
||||
}
|
||||
if (pending) {
|
||||
closedDrafts.add(sessionId)
|
||||
if (session.isSubmitting(sessionId) || isPendingSend(sessionId)) discardPendingDraft(sessionId)
|
||||
queueMicrotask(() => deletePendingDraft(sessionId))
|
||||
}
|
||||
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
|
||||
tabFocus.restore()
|
||||
}
|
||||
|
||||
@@ -2914,54 +2939,29 @@ const AgentManagerContent: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{(() => {
|
||||
// Show setup overlay: either the transient ready/error state for the selected worktree,
|
||||
// or if the selected worktree is still being set up (from busyWorktrees map)
|
||||
const overlayState = (): SetupState | null => {
|
||||
const s = setup()
|
||||
const sel = selection()
|
||||
// Transient ready/error overlay for the selected worktree (or worktree-less setup)
|
||||
if (s.active && (!s.worktreeId || sel === s.worktreeId)) return s
|
||||
// Persistent setup-in-progress for the currently selected worktree
|
||||
if (typeof sel === "string" && sel !== LOCAL) {
|
||||
const busy = busyWorktrees().get(sel)
|
||||
if (busy?.reason === "setting-up") {
|
||||
const wt = worktrees().find((w) => w.id === sel)
|
||||
return {
|
||||
active: true,
|
||||
message: busy.message ?? "",
|
||||
branch: busy.branch ?? wt?.branch,
|
||||
} satisfies SetupState
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Show when={overlayState()}>
|
||||
{(state) => (
|
||||
<div class="am-setup-overlay">
|
||||
<div class="am-setup-card">
|
||||
<Icon name="branch" size="large" />
|
||||
<div class="am-setup-title">
|
||||
{state().error ? t("agentManager.setup.failed") : t("agentManager.setup.settingUp")}
|
||||
</div>
|
||||
<Show when={state().branch}>
|
||||
<div class="am-setup-branch">{state().branch}</div>
|
||||
</Show>
|
||||
<div class="am-setup-status">
|
||||
<Show when={!state().error} fallback={<Icon name="circle-x" size="small" />}>
|
||||
<Spinner class="am-setup-spinner" />
|
||||
</Show>
|
||||
<span>
|
||||
{state().errorCode ? t(`agentManager.setup.error.${state().errorCode}`) : state().message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={overlay()}>
|
||||
{(state) => (
|
||||
<div class="am-setup-overlay">
|
||||
<div class="am-setup-card">
|
||||
<Icon name="branch" size="large" />
|
||||
<div class="am-setup-title">
|
||||
{state().error ? t("agentManager.setup.failed") : t("agentManager.setup.settingUp")}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
<Show when={state().branch}>
|
||||
<div class="am-setup-branch">{state().branch}</div>
|
||||
</Show>
|
||||
<div class="am-setup-status">
|
||||
<Show when={!state().error} fallback={<Icon name="circle-x" size="small" />}>
|
||||
<Spinner class="am-setup-spinner" />
|
||||
</Show>
|
||||
<span>
|
||||
{state().errorCode ? t(`agentManager.setup.error.${state().errorCode}`) : state().message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={history()}>
|
||||
<HistoryView
|
||||
onSelectSession={(id) => {
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import { type Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js"
|
||||
import type { AgentManagerBranchesMessage, AgentManagerImportResultMessage, BranchInfo } from "../src/types/messages"
|
||||
import type {
|
||||
AgentManagerBranchesMessage,
|
||||
AgentManagerImportResultMessage,
|
||||
BranchInfo,
|
||||
EnhancePromptResultMessage,
|
||||
EnhancePromptErrorMessage,
|
||||
} from "../src/types/messages"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
@@ -34,6 +40,7 @@ import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImage
|
||||
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
|
||||
import { convertToMentionPath } from "../src/utils/path-mentions"
|
||||
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
|
||||
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
|
||||
import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect"
|
||||
import { tracker } from "./telemetry"
|
||||
|
||||
@@ -96,6 +103,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const [modelAllocations, setModelAllocations] = createSignal<ModelAllocations>(new Map())
|
||||
const [agent, setAgent] = createSignal(session.selectedAgent())
|
||||
const [starting, setStarting] = createSignal(false)
|
||||
const [enhancing, setEnhancing] = createSignal(false)
|
||||
const [showAdvanced, setShowAdvanced] = createSignal(false)
|
||||
const [branchName, setBranchName] = createSignal("")
|
||||
const [baseBranch, setBaseBranch] = createSignal<string | null>(null)
|
||||
@@ -114,6 +122,13 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const speech = useSpeechToText(vscode, server, { t })
|
||||
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
|
||||
const speechModel = () => selectedSpeechToTextModel(config())
|
||||
let prior: string | null = null
|
||||
let request: string | undefined
|
||||
const cancel = () => {
|
||||
prior = null
|
||||
request = undefined
|
||||
setEnhancing(false)
|
||||
}
|
||||
|
||||
// Variant list for the currently selected model
|
||||
const variants = createMemo(() => {
|
||||
@@ -203,6 +218,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const inserted = resolved.map((p) => `@${p}`).join(" ")
|
||||
const result = before + inserted + " " + after
|
||||
ref.value = result
|
||||
cancel()
|
||||
setPrompt(result)
|
||||
persistPrompt(result)
|
||||
const pos = cursor + inserted.length + 1
|
||||
@@ -310,6 +326,19 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
}
|
||||
}
|
||||
|
||||
const undo = (e: KeyboardEvent) => {
|
||||
if (e.key !== "z" || (!e.metaKey && !e.ctrlKey) || e.shiftKey || prior === null) return
|
||||
e.preventDefault()
|
||||
const restored = prior
|
||||
cancel()
|
||||
setPrompt(restored)
|
||||
persistPrompt(restored)
|
||||
if (!textareaRef) return
|
||||
textareaRef.value = restored
|
||||
adjustHeight()
|
||||
textareaRef.focus()
|
||||
}
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (!textareaRef) return
|
||||
textareaRef.style.height = "auto"
|
||||
@@ -323,6 +352,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const end = ref?.selectionEnd ?? start
|
||||
const result = insertSpacedText(current, value, start, end)
|
||||
|
||||
cancel()
|
||||
setPrompt(result.text)
|
||||
persistPrompt(result.text)
|
||||
if (!ref) return
|
||||
@@ -336,6 +366,29 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
speech.start({ model: speechModel(), insert: insertSpeechText })
|
||||
}
|
||||
|
||||
const canEnhance = () => !starting() && !enhancing() && !speech.active() && server.isConnected()
|
||||
|
||||
const handleEnhance = () => {
|
||||
if (!canEnhance()) return
|
||||
const draft = prompt().trim()
|
||||
if (!draft) {
|
||||
const description = t("prompt.action.enhanceDescription")
|
||||
setPrompt(description)
|
||||
persistPrompt(description)
|
||||
if (textareaRef) {
|
||||
textareaRef.value = description
|
||||
adjustHeight()
|
||||
textareaRef.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
prior = prompt()
|
||||
const id = `enhance-newworktree-${crypto.randomUUID()}`
|
||||
request = id
|
||||
setEnhancing(true)
|
||||
vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: id })
|
||||
}
|
||||
|
||||
// --- Import tab state ---
|
||||
const [prUrl, setPrUrl] = createSignal("")
|
||||
const [prPending, setPrPending] = createSignal(false)
|
||||
@@ -363,9 +416,30 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
showToast({ variant: "error", title: t("agentManager.import.failed"), description })
|
||||
}
|
||||
}
|
||||
if (msg.type === "enhancePromptResult") {
|
||||
const ev = msg as EnhancePromptResultMessage
|
||||
if (ev.requestId === request) {
|
||||
request = undefined
|
||||
setPrompt(ev.text)
|
||||
persistPrompt(ev.text)
|
||||
setEnhancing(false)
|
||||
if (textareaRef) {
|
||||
textareaRef.value = ev.text
|
||||
adjustHeight()
|
||||
textareaRef.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg.type === "enhancePromptError") {
|
||||
const ev = msg as EnhancePromptErrorMessage
|
||||
if (ev.requestId === request) cancel()
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => importUnsub())
|
||||
onCleanup(() => {
|
||||
request = undefined
|
||||
importUnsub()
|
||||
})
|
||||
|
||||
const handlePRSubmit = () => {
|
||||
const url = prUrl().trim()
|
||||
@@ -462,10 +536,12 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
value={prompt()}
|
||||
onInput={(e) => {
|
||||
const val = e.currentTarget.value
|
||||
cancel()
|
||||
setPrompt(val)
|
||||
persistPrompt(val)
|
||||
adjustHeight()
|
||||
}}
|
||||
onKeyDown={undo}
|
||||
onPaste={(e) => imageAttach.handlePaste(e)}
|
||||
rows={3}
|
||||
dir="auto"
|
||||
@@ -517,6 +593,17 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-input-hint-actions">
|
||||
<Tooltip value={t("prompt.action.enhance")} placement="top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={handleEnhance}
|
||||
disabled={!canEnhance()}
|
||||
aria-label={t("prompt.action.enhance")}
|
||||
>
|
||||
<WandSparkles size={16} class={enhancing() ? "enhance-spinner" : ""} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Show when={sandboxVisible()}>
|
||||
<SandboxButtonBase
|
||||
enabled={sandbox() ?? false}
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
* Returns the action to take: select a session by ID, go to local, or do nothing.
|
||||
*/
|
||||
|
||||
import { isRootSession } from "../src/context/session-utils"
|
||||
|
||||
/** Sentinel value for the local repo selection. */
|
||||
export const LOCAL = "local" as const
|
||||
|
||||
@@ -16,13 +14,22 @@ type NavResult = { action: "select"; id: string } | { action: typeof LOCAL } | {
|
||||
|
||||
type SessionLike = { id: string; parentID?: string | null; createdAt: string }
|
||||
|
||||
export function isKnownRootSession(session: Pick<SessionLike, "parentID">): boolean {
|
||||
return session.parentID === null
|
||||
}
|
||||
|
||||
export function canOpenRootSession(id: string, sessions: Pick<SessionLike, "id" | "parentID">[]): boolean {
|
||||
const session = sessions.find((item) => item.id === id)
|
||||
return !!session && isKnownRootSession(session)
|
||||
}
|
||||
|
||||
export function filterUnassignedSessions<T extends SessionLike>(
|
||||
sessions: T[],
|
||||
worktree: Set<string>,
|
||||
local: Set<string>,
|
||||
): T[] {
|
||||
return [...sessions]
|
||||
.filter((s) => isRootSession(s) && !worktree.has(s.id) && !local.has(s.id))
|
||||
.filter((s) => isKnownRootSession(s) && !worktree.has(s.id) && !local.has(s.id))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,17 @@ type Bridge = {
|
||||
postMessage(message: { type: "agentManager.openSessions"; sessionIDs: string[] }): void
|
||||
}
|
||||
|
||||
type VisibleBridge = {
|
||||
postMessage(message: { type: "agentManager.visibleSession"; sessionID: string | null }): void
|
||||
}
|
||||
|
||||
type Managed = { id: string; worktreeId: string | null }
|
||||
|
||||
export function visible(id: string | undefined, blocked: boolean): string | null {
|
||||
if (blocked || !id?.startsWith("ses")) return null
|
||||
return id
|
||||
}
|
||||
|
||||
export function reportRemoteSessions(
|
||||
vscode: Bridge,
|
||||
local: Accessor<string[]>,
|
||||
@@ -20,3 +29,12 @@ export function reportRemoteSessions(
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Report the actually displayed real session id, or null when a terminal,
|
||||
// review, pending, or empty tab is shown. Drives only visible presence;
|
||||
// retained attached tabs are unaffected.
|
||||
export function reportVisibleSession(vscode: VisibleBridge, visible: Accessor<string | null>): void {
|
||||
createEffect(() => {
|
||||
vscode.postMessage({ type: "agentManager.visibleSession", sessionID: visible() })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,16 +25,15 @@ import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useMemory } from "../../context/memory"
|
||||
import { useServer } from "../../context/server"
|
||||
import { snapshotProgress } from "../../context/session-utils"
|
||||
import { planDisplayPath } from "../../utils/plan-path"
|
||||
import { isRenderable, UPSTREAM_SUPPRESSED_TOOLS } from "../../utils/transcript-parts"
|
||||
import { MemoryMarkerMeta } from "@kilocode/kilo-memory/marker-meta"
|
||||
import { color as timelineColor } from "../../utils/timeline/colors"
|
||||
import type { Part as TimelinePart } from "../../types/messages"
|
||||
import type { TimelineHighlight } from "../../utils/timeline/highlight"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
|
||||
// Tools that the upstream message-part renderer suppresses (returns null for).
|
||||
// We render these ourselves via ToolRegistry when they complete,
|
||||
// so the user can see what the AI set up.
|
||||
export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"])
|
||||
const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"])
|
||||
|
||||
function editOpen(part: SDKPart, open: boolean) {
|
||||
@@ -87,24 +86,6 @@ function PlanExitCard(props: { part: ToolPart }) {
|
||||
)
|
||||
}
|
||||
|
||||
function isRenderable(part: SDKPart): boolean {
|
||||
if (part.type === "tool") {
|
||||
const tool = (part as SDKPart & { tool: string }).tool
|
||||
const state = (part as SDKPart & { state: { status: string } }).state
|
||||
if (UPSTREAM_SUPPRESSED_TOOLS.has(tool)) {
|
||||
// Show completed todo parts only when kilo-ui provides a visible renderer.
|
||||
return state.status === "completed" && !!ToolRegistry.render(tool)
|
||||
}
|
||||
// Always render question tool parts — active ones get the inline QuestionDock
|
||||
return true
|
||||
}
|
||||
if (part.type === "text") return !snapshotProgress(part) && !!(part as SDKPart & { text: string }).text?.trim()
|
||||
if (part.type === "reasoning") {
|
||||
return !!(part as SDKPart & { text: string }).text?.replace("[REDACTED]", "").trim()
|
||||
}
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a tool part to an active request (question or suggestion) by tool name
|
||||
* and callID/messageID. Returns the matched request or undefined.
|
||||
@@ -125,6 +106,8 @@ interface AssistantMessageProps {
|
||||
parts?: SDKPart[]
|
||||
showAssistantCopyPartID?: string | null
|
||||
feedback?: MessageFeedbackControls
|
||||
/** Part behind the currently hovered/focused task-timeline bar, if any. */
|
||||
highlight?: () => TimelineHighlight | undefined
|
||||
}
|
||||
|
||||
type ToolStateProps = {
|
||||
@@ -198,8 +181,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
const stored = props.parts ?? data.store.part?.[props.message.id]
|
||||
if (!stored) return []
|
||||
return (stored as SDKPart[]).filter((part) => {
|
||||
if (!isRenderable(part)) return false
|
||||
if (part.type === "text" && part.synthetic && props.message.time.completed) return false
|
||||
if (!isRenderable(part, props.message)) return false
|
||||
if (part.type !== "tool" || part.tool !== "question") return true
|
||||
if (part.state.status !== "pending" && part.state.status !== "running") return true
|
||||
return !!matchToolRequest(part, "question", session.questions())
|
||||
@@ -280,6 +262,13 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
return part as unknown as ToolPart
|
||||
})
|
||||
|
||||
// Lights up when this part is behind the hovered/focused task-timeline
|
||||
// bar, using that bar's own color so the two stay easy to correlate.
|
||||
const highlighted = createMemo(() => {
|
||||
const h = props.highlight?.()
|
||||
return h?.msgId === props.message.id && h?.partId === part.id
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={
|
||||
@@ -291,7 +280,14 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
PART_MAPPING[part.type]
|
||||
}
|
||||
>
|
||||
<div data-component="tool-part-wrapper" data-part-type={part.type}>
|
||||
<div
|
||||
data-component="tool-part-wrapper"
|
||||
data-part-type={part.type}
|
||||
data-timeline-highlight={highlighted() ? "" : undefined}
|
||||
style={
|
||||
highlighted() ? { "--timeline-color": timelineColor(part as unknown as TimelinePart) } : undefined
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={activeQuestion()}
|
||||
fallback={
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
type TranscriptHold,
|
||||
type TranscriptRow,
|
||||
} from "../../context/transcript-rows"
|
||||
import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight"
|
||||
import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search"
|
||||
import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight"
|
||||
import {
|
||||
@@ -626,6 +627,11 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
window.addEventListener("scrollToMessage", onScrollToMessage)
|
||||
onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage))
|
||||
|
||||
// Highlights the part behind the currently hovered/focused timeline bar
|
||||
// (dispatched by TaskTimeline) so the two stay visually correlated.
|
||||
const [highlight, setHighlight] = createSignal<TimelineHighlight>()
|
||||
onCleanup(onTimelineHighlight(setHighlight))
|
||||
|
||||
const measurement = createMemo(() => {
|
||||
const id = session.currentSessionID()
|
||||
const token = layout()
|
||||
@@ -819,6 +825,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
row={row}
|
||||
index={index()}
|
||||
onForkMessage={props.onForkMessage}
|
||||
highlight={highlight}
|
||||
activeSearch={activeKey() === row.key}
|
||||
/>
|
||||
)}
|
||||
@@ -829,6 +836,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
<TranscriptRowView
|
||||
row={lookup().get(key)!}
|
||||
onForkMessage={props.onForkMessage}
|
||||
highlight={highlight}
|
||||
activeSearch={activeKey() === key}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1119,9 +1119,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
reviewDrafts.delete(key)
|
||||
imageDrafts.delete(key)
|
||||
scrollDrafts.delete(key)
|
||||
history.append(draft)
|
||||
if (draftKey() !== key) return
|
||||
|
||||
history.append(draft)
|
||||
history.reset()
|
||||
setText("")
|
||||
clearReviewComments()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
/**
|
||||
* Horizontal session activity timeline rendered as color-grouped SVG paths.
|
||||
* Pointer and keyboard interaction use the same pure bar geometry.
|
||||
@@ -5,10 +6,14 @@
|
||||
|
||||
import { Component, For, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart } from "@kilocode/sdk/v2"
|
||||
import { useSession } from "../../context/session"
|
||||
import { visibleParts } from "../../context/session-queue"
|
||||
import { color, label } from "../../utils/timeline/colors"
|
||||
import { geometry, hit, navigate } from "../../utils/timeline/geometry"
|
||||
import { dispatchTimelineHighlight, same, type TimelineHighlight } from "../../utils/timeline/highlight"
|
||||
import { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes"
|
||||
import { isRenderable } from "../../utils/transcript-parts"
|
||||
import type { Part, Message } from "../../types/messages"
|
||||
|
||||
export interface TimelineBar {
|
||||
@@ -60,9 +65,18 @@ export const TaskTimeline: Component = () => {
|
||||
const messages = () => session.visibleMessages()
|
||||
const allParts = () => {
|
||||
const msgs = messages()
|
||||
const revert = session.revert() ?? undefined
|
||||
const qs = session.questions()
|
||||
const result: Record<string, Part[]> = {}
|
||||
for (const m of msgs) {
|
||||
const p = session.getParts(m.id)
|
||||
if (m.role === "user") continue
|
||||
const p = visibleParts(m.id, session.getParts(m.id), revert).filter((part) => {
|
||||
if (!isRenderable(part as SDKPart, m as SDKAssistantMessage)) return false
|
||||
if (part.type !== "tool" || part.tool !== "question") return true
|
||||
if (part.state.status !== "pending" && part.state.status !== "running") return true
|
||||
const call = (part as SDKPart & { callID: string }).callID
|
||||
return qs.some((item) => item.tool?.callID === call && item.tool?.messageID === m.id)
|
||||
})
|
||||
if (p.length > 0) result[m.id] = p
|
||||
}
|
||||
return result
|
||||
@@ -79,9 +93,10 @@ export const TaskTimeline: Component = () => {
|
||||
const aria = () => {
|
||||
const idx = selected()
|
||||
const bar = bars()[idx]
|
||||
if (!bar) return "Session activity timeline, no activity"
|
||||
return `Session activity timeline, bar ${idx + 1} of ${bars().length}: ${bar.tip}`
|
||||
if (!bar) return "No activity"
|
||||
return `Bar ${idx + 1} of ${bars().length}: ${bar.tip}`
|
||||
}
|
||||
const value = () => Math.max(0, selected() + 1)
|
||||
|
||||
let prev = 0
|
||||
let frame: number | undefined
|
||||
@@ -114,7 +129,29 @@ export const TaskTimeline: Component = () => {
|
||||
setTip(undefined)
|
||||
}
|
||||
|
||||
createEffect(on(bars, hideTip, { defer: true }))
|
||||
createEffect(
|
||||
on(
|
||||
bars,
|
||||
(next, previous) => {
|
||||
const idx = hover()
|
||||
if (idx < 0 || same(previous?.[idx], next[idx])) return
|
||||
hideTip()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Highlight the chat part behind the hovered/focused bar, using its own
|
||||
// color, so it's easy to follow which bar belongs to which tool call.
|
||||
createEffect<TimelineHighlight | undefined>((previous) => {
|
||||
const idx = hover()
|
||||
const bar = idx >= 0 ? bars()[idx] : undefined
|
||||
const next = bar ? { msgId: bar.msgId, partId: bar.partId } : undefined
|
||||
if (same(previous, next)) return previous
|
||||
dispatchTimelineHighlight(next)
|
||||
return next
|
||||
})
|
||||
onCleanup(() => dispatchTimelineHighlight(undefined))
|
||||
|
||||
const showTip = (idx: number) => {
|
||||
const item = layout().items[idx]
|
||||
@@ -148,11 +185,12 @@ export const TaskTimeline: Component = () => {
|
||||
ref.style.userSelect = "none"
|
||||
}
|
||||
|
||||
const jumpToMessage = (idx: number) => {
|
||||
const select = (idx: number) => {
|
||||
const bar = bars()[idx]
|
||||
if (!bar) return
|
||||
setActive(idx)
|
||||
window.dispatchEvent(new CustomEvent("scrollToMessage", { detail: { id: bar.msgId, partId: bar.partId } }))
|
||||
showTip(idx)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
@@ -174,7 +212,9 @@ export const TaskTimeline: Component = () => {
|
||||
if (ref.hasPointerCapture(e.pointerId)) ref.releasePointerCapture(e.pointerId)
|
||||
ref.style.cursor = "grab"
|
||||
ref.style.userSelect = ""
|
||||
if (wasDragging && !dragMoved) jumpToMessage(pointerIndex(e))
|
||||
if (!wasDragging || dragMoved) return
|
||||
const idx = pointerIndex(e)
|
||||
select(idx)
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
@@ -187,7 +227,7 @@ export const TaskTimeline: Component = () => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
jumpToMessage(selected())
|
||||
select(selected())
|
||||
return
|
||||
}
|
||||
if (!ref || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
|
||||
@@ -235,9 +275,14 @@ export const TaskTimeline: Component = () => {
|
||||
ref={ref}
|
||||
class="task-timeline"
|
||||
data-timeline-count={bars().length}
|
||||
role="img"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={aria()}
|
||||
aria-label="Session activity timeline"
|
||||
aria-keyshortcuts="ArrowLeft ArrowRight Home End Enter Space"
|
||||
aria-valuemin={bars().length > 0 ? 1 : 0}
|
||||
aria-valuemax={bars().length}
|
||||
aria-valuenow={value()}
|
||||
aria-valuetext={aria()}
|
||||
style={{ height: `${MAX_HEIGHT}px` }}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={hideTip}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
|
||||
import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, SnapshotFileDiff } from "@kilocode/sdk/v2"
|
||||
import type { TranscriptRow } from "../../context/transcript-rows"
|
||||
import type { TimelineHighlight } from "../../utils/timeline/highlight"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useServer } from "../../context/server"
|
||||
import { useLanguage } from "../../context/language"
|
||||
@@ -17,6 +18,8 @@ interface TranscriptRowViewProps {
|
||||
row: TranscriptRow
|
||||
index?: number
|
||||
onForkMessage?: (sessionId: string, messageId: string) => void
|
||||
/** Part behind the currently hovered/focused task-timeline bar, if any. */
|
||||
highlight?: () => TimelineHighlight | undefined
|
||||
activeSearch?: boolean
|
||||
}
|
||||
|
||||
@@ -78,6 +81,7 @@ export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
|
||||
message={row().message as unknown as SDKAssistantMessage}
|
||||
parts={row().parts as unknown as SDKPart[]}
|
||||
showAssistantCopyPartID={row().copy}
|
||||
highlight={props.highlight}
|
||||
feedback={{
|
||||
enabled: feedback.telemetryEnabled(),
|
||||
rating: feedback.getRating(row().message.id),
|
||||
|
||||
@@ -655,6 +655,25 @@ export const ToolCards: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const TimelineHighlightedTool: Story = {
|
||||
name: "Task Timeline — highlighted tool",
|
||||
render: () => {
|
||||
const data = dataWith([readCompleted])
|
||||
return (
|
||||
<StoryProviders data={data} sessionID={SESSION_ID}>
|
||||
<div class="vscode-session-turn" data-row="assistant">
|
||||
<div class="vscode-session-turn-assistant">
|
||||
<AssistantMessage
|
||||
message={baseAssistantMessage}
|
||||
highlight={() => ({ msgId: ASST_MSG_ID, partId: readCompleted.id })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const TitleOnlyReasoning: Story = {
|
||||
name: "Reasoning - title only",
|
||||
render: () => {
|
||||
|
||||
@@ -251,6 +251,48 @@
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Lights up the part behind a hovered/focused task-timeline bar, using the
|
||||
bar's own color, so it's easy to follow which bar belongs to which tool
|
||||
call (mirrors the legacy extension's task-timeline row gutter highlight).
|
||||
Drawn as an absolutely positioned strip flush with the card's left edge,
|
||||
not an inset box-shadow: a box-shadow paints as part of the wrapper's own
|
||||
background layer, underneath every child, so tool cards with a negative-
|
||||
margin background (bleeding past this edge) would otherwise cover it.
|
||||
An absolutely positioned element always paints above normal-flow
|
||||
(position: static) children, so it stays visible without needing to
|
||||
extend past the wrapper's own box — virtua's row virtualizer clips its
|
||||
content to exactly that box (`overflow: clip`). */
|
||||
[data-component="tool-part-wrapper"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-component="tool-part-wrapper"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
/* Use the turn's existing 4px inset: the 3px strip stays within the
|
||||
virtualized row and leaves a 1px gap before the card. */
|
||||
left: -4px;
|
||||
width: 3px;
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--timeline-color, transparent);
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-component="tool-part-wrapper"][data-timeline-highlight]::before {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="tool-part-wrapper"]::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-view .message-list-content > .revert-banner,
|
||||
.chat-view .message-list-content > [data-component="question-dock"],
|
||||
.chat-view .message-list-content > .working-indicator-slot,
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
height: 14px;
|
||||
padding: 1px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-base);
|
||||
border: 1px solid var(--vscode-button-background);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -606,7 +606,7 @@ export interface SidebarForkSessionRequest {
|
||||
messageId?: string
|
||||
}
|
||||
|
||||
// Close (remove) a session from its worktree
|
||||
// Stop and remove a Local or worktree session from Agent Manager
|
||||
export interface CloseSessionRequest {
|
||||
type: "agentManager.closeSession"
|
||||
sessionId: string
|
||||
@@ -616,6 +616,7 @@ export interface CloseSessionRequest {
|
||||
export interface PersistSessionRequest {
|
||||
type: "agentManager.persistSession"
|
||||
sessionId: string
|
||||
draftID?: string
|
||||
}
|
||||
|
||||
/** Remove a non-worktree session from agent-manager.json. */
|
||||
@@ -980,6 +981,11 @@ export interface SidebarOpenSessionsMessage {
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
export interface AgentManagerVisibleSessionMessage {
|
||||
type: "agentManager.visibleSession"
|
||||
sessionID: string | null
|
||||
}
|
||||
|
||||
export interface RequestAutoApproveStateMessage {
|
||||
type: "requestAutoApproveState"
|
||||
}
|
||||
@@ -1366,6 +1372,7 @@ export type WebviewMessage =
|
||||
| SetDefaultBaseBranchRequest
|
||||
| AgentManagerOpenSessionsMessage
|
||||
| SidebarOpenSessionsMessage
|
||||
| AgentManagerVisibleSessionMessage
|
||||
| RequestAutoApproveStateMessage
|
||||
| ToggleAutoApproveMessage
|
||||
| RequestSandboxStatusMessage
|
||||
|
||||
@@ -11,6 +11,34 @@ export type ApplyLocalTabOrder = (items: { id: string }[], order: string[]) => {
|
||||
export interface LocalTabInventory {
|
||||
local: readonly string[]
|
||||
external?: ReadonlySet<string>
|
||||
unresolved?: ReadonlySet<string>
|
||||
rejected?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
type TrackedSession = { id: string; worktreeId: string | null }
|
||||
type LoadedSession = { id: string; parentID?: string | null }
|
||||
|
||||
export function trackedSessionInventory(managed: TrackedSession[], loaded: LoadedSession[]): LocalTabInventory {
|
||||
const lookup = new Map(loaded.map((item) => [item.id, item]))
|
||||
const root = (id: string) => {
|
||||
const info = lookup.get(id)
|
||||
return !info || info.parentID === null
|
||||
}
|
||||
const unresolved = new Set(loaded.filter((item) => item.parentID === undefined).map((item) => item.id))
|
||||
const rejected = new Set(
|
||||
managed
|
||||
.filter((item) => {
|
||||
const info = lookup.get(item.id)
|
||||
return info?.parentID !== undefined && info.parentID !== null
|
||||
})
|
||||
.map((item) => item.id),
|
||||
)
|
||||
return {
|
||||
local: managed.filter((item) => !item.worktreeId && root(item.id)).map((item) => item.id),
|
||||
external: new Set(managed.filter((item) => item.worktreeId && root(item.id)).map((item) => item.id)),
|
||||
unresolved,
|
||||
rejected,
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalTabReconcileResult {
|
||||
@@ -123,8 +151,8 @@ export function restoreTrackedTabs(
|
||||
apply: ApplyLocalTabOrder,
|
||||
): string[] | undefined {
|
||||
const locals = [...inventory.local]
|
||||
const external = inventory.external
|
||||
const evict = (ids: string[]) => (external?.size ? ids.filter((id) => !external.has(id)) : ids)
|
||||
const evict = (ids: string[]) =>
|
||||
ids.filter((id) => !inventory.external?.has(id) && !inventory.unresolved?.has(id) && !inventory.rejected?.has(id))
|
||||
const real = current.filter((id) => !check(id))
|
||||
|
||||
if (locals.length > 0 && real.length === 0) {
|
||||
@@ -158,23 +186,22 @@ export function reconcileTrackedTabs(
|
||||
): LocalTabReconcileResult | undefined {
|
||||
const seen = new Set(loaded)
|
||||
const local = new Set(inventory.local)
|
||||
const external = inventory.external
|
||||
const ids: string[] = []
|
||||
const forget: string[] = []
|
||||
const forget = new Set(inventory.rejected)
|
||||
|
||||
for (const id of current) {
|
||||
if (check(id)) {
|
||||
ids.push(id)
|
||||
continue
|
||||
}
|
||||
if (external?.has(id)) continue
|
||||
if (inventory.external?.has(id) || inventory.unresolved?.has(id) || inventory.rejected?.has(id)) continue
|
||||
if (seen.has(id) || local.has(id)) {
|
||||
ids.push(id)
|
||||
continue
|
||||
}
|
||||
forget.push(id)
|
||||
forget.add(id)
|
||||
}
|
||||
|
||||
if (ids.length === current.length && forget.length === 0) return undefined
|
||||
return { ids, forget }
|
||||
if (ids.length === current.length && forget.size === 0) return undefined
|
||||
return { ids, forget: [...forget] }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Cross-component signal correlating a hovered/selected task-timeline bar with
|
||||
* the chat part it represents. TaskTimeline dispatches on hover/keyboard-nav
|
||||
* change (no direct props/context link to the transcript, same convention as
|
||||
* the `scrollToMessage` and `resumeAutoScroll` window events); AssistantMessage
|
||||
* listens and highlights the matching part using the bar's own color, so users
|
||||
* can visually follow which bar belongs to which tool call — mirroring the
|
||||
* legacy extension's task-timeline row gutter highlight.
|
||||
*/
|
||||
|
||||
export interface TimelineHighlight {
|
||||
msgId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
export function same(a: TimelineHighlight | undefined, b: TimelineHighlight | undefined) {
|
||||
return a?.msgId === b?.msgId && a?.partId === b?.partId
|
||||
}
|
||||
|
||||
const EVENT = "timelineHighlight"
|
||||
|
||||
export function dispatchTimelineHighlight(value: TimelineHighlight | undefined) {
|
||||
window.dispatchEvent(new CustomEvent<TimelineHighlight | undefined>(EVENT, { detail: value }))
|
||||
}
|
||||
|
||||
/** Registers a listener and returns an unregister function for onCleanup. */
|
||||
export function onTimelineHighlight(handler: (value: TimelineHighlight | undefined) => void) {
|
||||
const listener = (e: Event) => handler((e as CustomEvent<TimelineHighlight | undefined>).detail)
|
||||
window.addEventListener(EVENT, listener)
|
||||
return () => window.removeEventListener(EVENT, listener)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part"
|
||||
import type { AssistantMessage, Part } from "@kilocode/sdk/v2"
|
||||
import { snapshotProgress } from "../context/session-utils"
|
||||
|
||||
export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"])
|
||||
|
||||
export function isRenderable(part: Part, message: AssistantMessage): boolean {
|
||||
if (part.type === "tool") {
|
||||
if (UPSTREAM_SUPPRESSED_TOOLS.has(part.tool)) {
|
||||
return part.state.status === "completed" && !!ToolRegistry.render(part.tool)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (part.type === "text") {
|
||||
return !snapshotProgress(part) && !!part.text?.trim() && !(part.synthetic && message?.time.completed)
|
||||
}
|
||||
if (part.type === "reasoning") return !!part.text?.replace("[REDACTED]", "").trim()
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
@@ -441,17 +441,21 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
</text>
|
||||
<text fg={theme.text}>{current.title}</text>
|
||||
</box>
|
||||
{/* kilocode_change start - explain config file edits always require approval */}
|
||||
{/* kilocode_change start - explain protected Kilo configuration access */}
|
||||
<Show when={props.request.metadata?.[ConfigProtection.CONFIG_PROTECTED_KEY]}>
|
||||
<box paddingLeft={4} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>Config file edits always require approval</text>
|
||||
<text fg={theme.textMuted}>
|
||||
{props.request.permission === "edit"
|
||||
? "Config file edits always require approval"
|
||||
: "Kilo configuration access always requires approval"}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
{/* kilocode_change end */}
|
||||
</box>
|
||||
)
|
||||
|
||||
// kilocode_change start — hide "Always allow" for config file edits
|
||||
// kilocode_change start - hide "Always allow" for protected Kilo configuration access
|
||||
const options: Record<string, string> = props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY]
|
||||
? { once: "Allow once", reject: "Reject" }
|
||||
: { once: "Allow once", always: "Allow always", reject: "Reject" }
|
||||
|
||||
@@ -261,6 +261,7 @@ export const layer = Layer.effect(
|
||||
const npmSvc = yield* Npm.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const git = yield* Git.Service // kilocode_change
|
||||
const flock = yield* EffectFlock.Service // kilocode_change - serialize global config read-merge-write updates
|
||||
|
||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||
|
||||
@@ -971,24 +972,34 @@ export const layer = Layer.effect(
|
||||
const dispose = options?.dispose ?? true
|
||||
// kilocode_change end
|
||||
const file = globalConfigFile()
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
const patch = writableGlobal(config)
|
||||
// kilocode_change start - serialize read-merge-write so concurrent approvals cannot lose rules
|
||||
const result = yield* flock
|
||||
.withLock(
|
||||
Effect.gen(function* () {
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
const patch = writableGlobal(config)
|
||||
|
||||
let next: Info
|
||||
let changed: boolean
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file)
|
||||
const merged = KilocodeConfig.mergeConfig(writable(existing), patch) // kilocode_change
|
||||
const serialized = JSON.stringify(merged, null, 2)
|
||||
changed = serialized !== before
|
||||
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, patch)
|
||||
next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file)
|
||||
changed = updated !== before
|
||||
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file)
|
||||
const next = KilocodeConfig.mergeConfig(writable(existing), patch)
|
||||
const serialized = JSON.stringify(next, null, 2)
|
||||
const changed = serialized !== before
|
||||
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
|
||||
return { next, changed }
|
||||
}
|
||||
|
||||
const updated = patchJsonc(before, patch)
|
||||
const next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file)
|
||||
const changed = updated !== before
|
||||
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
return { next, changed }
|
||||
}),
|
||||
`config:global:${path.resolve(Global.Path.config)}`,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const next = result.next
|
||||
const changed = result.changed
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - skip dispose when caller opts out
|
||||
if (!dispose) {
|
||||
@@ -1037,11 +1048,10 @@ export const layer = Layer.effect(
|
||||
warnings, // kilocode_change
|
||||
})
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.provide(EffectFlock.defaultLayer)) // kilocode_change - serialize global config updates in every layer
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer), // kilocode_change
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
|
||||
@@ -38,6 +38,12 @@ async function provide<R>(input: { directory: string; fn: () => R }): Promise<R>
|
||||
return provide(input)
|
||||
}
|
||||
|
||||
function same(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const id of a) if (!b.has(id)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export namespace KiloSessions {
|
||||
export const Event = {
|
||||
RemoteStatusChanged: BusEvent.define(
|
||||
@@ -203,8 +209,7 @@ export namespace KiloSessions {
|
||||
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined
|
||||
let enabling: Promise<void> | undefined
|
||||
let remoteSeq = 0
|
||||
const focused = new Set<string>()
|
||||
const opened = new Set<string>()
|
||||
const attached = new Set<string>()
|
||||
const statusSyncs = new Map<string, { running: boolean; dirty: boolean }>()
|
||||
const STATUS_TIMEOUT_MS = 3_000
|
||||
|
||||
@@ -416,8 +421,7 @@ export namespace KiloSessions {
|
||||
const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
|
||||
const statuses: Record<string, SessionStatus.Info> = Object.fromEntries(statusMap)
|
||||
const ids = new Set(Object.keys(statuses))
|
||||
for (const id of focused) ids.add(id)
|
||||
for (const id of opened) ids.add(id)
|
||||
for (const id of attached) ids.add(id)
|
||||
const results = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
Effect.all(
|
||||
@@ -438,11 +442,7 @@ export namespace KiloSessions {
|
||||
),
|
||||
)
|
||||
const sessions = results.filter((r): r is NonNullable<typeof r> => !!r)
|
||||
return {
|
||||
sessions,
|
||||
focused: focused.size > 0 ? [...focused] : undefined,
|
||||
open: opened.size > 0 ? [...opened] : undefined,
|
||||
}
|
||||
return { sessions }
|
||||
}
|
||||
|
||||
const conn = RemoteWS.connect({
|
||||
@@ -515,15 +515,11 @@ export namespace KiloSessions {
|
||||
connected: remote?.conn.connected ?? false,
|
||||
}
|
||||
}
|
||||
export function setViewedSessions(input: { focused: readonly string[]; open?: readonly string[] }) {
|
||||
focused.clear()
|
||||
opened.clear()
|
||||
for (const id of input.focused) {
|
||||
focused.add(id)
|
||||
}
|
||||
for (const id of input.open ?? []) {
|
||||
opened.add(id)
|
||||
}
|
||||
export function setAttachedSessions(ids: readonly string[]) {
|
||||
const next = new Set(ids)
|
||||
if (same(next, attached)) return
|
||||
attached.clear()
|
||||
for (const id of next) attached.add(id)
|
||||
if (remote) void remote.conn.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) }))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ export namespace RemoteProtocol {
|
||||
export const Heartbeat = z.object({
|
||||
type: z.literal("heartbeat"),
|
||||
sessions: z.array(SessionInfo),
|
||||
focused: z.array(z.string()).optional(),
|
||||
open: z.array(z.string()).optional(),
|
||||
protocolVersion: z.string().optional(), // lets relay detect CLI capabilities without probing commands
|
||||
})
|
||||
export type Heartbeat = z.infer<typeof Heartbeat>
|
||||
|
||||
@@ -73,6 +73,7 @@ function normalizePrompt(input: RemotePromptInput): SessionPrompt.PromptInput {
|
||||
return {
|
||||
...input,
|
||||
model: normalizeModel(input.model),
|
||||
ephemeralTools: { interactive_terminal: false },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +98,11 @@ export namespace RemoteSender {
|
||||
readonly reject: (requestID: QuestionID) => Promise<void>
|
||||
}
|
||||
prompt?: (input: SessionPrompt.PromptInput) => Promise<unknown>
|
||||
cancel?: (sessionID: SessionID) => Promise<void>
|
||||
session?: {
|
||||
readonly get: (sessionID: SessionID) => Promise<Session.Info>
|
||||
readonly children: (sessionID: SessionID) => Promise<Session.Info[]>
|
||||
}
|
||||
catalog?: {
|
||||
readonly get: (sessionID: SessionID) => Promise<Session.Info>
|
||||
readonly messages: (sessionID: SessionID) => Promise<MessageV2.WithParts[]>
|
||||
@@ -144,6 +150,12 @@ export namespace RemoteSender {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt(input)))
|
||||
})
|
||||
const cancel =
|
||||
options.cancel ??
|
||||
(async (sessionID: SessionID) => {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(sessionID)))
|
||||
})
|
||||
const catalog = options.catalog ?? {
|
||||
get: async (sessionID: SessionID) => {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
@@ -168,6 +180,16 @@ export namespace RemoteSender {
|
||||
return AppRuntime.runPromise(Provider.Service.use((svc) => svc.defaultModel()))
|
||||
},
|
||||
}
|
||||
const session = options.session ?? {
|
||||
get: async (sessionID: SessionID) => {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
return AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
},
|
||||
children: async (sessionID: SessionID) => {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
return AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID)))
|
||||
},
|
||||
}
|
||||
|
||||
const sub =
|
||||
options.subscribe ??
|
||||
@@ -180,10 +202,7 @@ export namespace RemoteSender {
|
||||
})
|
||||
|
||||
async function directoryFor(sid: string): Promise<string> {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const info = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.get(SessionID.make(sid)).pipe(Effect.orElseSucceed(() => undefined))),
|
||||
)
|
||||
const info = await session.get(SessionID.make(sid)).catch(() => undefined)
|
||||
return info?.directory ?? options.directory
|
||||
}
|
||||
|
||||
@@ -218,6 +237,7 @@ export namespace RemoteSender {
|
||||
// sees state that was asked before it connected — analogous to the Cloud
|
||||
// Agent's `connected` event carrying pending question/permission fields.
|
||||
async function replay(sessionId: string) {
|
||||
const root = rootOf(sessionId)
|
||||
const [suggestions, questions, permissions] = await Promise.all([
|
||||
Suggestion.list(),
|
||||
question.list(),
|
||||
@@ -228,6 +248,7 @@ export namespace RemoteSender {
|
||||
options.conn.send({
|
||||
type: "event",
|
||||
sessionId,
|
||||
...(root ? { parentSessionId: root } : {}),
|
||||
event: "suggestion.shown",
|
||||
data: suggestion,
|
||||
})
|
||||
@@ -237,6 +258,7 @@ export namespace RemoteSender {
|
||||
options.conn.send({
|
||||
type: "event",
|
||||
sessionId,
|
||||
...(root ? { parentSessionId: root } : {}),
|
||||
event: "question.asked",
|
||||
data: q,
|
||||
})
|
||||
@@ -246,6 +268,7 @@ export namespace RemoteSender {
|
||||
options.conn.send({
|
||||
type: "event",
|
||||
sessionId,
|
||||
...(root ? { parentSessionId: root } : {}),
|
||||
event: "permission.asked",
|
||||
data: p,
|
||||
})
|
||||
@@ -266,10 +289,7 @@ export namespace RemoteSender {
|
||||
}
|
||||
|
||||
async function discoverChildren(parentId: string) {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const childSessions = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.children(SessionID.make(parentId))),
|
||||
)
|
||||
const childSessions = await session.children(SessionID.make(parentId))
|
||||
for (const child of childSessions) {
|
||||
children.set(child.id, parentId)
|
||||
const root = rootOf(child.id) ?? parentId
|
||||
@@ -401,7 +421,8 @@ export namespace RemoteSender {
|
||||
})
|
||||
return
|
||||
}
|
||||
const input = SessionPrompt.PromptInput.zod.safeParse(normalizePrompt(parsed.data as RemotePromptInput))
|
||||
const normalized = normalizePrompt(parsed.data as RemotePromptInput)
|
||||
const input = SessionPrompt.PromptInput.zod.safeParse(normalized)
|
||||
if (!input.success) {
|
||||
options.conn.send({
|
||||
type: "response",
|
||||
@@ -410,11 +431,25 @@ export namespace RemoteSender {
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => {
|
||||
await prompt(input.data as SessionPrompt.PromptInput)
|
||||
const promptInput = { ...input.data, ephemeralTools: normalized.ephemeralTools } as SessionPrompt.PromptInput
|
||||
dispatchLongRunning(msg, directoryFor(promptInput.sessionID), async () => {
|
||||
await prompt(promptInput)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg.command === "interrupt") {
|
||||
const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none<SessionID>()
|
||||
if (Option.isNone(session)) {
|
||||
options.conn.send({
|
||||
type: "response",
|
||||
id: msg.id,
|
||||
error: "invalid interrupt command",
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatchQuick(msg, directoryFor(session.value), () => cancel(session.value))
|
||||
return
|
||||
}
|
||||
if (msg.command === "question_reply") {
|
||||
const parsed = QuestionData.safeParse(msg.data)
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -7,7 +7,7 @@ export namespace RemoteWS {
|
||||
export type Options = {
|
||||
url: string
|
||||
getToken: () => Promise<string | undefined>
|
||||
getSessions: () => Promise<{ sessions: SessionInfo[]; focused?: string[]; open?: string[] }>
|
||||
getSessions: () => Promise<{ sessions: SessionInfo[] }>
|
||||
log: {
|
||||
info: (...args: any[]) => void
|
||||
error: (...args: any[]) => void
|
||||
|
||||
@@ -24,6 +24,7 @@ export const AgentManagerMode = Schema.Literals(["worktree", "local"])
|
||||
export const AgentManagerStart = Schema.Struct({
|
||||
requestID: Schema.String,
|
||||
sessionID: SessionID,
|
||||
sandboxInheritanceToken: Schema.optional(Schema.String),
|
||||
mode: AgentManagerMode,
|
||||
versions: Schema.optional(Schema.Boolean),
|
||||
tasks: Schema.Array(AgentManagerTask).check(Schema.isMinLength(1), Schema.isMaxLength(20)),
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ChatToken,
|
||||
ContentBlock,
|
||||
ConversationActivityEvent,
|
||||
ConversationLeftEvent,
|
||||
ConversationListItem,
|
||||
ConversationRenamedEvent,
|
||||
ConversationStatusEvent,
|
||||
@@ -29,7 +30,7 @@ import type {
|
||||
TypingMember,
|
||||
} from "./types"
|
||||
import { KiloChatClient } from "./kilo-chat-client"
|
||||
import { EventServiceClient } from "./event-service-client"
|
||||
import { EventServiceClient } from "@/kilocode/event-service/client"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "claw-chat" })
|
||||
@@ -282,7 +283,7 @@ export async function connect(input: ConnectInput): Promise<ClawChatClient> {
|
||||
emit(conversationsListeners, conversations)
|
||||
})
|
||||
|
||||
events.on("conversation.left", (ctx, e) => {
|
||||
events.on("conversation.left", (ctx, e: ConversationLeftEvent) => {
|
||||
if (ctx !== sandboxCtx) return
|
||||
conversations = conversations.filter((c) => c.conversationId !== e.conversationId)
|
||||
emit(conversationsListeners, conversations)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* via thin integration points so the upstream diff stays minimal.
|
||||
*/
|
||||
|
||||
import { createEffect, on } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { useBindings } from "@tui/keymap"
|
||||
@@ -77,37 +77,72 @@ export function useSessionEffects(deps: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
}) {
|
||||
const pty = process.env.KILO_PTY_ID
|
||||
const state = { prev: "" }
|
||||
const viewerId = crypto.randomUUID()
|
||||
const renderer = useRenderer()
|
||||
const session = createMemo(() => (deps.route.data.type === "session" ? deps.route.data.sessionID : undefined))
|
||||
let active = true
|
||||
const meta = { prev: "" }
|
||||
|
||||
function send() {
|
||||
const id = session()
|
||||
const ids = id ? [id] : []
|
||||
deps.sdk.client.session.viewed({ viewer: { id: viewerId, active }, attached: ids, visible: ids }).catch(() => {})
|
||||
}
|
||||
|
||||
createEffect(() => send())
|
||||
|
||||
const onFocus = () => {
|
||||
active = true
|
||||
send()
|
||||
}
|
||||
const onBlur = () => {
|
||||
active = false
|
||||
send()
|
||||
}
|
||||
renderer.on("focus", onFocus)
|
||||
renderer.on("blur", onBlur)
|
||||
|
||||
// The server prepends `server.connected` to every SSE (re)connect; a restarted
|
||||
// backend has an empty viewer map, so resend the snapshot immediately instead
|
||||
// of waiting for the 60s check-in.
|
||||
const offConnected = deps.sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "server.connected") send()
|
||||
})
|
||||
|
||||
const timer = setInterval(send, 60_000)
|
||||
|
||||
// Notify server which session the user is viewing
|
||||
createEffect(() => {
|
||||
const sessionID = deps.route.data.type === "session" ? deps.route.data.sessionID : undefined
|
||||
deps.sdk.client.session.viewed({ focused: sessionID ? [sessionID] : [] }).catch(() => {})
|
||||
|
||||
const sessionID = session()
|
||||
if (!pty) return
|
||||
const session = sessionID ? deps.sync.session.get(sessionID) : undefined
|
||||
const key = [sessionID ?? "", session?.title ?? ""].join("\n")
|
||||
if (key === state.prev) return
|
||||
state.prev = key
|
||||
|
||||
const s = sessionID ? deps.sync.session.get(sessionID) : undefined
|
||||
const key = [sessionID ?? "", s?.title ?? ""].join("\n")
|
||||
if (key === meta.prev) return
|
||||
meta.prev = key
|
||||
deps.sdk.client.pty
|
||||
.update({
|
||||
ptyID: pty,
|
||||
sessionID: sessionID ?? null,
|
||||
...(session?.title ? { title: session.title } : {}),
|
||||
...(s?.title ? { title: s.title } : {}),
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
// Evict per-session data from store when navigating away
|
||||
createEffect(
|
||||
on(
|
||||
() => (deps.route.data.type === "session" ? deps.route.data.sessionID : undefined),
|
||||
(current, prev) => {
|
||||
if (prev && prev !== current) deps.sync.session.evict(prev)
|
||||
},
|
||||
),
|
||||
on(session, (current, prev) => {
|
||||
if (prev && prev !== current) deps.sync.session.evict(prev)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off("focus", onFocus)
|
||||
renderer.off("blur", onBlur)
|
||||
offConnected()
|
||||
clearInterval(timer)
|
||||
active = false
|
||||
deps.sdk.client.session
|
||||
.viewed({ viewer: { id: viewerId, active: false }, attached: [], visible: [] })
|
||||
.catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+38
-35
@@ -1,9 +1,6 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Event Service WebSocket client for the TUI.
|
||||
* Generic Event Service WebSocket client.
|
||||
*
|
||||
* Minimal inline port of `@kilocode/event-service` (cloud monorepo).
|
||||
* Connects via a two-step ticket flow:
|
||||
* 1. POST `/connect-ticket` with `Authorization: Bearer <JWT>` to mint a
|
||||
* single-use ticket (30 s TTL).
|
||||
@@ -11,10 +8,15 @@
|
||||
* `kilo.events.v1`.
|
||||
*
|
||||
* Uses the global `WebSocket` constructor (Bun, Node 22+, browsers).
|
||||
*
|
||||
* Disconnect invalidation: every `connect()` and `disconnect()` bumps a
|
||||
* generation counter. `connectOnce()` captures the generation at entry and,
|
||||
* after the ticket mint resolves, refuses to construct a socket if the
|
||||
* generation changed or the client was disposed. `disconnect()` also aborts
|
||||
* an in-flight ticket request and the pending handshake, so a ticket response
|
||||
* arriving after disposal can never create a socket.
|
||||
*/
|
||||
|
||||
import type { KiloChatEventMap, KiloChatEventName } from "./types"
|
||||
|
||||
const WS_SUBPROTOCOL = "kilo.events.v1"
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000
|
||||
const PING_INTERVAL_MS = 15_000
|
||||
@@ -44,12 +46,9 @@ export class HandshakeTimeoutError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Close codes that signal the server rejected us for auth/policy reasons
|
||||
// and reconnecting with the same token is pointless. Everything else
|
||||
// (including 1006 "abnormal closure" from flaky networks) is transient.
|
||||
function isAuthCloseCode(code: number): boolean {
|
||||
if (code === 1008) return true // Policy Violation
|
||||
if (code === 4401 || code === 4403) return true // Custom auth rejection
|
||||
if (code === 1008) return true
|
||||
if (code === 4401 || code === 4403) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -59,13 +58,10 @@ export type EventServiceConfig = {
|
||||
url: string
|
||||
getToken: () => Promise<string>
|
||||
onUnauthorized?: () => void
|
||||
onServerError?: (error: unknown) => void
|
||||
handshakeTimeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The event-service base URL is configured as a WebSocket URL (`wss://…` /
|
||||
* `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip
|
||||
* the trailing slash and swap the protocol so `fetch()` accepts the URL.
|
||||
*/
|
||||
function toHttpBase(wsBase: string): string {
|
||||
const trimmed = wsBase.replace(/\/$/, "")
|
||||
if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6)
|
||||
@@ -77,16 +73,20 @@ export class EventServiceClient {
|
||||
private readonly url: string
|
||||
private readonly getToken: () => Promise<string>
|
||||
private readonly onUnauthorized: (() => void) | undefined
|
||||
private readonly onServerError: ((error: unknown) => void) | undefined
|
||||
private readonly handshakeTimeoutMs: number
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private connected = false
|
||||
private destroyed = false
|
||||
private generation = 0
|
||||
private reconnectAttempts = 0
|
||||
private hasConnectedBefore = false
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private abortHandshake: ((err: Error) => void) | null = null
|
||||
private tickets = new Set<AbortController>()
|
||||
|
||||
private eventHandlers = new Map<string, Set<EventHandler>>()
|
||||
private activeContexts = new Set<string>()
|
||||
@@ -96,9 +96,12 @@ export class EventServiceClient {
|
||||
this.url = config.url
|
||||
this.getToken = config.getToken
|
||||
this.onUnauthorized = config.onUnauthorized
|
||||
this.onServerError = config.onServerError
|
||||
this.handshakeTimeoutMs = config.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const gen = ++this.generation
|
||||
this.destroyed = false
|
||||
this.reconnectAttempts = 0
|
||||
if (this.reconnectTimer !== null) {
|
||||
@@ -108,13 +111,17 @@ export class EventServiceClient {
|
||||
try {
|
||||
await this.connectOnce()
|
||||
} catch (err) {
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
if (this.handleAuthFailure(err)) return
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.generation++
|
||||
this.destroyed = true
|
||||
for (const ctrl of this.tickets) ctrl.abort()
|
||||
this.tickets.clear()
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
@@ -149,9 +156,9 @@ export class EventServiceClient {
|
||||
}
|
||||
}
|
||||
|
||||
on<N extends KiloChatEventName>(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void {
|
||||
on<T = unknown>(event: string, handler: (context: string, payload: T) => void): () => void {
|
||||
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
|
||||
const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N])
|
||||
const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as T)
|
||||
set.add(wrapped)
|
||||
this.eventHandlers.set(event, set)
|
||||
return () => {
|
||||
@@ -181,6 +188,7 @@ export class EventServiceClient {
|
||||
}
|
||||
|
||||
private async connectOnce(): Promise<void> {
|
||||
const gen = this.generation
|
||||
if (this.ws) {
|
||||
const old = this.ws
|
||||
this.ws = null
|
||||
@@ -188,7 +196,9 @@ export class EventServiceClient {
|
||||
}
|
||||
|
||||
const token = await this.getToken()
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
const ticket = await this.fetchTicket(token)
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL])
|
||||
@@ -215,9 +225,10 @@ export class EventServiceClient {
|
||||
this.handshakeTimer = null
|
||||
if (this.ws === ws) ws.close(1000, "handshake-timeout")
|
||||
settleReject(new HandshakeTimeoutError())
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}, this.handshakeTimeoutMs)
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
if (this.ws !== ws) return
|
||||
const isReconnect = this.hasConnectedBefore
|
||||
this.connected = true
|
||||
this.hasConnectedBefore = true
|
||||
@@ -231,6 +242,7 @@ export class EventServiceClient {
|
||||
})
|
||||
|
||||
ws.addEventListener("message", (event: MessageEvent) => {
|
||||
if (this.ws !== ws) return
|
||||
this.handleMessage(String(event.data))
|
||||
})
|
||||
|
||||
@@ -240,10 +252,6 @@ export class EventServiceClient {
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
this.clearHandshakeTimer()
|
||||
// A handshake failure always fires `close` after `error`, so we
|
||||
// settle here with a classification based on the close code:
|
||||
// explicit auth/policy codes → fatal; anything else → transient
|
||||
// and the caller (`connect`) will schedule a reconnect.
|
||||
if (!wasConnected) {
|
||||
if (isAuthCloseCode(event.code)) {
|
||||
settleReject(new WebSocketAuthError())
|
||||
@@ -257,23 +265,13 @@ export class EventServiceClient {
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
})
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
// Swallowed: the `close` event fires right after and carries the
|
||||
// close code we need to distinguish auth failures from network
|
||||
// blips. Settling here loses that context.
|
||||
})
|
||||
ws.addEventListener("error", () => {})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a single-use connection ticket. The event-service issues a 30 s ticket
|
||||
* scoped to the bearer JWT; the WebSocket upgrade then consumes it.
|
||||
*
|
||||
* `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only
|
||||
* accepts `http(s)`, so we rewrite the protocol before the HTTP call.
|
||||
*/
|
||||
private async fetchTicket(token: string): Promise<string> {
|
||||
const ctrl = new AbortController()
|
||||
this.tickets.add(ctrl)
|
||||
const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(toHttpBase(this.url) + "/connect-ticket", {
|
||||
@@ -300,6 +298,7 @@ export class EventServiceClient {
|
||||
throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
this.tickets.delete(ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +334,7 @@ export class EventServiceClient {
|
||||
}
|
||||
if (m.type === "error") {
|
||||
console.warn("[Kilo] event-service server error", m)
|
||||
this.onServerError?.(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +370,10 @@ export class EventServiceClient {
|
||||
this.reconnectAttempts++
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
if (this.destroyed) return
|
||||
const gen = this.generation
|
||||
this.connectOnce().catch((err) => {
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
if (this.handleAuthFailure(err)) return
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { type IndexingTelemetryEvent, type VectorStoreSearchResult } from "@kilocode/kilo-indexing/engine"
|
||||
import { toIndexingConfigInput, type IndexingConfig } from "@kilocode/kilo-indexing/config"
|
||||
import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
|
||||
@@ -16,6 +16,7 @@ import { makeRuntime } from "@/effect/run-service"
|
||||
import { registerDisposer } from "@/effect/instance-registry"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Event as IndexingEvent, Warning as IndexingWarningEvent } from "./indexing-event"
|
||||
@@ -31,6 +32,10 @@ const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for
|
||||
const noWorkspace = () =>
|
||||
disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.")
|
||||
|
||||
export const IndexingModelError = NamedError.create("IndexingModelError", {
|
||||
model: Schema.String,
|
||||
})
|
||||
|
||||
const baselineDirectory = Effect.fn("KiloIndexing.baselineDirectory")(function* (dir: string) {
|
||||
if (Instance.project.vcs !== "git") return undefined
|
||||
const checkout = path.resolve(Instance.worktree)
|
||||
@@ -46,8 +51,12 @@ const baselineDirectory = Effect.fn("KiloIndexing.baselineDirectory")(function*
|
||||
})
|
||||
|
||||
function failed(err: unknown): z.infer<typeof IndexingStatus> {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const text = msg.startsWith("Failed to initialize:") ? msg : `Failed to initialize: ${msg}`
|
||||
const base = IndexingModelError.isInstance(err)
|
||||
? `Invalid indexing.model "${err.data.model}"`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
const text = base.startsWith("Failed to initialize:") ? base : `Failed to initialize: ${base}`
|
||||
|
||||
return {
|
||||
state: "Error",
|
||||
@@ -85,14 +94,28 @@ function enrichKilo(input: ReturnType<typeof toIndexingConfigInput>, auth: KiloI
|
||||
}
|
||||
|
||||
async function model(input: ReturnType<typeof toIndexingConfigInput>, auth: KiloIndexingAuth) {
|
||||
if (input.embedderProvider !== "kilo") return input
|
||||
if (input.embedderProvider !== "kilo" || !input.enabled) return input
|
||||
|
||||
const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: auth.baseUrl, token: auth.apiKey })
|
||||
const id = input.modelId ? (catalog.aliases[input.modelId] ?? input.modelId) : catalog.defaultModel
|
||||
const chosen = catalog.models.find((item) => item.id === id)
|
||||
const fallback = catalog.aliases[catalog.defaultModel] ?? catalog.defaultModel
|
||||
const found = chosen ?? catalog.models.find((item) => item.id === fallback)
|
||||
|
||||
if (input.modelId) {
|
||||
const id = catalog.aliases[input.modelId] ?? input.modelId
|
||||
const chosen = catalog.models.find((item) => item.id === id)
|
||||
if (catalog.models.length > 0 && !chosen) {
|
||||
throw new IndexingModelError({ model: input.modelId })
|
||||
}
|
||||
if (chosen) {
|
||||
return {
|
||||
...input,
|
||||
modelId: chosen.id,
|
||||
modelDimension: chosen.dimension,
|
||||
searchMinScore: input.searchMinScore ?? chosen.scoreThreshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = catalog.aliases[catalog.defaultModel] ?? catalog.defaultModel
|
||||
const found = catalog.models.find((item) => item.id === fallback)
|
||||
if (!found) {
|
||||
if (input.modelId || input.modelDimension) {
|
||||
log.warn("ignoring unsupported Kilo embedding model configuration", { model: input.modelId })
|
||||
@@ -100,13 +123,6 @@ async function model(input: ReturnType<typeof toIndexingConfigInput>, auth: Kilo
|
||||
return { ...input, modelId: undefined, modelDimension: undefined }
|
||||
}
|
||||
|
||||
if (input.modelId && !chosen) {
|
||||
log.warn("using default Kilo embedding model instead of unsupported configuration", {
|
||||
model: input.modelId,
|
||||
fallback: found.id,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
modelId: found.id,
|
||||
@@ -266,7 +282,13 @@ export namespace KiloIndexing {
|
||||
const globalConfig = await AppRuntime.runPromise(Config.Service.use((svc) => svc.getGlobal()))
|
||||
const global = globalConfig.indexing
|
||||
const merged = indexingWithKiloDefault({ ...global, ...cfg.indexing }, auth)
|
||||
const cfgInput = await model(enrichKilo(input(merged, global), auth), auth)
|
||||
let cfgInput: Awaited<ReturnType<typeof model>>
|
||||
try {
|
||||
cfgInput = await model(enrichKilo(input(merged, global), auth), auth)
|
||||
} catch (err) {
|
||||
log.warn("indexing model resolution failed", { err })
|
||||
return track(hit, await inert(() => failed(err)))
|
||||
}
|
||||
const workspaces = new Set<WorkspaceV2.ID | undefined>([WorkspaceContext.workspaceID])
|
||||
const box = { status: pending() }
|
||||
const warnings = new Map<string, IndexingWarning>()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path"
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { KilocodePaths } from "@/kilocode/paths"
|
||||
|
||||
@@ -75,6 +76,42 @@ export namespace ConfigProtection {
|
||||
).filter(Boolean)
|
||||
}
|
||||
|
||||
function physical(filepath: string): string | undefined {
|
||||
try {
|
||||
const parts: string[] = []
|
||||
let current = path.resolve(filepath)
|
||||
while (!existsSync(current)) {
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) return
|
||||
parts.unshift(path.basename(current))
|
||||
current = parent
|
||||
}
|
||||
return path.join(realpathSync.native(current), ...parts)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function skillRoot(pattern: string): string | undefined {
|
||||
const dir = pattern.replace(/[\\/]\*$/, "")
|
||||
if (!path.isAbsolute(dir)) return
|
||||
const target = physical(dir)
|
||||
if (!target) return
|
||||
|
||||
const roots = [...configs(), ...KilocodePaths.globalDirs()]
|
||||
for (const root of roots) {
|
||||
for (const name of ["skill", "skills"]) {
|
||||
const skills = physical(path.join(root, name))
|
||||
if (!skills || !within(target, skills) || within(skills, target)) continue
|
||||
const skill = path.relative(skills, target).split(path.sep)[0]
|
||||
if (!skill || /[*?\[\]{}]/.test(skill)) continue
|
||||
const candidate = normalize(path.join(skills, skill))
|
||||
if (/[*?\[\]{}]/.test(candidate)) continue
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fallback(p: string): boolean {
|
||||
if (process.platform !== "win32") return false
|
||||
return keys(p).some(
|
||||
@@ -97,20 +134,37 @@ export namespace ConfigProtection {
|
||||
/** Check if an absolute path is inside a known CLI config directory. */
|
||||
export function isAbsolute(filepath: string): boolean {
|
||||
if (fallback(filepath)) return true
|
||||
const target = physical(filepath)
|
||||
|
||||
// ~/.config/kilo/ (XDG config)
|
||||
for (const dir of configs()) {
|
||||
if (within(filepath, dir)) return true
|
||||
const root = physical(dir)
|
||||
if (within(filepath, dir) || (target && root && within(target, root))) return true
|
||||
}
|
||||
|
||||
// ~/.kilo/ and ~/.kilocode/ (legacy global dirs)
|
||||
for (const dir of KilocodePaths.globalDirs()) {
|
||||
if (within(filepath, dir)) return true
|
||||
const root = physical(dir)
|
||||
if (within(filepath, dir) || (target && root && within(target, root))) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** Return the only persistent rule allowed for one exact global skill subtree. */
|
||||
export function globalSkillPattern(request: { permission: string; patterns: readonly string[] }): string | undefined {
|
||||
if (request.permission !== "external_directory" || request.patterns.length === 0) return
|
||||
|
||||
const roots = request.patterns.map(skillRoot)
|
||||
const first = roots[0]
|
||||
if (!first || roots.some((root) => !root || !within(root, first) || !within(first, root))) return
|
||||
return normalize(path.join(first, "*"))
|
||||
}
|
||||
|
||||
export function isGlobalSkillRequest(request: { permission: string; patterns: readonly string[] }): boolean {
|
||||
return globalSkillPattern(request) !== undefined
|
||||
}
|
||||
|
||||
/** Check a single path (absolute or relative) against config protection. */
|
||||
function protected_(p: string): boolean {
|
||||
return path.isAbsolute(p) ? isAbsolute(p) : isRelative(p)
|
||||
@@ -134,7 +188,8 @@ export namespace ConfigProtection {
|
||||
if (request.metadata?.access === "read") return false
|
||||
for (const pattern of request.patterns) {
|
||||
const dir = pattern.replace(/[\\/]\*$/, "")
|
||||
if (isAbsolute(dir)) return true
|
||||
const target = physical(dir)
|
||||
if (isAbsolute(dir) || (target && isAbsolute(target))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user