fix(web): load and scroll to unloaded prompts (#3412)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-09-05 20:04:09 +00:00
committed by GitHub
parent 57ed887634
commit 5af573ced6
11 changed files with 2365 additions and 281 deletions
@@ -7,7 +7,7 @@ import { SessionMobileTopBar } from "./session-mobile-top-bar";
import { SessionMobileBottomNav } from "./session-mobile-bottom-nav";
import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet";
import { MobileFileViewerPanel } from "./mobile-file-viewer-panel";
import { TaskChatPanel } from "../task-chat-panel";
import { TaskChatPanel, type PendingMessageScrollTarget } from "../task-chat-panel";
import { TaskPlanPanel } from "../task-plan-panel";
import { MobileChangesPanel } from "./mobile-changes-panel";
import { SessionMobileReviewDialog } from "./session-mobile-review-dialog";
@@ -100,15 +100,17 @@ function MobileChatPanelContent({
isPassthroughMode,
effectiveSessionId,
onOpenFile,
scrollToMessageId,
scrollTarget,
onScrollTargetConsumed,
isVisible,
}: {
activeTaskId: string | null;
isPassthroughMode: boolean;
effectiveSessionId: string | null;
onOpenFile: (path: string, repo?: string) => void;
scrollToMessageId: string | null;
scrollTarget: PendingMessageScrollTarget | null;
onScrollTargetConsumed: (messageId: string) => void;
isVisible: boolean;
}) {
const { t } = useTranslation();
if (!activeTaskId) {
@@ -136,7 +138,8 @@ function MobileChatPanelContent({
sessionId={effectiveSessionId}
taskId={effectiveSessionId ? activeTaskId : null}
onOpenFile={onOpenFile}
pendingScrollToMessageId={scrollToMessageId}
pendingScrollTarget={scrollTarget}
isVisible={isVisible}
onPendingScrollConsumed={onScrollTargetConsumed}
/>
)}
@@ -158,7 +161,7 @@ type MobilePanelAreaProps = {
handlePanelChangeAndClearSheet: (panel: MobileSessionPanel) => void;
onNavigateToPrompt: (messageId: string) => void;
onScrollTargetConsumed?: (messageId: string) => void;
mobileScrollTarget: string | null;
mobileScrollTarget: PendingMessageScrollTarget | null;
topNavHeight: string;
bottomNavHeight: string;
reviews: readonly ReviewItemSummary[];
@@ -219,8 +222,9 @@ export function MobilePanelArea({
isPassthroughMode={isPassthroughMode}
effectiveSessionId={effectiveSessionId}
onOpenFile={handleOpenFileFromChat}
scrollToMessageId={mobileScrollTarget}
scrollTarget={mobileScrollTarget}
onScrollTargetConsumed={onScrollTargetConsumed}
isVisible
/>
</div>
)}
@@ -609,17 +613,35 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
handleOpenFile,
handlePanelChangeAndClearSheet,
} = useMobilePanelHandlers({ effectiveSessionId, handlePanelChange });
const [mobileScrollTarget, setMobileScrollTarget] = useState<string | null>(null);
const [mobileScrollTarget, setMobileScrollTarget] = useState<PendingMessageScrollTarget | null>(
null,
);
const mobileScrollTokenRef = useRef(0);
useEffect(() => {
mobileScrollTokenRef.current += 1;
setMobileScrollTarget(null);
}, [effectiveSessionId]);
const handleNavigateToPrompt = useCallback(
(messageId: string) => {
setMobileScrollTarget(messageId);
handlePanelChangeAndClearSheet("chat");
const handlePanelChangeAndClearTarget = useCallback(
(panel: MobileSessionPanel) => {
setMobileScrollTarget(null);
handlePanelChangeAndClearSheet(panel);
},
[handlePanelChangeAndClearSheet],
);
const handleNavigateToPrompt = useCallback(
(messageId: string) => {
if (!effectiveSessionId) return;
const token = ++mobileScrollTokenRef.current;
setMobileScrollTarget({
sessionId: effectiveSessionId,
messageId,
token,
hostPanelId: "mobile-chat",
});
handlePanelChangeAndClearSheet("chat");
},
[effectiveSessionId, handlePanelChangeAndClearSheet],
);
const handleMobileScrollTargetConsumed = useCallback(() => {
setMobileScrollTarget(null);
}, []);
@@ -628,8 +650,14 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
activeTaskId,
effectiveSessionId,
currentMobilePanel,
handlePanelChangeAndClearSheet,
handlePanelChangeAndClearSheet: handlePanelChangeAndClearTarget,
});
useLayoutEffect(() => {
if (currentMobilePanel !== "chat" || effectiveMobilePanel !== "chat") {
mobileScrollTokenRef.current += 1;
setMobileScrollTarget(null);
}
}, [currentMobilePanel, effectiveMobilePanel]);
return (
<div className="h-dvh relative bg-background" data-testid="mobile-task-layout">
<MobileTopBarSticky
@@ -651,7 +679,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
handleOpenFileFromChat={handleOpenFileFromChat}
handleClearSelectedDiff={handleClearSelectedDiff}
handleOpenFile={handleOpenFile}
handlePanelChangeAndClearSheet={handlePanelChangeAndClearSheet}
handlePanelChangeAndClearSheet={handlePanelChangeAndClearTarget}
onNavigateToPrompt={handleNavigateToPrompt}
onScrollTargetConsumed={handleMobileScrollTargetConsumed}
mobileScrollTarget={mobileScrollTarget}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import type { ApiClient } from "./api-client";
export const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
export const FIRST_PROMPT_MARKER = "prompt-history-auto-load-first-7f1c";
export const SECOND_PROMPT_MARKER = "prompt-history-auto-load-second-2b9e";
export const MIDDLE_PROMPT_MARKER = "prompt-history-unloaded-middle-8c42";
type SeedContext = {
workspaceId: string;
@@ -16,10 +17,11 @@ type SeedContext = {
/**
* Seeds a 121-prompt session: the boot description becomes user prompt #1,
* the SECOND_PROMPT_MARKER gets the earliest seed timestamp (absolute ordinal
* #2), and 119 more user prompts follow at one-microsecond-aligned timestamps
* at least 1ms apart, so the last seed is #121. Returns the task id. The
* pre-seed count (exactly one user message) pins the marker's absolute
* ordinal deterministically.
* #2), and 119 more user prompts follow at least 2ms apart with one agent
* response between each prompt. The response rows make a middle prompt absent
* from the transcript's newest-message page while it remains in the history
* panel's newest-prompt page. The pre-seed count (exactly one user message)
* pins the marker's absolute ordinal.
*/
export async function seedLongPromptHistory(
apiClient: ApiClient,
@@ -63,8 +65,7 @@ export async function seedLongPromptHistory(
);
const seedBaseMs = maxCreatedAt + 1000;
// RFC3339 with a zero-padded 6-digit (microsecond) fraction: pad the
// millisecond fraction instead of replacing it, so every seed is a distinct,
// strictly increasing, one-microsecond-aligned timestamp.
// millisecond fraction instead of replacing it, so every seed is distinct.
const aligned = (offsetMs: number): string =>
new Date(seedBaseMs + offsetMs)
.toISOString()
@@ -75,12 +76,24 @@ export async function seedLongPromptHistory(
authorType: "user",
createdAt: aligned(0),
});
await apiClient.seedSessionMessage(sessionId, {
type: "message",
content: "auto-load agent response 0",
authorType: "agent",
createdAt: aligned(1),
});
for (let i = 1; i < 120; i++) {
await apiClient.seedSessionMessage(sessionId, {
type: "message",
content: `auto-load filler prompt ${i}`,
content: i === 59 ? MIDDLE_PROMPT_MARKER : `auto-load filler prompt ${i}`,
authorType: "user",
createdAt: aligned(i),
createdAt: aligned(i * 2),
});
await apiClient.seedSessionMessage(sessionId, {
type: "message",
content: `auto-load agent response ${i}`,
authorType: "agent",
createdAt: aligned(i * 2 + 1),
});
}
@@ -4,10 +4,73 @@ import { test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import {
FIRST_PROMPT_MARKER,
MIDDLE_PROMPT_MARKER,
SECOND_PROMPT_MARKER,
seedLongPromptHistory,
} from "../../helpers/prompt-history-long-seed";
async function installTargetScrollCounter(
page: Page,
messageId: string,
displaceAfterFirstScroll = false,
) {
await page.evaluate(
({ id, displace }) => {
const windowWithCalls = window as typeof window & {
__promptHistoryTargetScrollCalls?: number;
__promptHistoryScrollOptions?: ScrollIntoViewOptions[];
__promptHistoryScrollTimes?: number[];
__promptHistoryDisplacementApplied?: boolean;
__promptHistoryDisplacementOffset?: number;
};
const original = Element.prototype.scrollIntoView;
const targetSelector = `[id="msg-${CSS.escape(id)}"]`;
const applyDisplacement = () => {
const current = document.querySelector<HTMLElement>(targetSelector);
const list = current?.closest<HTMLElement>(".chat-message-list");
if (!current || !list) return;
current.style.setProperty("transform", "translateY(200px)", "important");
windowWithCalls.__promptHistoryDisplacementOffset =
current.getBoundingClientRect().top - list.getBoundingClientRect().top;
windowWithCalls.__promptHistoryDisplacementApplied = true;
};
windowWithCalls.__promptHistoryTargetScrollCalls = 0;
windowWithCalls.__promptHistoryScrollOptions = [];
windowWithCalls.__promptHistoryScrollTimes = [];
windowWithCalls.__promptHistoryDisplacementApplied = false;
Element.prototype.scrollIntoView = function (...args) {
const isTarget = this.id === `msg-${id}`;
let list: Element | null = null;
let calls = 0;
if (isTarget) {
calls = (windowWithCalls.__promptHistoryTargetScrollCalls ?? 0) + 1;
windowWithCalls.__promptHistoryTargetScrollCalls = calls;
windowWithCalls.__promptHistoryScrollOptions?.push(args[0]);
windowWithCalls.__promptHistoryScrollTimes?.push(performance.now());
list = this.closest(".chat-message-list");
}
if (
displace &&
isTarget &&
calls === 2 &&
windowWithCalls.__promptHistoryDisplacementApplied
) {
document.querySelectorAll<HTMLElement>(targetSelector).forEach((element) => {
element.style.removeProperty("transform");
});
}
const result = original.apply(this, args);
if (displace && isTarget && calls === 1 && list instanceof HTMLElement) {
window.setTimeout(() => {
if (list.isConnected) applyDisplacement();
}, 50);
}
return result;
};
},
{ id: messageId, displace: displaceAfterFirstScroll },
);
}
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
/** CDP touch-scrolls the element upward, which scrolls its content DOWN
@@ -35,6 +98,20 @@ async function touchScrollDown(page: Page, scrollElement: Locator) {
touchPoints: [],
});
}
async function revealPromptHistoryTarget(page: Page, panel: Locator, targetBubble: Locator) {
const scroller = panel.getByTestId("prompt-history-scroll");
await expect(scroller).toBeVisible();
for (let attempt = 0; attempt < 10; attempt++) {
if ((await targetBubble.count()) > 0) return;
await touchScrollDown(page, scroller);
await targetBubble
.first()
.waitFor({ state: "attached", timeout: 5_000 })
.catch(() => undefined);
}
if ((await targetBubble.count()) > 0) return;
throw new Error("prompt history target did not load after 10 touch-scroll attempts");
}
test.describe("Prompt history panel on mobile", () => {
test("opens from Panels and returns to Chat for a prompt jump", async ({
@@ -145,8 +222,11 @@ test.describe("Prompt history panel on mobile", () => {
releaseHeld = resolve;
});
let heldOlderRequests = 0;
let aroundRequests = 0;
await testPage.route("**/api/v1/task-sessions/*/messages*", async (route) => {
if (route.request().url().includes("before=")) {
const url = new URL(route.request().url());
if (url.searchParams.get("around")) aroundRequests += 1;
if (url.searchParams.get("before")) {
heldOlderRequests += 1;
await olderRequestGate;
}
@@ -187,18 +267,161 @@ test.describe("Prompt history panel on mobile", () => {
await expect(firstRow).toBeAttached({ timeout: 10_000 });
await expect(firstRow.locator('[data-testid^="prompt-history-number-"]')).toHaveText("#1");
// The first prompt is outside the initial transcript page. Selecting it
// must load its around-window before Chat attempts to scroll.
const firstPromptMessageId = await firstRow
const middleRow = panel.locator('[data-testid^="prompt-history-row-"]', {
hasText: MIDDLE_PROMPT_MARKER,
});
await expect(middleRow).toBeAttached({ timeout: 10_000 });
// The middle prompt remains outside the transcript's initial newest page.
// Selecting it must load its around-window before Chat attempts to scroll.
const middlePromptMessageId = await middleRow
.locator("[data-message-id]")
.getAttribute("data-message-id");
if (!firstPromptMessageId) throw new Error("First prompt row has no message id");
await firstRow.locator('[role="button"]').first().tap();
await expect(testPage.locator(`#msg-${firstPromptMessageId}`)).toBeAttached({
timeout: 10_000,
if (!middlePromptMessageId) throw new Error("Middle prompt row has no message id");
await installTargetScrollCounter(testPage, middlePromptMessageId, true);
await middleRow.locator('[role="button"]').first().tap();
await expect.poll(() => aroundRequests, { timeout: 10_000 }).toBe(1);
const targetMessage = new SessionPage(testPage)
.activeChat()
.locator(`#msg-${middlePromptMessageId}`);
await expect(targetMessage).toBeAttached({ timeout: 10_000 });
await expect
.poll(
() =>
testPage.evaluate(
() =>
(window as typeof window & { __promptHistoryDisplacementOffset?: number })
.__promptHistoryDisplacementOffset ?? Number.NEGATIVE_INFINITY,
),
{ timeout: 10_000 },
)
.toBeGreaterThan(164);
await expect
.poll(
() =>
testPage.evaluate(
() =>
(
window as typeof window & {
__promptHistoryTargetScrollCalls?: number;
__promptHistoryScrollTimes?: number[];
}
).__promptHistoryScrollTimes ?? [],
),
{ timeout: 10_000 },
)
.toHaveLength(2);
const scrollTimes = await testPage.evaluate(
() =>
(window as typeof window & { __promptHistoryScrollTimes?: number[] })
.__promptHistoryScrollTimes ?? [],
);
expect(scrollTimes[1] - scrollTimes[0]).toBeGreaterThanOrEqual(250);
expect(scrollTimes[1] - scrollTimes[0]).toBeLessThanOrEqual(1_000);
const stableTargetOffset = await targetMessage.evaluate(async (element) => {
const readOffset = () => {
const list = element.closest(".chat-message-list");
if (!list) return { offset: Number.POSITIVE_INFINITY, margin: 0 };
return {
offset: element.getBoundingClientRect().top - list.getBoundingClientRect().top,
margin: parseFloat(getComputedStyle(element).scrollMarginTop) || 0,
};
};
const first = readOffset();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
return { first, second: readOffset() };
});
expect(
Math.abs(stableTargetOffset.second.offset - stableTargetOffset.first.offset),
).toBeLessThanOrEqual(1);
expect(stableTargetOffset.second.offset).toBeGreaterThanOrEqual(-2);
expect(stableTargetOffset.second.offset).toBeLessThanOrEqual(
stableTargetOffset.second.margin + 2,
);
// No load-more button inside the panel.
await expect(panel.getByTestId("load-older-messages")).toHaveCount(0);
});
test("cancels a mobile unloaded target when Chat is left before reassertion", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const taskId = await seedLongPromptHistory(apiClient, {
workspaceId: seedData.workspaceId,
agentProfileId: seedData.agentProfileId,
workflowId: seedData.workflowId,
startStepId: seedData.startStepId,
repositoryId: seedData.repositoryId,
});
const { sessions } = await apiClient.listTaskSessions(taskId);
const sessionId = sessions[0]?.id;
if (!sessionId) throw new Error("mobile cancellation task has no session");
const { messages } = await apiClient.listSessionMessages(sessionId);
const targetMessage = messages.find((message) => message.content === MIDDLE_PROMPT_MARKER);
if (!targetMessage) throw new Error("mobile cancellation marker was not persisted");
await testPage.goto(`/t/${taskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const panelsButton = testPage.getByRole("button", { name: "Panels" });
await panelsButton.tap();
await testPage.getByTestId("mobile-prompt-history-option").tap();
const panel = testPage.getByTestId("prompt-history-panel");
const targetBubble = panel.locator(`[data-message-id="${targetMessage.id}"]`);
await revealPromptHistoryTarget(testPage, panel, targetBubble);
await expect(targetBubble).toBeVisible();
let releaseAround: (() => void) | null = null;
const aroundGate = Promise.withResolvers<void>();
releaseAround = aroundGate.resolve;
let aroundRequests = 0;
await testPage.route("**/api/v1/task-sessions/*/messages*", async (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get("around") === targetMessage.id) {
aroundRequests += 1;
await aroundGate.promise;
}
await route.continue();
});
await panelsButton.tap();
await testPage.getByTestId("mobile-prompt-history-option").tap();
await revealPromptHistoryTarget(testPage, panel, targetBubble);
await testPage.clock.install();
await installTargetScrollCounter(testPage, targetMessage.id);
await targetBubble.tap();
await expect.poll(() => aroundRequests, { timeout: 10_000 }).toBe(1);
releaseAround?.();
await testPage.clock.fastForward(50);
const chat = session.activeChat();
await expect(chat.locator(`#msg-${targetMessage.id}`)).toBeAttached();
await expect
.poll(
() =>
testPage.evaluate(
() =>
(window as typeof window & { __promptHistoryTargetScrollCalls?: number })
.__promptHistoryTargetScrollCalls ?? 0,
),
{ timeout: 10_000 },
)
.toBe(1);
await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
const leavingChatScrollCalls = await testPage.evaluate(
() =>
(window as typeof window & { __promptHistoryTargetScrollCalls?: number })
.__promptHistoryTargetScrollCalls ?? 0,
);
await testPage.clock.fastForward(1000);
await testPage.getByRole("button", { name: "Chat", exact: true }).tap();
await expect(chat.locator(`#msg-${targetMessage.id}`)).toBeAttached();
const finalScrollCalls = await testPage.evaluate(
() =>
(window as typeof window & { __promptHistoryTargetScrollCalls?: number })
.__promptHistoryTargetScrollCalls ?? 0,
);
expect(finalScrollCalls).toBe(leavingChatScrollCalls);
expect(aroundRequests).toBe(1);
await testPage.unroute("**/api/v1/task-sessions/*/messages*");
});
});
@@ -1,9 +1,13 @@
import { expect } from "@playwright/test";
import { test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import {
MIDDLE_PROMPT_MARKER,
seedLongPromptHistory,
} from "../../helpers/prompt-history-long-seed";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
const SENTINEL = "sentinel-jump-token-9f3a";
// `formatPromptDuration` shapes: 0s / 5s / 5m 23s / 1h 2m 3s. Elapsed wall
// time between seeding and sending is uncontrolled, so only the shape is
// asserted (exact-`0s` stays in fixed-time unit coverage).
@@ -192,4 +196,66 @@ test.describe("Prompt history panel", () => {
});
expect(Math.abs(elTop - listTop - targetMargin)).toBeLessThanOrEqual(2);
});
test("loads and aligns a genuinely unloaded middle prompt on desktop", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const taskId = await seedLongPromptHistory(apiClient, {
workspaceId: seedData.workspaceId,
agentProfileId: seedData.agentProfileId,
workflowId: seedData.workflowId,
startStepId: seedData.startStepId,
repositoryId: seedData.repositoryId,
});
await testPage.goto(`/t/${taskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForDockviewReady();
await session.addPanelButton().click();
await testPage.getByTestId("add-panel-prompt-history-item").click();
const panel = testPage.getByTestId("prompt-history-panel");
await expect(panel).toBeVisible();
const middleRow = panel.locator('[data-testid^="prompt-history-row-"]', {
hasText: MIDDLE_PROMPT_MARKER,
});
await expect(middleRow).toBeAttached({ timeout: 10_000 });
const messageId = await middleRow.locator("[data-message-id]").getAttribute("data-message-id");
if (!messageId) throw new Error("Middle prompt row has no message id");
await expect(testPage.locator(`#msg-${messageId}`)).toHaveCount(0);
let aroundRequests = 0;
await testPage.route("**/api/v1/task-sessions/*/messages*", async (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get("around") === messageId) aroundRequests += 1;
await route.continue();
});
await middleRow.locator('[role="button"]').first().click();
await expect.poll(() => aroundRequests, { timeout: 10_000 }).toBe(1);
const chat = session.activeChat();
await expect(chat).toBeVisible();
const target = chat.locator(`#msg-${messageId}`);
await expect(target).toBeAttached({ timeout: 10_000 });
const messageList = chat.locator(".chat-message-list").first();
await expect
.poll(
async () => {
const targetTop = await target.evaluate((element) => element.getBoundingClientRect().top);
const listTop = await messageList.evaluate(
(element) => element.getBoundingClientRect().top,
);
const margin = await target.evaluate(
(element) => parseFloat(getComputedStyle(element).scrollMarginTop) || 0,
);
return Math.abs(targetTop - listTop - margin);
},
{ timeout: 10_000 },
)
.toBeLessThanOrEqual(2);
expect(aroundRequests).toBe(1);
});
});
+7 -6
View File
@@ -181,6 +181,12 @@ export type ApplyCustomLayoutOptions = {
activeSessionId?: string | null;
sessionIds?: string[];
};
export type TranscriptScrollTarget = {
sessionId: string;
messageId: string;
token: number;
hostPanelId: string;
};
type DockviewStore = {
api: DockviewApi | null;
@@ -259,12 +265,7 @@ type DockviewStore = {
addDevServerPanel: (groupId?: string) => void;
selectedDiff: { path: string; content?: string } | null;
setSelectedDiff: (diff: { path: string; content?: string } | null) => void;
scrollTarget: {
sessionId: string;
messageId: string;
token: number;
hostPanelId: string;
} | null;
scrollTarget: TranscriptScrollTarget | null;
scrollTranscriptToMessage: (sessionId: string, messageId: string, title: string) => void;
clearScrollTarget: (token: number) => void;
clearScrollTargetForOwner: (sessionId: string, hostPanelId: string) => void;
@@ -0,0 +1,220 @@
---
created: 2026-09-03
status: completed
requirements:
- REQ-UI-PROMPT-HISTORY-PANEL-001
system_design:
- ../../specs/ui/system-design/prompt-history-panel.md
legacy_specs: []
---
# Implementation Plan: Stabilize Prompt History Navigation Scroll
## Overview
Repair prompt-history navigation when the selected prompt is outside the loaded
transcript window. The around-window merge currently permits the first target
scroll to be displaced by transcript pagination or live content; the repair
retains the same navigation intent and performs one bounded reassertion after
the initial placement. The implementation is a single frontend vertical slice,
covering Dockview and phone hosts without changing the message API or transcript
pagination contract.
## Confirmed root cause
`useScrollTargetConsumption` in
`apps/web/components/task/task-chat-panel.tsx` requests an around window when the
selected row is absent, then clears the target after the first successful
rendered-row scroll. `usePendingMessageScroll` owns the equivalent phone/non-
Dockview path. Once the target is cleared, later transcript pagination or live
content can move the viewport without another navigation pass. The existing
`useScrollToMessage` verifier protects one scroll operation but cannot restore a
position changed after that operation has completed.
The same Dockview hook also has two adjacent lifecycle hazards that the repair
must close: it can start an around request while the initial transcript request
is still loading, and its shared `jumpRequestCount` only decrements when the
settling request is still the active target identity. A superseded request can
therefore leave the loading indicator permanently asserted. These are part of
the target lifecycle because both affect whether the first and reasserted scroll
can settle deterministically.
## Scope
### In scope
- Retain an unloaded prompt's target through one post-load reassertion.
- Re-run the existing start-aligned transcript scroll once, after the initial
target scroll succeeds, within a defined one-second settling window.
- Defer an around-window request while the initial transcript request is still
loading; do not create competing initial and around fetches.
- Ensure every settled around request releases its loading accounting, while
stale requests cannot update visible loading state or clear a newer target.
- Preserve session, host-owner, token, stale-request, teardown, and failure
guards.
- Keep already-loaded prompt navigation single-pass and bound retries to one
reassertion.
- Add focused unit regressions and extend the existing desktop and mobile
prompt-history browser flows.
### Out of scope
- Changes to message API payloads, around-window pagination, or transcript row
ordering.
- Changes to generic auto-scroll behavior, persisted scroll offsets, or prompt
history presentation.
- New navigation controls, settings, mobile surfaces, or public documentation.
## Technical approach
Update the shared target-consumption lifecycle in
`apps/web/components/task/task-chat-panel.tsx`. Distinguish the initial scroll
that follows an around-window merge from ordinary loaded-target consumption;
keep the matching target token live until a single delayed reassertion has
attempted the same start alignment. The first `scrollToMessage` return is the
observable completion boundary because the existing handle does not expose its
internal verifier. Start a fixed 250 ms delay at that boundary, and require
the reassertion to execute no later than the one-second navigation window.
Schedule one reassertion after that initial pass, without restarting it for
every rendered-message revision. Re-check the live session, host, token, active
visibility, and row guards before the reassertion, cancel it on supersession,
deactivation, or teardown, and compare-and-clear only after the final
successful pass. Apply the equivalent bounded lifecycle to
`usePendingMessageScroll` so phone navigation has the same contract. Replace
the phone message-only state with an explicit mobile target identity containing
session ID, message ID, and a monotonic token. Use a stable mobile-chat host key,
pass Chat visibility into the hook, and clear or cancel the target when Chat is
deactivated, the selected mobile panel changes, or the Chat host unmounts.
Returning to Chat must not consume an abandoned target.
While the initial transcript is loading, retain the target and wait for the
existing readiness transition before starting `loadMessageWindowAround`.
Deduplicate around requests with an in-flight key containing the session, host,
and target token; a rendered-message revision must not start a second request
for the same intent. Track each request with request-local settlement cleanup:
every settled request releases its loading contribution, while stale
completions may not publish stale loading/error state or clear a newer target.
Use existing `useScrollToMessage` and `loadMessageWindowAround` behavior rather
than adding another scroll implementation. The reassertion must be a single
bounded pass, not an unbounded animation-frame or timer loop.
The desktop browser regression must make a middle target genuinely absent from
the initial bounded transcript window. Seed prompts both before and after that
target, with enough newer transcript content after it for start alignment to be
reachable; never select the oldest or newest boundary prompt. Before
`page.goto`, attach `watchWs(page)` if the revision is delivered over
WebSocket; if using an HTTP mutation, prepare `waitForHttp` instead. Assert the
active Chat does not contain `#msg-<target-id>` immediately before selection,
intercept and assert exactly one target-specific `around` request, hold and
release that response, and use a controlled browser clock to observe the first
target placement. Record the monotonic timestamp when that first scroll
operation succeeds. Before mutating content, require two consecutive geometry
samples one animation frame apart with target top, transcript top, and scrollTop
unchanged within 0.5px. Immediately before the controlled mutation, arm the
prepared transport wait. Give the mutation a unique message ID/marker and,
for the new-row branch, insert that row before the target; alternatively,
change the height or scroll range of a target-preceding row. Assert the marker
was absent before mutation, await the exact transport event, assert the marker
is rendered in active Chat, and assert the target's offset relative to the
scrollport changed before advancing 250 ms. Assert the second scroll call and
await the same two-frame stable final geometry before the one-second deadline.
Polling is bounded by the remaining time from the first-scroll timestamp. The
mobile regression uses the same middle-target, reachable-start precondition
through the `Panels` picker.
## Tests
| Acceptance criterion | Test evidence |
| --- | --- |
| `AC-UI-PROMPT-HISTORY-PANEL-001.12` | Add deterministic fake-timer/RAF regressions proving an unloaded target is scrolled initially, remains owned while multiple transcript revisions occur, is scrolled exactly once more 250 ms after the first successful scroll and no later than one second, and is then cleared; prove loaded targets are not double-scrolled, initial-loading defers the request, same-token revisions do not duplicate an in-flight request, stale/superseded requests release loading state without clearing the active target, and hidden hosts cancel the delayed pass. Cover the mobile target identity and leave-Chat/return lifecycle separately. |
## E2E tests
- Desktop: extend `apps/web/e2e/tests/task/prompt-history-panel.spec.ts` to
attach `watchWs(page)` before `page.goto` (or prepare an HTTP wait when the
mutation is HTTP-only), seed or evict a middle prompt absent from the initial
bounded transcript window, and retain prompts before plus at least one
viewport of newer transcript content after the target. Do not select the
oldest or newest boundary prompt. Assert the active Chat does not contain the
target row immediately before selection, intercept and assert exactly one
target-specific `around` request, hold and release its response, install a
controlled browser clock, and record the monotonic timestamp when the first
target scroll operation succeeds. Before the controlled mutation, require two
consecutive samples one animation frame apart with target top, transcript top,
and scrollTop unchanged within 0.5px. Give that mutation a unique message
ID/marker and, for the new-row branch, insert the row before the target; the
alternative must change the height or scroll range of a target-preceding row.
Arm the transport wait immediately before the mutation, assert the marker was
absent before it, await the exact event, assert the marker is rendered in
active Chat, and assert the target offset relative to the scrollport changed.
Advance 250 ms, assert exactly one reassertion, then await two consecutive
samples one animation frame apart with target top, transcript top, and
scrollTop unchanged within 0.5px. Bound that stable-scroll poll by the
remaining time from the first-scroll timestamp, and assert the final target
top relative to the active chat scrollport equals its computed
`scrollMarginTop` within two pixels before the one-second deadline.
- Mobile: extend
`apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts` with the same
genuinely unloaded, middle-target precondition and exact around-request
assertion through the `Panels` picker and selected Chat surface. Ensure
enough newer content remains after the target for exact start alignment.
Scenario A uses the prepared transport wait, unique mutation marker, and
target-path displacement before the target to await the rendered revision and
changed target offset, advance 250 ms, assert exactly one reassertion, await
the same bounded stable-scroll condition, and assert target top relative to
the phone transcript scrollport equals the target's computed
`scrollMarginTop` within two pixels. Scenario B leaves Chat before the delayed
pass and verifies that returning does not consume the cancelled target.
- Run each through the matching `chromium` and `mobile-chrome` projects with
the managed E2E runner so the production Vite build is rebuilt.
## Mobile design contract
- **Desktop outcome:** selecting an unloaded prompt from Prompt history returns
to Chat and leaves that prompt start-aligned after the around-window merge and
one controlled transcript revision.
- **Mobile entry point:** Prompt history is opened from the existing grouped
`Panels` bottom-navigation picker; selecting its arrow returns to the
existing selected Chat surface.
- **Nearest shipped exemplar:** `SessionMobileLayout` and
`mobile-prompt-history-panel.spec.ts` provide the phone panel and transcript
composition.
- **Hierarchy and presentation:** Chat remains a full-height surface with one
vertical transcript scroll owner; no drawer or new navigation surface is
added. Existing dynamic viewport and safe-area handling remain unchanged.
- **Shared state and logic:** target identity, around-window loading, and the
one-pass reassertion are shared. The phone-specific `usePendingMessageScroll`
lifecycle owns only the non-Dockview handoff and consumption callback.
- **Mobile evidence:** the focused mobile Playwright scenario proves the same
final start alignment and bounded reassertion outcome as desktop.
## Work orders
- [x] [Task 01: Reassert unloaded prompt navigation](task-01-reassert-unloaded-prompt-navigation.md)
## Verification results
Implemented and verified:
- Focused navigation and mobile layout tests: 50 passed.
- Desktop prompt-history E2E: 2 passed.
- Mobile prompt-history E2E: 4 passed.
- `make fmt`, `make typecheck`, and `make lint` passed.
- `make test` was attempted twice; unrelated backend environment-sensitive tests
failed in process lifecycle, configuration discovery, service metadata, and
update-handler suites.
## Risks
- A delayed reassertion could override a newer user navigation unless it
compare-checks the target token at execution time.
- Clearing the target after the first pass would preserve the current defect;
the regression must assert both scroll calls and final target ownership.
- A stale around request can leak the loading indicator unless request-local
settlement accounting is tested.
- The phone path has a separate pending-target hook and must not rely only on
Dockview coverage.
## Open questions
None.
@@ -0,0 +1,158 @@
---
id: "01-reassert-unloaded-prompt-navigation"
title: "Reassert unloaded prompt navigation"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-PROMPT-HISTORY-PANEL-001
acceptance_criteria:
- AC-UI-PROMPT-HISTORY-PANEL-001.11
- AC-UI-PROMPT-HISTORY-PANEL-001.12
system_design:
- ../../specs/ui/system-design/prompt-history-panel.md
---
# Task 01: Reassert Unloaded Prompt Navigation
## Summary
Make prompt-history navigation resilient to transcript growth after an around-
window load. An unloaded target receives its normal initial start-aligned scroll,
then one guarded reassertion 250 ms later, within one second of the first
successful scroll, before the target is consumed; already-loaded targets remain
single-pass.
## In scope
- TDD regressions for Dockview target consumption and the phone/non-Dockview
pending-message path.
- Explicit mobile target identity, stable mobile-chat host ownership, Chat
visibility propagation, and leave-Chat/return cancellation.
- Target-token and session/host ownership checks across the delayed pass,
including active visibility and deactivation cancellation.
- Initial-transcript loading deferral, same-token around-request deduplication,
and request-local around-request settlement accounting.
- A bounded single reassertion using the existing transcript scroll handle.
- Desktop and mobile Playwright coverage for a genuinely unloaded target and
the user-visible navigation result.
## Out of scope
- Message fetching, transcript pagination, auto-scroll policy, or scroll API
changes outside target navigation.
- Prompt history row layout, localization, settings, or backend changes.
## Acceptance
- An unloaded prompt is initially navigated after its around-window response,
remains owned while that first position can be displaced, and is re-navigated
exactly once 250 ms after the first successful target scroll and no later
than one second after that boundary before compare-and-clear.
- Loaded prompts are not double-scrolled. A target whose host becomes inactive,
is superseded, changes session, is torn down, or whose request fails cannot
run or clear the delayed pass.
- Around navigation requested during initial transcript loading waits for
readiness; rendered-message revisions cannot duplicate an in-flight request
for the same session/host/token; every settled request releases loading
accounting without allowing stale state to overwrite the active target.
- Desktop and phone prompt-history selection leave the target message aligned to
the transcript viewport's start after the around-window/content-change flow.
## Verification
Focused unit tests:
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/task/task-chat-panel.scroll-target.test.tsx
```
The regression cases must cover: absent target while initial messages are
loading; first placement followed by multiple transcript revisions and exactly
one delayed reassertion at 250 ms; already-loaded single-pass navigation;
same-token rerenders that do not duplicate an in-flight request; stale A/B
requests whose loading accounting settles; superseded, session-swapped,
hidden, or unmounted targets that cannot run or clear the delayed pass; and the
mobile target's leave-Chat/return cancellation.
Focused browser tests after a production build:
```bash
cd apps/web && pnpm e2e:run --project chromium e2e/tests/task/prompt-history-panel.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/task/mobile-prompt-history-panel.spec.ts
```
Each browser regression must attach `watchWs(page)` before `page.goto` when
the controlled revision is delivered over WebSocket, or prepare `waitForHttp`
when the mutation is HTTP-only. It must seed or evict a middle target absent
from the initial bounded transcript window, with prompts before and at least
one viewport of newer transcript content after it so exact start alignment is
reachable. Do not select the oldest or newest boundary prompt. Assert the
active Chat does not contain `#msg-<target-id>` immediately before selection,
intercept and assert exactly one target-specific `around` request, hold and
release that response, and control the browser clock. Record the monotonic
timestamp when the first target scroll operation succeeds. Before mutation,
require two consecutive geometry samples one animation frame apart with target
top, transcript top, and scrollTop unchanged within 0.5px. Give the controlled
mutation a unique message ID/marker and, for the new-row branch, insert that
row before the target; the alternative must change the height or scroll range
of a target-preceding row. Arm the transport wait immediately before the
mutation, assert the marker was absent before mutation, await the exact
transport event and active-Chat DOM marker, assert it is rendered, and assert
the target offset relative to the scrollport changed. Advance 250 ms and assert
exactly one reassertion. Await two consecutive samples one animation frame
apart with target top, transcript top, and scrollTop unchanged within 0.5px.
Bound this stable-scroll poll by the remaining time from the first-scroll
timestamp, then verify target top minus transcript top equals the target's
computed `scrollMarginTop` within two pixels before the one-second deadline.
The mobile test has two scenarios: Scenario A completes the gated reassertion
and asserts target top minus phone transcript top equals its computed
`scrollMarginTop` within two pixels; Scenario B leaves Chat before the delayed
pass and verifies that returning does not consume the cancelled target.
Final checks required by the task:
```bash
make fmt
make typecheck test lint
```
## Files likely touched
- `apps/web/components/task/task-chat-panel.tsx`
- `apps/web/components/task/task-chat-panel.scroll-target.test.tsx`
- `apps/web/components/task/mobile/session-mobile-layout.tsx`
- `apps/web/e2e/tests/task/prompt-history-panel.spec.ts`
- `apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts`
- `docs/specs/ui/requirements/prompt-history-panel.md`
- `docs/specs/ui/system-design/prompt-history-panel.md`
## Dependencies
None.
## Risks
- React effect reruns and Dockview activation can race the delayed pass; every
callback must re-read the current target identity.
- Mobile uses a separate pending-target lifecycle, so unit and browser coverage
must exercise both hosts.
## Parallelism
`sequential`
## Inputs
- `docs/specs/ui/requirements/prompt-history-panel.md`, especially
`AC-UI-PROMPT-HISTORY-PANEL-001.11` and `.12`.
- `docs/specs/ui/system-design/prompt-history-panel.md`, Prompt navigation.
- Existing target tests and `useScrollToMessage` behavior.
## Results
Implemented. Dockview and mobile target lifecycles now defer initial loading,
deduplicate same-token around requests, retain unloaded targets through one
guarded delayed reassertion, and cancel stale or inactive work. Focused tests,
desktop E2E, mobile E2E, formatting, typecheck, and lint passed. The repository
test target remains environment-flaky in unrelated backend suites.
@@ -30,6 +30,7 @@ Reviewing what was asked of an agent requires scrolling the transcript; past pro
- **AC-UI-PROMPT-HISTORY-PANEL-001.9:** Prompt history requests user messages with the session message API's `author_type=user` filter. It uses the returned cursor and `has_more` value for older prompt pages, and it keeps this pagination state separate from transcript pagination.
- **AC-UI-PROMPT-HISTORY-PANEL-001.10:** Session message create, update, and delete events reconcile both the transcript cache and the prompt projection. A stale HTTP response cannot overwrite a newer live event, and removing a session cancels its pending prompt updates.
- **AC-UI-PROMPT-HISTORY-PANEL-001.11:** Selecting a prompt activates the session chat. If the prompt is not in the loaded transcript, chat requests an around window for that message, merges the response, and scrolls to the prompt after it renders. Desktop and phone use the same behavior.
- **AC-UI-PROMPT-HISTORY-PANEL-001.12:** When an unloaded prompt is selected and the around-window response causes transcript pagination or live content to change the initial viewport, the session chat shall repeat the same prompt navigation once within one second of the initial navigation completing, so the selected prompt remains aligned at the top of the transcript viewport. This applies equally on desktop and phone and does not repeat indefinitely.
## System design
@@ -7,6 +7,7 @@ created: 2026-08-13
owners:
- clem
---
# Prompt History Panel System Design
## Purpose and boundaries
@@ -15,8 +16,8 @@ This design preserves the technical source detail for `REQ-UI-PROMPT-HISTORY-PAN
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| Requirement | Design section |
| --------------------------------- | ------------------------------------------------- |
| `REQ-UI-PROMPT-HISTORY-PANEL-001` | [Migrated source detail](#migrated-source-detail) |
## Migrated source detail
@@ -43,6 +44,7 @@ Reviewing what was asked of an agent requires scrolling the transcript; past pro
- When the prompt rows fit (the panel does not scroll), the indicator renders in the content flow directly under the last message.
The panel root is a positioned outer wrapper whose scrollable content lives in a distinct inner scroller: the floating message anchors to the outer wrapper (the panel viewport) and is a sibling of the scroller, so it never scrolls with the content (an absolutely positioned child of a scroll container moves with its content). In both modes the sentinel stays the final in-flow child of the content wrapper (the indicator is either a sibling after the wrapper or an out-of-flow overlay, never an in-flow element before the sentinel), so the sentinel's geometry is stable while loading. While the user is pinned at the panel's bottom, a completed load scrolls the scroller back to the new bottom so the sentinel stays in view and the next older page loads without requiring a scroll-away/scroll-back; scrolling away from the bottom cancels the stick. The indicator stays mounted for a short grace window after each page settles so consecutive auto-loads (the sentinel re-arms while still intersecting) render one continuous indicator instead of a per-page flash; it disappears once a settle is not followed by another load within the window or pagination ends. A rejected or zero-result request disarms the sentinel until it observes the user scroll away and back; when short content prevents that false transition, a wheel/touch gesture on the panel root retries through the same user-gesture path. Loading stops automatically once the first prompt of the session is reached. Prompt history and transcript pagination have separate requests and metadata; each consumer serializes its own cursor, while WebSocket events fan out to both caches. The panel never renders a load-more button.
- The expand button behaves like the anchored last-prompt bar: expanded rows show the full prompt untruncated (wrapping text) inside the row, capped at 40 % of the panel's height, with in-box scrolling. The cap is measured from the panel's own root element (the component's container, via a ref), not from any transcript selector; it re-measures on panel resize and falls back to 40 % of the viewport height when no container can be measured. The chevron toggles `aria-expanded` and shows the collapse state.
- Clicking a row's arrow button navigates to that prompt in the transcript panel, the same way the scroll-to-last-prompt button navigates to the last prompt: the session's chat panel is activated (opened if absent, focused if present) and the transcript scrolls so the prompt message's top is aligned to the top of the transcript viewport, or to the nearest reachable position when the scroll range is shorter.
- A session with no user prompts shows a neutral loading state while its initial message request is in flight, and shows the definitive empty state only once `entries` is empty, `has_more` is false, and loading has ended; while older pages remain (`has_more`), the panel remains paginatable instead of declaring the session empty. When entries are empty but an older-page request is in flight (`isLoadingMore`), older-page loading takes precedence: the panel shows the loading message in the content flow (an empty panel has nothing to scroll, so it never floats), not the neutral initial loading text or the empty state.
@@ -56,7 +58,7 @@ For each prompt message `M`, the agent-work duration is the wall-clock time from
Duration is displayed only when such an end time exists, rounded down to whole seconds, clamped at zero. The last prompt (newest user message) shows a duration only when its turn is already completed; while the agent is still working on it, no duration is shown. A prompt whose `turn_id` is absent shows a duration only when a later prompt bounds it.
Robustness rules: a prompt whose `created_at` is not parseable as a timestamp is excluded from the list (its ordering position is undefined). The "next prompt" bound is defined over the filtered prompt list only: after exclusion and sorting, the next prompt is the immediately following entry of the same session — so an unparseable user message can never become a next-prompt candidate, and a valid prompt followed by an invalid user message is bounded by the next *valid* prompt after it. Unparseable `Turn.completed_at` is treated as absent rather than producing `NaN`. Ordering is deterministic: prompts sort by `created_at` ascending, ties broken by `message id` ascending (entries are then displayed newest first). All associations are session-scoped: a turn only bounds a prompt when both share the same `session_id` (matched by `turn_id`), and only the next prompt of the same session bounds the duration.
Robustness rules: a prompt whose `created_at` is not parseable as a timestamp is excluded from the list (its ordering position is undefined). The "next prompt" bound is defined over the filtered prompt list only: after exclusion and sorting, the next prompt is the immediately following entry of the same session — so an unparseable user message can never become a next-prompt candidate, and a valid prompt followed by an invalid user message is bounded by the next _valid_ prompt after it. Unparseable `Turn.completed_at` is treated as absent rather than producing `NaN`. Ordering is deterministic: prompts sort by `created_at` ascending, ties broken by `message id` ascending (entries are then displayed newest first). All associations are session-scoped: a turn only bounds a prompt when both share the same `session_id` (matched by `turn_id`), and only the next prompt of the same session bounds the duration.
Prompt history derives entries from its own user-message projection. The projection is ordered by the backend's normalized microsecond timestamp and message ID, and older pages are prepended. The "next prompt" bound is evaluated over the prompts currently in this projection. The transcript cache remains a separate mixed-author projection and can contain rows that prompt history has not loaded yet.
@@ -66,7 +68,20 @@ Zustand owns `messagePrompts.bySession` and its per-session pagination metadata.
## Prompt navigation
Selecting a row creates a session-scoped jump target and activates Chat. Chat first searches the loaded transcript. If the target is absent, it requests `GET /messages?around=<message-id>`, merges the target and newer rows, and retries after the row renders. Desktop and phone clear the target only after a successful scroll or a confirmed missing target. A request cannot consume a newer target or a target for another session.
Selecting a row creates a session-scoped jump target and activates Chat. Chat first searches the loaded transcript. If the target is absent, it waits for the initial transcript request to settle before requesting `GET /messages?around=<message-id>`, merges the target and newer rows, and retries after the row renders. Desktop and phone compare-and-clear the exact target after a successful scroll, a confirmed missing target, or a failed containing-window request. A request cannot consume a newer target or a target for another session.
After an around-window merge, the first rendered target scroll is treated as an initial placement rather than the final settled position. The existing scroll handle exposes success when it finds the row and starts the scroll; that return is the navigation completion boundary. The owner retains the target and starts a fixed 250 ms delay for exactly one reassertion, which must execute within one second of that boundary. The delay is not restarted by later rendered-message revisions. The reassertion uses the existing start-aligned scroll handle and then compare-and-clears the same target token. It runs only when the session, host panel, token, active visibility, and target row still match; a superseding target, session change, panel deactivation, panel teardown, missing target, or failed request cancels the retained intent. Already-loaded prompts keep the existing single-pass behavior, and no navigation path retries indefinitely.
Around-window requests are gated behind initial transcript readiness and deduplicated by `(session_id, host_panel_id, target_token)`. Request-local settlement accounting releases every request's loading contribution, including stale or superseded requests; stale completions cannot publish loading/error state or clear a newer target.
The phone `SessionMobileLayout` owns a separate non-Dockview target with the
same session ID, message ID, and monotonic token plus the stable host key
`mobile-chat`. The target is passed only while Chat is the selected mobile
panel. Leaving Chat, changing the selected panel, or unmounting the Chat host
cancels and clears the target and its delayed reassertion; returning to Chat
cannot consume an abandoned intent. The phone hook receives active visibility
explicitly, so its request deduplication and stale-result checks use the same
identity and lifecycle guarantees as Dockview.
## API surface