diff --git a/.changeset/clickable-thinking-indicator.md b/.changeset/clickable-thinking-indicator.md
new file mode 100644
index 00000000000..93840693caf
--- /dev/null
+++ b/.changeset/clickable-thinking-indicator.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": minor
+---
+
+Click the thinking indicator to scroll the current chat to the latest message.
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png
index 788bf55d832..adc2fcfa53f 100644
--- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:adbd7f9e9a98585cf00acb7600a37481ffa97ea4a16731fa430092b91287dda8
-size 746956
+oid sha256:007c4973e3ce3e81263f39ab1bdcbc103a7ae5880ad0c251a51f77847a9e58a6
+size 746937
diff --git a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts
index ca12c537928..9c68bcba4b6 100644
--- a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts
+++ b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts
@@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test"
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
const STORY_ID = "chat--message-list-layout-correction"
+const scrollButton = (page: Page) => page.locator(".scroll-to-bottom-button")
test.use({
launchOptions: {
@@ -33,7 +34,7 @@ async function open(page: Page) {
await list.hover()
await page.mouse.wheel(0, -2 * (await list.evaluate((el) => el.clientHeight)))
- const bottom = page.getByRole("button", { name: "Scroll to bottom" })
+ const bottom = scrollButton(page)
await expect(bottom).toBeVisible()
await settle(page, 10)
await bottom.click()
@@ -127,12 +128,12 @@ test("keeps following after a stable-height layout correction", async ({ page })
expect(corrected.height).toBe(before.height)
expect(before.top - corrected.top).toBe(120)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
+ await expect(scrollButton(page)).toBeHidden()
await page.getByTestId("append-stream").click()
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
+ await expect(scrollButton(page)).toBeHidden()
})
test("keeps the reading position when the prompt rail scrolls upward", async ({ page }) => {
@@ -146,13 +147,13 @@ test("keeps the reading position when the prompt rail scrolls upward", async ({
await page.mouse.wheel(0, -240)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
+ await expect(scrollButton(page)).toBeVisible()
const top = await list.evaluate((el) => el.scrollTop)
await page.getByTestId("append-stream").click()
await expect.poll(() => list.evaluate((el) => el.scrollTop)).toBe(top)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
+ await expect(scrollButton(page)).toBeVisible()
})
test("pauses on a native scrollbar drag and resumes at the bottom", async ({ page }) => {
@@ -207,7 +208,7 @@ test("pauses on a native scrollbar drag and resumes at the bottom", async ({ pag
expect(await list.getAttribute("data-pointer")).toBe("0")
expect(await list.getAttribute("data-mouse")).toBe("0")
expect(await list.getAttribute("data-scroll")).toMatch(/[1-9]/)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
+ await expect(scrollButton(page)).toBeVisible()
await page.getByTestId("append-stream").click()
await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(stable.height)
@@ -215,15 +216,15 @@ test("pauses on a native scrollbar drag and resumes at the bottom", async ({ pag
const after = await state(page)
expect(after.top).toBeCloseTo(stable.top, 0)
expect(after.distance).toBeGreaterThan(40)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
+ await expect(scrollButton(page)).toBeVisible()
- await page.getByRole("button", { name: "Scroll to bottom" }).click()
+ await scrollButton(page).click()
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
+ await expect(scrollButton(page)).toBeHidden()
await page.getByTestId("append-stream").click()
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
+ await expect(scrollButton(page)).toBeHidden()
})
test("keeps a long native scrollbar drag user-controlled", async ({ page }) => {
@@ -265,7 +266,7 @@ test("keeps a long native scrollbar drag user-controlled", async ({ page }) => {
await expect.poll(() => list.evaluate((el) => Number(el.dataset.scroll ?? "0"))).toBeGreaterThan(0)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
+ await expect(scrollButton(page)).toBeVisible()
})
test("pauses on an upward wheel over the Copy response button", async ({ page }) => {
@@ -281,7 +282,7 @@ test("pauses on an upward wheel over the Copy response button", async ({ page })
await page.mouse.wheel(0, -240)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
+ await expect(scrollButton(page)).toBeVisible()
})
test("keeps a one-pixel upward wheel pause through delayed streaming", async ({ page }) => {
@@ -306,14 +307,14 @@ test("keeps a one-pixel upward wheel pause through delayed streaming", async ({
const after = await state(page)
expect(after.top).toBeCloseTo(before.top, 0)
expect(after.distance).toBeGreaterThan(40)
- await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
+ await expect(scrollButton(page)).toBeVisible()
})
test("keeps the pause after a pending bottom scroll event", async ({ page }) => {
await open(page)
const list = page.locator(".message-list")
const copy = page.getByRole("button", { name: "Copy response" }).first()
- const bottom = page.getByRole("button", { name: "Scroll to bottom" })
+ const bottom = scrollButton(page)
await expect(list).toBeVisible()
await expect(copy).toBeVisible()
await settle(page, 10)
@@ -344,7 +345,7 @@ for (const input of ["wheel", "keyboard"] as const) {
test(`keeps new upward ${input} input before a pending return-to-bottom scroll`, async ({ page }) => {
await open(page)
const list = page.locator(".message-list")
- const bottom = page.getByRole("button", { name: "Scroll to bottom" })
+ const bottom = scrollButton(page)
await expect(list).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
@@ -393,7 +394,7 @@ for (const input of ["wheel", "keyboard"] as const) {
test("preserves the pause across working status changes", async ({ page }) => {
await open(page)
const list = page.locator(".message-list")
- const bottom = page.getByRole("button", { name: "Scroll to bottom" })
+ const bottom = scrollButton(page)
await expect(list).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
diff --git a/packages/kilo-vscode/tests/unit/session-dock.test.ts b/packages/kilo-vscode/tests/unit/session-dock.test.ts
index 67e870a235f..bc7b8619ce4 100644
--- a/packages/kilo-vscode/tests/unit/session-dock.test.ts
+++ b/packages/kilo-vscode/tests/unit/session-dock.test.ts
@@ -95,7 +95,7 @@ describe("session dock layout", () => {
const goal = read("webview-ui/src/components/chat/goal/useGoalDock.tsx")
const indicator = read("webview-ui/src/components/shared/WorkingIndicator.tsx")
expect(indicator).not.toMatch(/goal|DropdownMenu|Tooltip/)
- expect(dock).toContain("")
+ expect(dock).toContain("")
expect(goal).toContain('class="session-goal-action"')
expect(goal).toContain('variant="ghost"')
expect(goal).toContain("disabled={props.readonly || !actions()}")
@@ -124,11 +124,23 @@ describe("session dock layout", () => {
expect(css).not.toContain(".session-goal-dot")
expect(css).not.toContain("@container chat (max-width: 640px)")
expect(indicator).toContain("gap: 8px")
- expect(indicator).toContain("padding: 8px 16px")
+ expect(indicator).toContain("padding: 4px 10px")
expect(css).not.toContain("working-goal")
expect(css).not.toContain(".working-indicator[data-goal]")
})
+ it("keeps the working status accessible and constrained", () => {
+ const indicator = read("webview-ui/src/components/shared/WorkingIndicator.tsx")
+ const css = read("webview-ui/src/styles/chat-layout.css")
+ expect(indicator).toContain('{language.t("session.messages.scrollToBottom")}')
+ const button = css.match(/\.working-indicator-scroll\[data-component="button"\] \{([\s\S]*?)\}/)
+ expect(button).not.toBeNull()
+ expect(button![1]).toContain("max-width: 100%")
+ expect(button![1]).toContain("min-width: 0")
+ expect(css).toContain(".working-indicator-scroll:hover:not(:disabled)")
+ expect(css).toContain("var(--surface-interactive-hover, var(--vscode-list-hoverBackground))")
+ })
+
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.
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
index 2b4467981f6..76634f0f55f 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
@@ -85,6 +85,11 @@ export const ChatView: Component = (props) => {
const [transferDetail, setTransferDetail] = createSignal("")
const [repoBranch, setRepoBranch] = createSignal()
let worktreeRef: HTMLDivElement | undefined
+ let scroll: (() => void) | undefined
+ const setScroll = (handler: (() => void) | undefined) => {
+ scroll = handler
+ }
+ const scrollToBottom = () => scroll?.()
// Permissions and questions scoped to this session's family (self + subagents).
// Each ChatView only sees its own session tree — no cross-session leakage.
@@ -381,6 +386,7 @@ export const ChatView: Component = (props) => {
onShowHistory={props.onShowHistory}
onForkMessage={props.onForkMessage}
onEditMessage={edit}
+ onScrollToBottomReady={setScroll}
queuedDisabled={editing()?.sessionID === id() && !!editing()}
editDisabled={!editable() || !!editing()}
questions={standaloneQuestions}
@@ -412,6 +418,7 @@ export const ChatView: Component = (props) => {
blocked={dockBlocked()}
hasActions={() => !props.readonly && (hasActions(hasMessages()) || !!goal())}
actions={(control) => renderActions(hasMessages(), control)}
+ onScrollToBottom={scrollToBottom}
readonly={props.readonly}
/>
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
index b2fb3919d7d..2b1be688b92 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
@@ -89,6 +89,7 @@ interface MessageListProps {
onShowHistory?: () => void
onForkMessage?: (sessionId: string, messageId: string) => void
onEditMessage?: (sessionID: string, messageID: string) => void
+ onScrollToBottomReady?: (handler: (() => void) | undefined) => void
/** Non-tool question requests to render inline at the bottom of the message list */
questions?: () => QuestionRequest[]
/** Non-tool suggestion requests to render inline at the bottom of the message list */
@@ -126,6 +127,8 @@ export const MessageList: Component = (props) => {
const autoScroll = createAutoScroll({
working: () => session.status() !== "idle",
})
+ props.onScrollToBottomReady?.(() => autoScroll.resume())
+ onCleanup(() => props.onScrollToBottomReady?.(undefined))
const [announcement, setAnnouncement] = createSignal("")
createEffect(
(prev: { sid?: string; working: boolean }) => {
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionDock.tsx
index 68d8780aa1b..2bfe0854173 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionDock.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionDock.tsx
@@ -18,6 +18,7 @@ interface SessionDockProps {
hasActions?: () => boolean
/** True while a permission, question, suggestion, or requirement owns the row. */
blocked?: boolean
+ onScrollToBottom?: () => void
readonly?: boolean
}
@@ -38,7 +39,7 @@ export const SessionDock: Component = (props) => {
-
+
{goal.status()}
diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx
index ff12cc1f891..c2992a739ef 100644
--- a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx
@@ -3,9 +3,9 @@
* 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.
+ * `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, createMemo, onCleanup } from "solid-js"
@@ -17,7 +17,11 @@ import { useVSCode } from "../../context/vscode"
import { StatusText } from "./StatusText"
import { tracksElapsed } from "./working-indicator-utils"
-export const WorkingIndicator: Component = () => {
+interface WorkingIndicatorProps {
+ onScrollToBottom?: () => void
+}
+
+export const WorkingIndicator: Component = (props) => {
const session = useSession()
const language = useLanguage()
const vscode = useVSCode()
@@ -95,20 +99,23 @@ export const WorkingIndicator: Component = () => {
return (
-
-
- {/* Kept out of the label: a countdown inside the morphing text would swap it
- once a second, and every tick would read as a new status. */}
- 0}>
- ({retryCountdown()}s)
-
- {/* Laid out for the whole turn and only faded until the first tick: mounting
- the counter a second in shifted the whole cluster sideways. */}
-
- 0 ? undefined : ""}>
- {formatElapsed()}
-
-
+