mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 04:46:43 +08:00
Merge pull request #13231 from Kilo-Org/optimize-message-submission-ux-transitions
fix(vscode): stabilize session status dock
This commit is contained in:
@@ -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.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cef414cf59da1f632a7d5d9a2e44f792e7e2ea6b4041bcc214cb32c052f851d8
|
||||
size 46930
|
||||
oid sha256:24f8e46671ff667d05a5db36fcd45535040a2587202562a40f8735efa351a1e5
|
||||
size 46989
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cd6334875ae3c633396b90ffbcbd53e5567668e430c8ca97a4c1cc8bc30a0585
|
||||
size 17933
|
||||
oid sha256:b7ecf054c5e5ab8daa2ea0e3ef42e2e720a9dc3673d77058ad7df05751990193
|
||||
size 17036
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c0a184f1d7c71f9877c4918e93b7e18f2ebe699f802c46846bd60f29459dbc21
|
||||
size 15864
|
||||
oid sha256:a2c0fe6b4fefd0925b670b8ffc42acd09eb7ca7bd848d100e7e4a7a85d67a9f5
|
||||
size 15807
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
|
||||
size 27159
|
||||
oid sha256:94377b6b66f1b35803963ce7951f473e44f16ca8463dfc8a2c81f3e816296533
|
||||
size 27245
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
|
||||
size 27302
|
||||
oid sha256:102793cbc80c80c7c889db669016566ceafe86eb37d65b7505a7483d1e883cbd
|
||||
size 27527
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3
|
||||
size 29709
|
||||
oid sha256:cbe5bc3635a41e9aac342c2fcfd6adb1f38e0ce28ea7cf10b9f7297067103673
|
||||
size 29743
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d5a5fdd318bd14100e0c71f7f9055d830db1e59b6af57007608881f433a177ca
|
||||
size 18703
|
||||
oid sha256:db7fdb7b9699dc4b668259b61731e3a024c1ed20d8232055681f551889fac086
|
||||
size 18942
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e5b64674aaceb051ba48bf7db489845f50c8bd7f65a108685dda5243103c735a
|
||||
size 18477
|
||||
oid sha256:21097ecf30d0e605f2117c5e98f047db7e65eb43f921a380e7ccade21fc6fc34
|
||||
size 19594
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user