authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 17:53:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:53:57-07:00
log720e126b761ba0da112d8c68b589d20ab98a7eb0
tree01cf16d2af8a3efd9004fcc35318397abe14c99e
parent2d5464afbc19058be8c125d5ee70481d721d14f4
signature Signed by PGP key B5690EEEBB952194

fix(web): restore composer expansion after tool calls (#9782)


6 files changed, 274 insertions(+), 40 deletions(-)

apps/web/src/components/ChatView.tsx+5
...@@ -4757,6 +4757,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -4757,6 +4757,10 @@ export default function ChatView(props: ChatViewProps) {
4757 requestAnimationFrame(() => positionAnchor(12));4757 requestAnimationFrame(() => positionAnchor(12));
4758 }, []);4758 }, []);
47594759
4760 const onToolOutputCollapsedAtEnd = useCallback(() => {
4761 composerRef.current?.restoreAfterTimelineReachedEnd();
4762 }, []);
4763
4760 const onIsAtEndChange = useCallback((isAtEnd: boolean) => {4764 const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
4761 if (4765 if (
4762 !isAtEnd &&4766 !isAtEnd &&
...@@ -7828,6 +7832,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7828,6 +7832,7 @@ export default function ChatView(props: ChatViewProps) {
7828 contentInsetEndAdjustment={composerTimelineInset}7832 contentInsetEndAdjustment={composerTimelineInset}
7829 liveFollowEnabled={timelineLiveFollowEnabled}7833 liveFollowEnabled={timelineLiveFollowEnabled}
7830 onIsAtEndChange={onIsAtEndChange}7834 onIsAtEndChange={onIsAtEndChange}
7835 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
7831 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}7836 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
7832 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}7837 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
7833 topFadeEnabled={!hasTimelineTopBanner}7838 topFadeEnabled={!hasTimelineTopBanner}
apps/web/src/components/chat/ChatComposer.tsx+21-15
...@@ -88,6 +88,7 @@ import {...@@ -88,6 +88,7 @@ import {
88import { ComposerStashBadge } from "./ComposerStashBadge";88import { ComposerStashBadge } from "./ComposerStashBadge";
89import { ComposerStashMenu } from "./ComposerStashMenu";89import { ComposerStashMenu } from "./ComposerStashMenu";
90import { useComposerMenuState } from "./useComposerMenuState";90import { useComposerMenuState } from "./useComposerMenuState";
91import { useComposerFocusState } from "./useComposerFocusState";
91import {92import {
92 ComposerTasksBadge,93 ComposerTasksBadge,
93 ComposerTasksContent,94 ComposerTasksContent,
...@@ -1104,7 +1105,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1104,7 +1105,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1104export interface ChatComposerHandle {1105export interface ChatComposerHandle {
1105 focusAtEnd: () => void;1106 focusAtEnd: () => void;
1106 focusAt: (cursor: number) => void;1107 focusAt: (cursor: number) => void;
1107 /** Undo only a scroll-triggered collapse when the timeline returns to its live edge. */1108 /** Expand the desktop composer at the timeline end without taking focus. */
1108 restoreAfterTimelineReachedEnd: () => void;1109 restoreAfterTimelineReachedEnd: () => void;
1109 addDroppedFiles: (files: File[]) => void;1110 addDroppedFiles: (files: File[]) => void;
1110 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;1111 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
...@@ -1787,8 +1788,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1787,8 +1788,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1787 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);1788 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
1788 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);1789 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
1789 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);1790 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
1790 const [isComposerFocused, setIsComposerFocused] = useState(false);1791 const isMobileViewport = useMediaQuery("max-sm");
1791 const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false);1792 const {
1793 isComposerFocused,
1794 setIsComposerFocused,
1795 isComposerScrollCollapsed,
1796 setIsComposerScrollCollapsed,
1797 restoreAfterTimelineReachedEnd,
1798 } = useComposerFocusState(isMobileViewport);
1792 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);1799 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
1793 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(1800 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
1794 null,1801 null,
...@@ -1800,7 +1807,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1800,7 +1807,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1800 key: 0,1807 key: 0,
1801 active: false,1808 active: false,
1802 });1809 });
1803 const isMobileViewport = useMediaQuery("max-sm");
1804 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =1810 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
1805 usePanelAnimationSettings();1811 usePanelAnimationSettings();
1806 const isComposerCollapsedMobile =1812 const isComposerCollapsedMobile =
...@@ -2351,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2351,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2351 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));2357 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
2352 setIsDragOverComposer(false);2358 setIsDragOverComposer(false);
2353 setIsComposerScrollCollapsed(false);2359 setIsComposerScrollCollapsed(false);
2354 }, [draftId, activeThreadId, promptRef]);2360 }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]);
23552361
2356 // ------------------------------------------------------------------2362 // ------------------------------------------------------------------
2357 // Footer compact layout observation2363 // Footer compact layout observation
...@@ -2502,7 +2508,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2502,7 +2508,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2502 COMPOSER_SCROLL_GESTURE_RESET_MS,2508 COMPOSER_SCROLL_GESTURE_RESET_MS,
2503 );2509 );
2504 setIsComposerScrollCollapsed(false);2510 setIsComposerScrollCollapsed(false);
2505 }, []);2511 }, [setIsComposerScrollCollapsed]);
25062512
2507 const onPromptChange = useCallback(2513 const onPromptChange = useCallback(
2508 (2514 (
...@@ -2789,7 +2795,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2789,7 +2795,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2789 activeElement.blur();2795 activeElement.blur();
2790 }2796 }
2791 setIsComposerFocused(false);2797 setIsComposerFocused(false);
2792 }, [isMobileViewport]);2798 }, [isMobileViewport, setIsComposerFocused]);
27932799
2794 const shouldBlurMobileComposerOnSubmit = useCallback(() => {2800 const shouldBlurMobileComposerOnSubmit = useCallback(() => {
2795 if (!isMobileViewport) return false;2801 if (!isMobileViewport) return false;
...@@ -2949,7 +2955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2949,7 +2955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2949 mobileComposerExpandInFlightRef.current = false;2955 mobileComposerExpandInFlightRef.current = false;
2950 });2956 });
2951 });2957 });
2952 }, []);2958 }, [setIsComposerFocused]);
29532959
2954 // ------------------------------------------------------------------2960 // ------------------------------------------------------------------
2955 // Callbacks: command key2961 // Callbacks: command key
...@@ -3687,7 +3693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3687,7 +3693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3687 if (!canScrollCollapseComposer) {3693 if (!canScrollCollapseComposer) {
3688 setIsComposerScrollCollapsed(false);3694 setIsComposerScrollCollapsed(false);
3689 }3695 }
3690 }, [canScrollCollapseComposer]);3696 }, [canScrollCollapseComposer, setIsComposerScrollCollapsed]);
36913697
3692 // Returning to the window re-fires focus on the element that already held3698 // Returning to the window re-fires focus on the element that already held
3693 // it. That focus arrives after the window's own event, so a window focus3699 // it. That focus arrives after the window's own event, so a window focus
...@@ -3776,6 +3782,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3776,6 +3782,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3776 canTrackComposerScrollGesture,3782 canTrackComposerScrollGesture,
3777 getTimelineScrollableNode,3783 getTimelineScrollableNode,
3778 isTimelineAtLogicalEnd,3784 isTimelineAtLogicalEnd,
3785 setIsComposerScrollCollapsed,
3779 ]);3786 ]);
37803787
3781 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;3788 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;
...@@ -4365,7 +4372,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4365,7 +4372,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4365 }4372 }
4366 setIsComposerFocused(false);4373 setIsComposerFocused(false);
4367 });4374 });
4368 }, [getTimelineScrollableNode, isMobileViewport]);4375 }, [getTimelineScrollableNode, isMobileViewport, setIsComposerFocused]);
43694376
4370 // A held collapse settles when the selection goes away, whether the user4377 // A held collapse settles when the selection goes away, whether the user
4371 // clicked elsewhere, pressed Escape, or used the selection toolbar.4378 // clicked elsewhere, pressed Escape, or used the selection toolbar.
...@@ -4457,7 +4464,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4457,7 +4464,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4457 }4464 }
4458 desktopOutsidePointerInFlightRef.current = false;4465 desktopOutsidePointerInFlightRef.current = false;
4459 };4466 };
4460 }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck]);4467 }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck, setIsComposerFocused]);
44614468
4462 useEffect(() => {4469 useEffect(() => {
4463 return () => {4470 return () => {
...@@ -4486,7 +4493,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4486,7 +4493,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4486 setIsComposerFocused(true);4493 setIsComposerFocused(true);
4487 }4494 }
4488 setIsComposerModelPickerOpen(true);4495 setIsComposerModelPickerOpen(true);
4489 }, [composerControlsHidden]);4496 }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]);
44904497
4491 useImperativeHandle(4498 useImperativeHandle(
4492 composerRef,4499 composerRef,
...@@ -4497,9 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4497,9 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4497 focusAt: (cursor: number) => {4504 focusAt: (cursor: number) => {
4498 composerEditorRef.current?.focusAt(cursor);4505 composerEditorRef.current?.focusAt(cursor);
4499 },4506 },
4500 restoreAfterTimelineReachedEnd: () => {4507 restoreAfterTimelineReachedEnd,
4501 setIsComposerScrollCollapsed(false);
4502 },
4503 addDroppedFiles: (files: File[]) => {4508 addDroppedFiles: (files: File[]) => {
4504 void addComposerAttachments(files);4509 void addComposerAttachments(files);
4505 focusComposer();4510 focusComposer();
...@@ -4643,6 +4648,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4643,6 +4648,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4643 interactionMode,4648 interactionMode,
4644 planModeUiEnabled,4649 planModeUiEnabled,
4645 compactThreadContext,4650 compactThreadContext,
4651 restoreAfterTimelineReachedEnd,
4646 ],4652 ],
4647 );4653 );
46484654
apps/web/src/components/chat/MessagesTimeline.test.tsx+91-1
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
3import { createRef, type ReactNode, type Ref } from "react";3import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
4import { renderToStaticMarkup } from "react-dom/server";4import { renderToStaticMarkup } from "react-dom/server";
5import { create, type ReactTestRenderer } from "react-test-renderer";
5import { beforeAll, describe, expect, it, vi } from "vite-plus/test";6import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
6import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";7import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";
8import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
9import { useComposerFocusState } from "./useComposerFocusState";
710
8vi.mock("@legendapp/list/react", async () => {11vi.mock("@legendapp/list/react", async () => {
9 const legendListTestId = "legend-list";12 const legendListTestId = "legend-list";
...@@ -237,6 +240,93 @@ function buildAssistantTimelineEntry(text: string) {...@@ -237,6 +240,93 @@ function buildAssistantTimelineEntry(text: string) {
237}240}
238241
239describe("MessagesTimeline", () => {242describe("MessagesTimeline", () => {
243 it.each([
244 { toolLifecycleStatus: "inProgress", isAtEnd: true },
245 { toolLifecycleStatus: "inProgress", isAtEnd: false },
246 { toolLifecycleStatus: "completed", isAtEnd: true },
247 { toolLifecycleStatus: "completed", isAtEnd: false },
248 ] as const)(
249 "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd",
250 async ({ toolLifecycleStatus, isAtEnd }) => {
251 const frames = new Map<number, FrameRequestCallback>();
252 let nextFrame = 0;
253 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
254 frames.set(++nextFrame, callback);
255 return nextFrame;
256 });
257 vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
258 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
259 const flushFrame = () =>
260 act(() => {
261 const callbacks = [...frames.values()];
262 frames.clear();
263 callbacks.forEach((callback) => callback(0));
264 });
265 const props = buildProps();
266 let timelineIsAtEnd = isAtEnd;
267 props.listRef.current = {
268 getState: () => ({ isAtEnd: timelineIsAtEnd }),
269 getScrollableNode: () => null,
270 } as unknown as LegendListRef;
271 let isResting = true;
272 function ThreadProbe() {
273 const composer = useComposerFocusState(false);
274 useLayoutEffect(() => {
275 isResting = shouldUseRestingComposerLayout({
276 isExistingThread: true,
277 isMobileViewport: false,
278 isFocused: composer.isComposerFocused,
279 isScrollCollapsed: composer.isComposerScrollCollapsed,
280 hasExpandedChrome: false,
281 collapseOnBlur: true,
282 });
283 });
284 return (
285 <MessagesTimeline
286 {...props}
287 isWorking={toolLifecycleStatus === "inProgress"}
288 onToolOutputCollapsedAtEnd={composer.restoreAfterTimelineReachedEnd}
289 timelineEntries={[
290 {
291 id: "running-tool",
292 kind: "work",
293 createdAt: MESSAGE_CREATED_AT,
294 entry: {
295 id: "running-tool",
296 createdAt: MESSAGE_CREATED_AT,
297 label: "Run command",
298 tone: "tool",
299 toolLifecycleStatus,
300 detail: "Command output",
301 },
302 },
303 ]}
304 />
305 );
306 }
307 let renderer: ReactTestRenderer | undefined;
308 try {
309 await act(() => {
310 renderer = create(<ThreadProbe />);
311 });
312 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
313 await act(() => toggle.props.onClick());
314 await flushFrame();
315 await flushFrame();
316 expect(isResting).toBe(true);
317
318 timelineIsAtEnd = false;
319 await act(() => toggle.props.onClick());
320 await flushFrame();
321 timelineIsAtEnd = isAtEnd;
322 await flushFrame();
323 expect(isResting).toBe(!isAtEnd);
324 } finally {
325 await act(() => renderer?.unmount());
326 }
327 },
328 );
329
240 it("renders a feedback command and its pending response as normal thread messages", () => {330 it("renders a feedback command and its pending response as normal thread messages", () => {
241 const submission = {331 const submission = {
242 id: MessageId.make("feedback-command"),332 id: MessageId.make("feedback-command"),
apps/web/src/components/chat/MessagesTimeline.tsx+47-24
...@@ -206,7 +206,7 @@ interface TimelineRowSharedState {...@@ -206,7 +206,7 @@ interface TimelineRowSharedState {
206 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;206 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
207 onToggleTurnFold: (turnId: TurnId) => void;207 onToggleTurnFold: (turnId: TurnId) => void;
208 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;208 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
209 onToggleWorkEntry: (anchorKey: string) => void;209 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
210 workGroupViewState: WorkGroupViewState;210 workGroupViewState: WorkGroupViewState;
211 agentPanelModel: AgentPanelModel;211 agentPanelModel: AgentPanelModel;
212 onOpenAgents: () => void;212 onOpenAgents: () => void;
...@@ -230,7 +230,7 @@ interface WorkGroupViewState {...@@ -230,7 +230,7 @@ interface WorkGroupViewState {
230230
231const WorkGroupViewCtx = createContext<{231const WorkGroupViewCtx = createContext<{
232 state: WorkGroupViewState;232 state: WorkGroupViewState;
233 onToggleEntry: () => void;233 onToggleEntry: (collapsed: boolean) => void;
234} | null>(null);234} | null>(null);
235const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;235const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
236const TIMELINE_LIST_FADE_HEADER = (236const TIMELINE_LIST_FADE_HEADER = (
...@@ -333,6 +333,7 @@ interface MessagesTimelineProps {...@@ -333,6 +333,7 @@ interface MessagesTimelineProps {
333 */333 */
334 liveFollowEnabled: boolean;334 liveFollowEnabled: boolean;
335 onIsAtEndChange: (isAtEnd: boolean) => void;335 onIsAtEndChange: (isAtEnd: boolean) => void;
336 onToolOutputCollapsedAtEnd?: () => void;
336 onManualNavigation: () => void;337 onManualNavigation: () => void;
337 hideEmptyPlaceholder?: boolean;338 hideEmptyPlaceholder?: boolean;
338 topFadeEnabled?: boolean;339 topFadeEnabled?: boolean;
...@@ -379,6 +380,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -379,6 +380,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
379 contentInsetEndAdjustment,380 contentInsetEndAdjustment,
380 liveFollowEnabled,381 liveFollowEnabled,
381 onIsAtEndChange,382 onIsAtEndChange,
383 onToolOutputCollapsedAtEnd,
382 onManualNavigation,384 onManualNavigation,
383 hideEmptyPlaceholder = false,385 hideEmptyPlaceholder = false,
384 topFadeEnabled = false,386 topFadeEnabled = false,
...@@ -413,24 +415,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -413,24 +415,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({
413 };415 };
414 }, []);416 }, []);
415417
416 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => {418 const suspendEndScrollMaintenanceForDisclosure = useCallback(
417 disclosureAnchorKeyRef.current = anchorKey;419 (anchorKey: string, collapsed = false) => {
418 setDisclosureToggleSettling(true);420 disclosureAnchorKeyRef.current = anchorKey;
419 if (disclosureSettleFrameRef.current !== null) {421 setDisclosureToggleSettling(true);
420 cancelAnimationFrame(disclosureSettleFrameRef.current);422 if (disclosureSettleFrameRef.current !== null) {
421 }423 cancelAnimationFrame(disclosureSettleFrameRef.current);
422 if (disclosureSettleSecondFrameRef.current !== null) {424 }
423 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);425 if (disclosureSettleSecondFrameRef.current !== null) {
424 }426 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
425 disclosureSettleFrameRef.current = requestAnimationFrame(() => {427 }
426 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {428 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
427 disclosureAnchorKeyRef.current = null;429 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
428 setDisclosureToggleSettling(false);430 disclosureAnchorKeyRef.current = null;
429 disclosureSettleFrameRef.current = null;431 setDisclosureToggleSettling(false);
430 disclosureSettleSecondFrameRef.current = null;432 disclosureSettleFrameRef.current = null;
433 disclosureSettleSecondFrameRef.current = null;
434 // Wait for row measurement and the disclosure click's blur check.
435 // Closing output can reveal the end without a scroll event.
436 if (collapsed && resolveTimelineIsAtEnd(listRef.current?.getState()) === true) {
437 onToolOutputCollapsedAtEnd?.();
438 }
439 });
431 });440 });
432 });441 },
433 }, []);442 [listRef, onToolOutputCollapsedAtEnd],
443 );
434444
435 const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {445 const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {
436 const disclosureAnchorKey = disclosureAnchorKeyRef.current;446 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
...@@ -463,7 +473,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -463,7 +473,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
463 );473 );
464 const onToggleWorkGroup = useCallback(474 const onToggleWorkGroup = useCallback(
465 (groupId: string, anchorKey: string) => {475 (groupId: string, anchorKey: string) => {
466 suspendEndScrollMaintenanceForDisclosure(anchorKey);476 suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId));
467 setExpandedWorkGroupIds((existing) => {477 setExpandedWorkGroupIds((existing) => {
468 const next = new Set(existing);478 const next = new Set(existing);
469 if (next.has(groupId)) {479 if (next.has(groupId)) {
...@@ -474,7 +484,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -474,7 +484,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
474 return next;484 return next;
475 });485 });
476 },486 },
477 [suspendEndScrollMaintenanceForDisclosure],487 [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure],
478 );488 );
479489
480 // An in-session interrupt leaves its turn expanded so the user keeps their490 // An in-session interrupt leaves its turn expanded so the user keeps their
...@@ -1727,7 +1737,11 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1727,7 +1737,11 @@ const WorkGroupSection = memo(function WorkGroupSection({
1727 isExpandedToolGroup: boolean;1737 isExpandedToolGroup: boolean;
1728 displayLabel?: string | undefined;1738 displayLabel?: string | undefined;
1729}) {1739}) {
1730 const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx);1740 const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx);
1741 const onToggleStandaloneEntry = useCallback(
1742 (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed),
1743 [anchorKey, onToggleWorkEntry],
1744 );
1731 const nonEmptyEntries = useMemo(1745 const nonEmptyEntries = useMemo(
1732 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),1746 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
1733 [groupedEntries, isExpandedToolGroup],1747 [groupedEntries, isExpandedToolGroup],
...@@ -1755,6 +1769,7 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1755,6 +1769,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
1755 workspaceRoot={workspaceRoot}1769 workspaceRoot={workspaceRoot}
1756 isExpandedToolGroupEntry={false}1770 isExpandedToolGroupEntry={false}
1757 displayLabel={displayLabel}1771 displayLabel={displayLabel}
1772 onToggleEntry={onToggleStandaloneEntry}
1758 />1773 />
1759 ))}1774 ))}
1760 </div>1775 </div>
...@@ -1791,7 +1806,10 @@ function ExpandedWorkGroupEntries({...@@ -1791,7 +1806,10 @@ function ExpandedWorkGroupEntries({
1791 }1806 }
17921807
1793 const groupView = useMemo(1808 const groupView = useMemo(
1794 () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }),1809 () => ({
1810 state: viewState,
1811 onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed),
1812 }),
1795 [anchorKey, onToggleWorkEntry, viewState],1813 [anchorKey, onToggleWorkEntry, viewState],
1796 );1814 );
1797 const updateScrollFades = useCallback(() => {1815 const updateScrollFades = useCallback(() => {
...@@ -3045,6 +3063,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -3045,6 +3063,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
3045 workspaceRoot: string | undefined;3063 workspaceRoot: string | undefined;
3046 isExpandedToolGroupEntry: boolean;3064 isExpandedToolGroupEntry: boolean;
3047 displayLabel?: string | undefined;3065 displayLabel?: string | undefined;
3066 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
3048}) {3067}) {
3049 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;3068 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
3050 // Before any hooks: spawn CTA rows render their own component.3069 // Before any hooks: spawn CTA rows render their own component.
...@@ -3057,6 +3076,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -3057,6 +3076,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
3057 workspaceRoot={workspaceRoot}3076 workspaceRoot={workspaceRoot}
3058 isExpandedToolGroupEntry={isExpandedToolGroupEntry}3077 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
3059 displayLabel={displayLabel}3078 displayLabel={displayLabel}
3079 onToggleEntry={props.onToggleEntry}
3060 />3080 />
3061 );3081 );
3062});3082});
...@@ -3066,6 +3086,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3066,6 +3086,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3066 workspaceRoot: string | undefined;3086 workspaceRoot: string | undefined;
3067 isExpandedToolGroupEntry: boolean;3087 isExpandedToolGroupEntry: boolean;
3068 displayLabel?: string | undefined;3088 displayLabel?: string | undefined;
3089 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
3069}) {3090}) {
3070 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;3091 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
3071 const { threadRef, onImageExpand } = use(TimelineRowCtx);3092 const { threadRef, onImageExpand } = use(TimelineRowCtx);
...@@ -3076,9 +3097,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3076,9 +3097,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3076 const toggleExpanded = () => {3097 const toggleExpanded = () => {
3077 const next = !expanded;3098 const next = !expanded;
3078 if (groupView) {3099 if (groupView) {
3079 groupView.onToggleEntry();3100 groupView.onToggleEntry(!next);
3080 if (next) groupView.state.expandedEntries.add(workEntry.id);3101 if (next) groupView.state.expandedEntries.add(workEntry.id);
3081 else groupView.state.expandedEntries.delete(workEntry.id);3102 else groupView.state.expandedEntries.delete(workEntry.id);
3103 } else {
3104 props.onToggleEntry?.(!next);
3082 }3105 }
3083 setExpanded(next);3106 setExpanded(next);
3084 };3107 };
apps/web/src/components/chat/useComposerFocusState.test.tsx created+87
...@@ -0,0 +1,87 @@
1import { act, useLayoutEffect } from "react";
2import { createRoot, type Root } from "react-dom/client";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
6import { useComposerFocusState } from "./useComposerFocusState";
7
8let root: Root;
9let composer: ReturnType<typeof useComposerFocusState>;
10let isResting: boolean;
11
12function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolean }) {
13 const state = useComposerFocusState(isMobileViewport);
14 useLayoutEffect(() => {
15 composer = state;
16 isResting = shouldUseRestingComposerLayout({
17 isExistingThread: true,
18 isMobileViewport,
19 isFocused: state.isComposerFocused,
20 isScrollCollapsed: state.isComposerScrollCollapsed,
21 hasExpandedChrome: false,
22 collapseOnBlur: true,
23 });
24 });
25 return null;
26}
27
28beforeEach(async () => {
29 // The probe has no DOM output, but ReactDOM needs an event target.
30 const document = {
31 nodeType: 9,
32 addEventListener() {},
33 removeEventListener() {},
34 };
35 const container = {
36 nodeType: 1,
37 tagName: "DIV",
38 namespaceURI: "http://www.w3.org/1999/xhtml",
39 ownerDocument: document,
40 addEventListener() {},
41 removeEventListener() {},
42 };
43 vi.stubGlobal("document", document);
44 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
45 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
46 root = createRoot(container as unknown as HTMLElement);
47 await act(() => root.render(<ComposerProbe />));
48});
49
50afterEach(async () => {
51 await act(() => root.unmount());
52 vi.unstubAllGlobals();
53});
54
55describe("composer focus state", () => {
56 it("expands at the timeline end after a tool call takes focus", async () => {
57 await act(() => composer.setIsComposerFocused(true));
58 expect(isResting).toBe(false);
59
60 // A tool disclosure takes focus before the user scrolls through its output.
61 await act(() => composer.setIsComposerFocused(false));
62 expect(isResting).toBe(true);
63
64 await act(() => composer.restoreAfterTimelineReachedEnd());
65 expect(isResting).toBe(false);
66
67 await act(() => composer.setIsComposerFocused(false));
68 expect(isResting).toBe(true);
69 });
70
71 it("can collapse again on the next scroll after returning to the end", async () => {
72 await act(() => composer.setIsComposerScrollCollapsed(true));
73 expect(isResting).toBe(true);
74
75 await act(() => composer.restoreAfterTimelineReachedEnd());
76 expect(isResting).toBe(false);
77
78 await act(() => composer.setIsComposerScrollCollapsed(true));
79 expect(isResting).toBe(true);
80 });
81
82 it("does not expand the phone composer when the timeline reaches the end", async () => {
83 await act(() => root.render(<ComposerProbe isMobileViewport />));
84 await act(() => composer.restoreAfterTimelineReachedEnd());
85 expect(composer.isComposerFocused).toBe(false);
86 });
87});
apps/web/src/components/chat/useComposerFocusState.ts created+23
...@@ -0,0 +1,23 @@
1import { useCallback, useState } from "react";
2
3export function useComposerFocusState(isMobileViewport: boolean) {
4 const [isComposerFocused, setIsComposerFocused] = useState(false);
5 const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false);
6
7 const restoreAfterTimelineReachedEnd = useCallback(() => {
8 setIsComposerScrollCollapsed(false);
9 // Restore the expanded layout after a timeline control takes focus too.
10 // This state holds the layout open without moving DOM focus to the editor.
11 if (!isMobileViewport) {
12 setIsComposerFocused(true);
13 }
14 }, [isMobileViewport]);
15
16 return {
17 isComposerFocused,
18 setIsComposerFocused,
19 isComposerScrollCollapsed,
20 setIsComposerScrollCollapsed,
21 restoreAfterTimelineReachedEnd,
22 };
23}