diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 038274b274b5a..d65d49b9fa6ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -449,6 +449,57 @@ describe("MessagesTimeline work rows", () => { .toBeInTheDocument(); }); + it("keeps the bar still when work arrives while the history collapses at the bottom", async () => { + const runId = RunId.make("collapse-run"); + const props = { + ...baseProps(), + isWorking: true, + activeTurnStartedAt: CREATED_AT, + latestRun: { runId, status: "running" as const, startedAt: CREATED_AT, completedAt: null }, + }; + const entries = (steps: number) => [ + ...Array.from({ length: 40 }, (_, index) => + message(`Earlier ${index}`, {}, `earlier-${index}`), + ), + message("Build it", { runId }), + ...Array.from({ length: steps }, (_, index) => + message(`Step ${index}`, { runId, role: "assistant" }, `step-${index}`), + ), + ]; + await using screen = await renderElement( + , + ); + const bar = screen.getByRole("button", { name: "Open work history" }); + await expect.element(bar).toBeVisible(); + const viewport = props.listRef.current!.getScrollableNode()!; + await vi.waitFor(() => + expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2), + ); + const barElement = bar.element(); + await bar.click(); + const history = screen.getByRole("region", { name: "Work history" }).element(); + await expect.element(screen.getByText("Step 0", { exact: true })).toBeInTheDocument(); + await Promise.all(history.getAnimations().map((animation) => animation.finished)); + const barTop = barElement.getBoundingClientRect().top; + const expandedHeight = history.clientHeight; + const barTops = new Set(); + const resizes = new ResizeObserver(() => barTops.add(barElement.getBoundingClientRect().top)); + resizes.observe(history); + using _resizes = { [Symbol.dispose]: () => resizes.disconnect() }; + history.style.transitionDuration = "10s"; + + await screen.getByRole("button", { name: "Close work history" }).click(); + await vi.waitFor(() => expect(history.clientHeight).toBeLessThan(expandedHeight - 20)); + await screen.rerender(); + const arrivedHeight = history.clientHeight; + await vi.waitFor(() => expect(history.clientHeight).toBeLessThan(arrivedHeight - 60)); + for (const animation of history.getAnimations()) animation.finish(); + await vi.waitFor(() => + expect(history.querySelector('[data-slot="scroll-area-viewport"]')).not.toBeNull(), + ); + expect([...barTops]).toEqual([barTop]); + }); + it.each(["delegated", "legacy", "missing"] as const)( "uses the reported task for %s completion notifications", async (source) => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b56a533012d1e..f3f212f9639aa 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -499,6 +499,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ // The open history belongs to the prompt that opened it; sending closes it. const [workingHistoryKey, setWorkingHistoryKey] = useState(null); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); + const [workingHistoryCollapsing, setWorkingHistoryCollapsing] = useState(false); const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); @@ -1137,6 +1138,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, onToggleWorkingHistory, + onWorkingHistoryCollapsing: setWorkingHistoryCollapsing, onFocusComposer: onFocusComposer ?? null, workGroupViewState, }; @@ -1431,7 +1433,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ !liveFollowEnabled || fullscreenAppRowId !== null || disclosureToggleSettling || - workingHistoryOpen + workingHistoryOpen || + workingHistoryCollapsing ? false : isWorking && !prefersReducedMotion && settlingListIdentity === null ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 9c4933f318307..3dc64599a0351 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -898,17 +898,20 @@ function WorkingWindowView({ window, itemKey }: { window: WorkingWindow; itemKey if (open) return; setCollapseClips((windowRef.current?.scrollHeight ?? 0) > WORKING_WINDOW_COLLAPSED_HEIGHT); }, [open]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- older items stay mounted until the collapse transition finishes clipping them + const { onWorkingHistoryCollapsing } = ctx; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- older items stay mounted, and the timeline stops following its end, until the collapse transition finishes useEffect(() => { if (open || history === null) return; + onWorkingHistoryCollapsing(true); let cancelled = false; void transitionsSettled(windowRef.current).then(() => { if (!cancelled) setHistory(null); }); return () => { cancelled = true; + onWorkingHistoryCollapsing(false); }; - }, [open, history]); + }, [open, history, onWorkingHistoryCollapsing]); const head = history?.head; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts older items as the timeline scroll brings them near the viewport useEffect(() => { diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index e557e41856c5c..7ba464f4c7848 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -95,6 +95,7 @@ export interface TimelineRowSharedState { onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; onToggleWorkingHistory: (key: string) => void; + onWorkingHistoryCollapsing: (collapsing: boolean) => void; onFocusComposer: (() => void) | null; workGroupViewState: WorkGroupViewState; }