Merge remote-tracking branch 'origin/main' into re-disable-pull-10924

This commit is contained in:
Josh Lambert
2026-07-14 08:52:16 -04:00
150 changed files with 7168 additions and 1115 deletions
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-indexing": patch
---
Retry remote embedder validation up to twice on failure
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Task tool subagents out of Agent Manager tabs.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Remember initial session prompts when navigating chat input history with the arrow keys.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---
Report active CLI and VS Code app and session presence.
+6
View File
@@ -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.
+5
View File
@@ -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.
+7 -7
View File
@@ -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=="],
+36 -11
View File
@@ -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: [],
})
})
+7 -2
View File
@@ -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")
})
})
+4 -2
View File
@@ -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/',
]
@@ -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 */
@@ -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))
@@ -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
@@ -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"
}
}
@@ -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
@@ -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 {
@@ -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

@@ -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 }
@@ -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()
}
}
}
@@ -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)
@@ -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) {
+106 -18
View File
@@ -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" }
+29 -19
View File
@@ -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(() => {})
})
}
// ---------------------------------------------------------------------------
@@ -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()
})
+38 -16
View File
@@ -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