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()} - - +