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;
}