Merge pull request #13231 from Kilo-Org/optimize-message-submission-ux-transitions

fix(vscode): stabilize session status dock
This commit is contained in:
Marius
2026-08-19 19:09:55 +02:00
committed by GitHub
29 changed files with 722 additions and 175 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the conversation from shifting when a turn starts or finishes. The working indicator and the session actions now share one row above the composer that keeps its height in both states, instead of resizing two separate areas.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cef414cf59da1f632a7d5d9a2e44f792e7e2ea6b4041bcc214cb32c052f851d8
size 46930
oid sha256:24f8e46671ff667d05a5db36fcd45535040a2587202562a40f8735efa351a1e5
size 46989
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cd6334875ae3c633396b90ffbcbd53e5567668e430c8ca97a4c1cc8bc30a0585
size 17933
oid sha256:b7ecf054c5e5ab8daa2ea0e3ef42e2e720a9dc3673d77058ad7df05751990193
size 17036
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c0a184f1d7c71f9877c4918e93b7e18f2ebe699f802c46846bd60f29459dbc21
size 15864
oid sha256:a2c0fe6b4fefd0925b670b8ffc42acd09eb7ca7bd848d100e7e4a7a85d67a9f5
size 15807
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
size 27159
oid sha256:94377b6b66f1b35803963ce7951f473e44f16ca8463dfc8a2c81f3e816296533
size 27245
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
size 27302
oid sha256:102793cbc80c80c7c889db669016566ceafe86eb37d65b7505a7483d1e883cbd
size 27527
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3
size 29709
oid sha256:cbe5bc3635a41e9aac342c2fcfd6adb1f38e0ce28ea7cf10b9f7297067103673
size 29743
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5a5fdd318bd14100e0c71f7f9055d830db1e59b6af57007608881f433a177ca
size 18703
oid sha256:db7fdb7b9699dc4b668259b61731e3a024c1ed20d8232055681f551889fac086
size 18942
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e5b64674aaceb051ba48bf7db489845f50c8bd7f65a108685dda5243103c735a
size 18477
oid sha256:21097ecf30d0e605f2117c5e98f047db7e65eb43f921a380e7ccade21fc6fc34
size 19594
+11 -4
View File
@@ -73,14 +73,21 @@ function enqueue(job: Job) {
schedule()
}
// When a large batch of diffs becomes near-visible at once, render one diff per
// animation frame. This keeps the UI responsive while preserving expanded state.
const FRAME_BUDGET_MS = 12
// When a batch of diffs becomes near-visible at once, render within a per-frame
// time budget so visible diffs populate smoothly in the same frame while preserving
// 60fps responsiveness for large lists.
function schedule() {
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
const job = queue.shift()
if (job && !job.cancelled) job.run()
const deadline = performance.now() + FRAME_BUDGET_MS
while (queue.length > 0) {
const job = queue.shift()
if (job && !job.cancelled) job.run()
if (performance.now() >= deadline) break
}
if (queue.length > 0) schedule()
})
}
@@ -0,0 +1,159 @@
import { expect, test, type Page } from "@playwright/test"
/**
* The row above the composer swaps the working indicator for the session
* actions when a turn finishes. It used to grow by the actions row while the
* in-transcript indicator placeholder shrank, and the leftover difference
* pushed the conversation text up by a few pixels on every turn boundary.
*
* Measure the real geometry across that swap: the dock height, the transcript
* viewport height, and the on-screen position of the last message all have to
* stay put.
*/
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
const STORY_ID = "chat--chat-view-session-dock-stability"
async function openStory(page: Page) {
await page.setViewportSize({ width: 720, height: 640 })
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
await page.addStyleTag({
content: `*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }`,
})
await page.waitForSelector('[data-component="session-dock"]')
}
async function geometry(page: Page) {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
return page.evaluate(() => {
const dock = document.querySelector('[data-component="session-dock"]')
const list = document.querySelector(".message-list")
if (!(dock instanceof HTMLElement) || !(list instanceof HTMLElement)) throw new Error("dock or transcript missing")
return {
dock: dock.getBoundingClientRect().height,
viewport: list.getBoundingClientRect().height,
transcriptBottom: list.getBoundingClientRect().bottom,
}
})
}
test("session dock keeps the transcript still across the working swap", async ({ page }) => {
await openStory(page)
const idle = await geometry(page)
expect(idle.dock).toBeGreaterThan(0)
await page.getByTestId("toggle-busy").click()
await expect(page.locator(".working-indicator")).toBeVisible()
const working = await geometry(page)
expect(working.dock).toBe(idle.dock)
expect(working.viewport).toBe(idle.viewport)
expect(working.transcriptBottom).toBe(idle.transcriptBottom)
await page.getByTestId("toggle-busy").click()
await expect(page.locator(".new-task-button-wrapper")).toBeVisible()
const back = await geometry(page)
expect(back.dock).toBe(idle.dock)
expect(back.viewport).toBe(idle.viewport)
expect(back.transcriptBottom).toBe(idle.transcriptBottom)
})
test("only one of the two states is visible in the dock", async ({ page }) => {
await openStory(page)
// Both states stay laid out so the row keeps reserving the taller height;
// only visibility changes.
await expect(page.locator('[data-component="session-dock"] .new-task-button-wrapper')).toBeVisible()
await expect(page.locator('[data-component="session-dock"] .working-indicator')).toBeHidden()
await page.getByTestId("toggle-busy").click()
await expect(page.locator('[data-component="session-dock"] .working-indicator')).toBeVisible()
await expect(page.locator('[data-component="session-dock"] .new-task-button-wrapper')).toBeHidden()
})
test("the indicator stays a centered lane on a wide surface", async ({ page }) => {
await openStory(page)
// Agent Manager width: a full-width indicator put the spinner at the far-left
// edge and pinned the elapsed time to the far-right edge.
await page.setViewportSize({ width: 1400, height: 640 })
await page.getByTestId("toggle-busy").click()
await expect(page.locator('[data-component="session-dock"] .working-indicator')).toBeVisible()
const lane = await page.evaluate(() => {
const dock = document.querySelector('[data-component="session-dock"]')
const indicator = document.querySelector(".working-indicator")
if (!(dock instanceof HTMLElement) || !(indicator instanceof HTMLElement)) throw new Error("dock missing")
const d = dock.getBoundingClientRect()
// Measure the painted cluster (spinner, label, counter), not the box around it.
const parts = [...indicator.children].map((el) => el.getBoundingClientRect())
const left = Math.min(...parts.map((p) => p.left))
const right = Math.max(...parts.map((p) => p.right))
return {
dockWidth: d.width,
clusterWidth: right - left,
leftGap: left - d.left,
rightGap: d.right - right,
spread: right - left,
}
})
// The cluster stays compact instead of reaching for both edges of the surface.
expect(lane.clusterWidth).toBeLessThan(lane.dockWidth / 2)
// and sits on the dock's centre axis, like the actions row it replaces.
expect(lane.leftGap).toBeGreaterThan(0)
expect(Math.abs(lane.leftGap - lane.rightGap)).toBeLessThanOrEqual(2)
})
test("the counter keeps its width as it ticks", async ({ page }) => {
await openStory(page)
await page.getByTestId("toggle-busy").click()
const elapsed = page.locator(".working-elapsed")
await expect(elapsed).toBeVisible()
// A one-character growth (9s to 10s) must not reflow the cluster.
const before = await elapsed.evaluate((el) => el.getBoundingClientRect().width)
const wide = await elapsed.evaluate((el) => {
const original = el.textContent
el.textContent = "10s"
const width = el.getBoundingClientRect().width
el.textContent = original
return width
})
expect(wide).toBe(before)
})
test("a wrapped narrow-sidebar actions row is not clipped", async ({ page }) => {
await openStory(page)
// Narrow enough for the container query to wrap the actions row onto a
// second line, which a hard-coded dock height cut off behind the composer.
await page.setViewportSize({ width: 340, height: 640 })
const wrapped = await page.evaluate(() => {
const dock = document.querySelector('[data-component="session-dock"]')
const row = document.querySelector(".session-actions-row")
if (!(dock instanceof HTMLElement) || !(row instanceof HTMLElement)) throw new Error("dock or actions missing")
return {
dock: dock.getBoundingClientRect().height,
row: row.getBoundingClientRect().height,
overflowBelow: row.getBoundingClientRect().bottom - dock.getBoundingClientRect().bottom,
}
})
expect(wrapped.row).toBeGreaterThan(0)
expect(wrapped.dock).toBeGreaterThanOrEqual(wrapped.row)
expect(wrapped.overflowBelow).toBeLessThanOrEqual(0)
// The swap still leaves the transcript untouched at this width.
const idle = await geometry(page)
await page.getByTestId("toggle-busy").click()
await expect(page.locator('[data-component="session-dock"] .working-indicator')).toBeVisible()
const working = await geometry(page)
expect(working.dock).toBe(idle.dock)
expect(working.viewport).toBe(idle.viewport)
expect(working.transcriptBottom).toBe(idle.transcriptBottom)
})
@@ -6,6 +6,7 @@ import {
expandableOpenFiles,
initialOpenFiles,
isDiffExpandable,
reconcileOpenFiles,
sanitizeOpenFiles,
shouldVirtualizeDiff,
toggleOpenFiles,
@@ -148,6 +149,20 @@ describe("agent manager diff state", () => {
expect(toggleOpenFiles(diffs, files)).toEqual([])
})
it("opens newly arriving files while preserving a manual collapse", () => {
const current = [diff({ file: "src/app.ts" }), diff({ file: "src/new.ts" })]
expect(reconcileOpenFiles(current, ["src/app.ts"], ["src/app.ts"])).toEqual({
open: ["src/app.ts", "src/new.ts"],
known: ["src/app.ts", "src/new.ts"],
})
})
it("does not initialize a manual empty snapshot until the first state exists", () => {
const current = [diff({ file: "src/app.ts" })]
expect(reconcileOpenFiles(current, undefined, [])).toEqual({ open: undefined, known: ["src/app.ts"] })
expect(reconcileOpenFiles(current, [], ["src/app.ts"])).toEqual({ open: [], known: ["src/app.ts"] })
})
it("opens images while preventing other non-text diffs from entering open state", () => {
const audio = diff({ file: "audio/alert.wav", summarized: false, additions: 0 })
const image = diff({ file: "assets/banner.png", kind: "image", summarized: true, additions: 0 })
@@ -612,6 +612,29 @@ describe("Cloud import parts cleanup contract", () => {
})
})
describe("Optimistic parts preservation and smooth status contract", () => {
const source = readFile(SESSION_FILE)
it("handleMessageCreated preserves optimistic parts instead of deleting them", () => {
const created = extractFunctionBody(source, "handleMessageCreated")
expect(created).not.toMatch(/delete\s+p\[message\.id\]/)
expect(created).toContain("pendingOptimistic.get(message.sessionID)")
})
it("handlePartUpdated replaces matching optimistic parts in place", () => {
const updated = extractFunctionBody(source, "handlePartUpdated")
expect(updated).toContain("optimisticParts.get(effectiveMessageID)")
expect(updated).toContain("mergeOptimisticPart")
})
it("statusText derives status from the active turn instead of queued follow-ups", () => {
const match = source.match(/const statusText = createMemo<string \| undefined>\(\(\) => \{([\s\S]*?)\n \}\)/)
expect(match).not.toBeNull()
expect(match![1]).toContain("activeUserMessageID(msgs, statusInfo()")
expect(match![1]).toContain('language.t("ui.sessionTurn.status.thinking")')
})
})
describe("KiloConnectionService pruneSession contract", () => {
const source = readFile(CONNECTION_SERVICE_FILE)
@@ -0,0 +1,101 @@
/**
* Session dock layout contract.
*
* The row between the transcript and the composer used to be two things at
* once: the working indicator lived inside the scrollable transcript (with a
* 10px placeholder left behind when it disappeared) and the session actions
* were a separate block in the composer column. Both changed height when a turn
* ended, the two offsets did not cancel out, and the conversation text jumped a
* few pixels every time.
*
* The invariant now: one dock, one decision point, one fixed height.
*/
import { describe, expect, it } from "bun:test"
import fs from "node:fs"
import path from "node:path"
import { showsWorking } from "../../webview-ui/src/components/shared/working-indicator-utils"
const ROOT = path.resolve(import.meta.dir, "../..")
function read(file: string): string {
return fs.readFileSync(path.join(ROOT, file), "utf-8")
}
describe("showsWorking", () => {
it("shows the indicator for active backend statuses", () => {
expect(showsWorking("busy", false, false)).toBe(true)
expect(showsWorking("retry", false, false)).toBe(true)
expect(showsWorking("offline", false, false)).toBe(true)
})
it("shows the indicator for a submission before backend status arrives", () => {
expect(showsWorking("idle", true, false)).toBe(true)
})
it("yields the row to the session actions once idle", () => {
expect(showsWorking("idle", false, false)).toBe(false)
})
it("stays hidden while another surface owns the interaction", () => {
expect(showsWorking("busy", false, true)).toBe(false)
expect(showsWorking("idle", true, true)).toBe(false)
})
})
describe("session dock layout", () => {
it("stacks both states in one grid cell so the row measures the taller one", () => {
const css = read("webview-ui/src/styles/chat-layout.css")
const dock = css.match(/\.session-dock \{([\s\S]*?)\}/)
const state = css.match(/\.session-dock-state \{([\s\S]*?)\}/)
expect(dock).not.toBeNull()
expect(state).not.toBeNull()
expect(dock![1]).toContain("display: grid")
expect(state![1]).toContain("grid-area: 1 / 1")
// A hard-coded height clipped the actions row once it wrapped to a second
// line in a narrow sidebar, so the row must size itself.
expect(dock![1]).not.toMatch(/^\s*height:/m)
expect(dock![1]).not.toContain("overflow: hidden")
})
it("hides the inactive state instead of unmounting it", () => {
const css = read("webview-ui/src/styles/chat-layout.css")
const hidden = css.match(/\.session-dock-state:not\(\[data-active\]\) \{([\s\S]*?)\}/)
expect(hidden).not.toBeNull()
// display:none would stop reserving the height and bring the shift back.
expect(hidden![1]).toContain("visibility: hidden")
expect(hidden![1]).toContain("pointer-events: none")
expect(hidden![1]).not.toContain("display: none")
})
it("keeps the reserved actions row independent of the turn lifecycle", () => {
const view = read("webview-ui/src/components/chat/ChatView.tsx")
const fork = view.match(/const canFork = \(hasChat: boolean\) =>([^\n]*)/)
expect(fork).not.toBeNull()
// A button that came and went with the turn would resize the hidden row.
expect(fork![1]).not.toContain('session.status() === "idle"')
expect(view).toContain("session.messages().length > 0 || session.submitting()")
})
it("drops the placeholder that used to offset the session actions", () => {
const css = read("webview-ui/src/styles/chat-layout.css")
expect(css).not.toContain("working-indicator-slot")
expect(read("webview-ui/src/components/shared/WorkingIndicator.tsx")).not.toContain("working-indicator-slot")
})
it("keeps the composer column as the only owner of the row", () => {
// A second copy inside the scrollable transcript would resize the scroll
// content on every turn boundary again.
expect(read("webview-ui/src/components/chat/MessageList.tsx")).not.toContain("WorkingIndicator")
expect(read("webview-ui/src/components/chat/ChatView.tsx")).toContain("<SessionDock")
})
it("routes both states through the dock so neither can claim the row alone", () => {
const dock = read("webview-ui/src/components/chat/SessionDock.tsx")
expect(dock).toContain("showsWorking(session.status(), session.submitting()")
expect(dock).toContain('data-active={working() ? "" : undefined}')
expect(dock).toContain('data-active={actions() ? "" : undefined}')
// The indicator must not re-decide its own visibility.
expect(read("webview-ui/src/components/shared/WorkingIndicator.tsx")).not.toContain("session.submitting() ||")
})
})
@@ -101,11 +101,54 @@ describe("queuedUserMessageIDs", () => {
expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_3"])
})
it("returns no queued messages while idle", () => {
it("returns no queued messages while idle without submitting flag", () => {
const messages = [user("message_1"), user("message_2")]
expect(queuedUserMessageIDs(messages, { type: "idle" })).toEqual([])
})
it("queues follow-ups while idle when submitting is true", () => {
const messages = [user("message_1"), user("message_2")]
expect(queuedUserMessageIDs(messages, { type: "idle" }, undefined, true)).toEqual(["message_2"])
})
})
describe("activeUserMessageID", () => {
it("returns undefined while idle and not submitting", () => {
const messages = [user("message_1")]
expect(activeUserMessageID(messages, { type: "idle" })).toBeUndefined()
})
it("returns the pending user message while idle when submitting is true", () => {
const messages = [user("message_1")]
expect(activeUserMessageID(messages, { type: "idle" }, undefined, true)).toBe("message_1")
})
it("returns the next pending user message after finished turn when submitting is true", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", { finish: "stop", time: { created: 1, completed: 2 } }),
user("message_3"),
]
expect(activeUserMessageID(messages, { type: "idle" }, undefined, true)).toBe("message_3")
})
})
describe("active queued status boundary", () => {
it("keeps the active assistant status visible when a follow-up is queued", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", { finish: "tool-calls" }),
user("message_3"),
]
expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1")
expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_3"])
})
})
describe("partitionTurns", () => {
@@ -77,7 +77,11 @@ async function settle(page: Page) {
// Sandboxing rows can settle at different scroll heights after settings context updates.
// Side terminal tabs mount live xterm instances whose websocket error text
// lands at indeterminate times.
// The session-dock stability story exists to measure geometry across the
// working/idle swap and carries a debug toggle button, so it is not a meaningful
// appearance baseline.
const SKIP = new Set<string>([
"chat--chat-view-session-dock-stability",
"agentmanager--worktree-item-busy",
"agentmanager--full-screen-diff-agent-edit-scroll",
"agentmanager--side-terminal-panel-tabs",
@@ -51,6 +51,7 @@ import {
initialOpenFiles,
isDiffExpandable,
isLargeDiffFile,
reconcileOpenFiles,
sanitizeOpenFiles,
shouldVirtualizeDiff,
toggleOpenFiles,
@@ -132,7 +133,37 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [open, setOpen] = createSignal<string[]>([])
const [manualOpen, setManualOpen] = createSignal<Record<string, string[]>>({})
const [knownFiles, setKnownFiles] = createSignal<Record<string, string[]>>({})
const open = createMemo(() => {
const key = props.sessionKey ?? ""
const diffs = props.diffs
if (diffs.length === 0) return []
const manual = manualOpen()[key]
if (manual) return sanitizeOpenFiles(diffs, manual)
return initialOpenFiles(diffs)
})
createEffect(
on(
() => [props.sessionKey, props.diffs] as const,
([key, diffs]) => {
if (diffs.length === 0) return
const id = key ?? ""
const manual = manualOpen()[id]
const result = reconcileOpenFiles(diffs, manual, knownFiles()[id] ?? [])
setKnownFiles((prev) => ({ ...prev, [id]: result.known }))
if (!manual || !result.open) return
if (result.open.length === manual.length && result.open.every((file, index) => file === manual[index])) return
setManualOpen((prev) => ({ ...prev, [id]: result.open! }))
},
),
)
const setOpen = (files: string[] | ((prev: string[]) => string[])) => {
const key = props.sessionKey ?? ""
const current = open()
const next = typeof files === "function" ? files(current) : files
setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) }))
}
const [draft, setDraft] = createSignal<ReviewDraft | null>(reviewComposerDraft(composer()))
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
const speechKeys = createMemo(() => {
@@ -151,10 +182,6 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
keys: speechKeys,
})
let nextId = 0
// Initialize each worktree with every file expanded, then preserve manual
// collapse state while adding and removing files from live summaries.
let initializedKey: string | undefined
let known = new Set<string>()
// Reorder diffs to match the file-tree's depth-first visual order so
// scrolling through the accordion matches the tree grouping.
@@ -217,43 +244,6 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
focusRoot()
}
// Unified open-state effect: tracks both sessionKey and diffs in a single effect
// to eliminate the race condition between the old separate sessionKey-reset and
// diffs-watch effects. Uses the session key to decide when initialization is needed
// vs when we just prune stale entries from the open list.
createEffect(
on(
() => [props.sessionKey, props.diffs] as const,
([key, diffs]) => {
// No diffs yet (async fetch in progress) — don't mark as initialized
// so auto-open runs when data arrives.
// Important: do not prune on empty, otherwise transient empty updates
// collapse all files and they stay collapsed for the same key.
if (diffs.length === 0) return
const fileSet = new Set(diffs.map((diff) => diff.file))
// New context: initialize open state from the diff policy.
if (key !== initializedKey) {
initializedKey = key
known = fileSet
setOpen(initialOpenFiles(diffs))
return
}
// Preserve manual collapse state for known files, while keeping newly
// arriving files expanded when a live summary grows.
const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file)
known = fileSet
setOpen((prev) => {
const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added])
if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev
return next
})
},
),
)
createEffect(
on(
() => props.sessionKey,
@@ -53,6 +53,7 @@ import {
initialOpenFiles,
isDiffExpandable,
isLargeDiffFile,
reconcileOpenFiles,
sanitizeOpenFiles,
shouldVirtualizeDiff,
toggleOpenFiles,
@@ -134,7 +135,52 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [open, setOpen] = createSignal<string[]>([])
const [manualOpen, setManualOpen] = createSignal<Record<string, string[]>>({})
const [knownFiles, setKnownFiles] = createSignal<Record<string, string[]>>({})
const open = createMemo(() => {
const key = props.sessionKey ?? ""
const diffs = props.diffs
if (diffs.length === 0) return []
const manual = manualOpen()[key]
if (manual) return sanitizeOpenFiles(diffs, manual)
return initialOpenFiles(diffs)
})
createEffect(
on(
() => [props.sessionKey, props.diffs] as const,
([key, diffs]) => {
if (diffs.length === 0) return
const id = key ?? ""
const manual = manualOpen()[id]
const result = reconcileOpenFiles(diffs, manual, knownFiles()[id] ?? [])
setKnownFiles((prev) => ({ ...prev, [id]: result.known }))
if (!manual || !result.open) return
if (result.open.length === manual.length && result.open.every((file, index) => file === manual[index])) return
setManualOpen((prev) => ({ ...prev, [id]: result.open! }))
},
),
)
const setOpen = (files: string[] | ((prev: string[]) => string[])) => {
const key = props.sessionKey ?? ""
const current = open()
const next = typeof files === "function" ? files(current) : files
setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) }))
}
const [manualActiveFile, setManualActiveFile] = createSignal<Record<string, string | null>>({})
const activeFile = createMemo(() => {
const key = props.sessionKey ?? ""
const diffs = props.diffs
if (diffs.length === 0) return null
const manual = manualActiveFile()[key]
if (manual && diffs.some((d) => d.file === manual)) return manual
return diffs[0]?.file ?? null
})
const setActiveFile = (file: string | null) => {
const key = props.sessionKey ?? ""
setManualActiveFile((prev) => ({ ...prev, [key]: file }))
}
const [draft, setDraft] = createSignal<ReviewDraft | null>(reviewComposerDraft(composer()))
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
const speechKeys = createMemo(() => {
@@ -152,15 +198,10 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
label: t,
keys: speechKeys,
})
const [activeFile, setActiveFile] = createSignal<string | null>(null)
const [treeWidth, setTreeWidth] = createSignal(240)
let nextId = 0
let draftMeta: AnnotationMeta | null = composer().draft
let editMeta: AnnotationMeta | null = composer().edit
// Initialize each worktree with every file expanded, then preserve manual
// collapse state while adding and removing files from live summaries.
let initializedKey: string | undefined
let known = new Set<string>()
let rootRef: HTMLDivElement | undefined
const [scroller, setScroller] = createSignal<HTMLDivElement>()
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
@@ -214,50 +255,6 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
focusRoot()
}
// Unified open-state effect: tracks both sessionKey and diffs in a single effect
// to eliminate the race condition between the old separate sessionKey-reset and
// diffs-watch effects. Uses the session key to decide when initialization is needed
// vs when we just prune stale entries from the open list.
createEffect(
on(
() => [props.sessionKey, props.diffs] as const,
([key, diffs]) => {
if (diffs.length === 0) {
// No diffs yet — clear active file only for a new key; keep current
// selection for transient empty updates in the same key.
if (key !== initializedKey) setActiveFile(null)
return
}
const fileSet = new Set(diffs.map((diff) => diff.file))
// Keep active file in sync — pick first if current is stale
const current = activeFile()
if (!current || !diffs.some((d) => d.file === current)) {
setActiveFile(diffs[0]!.file)
}
// New context: initialize open state from the diff policy.
if (key !== initializedKey) {
initializedKey = key
known = fileSet
setOpen(initialOpenFiles(diffs))
return
}
// Preserve manual collapse state for known files, while keeping newly
// arriving files expanded when a live summary grows.
const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file)
known = fileSet
setOpen((prev) => {
const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added])
if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev
return next
})
},
),
)
createEffect(
on(
() => props.sessionKey,
@@ -30,6 +30,18 @@ export function initialOpenFiles(diffs: WorktreeFileDiff[]): string[] {
return diffs.filter((diff) => diff.kind !== "image" && isDiffExpandable(diff)).map((diff) => diff.file)
}
export function reconcileOpenFiles(
diffs: WorktreeFileDiff[],
manual: string[] | undefined,
known: string[] = [],
): { open: string[] | undefined; known: string[] } {
const files = expandableOpenFiles(diffs)
if (!manual) return { open: undefined, known: files }
const previous = new Set(known)
const added = files.filter((file) => !previous.has(file))
return { open: sanitizeOpenFiles(diffs, [...manual, ...added]), known: files }
}
export function allOpenFiles(diffs: WorktreeFileDiff[], open: string[]): boolean {
const targets = expandableOpenFiles(diffs)
if (targets.length === 0) return false
@@ -16,6 +16,7 @@ import { TaskHeader } from "./TaskHeader"
import { MessageList } from "./MessageList"
import { PromptInput } from "./PromptInput"
import { PermissionDock } from "./PermissionDock"
import { SessionDock } from "./SessionDock"
import { StartupErrorBanner } from "./StartupErrorBanner"
import { SessionTabStrip } from "./SessionTabStrip"
import { useSession } from "../../context/session"
@@ -60,8 +61,9 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const canContinueInWorktree = () => props.continueInWorktree === true
const id = () => session.currentSessionID()
const hasMessages = () => session.messages().length > 0
const idle = () => session.status() !== "busy"
// Counts the in-flight first message too, so the dock reserves the same row on
// the very first send instead of growing once the message lands.
const hasMessages = () => session.messages().length > 0 || session.submitting()
// "Continue in Worktree" state
const [transferring, setTransferring] = createSignal(false)
@@ -89,7 +91,11 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const suggesting = () => isSuggesting(blocked(), familySuggestions().length)
// Session is busy only because a question tool call is pending — prompt should behave as idle
const questioning = () => isQuestioning(blocked(), familyQuestions().length)
const dock = () => !props.readonly || !!permissionRequest()
const dock = () => !props.readonly || !!permissionRequest() || session.submitting() || session.status() !== "idle"
// The session dock stays empty while another surface owns the interaction:
// a permission card, a pending question or suggestion, or agent requirements.
// A spinner there would claim the agent is working while it waits on the user.
const dockBlocked = () => blocked() || familyQuestions().length > 0 || familySuggestions().length > 0
onMount(() => {
if (props.readonly) return
@@ -200,7 +206,11 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const canStartSession = (hasChat: boolean) => hasChat
const canFork = (hasChat: boolean) => hasChat && !isSidebar() && session.status() === "idle" && !!props.onForkSession
// Deliberately status-independent: the dock reserves this row's height even
// while the working indicator covers it, so a button that came and went with
// the turn would resize the row and shift the transcript. The row is hidden
// and non-interactive while a turn runs.
const canFork = (hasChat: boolean) => hasChat && !isSidebar() && !!props.onForkSession
const canStartWorktree = () => isSidebar() && server.gitInstalled()
@@ -367,9 +377,11 @@ export const ChatView: Component<ChatViewProps> = (props) => {
/>
)}
</Show>
<Show when={!props.readonly && idle() && !blocked() && hasActions(hasMessages())}>
{renderActions(hasMessages())}
</Show>
<SessionDock
blocked={dockBlocked()}
hasActions={() => !props.readonly && hasActions(hasMessages())}
actions={() => renderActions(hasMessages())}
/>
<Show when={!props.readonly}>
<PromptInput
blocked={blocked}
@@ -36,7 +36,6 @@ import type { ErrorDisplayProps } from "./ErrorDisplay"
import { RevertBanner } from "./RevertBanner"
import { AccountSwitcher } from "../shared/AccountSwitcher"
import { KiloNotifications } from "./KiloNotifications"
import { WorkingIndicator } from "../shared/WorkingIndicator"
import { TurnOutcome } from "../shared/TurnOutcome"
import { QuestionDock } from "./QuestionDock"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
@@ -179,10 +178,23 @@ export const MessageList: Component<MessageListProps> = (props) => {
const isEmpty = () => turns().length === 0 && !session.loading() && !revert()
const activeUserID = createMemo(() =>
getActiveUserMessageID(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id)),
getActiveUserMessageID(
session.messages(),
session.statusInfo(),
(msg) => session.getParts(msg.id),
session.submitting(),
),
)
const queuedIDs = createMemo(
() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))),
() =>
new Set(
queuedUserMessageIDs(
session.messages(),
session.statusInfo(),
(msg) => session.getParts(msg.id),
session.submitting(),
),
),
)
const rows = createMemo((prev: TranscriptRow[] | undefined) => {
const active = activeUserID()
@@ -1351,7 +1363,6 @@ export const MessageList: Component<MessageListProps> = (props) => {
/>
)}
</For>
<WorkingIndicator />
<TurnOutcome />
<For each={props.questions?.()}>{(req) => <QuestionDock request={req} />}</For>
<For each={props.suggestions?.()}>{(req) => <SuggestBar request={req} />}</For>
@@ -0,0 +1,49 @@
/** @jsxImportSource solid-js */
/**
* SessionDock component
*
* One row between the transcript and the composer. It shows the working
* indicator while a turn runs, the session actions (New Session, Fork Session,
* Move to Worktree, changes) once it finishes, and nothing while a permission,
* question, or requirement surface owns the interaction.
*
* The transcript viewport is whatever is left above the composer, so a row that
* grew when the actions appeared shifted the visible conversation by its own
* height. Both states are therefore always laid out, stacked in one grid cell,
* and only the active one is visible. The row measures the taller state at the
* current width, which also keeps the wrapped narrow-sidebar actions row from
* being clipped.
*/
import { type Component, type JSX } from "solid-js"
import { useSession } from "../../context/session"
import { WorkingIndicator } from "../shared/WorkingIndicator"
import { showsWorking } from "../shared/working-indicator-utils"
interface SessionDockProps {
/** Idle-state content. Renders nothing when no action applies. */
actions?: () => JSX.Element
/** Whether idle-state content exists for this surface. */
hasActions?: () => boolean
/** True while a permission, question, suggestion, or requirement owns the row. */
blocked?: boolean
}
export const SessionDock: Component<SessionDockProps> = (props) => {
const session = useSession()
const working = () => showsWorking(session.status(), session.submitting(), !!props.blocked)
const actions = () => !working() && !props.blocked && (props.hasActions?.() ?? false)
const active = () => working() || actions()
return (
<div class="session-dock" data-component="session-dock" data-active={active() ? "" : undefined}>
<div class="session-dock-state" data-active={working() ? "" : undefined} aria-hidden={!working()}>
<WorkingIndicator />
</div>
<div class="session-dock-state" data-active={actions() ? "" : undefined} aria-hidden={!actions()}>
{props.actions?.()}
</div>
</div>
)
}
@@ -2,6 +2,10 @@
* WorkingIndicator component
* Shows a spinner, status text, and elapsed time counter while the agent is active.
* Matches the v1.0.25 working indicator UX.
*
* Purely visual: `SessionDock` decides when this renders (see `showsWorking`).
* Keeping the decision in one place is what stops the dock from resizing when
* a turn starts or ends.
*/
import { type Component, Show, createSignal, createEffect, onCleanup } from "solid-js"
@@ -78,16 +82,6 @@ export const WorkingIndicator: Component = () => {
return `${m}m ${rem}s`
}
const blocked = () => {
const id = session.currentSessionID()
const perms = session
.permissions()
.filter((p) => p.sessionID === id && !(p.tool && ["todowrite", "todoread"].includes(p.toolName)))
const questions = session.questions().filter((q) => q.sessionID === id)
const suggestions = session.suggestions().filter((s) => s.sessionID === id)
return perms.length > 0 || questions.length > 0 || suggestions.length > 0
}
const isRetrying = () => session.statusInfo().type === "retry"
const handleCancelRetry = () => {
@@ -98,26 +92,22 @@ export const WorkingIndicator: Component = () => {
}
return (
<div class="working-indicator-slot">
<Show when={session.submitting() || (session.status() !== "idle" && !blocked())}>
<div class="working-indicator">
<Spinner />
<span class="working-text">{statusText()}</span>
<Show when={elapsed() > 0}>
<span class="working-elapsed">{formatElapsed()}</span>
</Show>
<Show when={isRetrying()}>
<Button
variant="secondary"
size="small"
onClick={handleCancelRetry}
class="working-cancel"
style={{ "font-weight": "600", color: "var(--vscode-errorForeground, #f85149)" }}
>
{language.t("ui.sessionTurn.cancel") || "Cancel"}
</Button>
</Show>
</div>
<div class="working-indicator">
<Spinner />
<span class="working-text">{statusText()}</span>
<Show when={elapsed() > 0}>
<span class="working-elapsed">{formatElapsed()}</span>
</Show>
<Show when={isRetrying()}>
<Button
variant="secondary"
size="small"
onClick={handleCancelRetry}
class="working-cancel"
style={{ "font-weight": "600", color: "var(--vscode-errorForeground, #f85149)" }}
>
{language.t("ui.sessionTurn.cancel") || "Cancel"}
</Button>
</Show>
</div>
)
@@ -3,3 +3,17 @@ import type { SessionStatus } from "../../types/messages"
export function tracksElapsed(status: SessionStatus, submitting: boolean, since: number | undefined): since is number {
return since !== undefined && (status !== "idle" || submitting)
}
/**
* Whether the session dock shows the working indicator instead of the idle
* session actions. Retry and offline count as working: their countdown and
* Cancel action belong in the dock, not beside "New Session".
*
* This is the dock's single decision point. The indicator itself renders
* unconditionally, so the two states can never both claim the row (or both
* stay empty) and change the dock's height.
*/
export function showsWorking(status: SessionStatus, submitting: boolean, blocked: boolean): boolean {
if (blocked) return false
return submitting || status !== "idle"
}
@@ -198,8 +198,9 @@ export function activeUserMessageID(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
submitting?: boolean,
) {
if (status.type === "idle") return undefined
if (status.type === "idle" && !submitting) return undefined
const id = active(messages, status, parts)
if (id) return id
return pending(messages, parts)
@@ -209,8 +210,9 @@ export function queuedUserMessageIDs(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
submitting?: boolean,
) {
if (status.type === "idle") return []
if (status.type === "idle" && !submitting) return []
const users = messages.filter((msg) => msg.role === "user")
const running = active(messages, status, parts)
if (running) {
@@ -84,7 +84,7 @@ import { sessionVariantKeys, transferVariants, variantKey } from "./session-vari
import { createSessionVariants } from "./session-variants"
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
import { activeUserMessageID, visibleMessages as filterVisibleMessages } from "./session-queue"
import { clearSessionDraftDiscarded, deleteDraftsForSession } from "../utils/draft-store"
import { createAbortState } from "./abort-state"
import { clearIfOn, createCloudPrune } from "./session-cloud-prune"
@@ -1625,11 +1625,12 @@ export const SessionProvider: ParentComponent = (props) => {
parts[effectiveMessageID] = []
}
const existingIndex = parts[effectiveMessageID].findIndex((p) => p.id === part.id)
const list = parts[effectiveMessageID]
const existingIndex = list.findIndex((p) => p.id === part.id)
if (existingIndex >= 0) {
// Update existing part
const existing = parts[effectiveMessageID][existingIndex]
const existing = list[existingIndex]
if (
delta?.type === "text-delta" &&
delta.textDelta &&
@@ -1648,7 +1649,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
} else {
// Add new part
parts[effectiveMessageID].push(part)
list.push(part)
}
}),
)
@@ -2211,19 +2212,24 @@ export const SessionProvider: ParentComponent = (props) => {
pendingOptimistic.set(sid, pending)
const parts: Part[] = []
const partIds = new Set<string>()
if (text) {
const partId = Identifier.ascending("part")
partIds.add(partId)
parts.push({
type: "text" as const,
id: Identifier.ascending("part"),
id: partId,
messageID,
text,
metadata: review ? reviewMetadata(review) : undefined,
})
}
for (const file of files ?? []) {
const partId = Identifier.ascending("part")
partIds.add(partId)
parts.push({
type: "file" as const,
id: Identifier.ascending("part"),
id: partId,
messageID,
mime: file.mime,
url: file.url,
@@ -2231,7 +2237,6 @@ export const SessionProvider: ParentComponent = (props) => {
source: file.source,
})
}
setStore("messages", sid, (msgs = []) => [...msgs, temp])
setStore("parts", messageID, parts)
if (parts.length > 0) optimisticParts.set(messageID, new Set(parts.map((part) => part.id)))
@@ -2901,16 +2906,23 @@ export const SessionProvider: ParentComponent = (props) => {
return buildCostBreakdown(id, costs, familyLabels(), language.t("context.stats.thisSession"))
})
// Status text derived from last assistant message parts
// Status text derived from current turn's assistant message parts
const statusText = createMemo<string | undefined>(() => {
if (status() === "idle") return undefined
const thinking = language.t("ui.sessionTurn.status.thinking")
const fallback = language.t("ui.sessionTurn.status.consideringNextSteps")
const id = currentSessionID()
const msgs = messages()
for (let i = msgs.length - 1; i >= 0; i--) {
const activeID = activeUserMessageID(msgs, statusInfo(), (msg) => getParts(msg.id), submitting())
const activeIdx = activeID
? msgs.findIndex((msg) => msg.id === activeID)
: msgs.findLastIndex((m) => m.role === "user")
if (activeIdx < 0) return thinking
for (let i = msgs.length - 1; i > activeIdx; i--) {
if (msgs[i].role !== "assistant") continue
const parts = getParts(msgs[i].id)
if (parts.length === 0) break
if (parts.length === 0) return thinking
const raw = computeStatus(parts[parts.length - 1], language.t) ?? fallback
// When delegating to a subagent and that subagent is blocked on a prompt,
// replace the generic "Delegating work" label with a more informative one
@@ -2923,7 +2935,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
return raw
}
return fallback
return thinking
})
const modelUsage = createMemo<SessionModelUsage | undefined>(() => {
@@ -189,6 +189,46 @@ export const ChatViewAgentManagerCompleted: Story = {
},
}
/**
* The session dock swaps the working indicator for the session actions when a
* turn finishes. Toggling `busy` here drives that swap inside one mounted view
* so a test can assert the transcript viewport keeps its exact height.
*/
export const ChatViewSessionDockStability: Story = {
name: "ChatView — session dock keeps its height",
render: () => {
const [busy, setBusy] = createSignal(false)
const status = () => (busy() ? "busy" : "idle")
const session = {
...mockSessionValue({ id: SESSION_ID, status: "idle", closeReason: "completed" }),
status,
statusInfo: () => ({ type: status() }),
statusText: () => (busy() ? "Thinking…" : undefined),
busySince: () => (busy() ? Date.now() - 2000 : undefined),
submitting: () => busy(),
isSubmitting: () => busy(),
messages: () => [{ id: "msg-001" }] as any[],
worktreeStats: () => ({ files: 2, additions: 164, deletions: 111 }),
}
return (
<StoryProviders sessionID={SESSION_ID} status="idle" noPadding>
<ServerContext.Provider value={mockServer as any}>
<SessionContext.Provider value={session as any}>
<WorktreeModeProvider>
<div style={{ height: "320px", display: "flex", "flex-direction": "column" }}>
<button data-testid="toggle-busy" onClick={() => setBusy(!busy())}>
toggle busy
</button>
<ChatView onForkSession={() => undefined} continueInWorktree />
</div>
</WorktreeModeProvider>
</SessionContext.Provider>
</ServerContext.Provider>
</StoryProviders>
)
},
}
export const UserMessageReviewComments: Story = {
name: "User message — interactive review comments",
render: () => {
@@ -185,6 +185,50 @@
background: var(--vscode-button-hoverBackground);
}
/* ============================================
Session Dock
============================================ */
/* The single row between the transcript and the composer. Both states occupy the
same grid cell and only one is visible, so the row is always exactly as tall
as the taller state at the current width. Swapping the working indicator for
the session actions therefore never resizes the transcript viewport and never
shifts the conversation text.
The height cannot be a fixed pixel value: the actions row wraps to a second
line in a narrow sidebar (see the container query in session-actions.css), and
a hard-coded height clipped it behind the composer. */
.session-dock {
/* Tracks the natural width of the actions row (measured at 434px: New Session,
Fork Session, Move to Worktree, and the diff stats), so the two dock states
read as one centered component on wide surfaces. Deliberately px and not ch:
a font-relative lane changes width with the webview font metrics. */
--session-dock-lane: 440px;
display: grid;
box-sizing: border-box;
align-items: center;
flex-shrink: 0;
}
.session-dock:not([data-active]) {
display: none;
}
.session-dock-state {
display: flex;
grid-area: 1 / 1;
align-items: center;
width: 100%;
min-width: 0;
}
/* Hidden rather than unmounted, so the inactive state keeps reserving its height
and the row cannot resize when the two swap. */
.session-dock-state:not([data-active]) {
visibility: hidden;
pointer-events: none;
}
/* ============================================
Working Indicator
============================================ */
@@ -192,28 +236,40 @@
.working-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
font-size: var(--kilo-font-size-12);
color: var(--vscode-descriptionForeground);
}
.working-indicator-slot {
/* Offset the session actions appearing when work finishes. */
min-height: 10px;
flex-shrink: 0;
/* On a wide surface (Agent Manager) the dock spans the whole composer. The lane
caps how far a long status label may stretch before it ellipsizes, so the
indicator stays a centered cluster on the same axis as the actions row instead
of reaching for both edges. */
.session-dock-state > .working-indicator {
width: 100%;
max-width: var(--session-dock-lane);
margin-inline: auto;
}
/* Sized to its text rather than stretched, so the spinner, the label, and the
counter stay one cluster. Stretching it pushed the counter onto the far edge of
whatever surface the dock spanned. */
.working-text {
flex: 1;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Reserving the width keeps the cluster still while the counter ticks: tabular
digits alone still reflow when the value gains a character (9s to 10s). */
.working-elapsed {
flex-shrink: 0;
min-width: 3.5ch;
text-align: right;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
@@ -331,7 +387,6 @@
.chat-view .message-list-content > .revert-banner,
.chat-view .message-list-content > [data-component="question-dock"],
.chat-view .message-list-content > .working-indicator-slot,
.chat-view .message-list-content > .vscode-session-turn[role="status"],
.chat-view .message-list-content > [data-component="suggest-bar"] {
width: calc(100% - 8px);
@@ -340,7 +395,7 @@
}
.chat-view .prompt-input-container,
.chat-view .new-task-button-wrapper {
.chat-view .session-dock {
box-sizing: border-box;
width: min(
calc(100% - var(--chat-gutter) - var(--chat-gutter) - var(--chat-scrollbar-width) - 8px),
@@ -8,7 +8,13 @@
width: 100%;
}
.chat-input > .new-task-button-wrapper + .prompt-input-container {
/* Inside the dock the row is a flex item. Keep it full width so the container
query below still measures the dock instead of the buttons' own width. */
.session-dock-state > .new-task-button-wrapper {
width: 100%;
}
.chat-input > .session-dock + .prompt-input-container {
margin-top: 0;
}