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.viewportTop - 1);
|
||||||
assert(jumpGeometry.itemTop < jumpGeometry.viewportBottom);
|
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 centering = await question12.evaluate((button) => {
|
||||||
const turn = button.getBoundingClientRect();
|
const turn = button.getBoundingClientRect();
|
||||||
const bar = button.querySelector(".turn-bar")!.getBoundingClientRect();
|
const bar = button.querySelector(".turn-bar")!.getBoundingClientRect();
|
||||||
|
|||||||
@@ -1531,6 +1531,12 @@
|
|||||||
handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1);
|
handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jumpToLatest() {
|
||||||
|
if (!consoleWorkerViewSelectionIsResolved()) return;
|
||||||
|
autoFollowConsole = true;
|
||||||
|
void scrollConsoleToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
async function scrollConsoleToBottom() {
|
async function scrollConsoleToBottom() {
|
||||||
if (!consoleWorkerViewSelectionIsResolved()) return;
|
if (!consoleWorkerViewSelectionIsResolved()) return;
|
||||||
const sessionId = selectedWorkerView.sessionId;
|
const sessionId = selectedWorkerView.sessionId;
|
||||||
@@ -1765,6 +1771,19 @@
|
|||||||
historyAvailable={selectedWorkerViewSessionId === null}
|
historyAvailable={selectedWorkerViewSessionId === null}
|
||||||
bind:element={turnNavigationElement}
|
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>
|
</section>
|
||||||
|
|
||||||
{#if taskPaneOpen}
|
{#if taskPaneOpen}
|
||||||
@@ -2193,6 +2212,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.console-scroll {
|
.console-scroll {
|
||||||
|
grid-area: 1 / 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -2200,6 +2220,45 @@
|
|||||||
scrollbar-width: none;
|
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 {
|
.console-scroll::-webkit-scrollbar {
|
||||||
display: none;
|
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");
|
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 () => {
|
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" })));
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
||||||
const view = render(ConsolePage, { data: pageData() });
|
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);
|
expect(scroll.scrollTop).toBe(800);
|
||||||
await settleMicrotasks();
|
await settleMicrotasks();
|
||||||
expect(scroll.scrollTop).toBe(800);
|
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 () => {
|
test("does not call Runtime APIs before the Worker execution target resolves", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user