| author | |
| committer | |
| log | 720e126b761ba0da112d8c68b589d20ab98a7eb0 |
| tree | 01cf16d2af8a3efd9004fcc35318397abe14c99e |
| parent | 2d5464afbc19058be8c125d5ee70481d721d14f4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4757 | requestAnimationFrame(() => positionAnchor(12)); |
| 4758 | 4758 | }, []); |
| 4759 | 4759 | |
| 4760 | const onToolOutputCollapsedAtEnd = useCallback(() => { | |
| 4761 | composerRef.current?.restoreAfterTimelineReachedEnd(); | |
| 4762 | }, []); | |
| 4763 | ||
| 4760 | 4764 | const onIsAtEndChange = useCallback((isAtEnd: boolean) => { |
| 4761 | 4765 | if ( |
| 4762 | 4766 | !isAtEnd && |
| ... | ... | @@ -7828,6 +7832,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7828 | 7832 | contentInsetEndAdjustment={composerTimelineInset} |
| 7829 | 7833 | liveFollowEnabled={timelineLiveFollowEnabled} |
| 7830 | 7834 | onIsAtEndChange={onIsAtEndChange} |
| 7835 | onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} | |
| 7831 | 7836 | onManualNavigation={cancelTimelineLiveFollowForUserNavigation} |
| 7832 | 7837 | hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} |
| 7833 | 7838 | topFadeEnabled={!hasTimelineTopBanner} |
apps/web/src/components/chat/ChatComposer.tsx+21-15| ... | ... | @@ -88,6 +88,7 @@ import { |
| 88 | 88 | import { ComposerStashBadge } from "./ComposerStashBadge"; |
| 89 | 89 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| 90 | 90 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 91 | import { useComposerFocusState } from "./useComposerFocusState"; | |
| 91 | 92 | import { |
| 92 | 93 | ComposerTasksBadge, |
| 93 | 94 | ComposerTasksContent, |
| ... | ... | @@ -1104,7 +1105,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 1104 | 1105 | export interface ChatComposerHandle { |
| 1105 | 1106 | focusAtEnd: () => void; |
| 1106 | 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 | 1109 | restoreAfterTimelineReachedEnd: () => void; |
| 1109 | 1110 | addDroppedFiles: (files: File[]) => void; |
| 1110 | 1111 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; |
| ... | ... | @@ -1787,8 +1788,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1787 | 1788 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); |
| 1788 | 1789 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); |
| 1789 | 1790 | 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); | |
| 1792 | 1799 | const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null); |
| 1793 | 1800 | const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>( |
| 1794 | 1801 | null, |
| ... | ... | @@ -1800,7 +1807,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1800 | 1807 | key: 0, |
| 1801 | 1808 | active: false, |
| 1802 | 1809 | }); |
| 1803 | const isMobileViewport = useMediaQuery("max-sm"); | |
| 1804 | 1810 | const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = |
| 1805 | 1811 | usePanelAnimationSettings(); |
| 1806 | 1812 | const isComposerCollapsedMobile = |
| ... | ... | @@ -2351,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2351 | 2357 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| 2352 | 2358 | setIsDragOverComposer(false); |
| 2353 | 2359 | setIsComposerScrollCollapsed(false); |
| 2354 | }, [draftId, activeThreadId, promptRef]); | |
| 2360 | }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); | |
| 2355 | 2361 | |
| 2356 | 2362 | // ------------------------------------------------------------------ |
| 2357 | 2363 | // Footer compact layout observation |
| ... | ... | @@ -2502,7 +2508,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2502 | 2508 | COMPOSER_SCROLL_GESTURE_RESET_MS, |
| 2503 | 2509 | ); |
| 2504 | 2510 | setIsComposerScrollCollapsed(false); |
| 2505 | }, []); | |
| 2511 | }, [setIsComposerScrollCollapsed]); | |
| 2506 | 2512 | |
| 2507 | 2513 | const onPromptChange = useCallback( |
| 2508 | 2514 | ( |
| ... | ... | @@ -2789,7 +2795,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2789 | 2795 | activeElement.blur(); |
| 2790 | 2796 | } |
| 2791 | 2797 | setIsComposerFocused(false); |
| 2792 | }, [isMobileViewport]); | |
| 2798 | }, [isMobileViewport, setIsComposerFocused]); | |
| 2793 | 2799 | |
| 2794 | 2800 | const shouldBlurMobileComposerOnSubmit = useCallback(() => { |
| 2795 | 2801 | if (!isMobileViewport) return false; |
| ... | ... | @@ -2949,7 +2955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2949 | 2955 | mobileComposerExpandInFlightRef.current = false; |
| 2950 | 2956 | }); |
| 2951 | 2957 | }); |
| 2952 | }, []); | |
| 2958 | }, [setIsComposerFocused]); | |
| 2953 | 2959 | |
| 2954 | 2960 | // ------------------------------------------------------------------ |
| 2955 | 2961 | // Callbacks: command key |
| ... | ... | @@ -3687,7 +3693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3687 | 3693 | if (!canScrollCollapseComposer) { |
| 3688 | 3694 | setIsComposerScrollCollapsed(false); |
| 3689 | 3695 | } |
| 3690 | }, [canScrollCollapseComposer]); | |
| 3696 | }, [canScrollCollapseComposer, setIsComposerScrollCollapsed]); | |
| 3691 | 3697 | |
| 3692 | 3698 | // Returning to the window re-fires focus on the element that already held |
| 3693 | 3699 | // 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 | 3782 | canTrackComposerScrollGesture, |
| 3777 | 3783 | getTimelineScrollableNode, |
| 3778 | 3784 | isTimelineAtLogicalEnd, |
| 3785 | setIsComposerScrollCollapsed, | |
| 3779 | 3786 | ]); |
| 3780 | 3787 | |
| 3781 | 3788 | const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; |
| ... | ... | @@ -4365,7 +4372,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4365 | 4372 | } |
| 4366 | 4373 | setIsComposerFocused(false); |
| 4367 | 4374 | }); |
| 4368 | }, [getTimelineScrollableNode, isMobileViewport]); | |
| 4375 | }, [getTimelineScrollableNode, isMobileViewport, setIsComposerFocused]); | |
| 4369 | 4376 | |
| 4370 | 4377 | // A held collapse settles when the selection goes away, whether the user |
| 4371 | 4378 | // clicked elsewhere, pressed Escape, or used the selection toolbar. |
| ... | ... | @@ -4457,7 +4464,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4457 | 4464 | } |
| 4458 | 4465 | desktopOutsidePointerInFlightRef.current = false; |
| 4459 | 4466 | }; |
| 4460 | }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck]); | |
| 4467 | }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck, setIsComposerFocused]); | |
| 4461 | 4468 | |
| 4462 | 4469 | useEffect(() => { |
| 4463 | 4470 | return () => { |
| ... | ... | @@ -4486,7 +4493,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4486 | 4493 | setIsComposerFocused(true); |
| 4487 | 4494 | } |
| 4488 | 4495 | setIsComposerModelPickerOpen(true); |
| 4489 | }, [composerControlsHidden]); | |
| 4496 | }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]); | |
| 4490 | 4497 | |
| 4491 | 4498 | useImperativeHandle( |
| 4492 | 4499 | composerRef, |
| ... | ... | @@ -4497,9 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4497 | 4504 | focusAt: (cursor: number) => { |
| 4498 | 4505 | composerEditorRef.current?.focusAt(cursor); |
| 4499 | 4506 | }, |
| 4500 | restoreAfterTimelineReachedEnd: () => { | |
| 4501 | setIsComposerScrollCollapsed(false); | |
| 4502 | }, | |
| 4507 | restoreAfterTimelineReachedEnd, | |
| 4503 | 4508 | addDroppedFiles: (files: File[]) => { |
| 4504 | 4509 | void addComposerAttachments(files); |
| 4505 | 4510 | focusComposer(); |
| ... | ... | @@ -4643,6 +4648,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4643 | 4648 | interactionMode, |
| 4644 | 4649 | planModeUiEnabled, |
| 4645 | 4650 | compactThreadContext, |
| 4651 | restoreAfterTimelineReachedEnd, | |
| 4646 | 4652 | ], |
| 4647 | 4653 | ); |
| 4648 | 4654 |
apps/web/src/components/chat/MessagesTimeline.test.tsx+91-1| ... | ... | @@ -1,9 +1,12 @@ |
| 1 | 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; |
| 2 | 2 | import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; |
| 3 | import { createRef, type ReactNode, type Ref } from "react"; | |
| 3 | import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; | |
| 4 | 4 | import { renderToStaticMarkup } from "react-dom/server"; |
| 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 5 | 6 | import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 7 | import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; |
| 8 | import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; | |
| 9 | import { useComposerFocusState } from "./useComposerFocusState"; | |
| 7 | 10 | |
| 8 | 11 | vi.mock("@legendapp/list/react", async () => { |
| 9 | 12 | const legendListTestId = "legend-list"; |
| ... | ... | @@ -237,6 +240,93 @@ function buildAssistantTimelineEntry(text: string) { |
| 237 | 240 | } |
| 238 | 241 | |
| 239 | 242 | describe("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 | 330 | it("renders a feedback command and its pending response as normal thread messages", () => { |
| 241 | 331 | const submission = { |
| 242 | 332 | id: MessageId.make("feedback-command"), |
apps/web/src/components/chat/MessagesTimeline.tsx+47-24| ... | ... | @@ -206,7 +206,7 @@ interface TimelineRowSharedState { |
| 206 | 206 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 207 | 207 | onToggleTurnFold: (turnId: TurnId) => void; |
| 208 | 208 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 209 | onToggleWorkEntry: (anchorKey: string) => void; | |
| 209 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; | |
| 210 | 210 | workGroupViewState: WorkGroupViewState; |
| 211 | 211 | agentPanelModel: AgentPanelModel; |
| 212 | 212 | onOpenAgents: () => void; |
| ... | ... | @@ -230,7 +230,7 @@ interface WorkGroupViewState { |
| 230 | 230 | |
| 231 | 231 | const WorkGroupViewCtx = createContext<{ |
| 232 | 232 | state: WorkGroupViewState; |
| 233 | onToggleEntry: () => void; | |
| 233 | onToggleEntry: (collapsed: boolean) => void; | |
| 234 | 234 | } | null>(null); |
| 235 | 235 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 236 | 236 | const TIMELINE_LIST_FADE_HEADER = ( |
| ... | ... | @@ -333,6 +333,7 @@ interface MessagesTimelineProps { |
| 333 | 333 | */ |
| 334 | 334 | liveFollowEnabled: boolean; |
| 335 | 335 | onIsAtEndChange: (isAtEnd: boolean) => void; |
| 336 | onToolOutputCollapsedAtEnd?: () => void; | |
| 336 | 337 | onManualNavigation: () => void; |
| 337 | 338 | hideEmptyPlaceholder?: boolean; |
| 338 | 339 | topFadeEnabled?: boolean; |
| ... | ... | @@ -379,6 +380,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 379 | 380 | contentInsetEndAdjustment, |
| 380 | 381 | liveFollowEnabled, |
| 381 | 382 | onIsAtEndChange, |
| 383 | onToolOutputCollapsedAtEnd, | |
| 382 | 384 | onManualNavigation, |
| 383 | 385 | hideEmptyPlaceholder = false, |
| 384 | 386 | topFadeEnabled = false, |
| ... | ... | @@ -413,24 +415,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 413 | 415 | }; |
| 414 | 416 | }, []); |
| 415 | 417 | |
| 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 | }); | |
| 431 | 440 | }); |
| 432 | }); | |
| 433 | }, []); | |
| 441 | }, | |
| 442 | [listRef, onToolOutputCollapsedAtEnd], | |
| 443 | ); | |
| 434 | 444 | |
| 435 | 445 | const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { |
| 436 | 446 | const disclosureAnchorKey = disclosureAnchorKeyRef.current; |
| ... | ... | @@ -463,7 +473,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 463 | 473 | ); |
| 464 | 474 | const onToggleWorkGroup = useCallback( |
| 465 | 475 | (groupId: string, anchorKey: string) => { |
| 466 | suspendEndScrollMaintenanceForDisclosure(anchorKey); | |
| 476 | suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId)); | |
| 467 | 477 | setExpandedWorkGroupIds((existing) => { |
| 468 | 478 | const next = new Set(existing); |
| 469 | 479 | if (next.has(groupId)) { |
| ... | ... | @@ -474,7 +484,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 474 | 484 | return next; |
| 475 | 485 | }); |
| 476 | 486 | }, |
| 477 | [suspendEndScrollMaintenanceForDisclosure], | |
| 487 | [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], | |
| 478 | 488 | ); |
| 479 | 489 | |
| 480 | 490 | // An in-session interrupt leaves its turn expanded so the user keeps their |
| ... | ... | @@ -1727,7 +1737,11 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1727 | 1737 | isExpandedToolGroup: boolean; |
| 1728 | 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 | 1745 | const nonEmptyEntries = useMemo( |
| 1732 | 1746 | () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), |
| 1733 | 1747 | [groupedEntries, isExpandedToolGroup], |
| ... | ... | @@ -1755,6 +1769,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1755 | 1769 | workspaceRoot={workspaceRoot} |
| 1756 | 1770 | isExpandedToolGroupEntry={false} |
| 1757 | 1771 | displayLabel={displayLabel} |
| 1772 | onToggleEntry={onToggleStandaloneEntry} | |
| 1758 | 1773 | /> |
| 1759 | 1774 | ))} |
| 1760 | 1775 | </div> |
| ... | ... | @@ -1791,7 +1806,10 @@ function ExpandedWorkGroupEntries({ |
| 1791 | 1806 | } |
| 1792 | 1807 | |
| 1793 | 1808 | const groupView = useMemo( |
| 1794 | () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }), | |
| 1809 | () => ({ | |
| 1810 | state: viewState, | |
| 1811 | onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), | |
| 1812 | }), | |
| 1795 | 1813 | [anchorKey, onToggleWorkEntry, viewState], |
| 1796 | 1814 | ); |
| 1797 | 1815 | const updateScrollFades = useCallback(() => { |
| ... | ... | @@ -3045,6 +3063,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 3045 | 3063 | workspaceRoot: string | undefined; |
| 3046 | 3064 | isExpandedToolGroupEntry: boolean; |
| 3047 | 3065 | displayLabel?: string | undefined; |
| 3066 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; | |
| 3048 | 3067 | }) { |
| 3049 | 3068 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; |
| 3050 | 3069 | // Before any hooks: spawn CTA rows render their own component. |
| ... | ... | @@ -3057,6 +3076,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 3057 | 3076 | workspaceRoot={workspaceRoot} |
| 3058 | 3077 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} |
| 3059 | 3078 | displayLabel={displayLabel} |
| 3079 | onToggleEntry={props.onToggleEntry} | |
| 3060 | 3080 | /> |
| 3061 | 3081 | ); |
| 3062 | 3082 | }); |
| ... | ... | @@ -3066,6 +3086,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3066 | 3086 | workspaceRoot: string | undefined; |
| 3067 | 3087 | isExpandedToolGroupEntry: boolean; |
| 3068 | 3088 | displayLabel?: string | undefined; |
| 3089 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; | |
| 3069 | 3090 | }) { |
| 3070 | 3091 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; |
| 3071 | 3092 | const { threadRef, onImageExpand } = use(TimelineRowCtx); |
| ... | ... | @@ -3076,9 +3097,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3076 | 3097 | const toggleExpanded = () => { |
| 3077 | 3098 | const next = !expanded; |
| 3078 | 3099 | if (groupView) { |
| 3079 | groupView.onToggleEntry(); | |
| 3100 | groupView.onToggleEntry(!next); | |
| 3080 | 3101 | if (next) groupView.state.expandedEntries.add(workEntry.id); |
| 3081 | 3102 | else groupView.state.expandedEntries.delete(workEntry.id); |
| 3103 | } else { | |
| 3104 | props.onToggleEntry?.(!next); | |
| 3082 | 3105 | } |
| 3083 | 3106 | setExpanded(next); |
| 3084 | 3107 | }; |
apps/web/src/components/chat/useComposerFocusState.test.tsx created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import { act, useLayoutEffect } from "react"; | |
| 2 | import { createRoot, type Root } from "react-dom/client"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; | |
| 6 | import { useComposerFocusState } from "./useComposerFocusState"; | |
| 7 | ||
| 8 | let root: Root; | |
| 9 | let composer: ReturnType<typeof useComposerFocusState>; | |
| 10 | let isResting: boolean; | |
| 11 | ||
| 12 | function 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 | ||
| 28 | beforeEach(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 | ||
| 50 | afterEach(async () => { | |
| 51 | await act(() => root.unmount()); | |
| 52 | vi.unstubAllGlobals(); | |
| 53 | }); | |
| 54 | ||
| 55 | describe("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 @@ |
| 1 | import { useCallback, useState } from "react"; | |
| 2 | ||
| 3 | export 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 | } |