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) {
47574757 requestAnimationFrame(() => positionAnchor(12));
47584758 }, []);
47594759
4760 const onToolOutputCollapsedAtEnd = useCallback(() => {
4761 composerRef.current?.restoreAfterTimelineReachedEnd();
4762 }, []);
4763
47604764 const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
47614765 if (
47624766 !isAtEnd &&
......@@ -7828,6 +7832,7 @@ export default function ChatView(props: ChatViewProps) {
78287832 contentInsetEndAdjustment={composerTimelineInset}
78297833 liveFollowEnabled={timelineLiveFollowEnabled}
78307834 onIsAtEndChange={onIsAtEndChange}
7835 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
78317836 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
78327837 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
78337838 topFadeEnabled={!hasTimelineTopBanner}
apps/web/src/components/chat/ChatComposer.tsx+21-15
......@@ -88,6 +88,7 @@ import {
8888import { ComposerStashBadge } from "./ComposerStashBadge";
8989import { ComposerStashMenu } from "./ComposerStashMenu";
9090import { useComposerMenuState } from "./useComposerMenuState";
91import { useComposerFocusState } from "./useComposerFocusState";
9192import {
9293 ComposerTasksBadge,
9394 ComposerTasksContent,
......@@ -1104,7 +1105,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
11041105export interface ChatComposerHandle {
11051106 focusAtEnd: () => void;
11061107 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. */
11081109 restoreAfterTimelineReachedEnd: () => void;
11091110 addDroppedFiles: (files: File[]) => void;
11101111 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
......@@ -1787,8 +1788,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17871788 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
17881789 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
17891790 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
1790 const [isComposerFocused, setIsComposerFocused] = useState(false);
1791 const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false);
1791 const isMobileViewport = useMediaQuery("max-sm");
1792 const {
1793 isComposerFocused,
1794 setIsComposerFocused,
1795 isComposerScrollCollapsed,
1796 setIsComposerScrollCollapsed,
1797 restoreAfterTimelineReachedEnd,
1798 } = useComposerFocusState(isMobileViewport);
17921799 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
17931800 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
17941801 null,
......@@ -1800,7 +1807,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18001807 key: 0,
18011808 active: false,
18021809 });
1803 const isMobileViewport = useMediaQuery("max-sm");
18041810 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
18051811 usePanelAnimationSettings();
18061812 const isComposerCollapsedMobile =
......@@ -2351,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23512357 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
23522358 setIsDragOverComposer(false);
23532359 setIsComposerScrollCollapsed(false);
2354 }, [draftId, activeThreadId, promptRef]);
2360 }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]);
23552361
23562362 // ------------------------------------------------------------------
23572363 // Footer compact layout observation
......@@ -2502,7 +2508,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25022508 COMPOSER_SCROLL_GESTURE_RESET_MS,
25032509 );
25042510 setIsComposerScrollCollapsed(false);
2505 }, []);
2511 }, [setIsComposerScrollCollapsed]);
25062512
25072513 const onPromptChange = useCallback(
25082514 (
......@@ -2789,7 +2795,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27892795 activeElement.blur();
27902796 }
27912797 setIsComposerFocused(false);
2792 }, [isMobileViewport]);
2798 }, [isMobileViewport, setIsComposerFocused]);
27932799
27942800 const shouldBlurMobileComposerOnSubmit = useCallback(() => {
27952801 if (!isMobileViewport) return false;
......@@ -2949,7 +2955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29492955 mobileComposerExpandInFlightRef.current = false;
29502956 });
29512957 });
2952 }, []);
2958 }, [setIsComposerFocused]);
29532959
29542960 // ------------------------------------------------------------------
29552961 // Callbacks: command key
......@@ -3687,7 +3693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36873693 if (!canScrollCollapseComposer) {
36883694 setIsComposerScrollCollapsed(false);
36893695 }
3690 }, [canScrollCollapseComposer]);
3696 }, [canScrollCollapseComposer, setIsComposerScrollCollapsed]);
36913697
36923698 // Returning to the window re-fires focus on the element that already held
36933699 // 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)
37763782 canTrackComposerScrollGesture,
37773783 getTimelineScrollableNode,
37783784 isTimelineAtLogicalEnd,
3785 setIsComposerScrollCollapsed,
37793786 ]);
37803787
37813788 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;
......@@ -4365,7 +4372,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
43654372 }
43664373 setIsComposerFocused(false);
43674374 });
4368 }, [getTimelineScrollableNode, isMobileViewport]);
4375 }, [getTimelineScrollableNode, isMobileViewport, setIsComposerFocused]);
43694376
43704377 // A held collapse settles when the selection goes away, whether the user
43714378 // clicked elsewhere, pressed Escape, or used the selection toolbar.
......@@ -4457,7 +4464,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44574464 }
44584465 desktopOutsidePointerInFlightRef.current = false;
44594466 };
4460 }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck]);
4467 }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck, setIsComposerFocused]);
44614468
44624469 useEffect(() => {
44634470 return () => {
......@@ -4486,7 +4493,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44864493 setIsComposerFocused(true);
44874494 }
44884495 setIsComposerModelPickerOpen(true);
4489 }, [composerControlsHidden]);
4496 }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]);
44904497
44914498 useImperativeHandle(
44924499 composerRef,
......@@ -4497,9 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44974504 focusAt: (cursor: number) => {
44984505 composerEditorRef.current?.focusAt(cursor);
44994506 },
4500 restoreAfterTimelineReachedEnd: () => {
4501 setIsComposerScrollCollapsed(false);
4502 },
4507 restoreAfterTimelineReachedEnd,
45034508 addDroppedFiles: (files: File[]) => {
45044509 void addComposerAttachments(files);
45054510 focusComposer();
......@@ -4643,6 +4648,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
46434648 interactionMode,
46444649 planModeUiEnabled,
46454650 compactThreadContext,
4651 restoreAfterTimelineReachedEnd,
46464652 ],
46474653 );
46484654
apps/web/src/components/chat/MessagesTimeline.test.tsx+91-1
......@@ -1,9 +1,12 @@
11import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
22import { 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";
44import { renderToStaticMarkup } from "react-dom/server";
5import { create, type ReactTestRenderer } from "react-test-renderer";
56import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
67import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";
8import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
9import { useComposerFocusState } from "./useComposerFocusState";
710
811vi.mock("@legendapp/list/react", async () => {
912 const legendListTestId = "legend-list";
......@@ -237,6 +240,93 @@ function buildAssistantTimelineEntry(text: string) {
237240}
238241
239242describe("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
240330 it("renders a feedback command and its pending response as normal thread messages", () => {
241331 const submission = {
242332 id: MessageId.make("feedback-command"),
apps/web/src/components/chat/MessagesTimeline.tsx+47-24
......@@ -206,7 +206,7 @@ interface TimelineRowSharedState {
206206 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
207207 onToggleTurnFold: (turnId: TurnId) => void;
208208 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
209 onToggleWorkEntry: (anchorKey: string) => void;
209 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
210210 workGroupViewState: WorkGroupViewState;
211211 agentPanelModel: AgentPanelModel;
212212 onOpenAgents: () => void;
......@@ -230,7 +230,7 @@ interface WorkGroupViewState {
230230
231231const WorkGroupViewCtx = createContext<{
232232 state: WorkGroupViewState;
233 onToggleEntry: () => void;
233 onToggleEntry: (collapsed: boolean) => void;
234234} | null>(null);
235235const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
236236const TIMELINE_LIST_FADE_HEADER = (
......@@ -333,6 +333,7 @@ interface MessagesTimelineProps {
333333 */
334334 liveFollowEnabled: boolean;
335335 onIsAtEndChange: (isAtEnd: boolean) => void;
336 onToolOutputCollapsedAtEnd?: () => void;
336337 onManualNavigation: () => void;
337338 hideEmptyPlaceholder?: boolean;
338339 topFadeEnabled?: boolean;
......@@ -379,6 +380,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
379380 contentInsetEndAdjustment,
380381 liveFollowEnabled,
381382 onIsAtEndChange,
383 onToolOutputCollapsedAtEnd,
382384 onManualNavigation,
383385 hideEmptyPlaceholder = false,
384386 topFadeEnabled = false,
......@@ -413,24 +415,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({
413415 };
414416 }, []);
415417
416 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => {
417 disclosureAnchorKeyRef.current = anchorKey;
418 setDisclosureToggleSettling(true);
419 if (disclosureSettleFrameRef.current !== null) {
420 cancelAnimationFrame(disclosureSettleFrameRef.current);
421 }
422 if (disclosureSettleSecondFrameRef.current !== null) {
423 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
424 }
425 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
426 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
427 disclosureAnchorKeyRef.current = null;
428 setDisclosureToggleSettling(false);
429 disclosureSettleFrameRef.current = null;
430 disclosureSettleSecondFrameRef.current = null;
418 const suspendEndScrollMaintenanceForDisclosure = useCallback(
419 (anchorKey: string, collapsed = false) => {
420 disclosureAnchorKeyRef.current = anchorKey;
421 setDisclosureToggleSettling(true);
422 if (disclosureSettleFrameRef.current !== null) {
423 cancelAnimationFrame(disclosureSettleFrameRef.current);
424 }
425 if (disclosureSettleSecondFrameRef.current !== null) {
426 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
427 }
428 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
429 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
430 disclosureAnchorKeyRef.current = null;
431 setDisclosureToggleSettling(false);
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 });
431440 });
432 });
433 }, []);
441 },
442 [listRef, onToolOutputCollapsedAtEnd],
443 );
434444
435445 const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {
436446 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
......@@ -463,7 +473,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
463473 );
464474 const onToggleWorkGroup = useCallback(
465475 (groupId: string, anchorKey: string) => {
466 suspendEndScrollMaintenanceForDisclosure(anchorKey);
476 suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId));
467477 setExpandedWorkGroupIds((existing) => {
468478 const next = new Set(existing);
469479 if (next.has(groupId)) {
......@@ -474,7 +484,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
474484 return next;
475485 });
476486 },
477 [suspendEndScrollMaintenanceForDisclosure],
487 [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure],
478488 );
479489
480490 // An in-session interrupt leaves its turn expanded so the user keeps their
......@@ -1727,7 +1737,11 @@ const WorkGroupSection = memo(function WorkGroupSection({
17271737 isExpandedToolGroup: boolean;
17281738 displayLabel?: string | undefined;
17291739}) {
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 );
17311745 const nonEmptyEntries = useMemo(
17321746 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
17331747 [groupedEntries, isExpandedToolGroup],
......@@ -1755,6 +1769,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
17551769 workspaceRoot={workspaceRoot}
17561770 isExpandedToolGroupEntry={false}
17571771 displayLabel={displayLabel}
1772 onToggleEntry={onToggleStandaloneEntry}
17581773 />
17591774 ))}
17601775 </div>
......@@ -1791,7 +1806,10 @@ function ExpandedWorkGroupEntries({
17911806 }
17921807
17931808 const groupView = useMemo(
1794 () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }),
1809 () => ({
1810 state: viewState,
1811 onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed),
1812 }),
17951813 [anchorKey, onToggleWorkEntry, viewState],
17961814 );
17971815 const updateScrollFades = useCallback(() => {
......@@ -3045,6 +3063,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
30453063 workspaceRoot: string | undefined;
30463064 isExpandedToolGroupEntry: boolean;
30473065 displayLabel?: string | undefined;
3066 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
30483067}) {
30493068 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
30503069 // Before any hooks: spawn CTA rows render their own component.
......@@ -3057,6 +3076,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
30573076 workspaceRoot={workspaceRoot}
30583077 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
30593078 displayLabel={displayLabel}
3079 onToggleEntry={props.onToggleEntry}
30603080 />
30613081 );
30623082});
......@@ -3066,6 +3086,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
30663086 workspaceRoot: string | undefined;
30673087 isExpandedToolGroupEntry: boolean;
30683088 displayLabel?: string | undefined;
3089 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
30693090}) {
30703091 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
30713092 const { threadRef, onImageExpand } = use(TimelineRowCtx);
......@@ -3076,9 +3097,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
30763097 const toggleExpanded = () => {
30773098 const next = !expanded;
30783099 if (groupView) {
3079 groupView.onToggleEntry();
3100 groupView.onToggleEntry(!next);
30803101 if (next) groupView.state.expandedEntries.add(workEntry.id);
30813102 else groupView.state.expandedEntries.delete(workEntry.id);
3103 } else {
3104 props.onToggleEntry?.(!next);
30823105 }
30833106 setExpanded(next);
30843107 };
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}