feat: add console jump to latest icon

This commit is contained in:
2026-10-01 10:59:13 +09:00
parent 2ddf547308
commit 1bc1f54ea0
3 changed files with 133 additions and 0 deletions
@@ -248,6 +248,32 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
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();
@@ -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")}
<button
class="console-jump-latest"
type="button"
aria-label="Jump to latest"
title="Jump to latest"
onclick={jumpToLatest}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 4V16M7 11L12 16L17 11M5 20H19" />
</svg>
</button>
{/if}
</section>
{#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;
}
@@ -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<HTMLElement>(".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 () => {