| 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,6 +4757,10 @@ export default function ChatView(props: ChatViewProps) { |
| 4757 | requestAnimationFrame(() => positionAnchor(12)); | 4757 | requestAnimationFrame(() => positionAnchor(12)); |
| 4758 | }, []); | 4758 | }, []); |
| 4759 | 4759 | ||
| 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 { |
| 88 | import { ComposerStashBadge } from "./ComposerStashBadge"; | 88 | import { ComposerStashBadge } from "./ComposerStashBadge"; |
| 89 | import { ComposerStashMenu } from "./ComposerStashMenu"; | 89 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| 90 | import { useComposerMenuState } from "./useComposerMenuState"; | 90 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 91 | import { useComposerFocusState } from "./useComposerFocusState"; | ||
| 91 | import { | 92 | import { |
| 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( |
| 1104 | export interface ChatComposerHandle { | 1105 | export 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]); |
| 2355 | 2361 | ||
| 2356 | // ------------------------------------------------------------------ | 2362 | // ------------------------------------------------------------------ |
| 2357 | // Footer compact layout observation | 2363 | // 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]); |
| 2506 | 2512 | ||
| 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]); |
| 2793 | 2799 | ||
| 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]); |
| 2953 | 2959 | ||
| 2954 | // ------------------------------------------------------------------ | 2960 | // ------------------------------------------------------------------ |
| 2955 | // Callbacks: command key | 2961 | // 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]); |
| 3691 | 3697 | ||
| 3692 | // Returning to the window re-fires focus on the element that already held | 3698 | // 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 focus | 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,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 | ]); |
| 3780 | 3787 | ||
| 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]); |
| 4369 | 4376 | ||
| 4370 | // A held collapse settles when the selection goes away, whether the user | 4377 | // 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]); |
| 4461 | 4468 | ||
| 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]); |
| 4490 | 4497 | ||
| 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 | ); |
| 4648 | 4654 |
apps/web/src/components/chat/MessagesTimeline.test.tsx+91-1| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; | 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; |
| 2 | import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; | 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 | import { renderToStaticMarkup } from "react-dom/server"; | 4 | import { renderToStaticMarkup } from "react-dom/server"; |
| 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 5 | import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; | 6 | import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; | 7 | import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; |
| 8 | import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; | ||
| 9 | import { useComposerFocusState } from "./useComposerFocusState"; | ||
| 7 | 10 | ||
| 8 | vi.mock("@legendapp/list/react", async () => { | 11 | vi.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 | } |
| 238 | 241 | ||
| 239 | describe("MessagesTimeline", () => { | 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 | 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 { |
| 230 | 230 | ||
| 231 | const WorkGroupViewCtx = createContext<{ | 231 | const WorkGroupViewCtx = createContext<{ |
| 232 | state: WorkGroupViewState; | 232 | state: WorkGroupViewState; |
| 233 | onToggleEntry: () => void; | 233 | onToggleEntry: (collapsed: boolean) => void; |
| 234 | } | null>(null); | 234 | } | null>(null); |
| 235 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; | 235 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 236 | const TIMELINE_LIST_FADE_HEADER = ( | 236 | const 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 | }, []); |
| 415 | 417 | ||
| 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 | ); | ||
| 434 | 444 | ||
| 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 | ); |
| 479 | 489 | ||
| 480 | // An in-session interrupt leaves its turn expanded so the user keeps their | 490 | // 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 | } |
| 1792 | 1807 | ||
| 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 @@ | ||
| 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 | } | ||