From 1bc1f54ea0cf004d5fb8f8131ecc6d046c1b5885 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 1 Oct 2026 10:59:13 +0900 Subject: [PATCH] feat: add console jump to latest icon --- .../console_history_shell_test.ts | 26 ++++++++ .../workers/[workerRef]/console/+page.svelte | 59 +++++++++++++++++++ .../console/console-display.browser.spec.ts | 48 +++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index 600ce2691..f755ef1eb 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -248,6 +248,32 @@ async function checkConsoleHistory(viewportHeight: number): Promise { assert(jumpGeometry.itemTop >= jumpGeometry.viewportTop - 1); assert(jumpGeometry.itemTop < jumpGeometry.viewportBottom); + const latest = page.getByRole("button", { name: "Jump to latest", exact: true }); + await latest.waitFor(); + assertEquals((await latest.textContent())?.trim(), ""); + const viewportBeforeJump = await consoleScroll.boundingBox(); + const latestGeometry = await latest.boundingBox(); + assert(viewportBeforeJump && latestGeometry); + assert(Math.abs(latestGeometry.x + latestGeometry.width / 2 - viewportBeforeJump.x - viewportBeforeJump.width / 2) <= 1, + "latest icon must be centered over the transcript, not the rail or Composer"); + const bottomGap = viewportBeforeJump.y + viewportBeforeJump.height - latestGeometry.y - latestGeometry.height; + assert(bottomGap >= 8 && bottomGap <= 24, `unexpected latest icon bottom gap: ${bottomGap}`); + const railTop = await turnList.evaluate((element) => element.scrollTop); + await latest.click(); + await latest.waitFor({ state: "detached" }); + assert(await consoleScroll.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop <= 1)); + assertEquals(await consoleScroll.boundingBox(), viewportBeforeJump, "overlay must not resize the transcript"); + assertEquals(await turnList.evaluate((element) => element.scrollTop), railTop); + await consoleScroll.evaluate((element) => { element.scrollTop = element.scrollHeight / 2; }); + await latest.waitFor(); + await latest.focus(); + await page.keyboard.press("Enter"); + await latest.waitFor({ state: "detached" }); + await consoleScroll.evaluate((element) => { element.scrollTop = element.scrollHeight / 2; }); + await latest.waitFor(); + await consoleScroll.evaluate((element) => { element.scrollTop = element.scrollHeight; }); + await latest.waitFor({ state: "detached" }); + const centering = await question12.evaluate((button) => { const turn = button.getBoundingClientRect(); const bar = button.querySelector(".turn-bar")!.getBoundingClientRect(); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 66474e15e..ec45f5688 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1531,6 +1531,12 @@ handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1); } + function jumpToLatest() { + if (!consoleWorkerViewSelectionIsResolved()) return; + autoFollowConsole = true; + void scrollConsoleToBottom(); + } + async function scrollConsoleToBottom() { if (!consoleWorkerViewSelectionIsResolved()) return; const sessionId = selectedWorkerView.sessionId; @@ -1765,6 +1771,19 @@ historyAvailable={selectedWorkerViewSessionId === null} bind:element={turnNavigationElement} /> + {#if !autoFollowConsole && lines.length > 0 && (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale")} + + {/if} {#if taskPaneOpen} @@ -2193,6 +2212,7 @@ } .console-scroll { + grid-area: 1 / 1; height: 100%; min-width: 0; min-height: 0; @@ -2200,6 +2220,45 @@ scrollbar-width: none; } + .console-jump-latest { + grid-area: 1 / 1; + align-self: end; + justify-self: center; + z-index: 2; + display: grid; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + margin-bottom: var(--space-3); + border: 1px solid var(--line); + border-radius: 50%; + background: var(--bg-raised); + color: var(--text-strong); + box-shadow: var(--shadow-overlay); + cursor: pointer; + } + + .console-jump-latest:hover { + color: var(--tui-cyan); + border-color: var(--tui-cyan); + } + + .console-jump-latest:focus-visible { + outline: 2px solid var(--tui-cyan); + outline-offset: 2px; + } + + .console-jump-latest svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; + } + .console-scroll::-webkit-scrollbar { display: none; } diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index 44e61f63a..69dd24f2a 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -481,6 +481,53 @@ test("mini task summary opens and closes details without a header Tasks button", expect(summary.getAttribute("aria-expanded")).toBe("false"); }); +test("latest jump is icon-only, hidden at the bottom, and resumes following streamed output", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); + const view = render(ConsolePage, { data: pageData() }); + expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame(subscribedFrame(sessionWithUserMessage("Follow question"))); + await screen.findByRole("button", { name: "Turn 1: Follow question" }); + expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); + const scroll = view.container.querySelector(".console-scroll")!; + let height = 2000; + Object.defineProperties(scroll, { + scrollHeight: { configurable: true, get: () => height }, + clientHeight: { configurable: true, value: 400 }, + }); + scroll.scrollTop = 400; + await fireEvent.scroll(scroll); + const jump = await screen.findByRole("button", { name: "Jump to latest" }); + expect(jump.textContent?.trim()).toBe(""); + expect(jump.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(jump.closest(".console-body")).not.toBeNull(); + expect(jump.closest(".console-scroll, .console-composer")).toBeNull(); + const emitDelta = (text: string) => latestListener().onFrame({ + frame: "event", message: { event: "event", data: { payload: { + event: "worker_protocol", data: { event: { event: "text_delta", data: { text } } }, + } } }, + }); + height = 2500; + emitDelta("new output"); + await screen.findByText("new output"); + expect(scroll.scrollTop).toBe(400); + await fireEvent.click(jump); + await waitFor(() => expect(scroll.scrollTop).toBe(2500)); + expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); + height = 3000; + emitDelta(" continues"); + await waitFor(() => expect(scroll.scrollTop).toBe(3000)); + scroll.scrollTop = 400; + await fireEvent.scroll(scroll); + await screen.findByRole("button", { name: "Jump to latest" }); + scroll.scrollTop = height - 400; + await fireEvent.scroll(scroll); + expect(screen.queryByRole("button", { name: "Jump to latest" })).toBeNull(); + height = 3500; + emitDelta(" after manual return"); + await waitFor(() => expect(scroll.scrollTop).toBe(3500)); +}); + test("turn navigation jumps only the transcript to its user message and stops bottom-follow", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); const view = render(ConsolePage, { data: pageData() }); @@ -500,6 +547,7 @@ test("turn navigation jumps only the transcript to its user message and stops bo expect(scroll.scrollTop).toBe(800); await settleMicrotasks(); expect(scroll.scrollTop).toBe(800); + expect(screen.getByRole("button", { name: "Jump to latest" })).not.toBeNull(); }); test("does not call Runtime APIs before the Worker execution target resolves", async () => {