feat: add console jump to latest icon
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+48
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user