Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clickable-thinking-indicator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Click the thinking indicator to scroll the current chat to the latest message.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 17 additions & 16 deletions packages/kilo-vscode/tests/chat-auto-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -207,23 +208,23 @@ 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)
await settle(page, 10)
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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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 }) => {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions packages/kilo-vscode/tests/unit/session-dock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<WorkingIndicator />")
expect(dock).toContain("<WorkingIndicator onScrollToBottom={props.onScrollToBottom} />")
expect(goal).toContain('class="session-goal-action"')
expect(goal).toContain('variant="ghost"')
expect(goal).toContain("disabled={props.readonly || !actions()}")
Expand Down Expand Up @@ -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('<span class="sr-only">{language.t("session.messages.scrollToBottom")}</span>')
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const [transferDetail, setTransferDetail] = createSignal("")
const [repoBranch, setRepoBranch] = createSignal<string>()
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.
Expand Down Expand Up @@ -381,6 +386,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
onShowHistory={props.onShowHistory}
onForkMessage={props.onForkMessage}
onEditMessage={edit}
onScrollToBottomReady={setScroll}
queuedDisabled={editing()?.sessionID === id() && !!editing()}
editDisabled={!editable() || !!editing()}
questions={standaloneQuestions}
Expand Down Expand Up @@ -412,6 +418,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
blocked={dockBlocked()}
hasActions={() => !props.readonly && (hasActions(hasMessages()) || !!goal())}
actions={(control) => renderActions(hasMessages(), control)}
onScrollToBottom={scrollToBottom}
readonly={props.readonly}
/>
<Show when={!props.readonly}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -126,6 +127,8 @@ export const MessageList: Component<MessageListProps> = (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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -38,7 +39,7 @@ export const SessionDock: Component<SessionDockProps> = (props) => {
<div class="session-dock" data-component="session-dock" data-active={active() ? "" : undefined}>
<div class="session-dock-state" ref={goal.row} data-active={working() ? "" : undefined} aria-hidden={!working()}>
<div class="session-working" ref={goal.lane} data-goal={goal.running() ? "" : undefined}>
<WorkingIndicator />
<WorkingIndicator onScrollToBottom={props.onScrollToBottom} />
{goal.status()}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<WorkingIndicatorProps> = (props) => {
const session = useSession()
const language = useLanguage()
const vscode = useVSCode()
Expand Down Expand Up @@ -95,20 +99,23 @@ export const WorkingIndicator: Component = () => {

return (
<div class="working-indicator">
<Spinner />
<StatusText text={statusText()} />
{/* 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. */}
<Show when={isRetrying() && retryCountdown() > 0}>
<span class="working-count">({retryCountdown()}s)</span>
</Show>
{/* Laid out for the whole turn and only faded until the first tick: mounting
the counter a second in shifted the whole cluster sideways. */}
<Show when={timing()}>
<span class="working-elapsed" data-empty={elapsed() > 0 ? undefined : ""}>
{formatElapsed()}
</span>
</Show>
<Button variant="ghost" size="small" class="working-indicator-scroll" onClick={() => props.onScrollToBottom?.()}>
<Spinner />
<StatusText text={statusText()} />
{/* 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. */}
<Show when={isRetrying() && retryCountdown() > 0}>
<span class="working-count">({retryCountdown()}s)</span>
</Show>
{/* Laid out for the whole turn and only faded until the first tick: mounting
the counter a second in shifted the whole cluster sideways. */}
<Show when={timing()}>
<span class="working-elapsed" data-empty={elapsed() > 0 ? undefined : ""}>
{formatElapsed()}
</span>
</Show>
<span class="sr-only">{language.t("session.messages.scrollToBottom")}</span>
</Button>
<Show when={isRetrying()}>
<Button
variant="secondary"
Expand Down
30 changes: 29 additions & 1 deletion packages/kilo-vscode/webview-ui/src/styles/chat-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,39 @@
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
/* The inset the scroll button adds inside is taken off here, so the row keeps
the 8px 16px footprint it had before the indicator became clickable. */
padding: 4px 10px;
font-size: var(--kilo-font-size-12);
color: var(--vscode-descriptionForeground);
}

.working-indicator-scroll[data-component="button"] {
height: auto;
max-width: 100%;
min-width: 0;
min-height: 0;
padding: 4px 6px;
border: 0;
border-radius: var(--session-action-radius);
background: transparent;
gap: 8px;
color: inherit;
font-size: inherit;
line-height: inherit;
cursor: pointer;
transition:
background-color 150ms ease,
color 150ms ease;
}

.working-indicator-scroll:hover:not(:disabled),
.working-indicator-scroll:focus-visible:not(:disabled),
.working-indicator-scroll:active:not(:disabled) {
background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground));
color: var(--vscode-foreground);
}

/* 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
Expand Down
Loading