From 39d0de6c2ca3f92b8ab43276ad0ea3b2556a8d86 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 14:07:26 -0700 Subject: [PATCH 1/2] fix(web): restore composer expansion after tool calls --- apps/web/src/components/ChatView.tsx | 5 ++ apps/web/src/components/chat/ChatComposer.tsx | 36 ++++---- .../components/chat/MessagesTimeline.test.tsx | 86 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 59 ++++++++----- .../chat/useComposerFocusState.test.tsx | 87 +++++++++++++++++++ .../components/chat/useComposerFocusState.ts | 23 +++++ 6 files changed, 257 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerFocusState.test.tsx create mode 100644 apps/web/src/components/chat/useComposerFocusState.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbeb3aa7f72..b79c291fbdac 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4757,6 +4757,10 @@ export default function ChatView(props: ChatViewProps) { requestAnimationFrame(() => positionAnchor(12)); }, []); + const onToolOutputCollapsedAtEnd = useCallback(() => { + composerRef.current?.restoreAfterTimelineReachedEnd(); + }, []); + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( !isAtEnd && @@ -7828,6 +7832,7 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} + onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5074d8a27c8e..2b19d6ec26ba 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -88,6 +88,7 @@ import { import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerMenuState } from "./useComposerMenuState"; +import { useComposerFocusState } from "./useComposerFocusState"; import { ComposerTasksBadge, ComposerTasksContent, @@ -1104,7 +1105,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; - /** Undo only a scroll-triggered collapse when the timeline returns to its live edge. */ + /** Expand the desktop composer at the timeline end without taking focus. */ restoreAfterTimelineReachedEnd: () => void; addDroppedFiles: (files: File[]) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; @@ -1787,8 +1788,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); - const [isComposerFocused, setIsComposerFocused] = useState(false); - const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); + const isMobileViewport = useMediaQuery("max-sm"); + const { + isComposerFocused, + setIsComposerFocused, + isComposerScrollCollapsed, + setIsComposerScrollCollapsed, + restoreAfterTimelineReachedEnd, + } = useComposerFocusState(isMobileViewport); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1800,7 +1807,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key: 0, active: false, }); - const isMobileViewport = useMediaQuery("max-sm"); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const isComposerCollapsedMobile = @@ -2351,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); - }, [draftId, activeThreadId, promptRef]); + }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -2502,7 +2508,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) COMPOSER_SCROLL_GESTURE_RESET_MS, ); setIsComposerScrollCollapsed(false); - }, []); + }, [setIsComposerScrollCollapsed]); const onPromptChange = useCallback( ( @@ -2789,7 +2795,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeElement.blur(); } setIsComposerFocused(false); - }, [isMobileViewport]); + }, [isMobileViewport, setIsComposerFocused]); const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; @@ -2949,7 +2955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mobileComposerExpandInFlightRef.current = false; }); }); - }, []); + }, [setIsComposerFocused]); // ------------------------------------------------------------------ // Callbacks: command key @@ -3687,7 +3693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!canScrollCollapseComposer) { setIsComposerScrollCollapsed(false); } - }, [canScrollCollapseComposer]); + }, [canScrollCollapseComposer, setIsComposerScrollCollapsed]); // Returning to the window re-fires focus on the element that already held // 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) canTrackComposerScrollGesture, getTimelineScrollableNode, isTimelineAtLogicalEnd, + setIsComposerScrollCollapsed, ]); const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; @@ -4365,7 +4372,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setIsComposerFocused(false); }); - }, [getTimelineScrollableNode, isMobileViewport]); + }, [getTimelineScrollableNode, isMobileViewport, setIsComposerFocused]); // A held collapse settles when the selection goes away, whether the user // clicked elsewhere, pressed Escape, or used the selection toolbar. @@ -4457,7 +4464,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } desktopOutsidePointerInFlightRef.current = false; }; - }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck]); + }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck, setIsComposerFocused]); useEffect(() => { return () => { @@ -4486,7 +4493,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerFocused(true); } setIsComposerModelPickerOpen(true); - }, [composerControlsHidden]); + }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]); useImperativeHandle( composerRef, @@ -4497,9 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - restoreAfterTimelineReachedEnd: () => { - setIsComposerScrollCollapsed(false); - }, + restoreAfterTimelineReachedEnd, addDroppedFiles: (files: File[]) => { void addComposerAttachments(files); focusComposer(); @@ -4643,6 +4648,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, planModeUiEnabled, compactThreadContext, + restoreAfterTimelineReachedEnd, ], ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ccf9be69a471..eab827b01df0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,9 +1,12 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; -import { createRef, type ReactNode, type Ref } from "react"; +import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; +import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; +import { useComposerFocusState } from "./useComposerFocusState"; vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -237,6 +240,87 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it.each([true, false])( + "restores the composer after closing tool output only at the end: %s", + async (isAtEnd) => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const flushFrame = () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }); + const props = buildProps(); + let timelineIsAtEnd = isAtEnd; + props.listRef.current = { + getState: () => ({ isAtEnd: timelineIsAtEnd }), + getScrollableNode: () => null, + } as unknown as LegendListRef; + let isResting = true; + function ThreadProbe() { + const composer = useComposerFocusState(false); + useLayoutEffect(() => { + isResting = shouldUseRestingComposerLayout({ + isExistingThread: true, + isMobileViewport: false, + isFocused: composer.isComposerFocused, + isScrollCollapsed: composer.isComposerScrollCollapsed, + hasExpandedChrome: false, + collapseOnBlur: true, + }); + }); + return ( + + ); + } + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(); + }); + const toggle = renderer!.root.findByProps({ "aria-expanded": false }); + await act(() => toggle.props.onClick()); + await flushFrame(); + await flushFrame(); + expect(isResting).toBe(true); + + timelineIsAtEnd = false; + await act(() => toggle.props.onClick()); + await flushFrame(); + timelineIsAtEnd = isAtEnd; + await flushFrame(); + expect(isResting).toBe(!isAtEnd); + } finally { + await act(() => renderer?.unmount()); + } + }, + ); + it("renders a feedback command and its pending response as normal thread messages", () => { const submission = { id: MessageId.make("feedback-command"), diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index df952aeca382..7a1f46346558 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -207,7 +207,7 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; - onToggleWorkEntry: (anchorKey: string) => void; + onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; workGroupViewState: WorkGroupViewState; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; @@ -231,7 +231,7 @@ interface WorkGroupViewState { const WorkGroupViewCtx = createContext<{ state: WorkGroupViewState; - onToggleEntry: () => void; + onToggleEntry: (collapsed: boolean) => void; } | null>(null); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER = ( @@ -334,6 +334,7 @@ interface MessagesTimelineProps { */ liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; + onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; @@ -380,6 +381,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, + onToolOutputCollapsedAtEnd, onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, @@ -414,24 +416,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); - const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => { - disclosureAnchorKeyRef.current = anchorKey; - setDisclosureToggleSettling(true); - if (disclosureSettleFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleFrameRef.current); - } - if (disclosureSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleSecondFrameRef.current); - } - disclosureSettleFrameRef.current = requestAnimationFrame(() => { - disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - disclosureAnchorKeyRef.current = null; - setDisclosureToggleSettling(false); - disclosureSettleFrameRef.current = null; - disclosureSettleSecondFrameRef.current = null; + const suspendEndScrollMaintenanceForDisclosure = useCallback( + (anchorKey: string, collapsed = false) => { + disclosureAnchorKeyRef.current = anchorKey; + setDisclosureToggleSettling(true); + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); + } + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + } + disclosureSettleFrameRef.current = requestAnimationFrame(() => { + disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + disclosureAnchorKeyRef.current = null; + setDisclosureToggleSettling(false); + disclosureSettleFrameRef.current = null; + disclosureSettleSecondFrameRef.current = null; + // Wait for row measurement and the disclosure click's blur check. + // Closing output can reveal the end without a scroll event. + if (collapsed && resolveTimelineIsAtEnd(listRef.current?.getState()) === true) { + onToolOutputCollapsedAtEnd?.(); + } + }); }); - }); - }, []); + }, + [listRef, onToolOutputCollapsedAtEnd], + ); const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; @@ -464,7 +474,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const onToggleWorkGroup = useCallback( (groupId: string, anchorKey: string) => { - suspendEndScrollMaintenanceForDisclosure(anchorKey); + suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId)); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { @@ -475,7 +485,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return next; }); }, - [suspendEndScrollMaintenanceForDisclosure], + [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], ); // An in-session interrupt leaves its turn expanded so the user keeps their @@ -1792,7 +1802,10 @@ function ExpandedWorkGroupEntries({ } const groupView = useMemo( - () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }), + () => ({ + state: viewState, + onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), + }), [anchorKey, onToggleWorkEntry, viewState], ); const updateScrollFades = useCallback(() => { @@ -3086,7 +3099,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const toggleExpanded = () => { const next = !expanded; if (groupView) { - groupView.onToggleEntry(); + groupView.onToggleEntry(!next); if (next) groupView.state.expandedEntries.add(workEntry.id); else groupView.state.expandedEntries.delete(workEntry.id); } diff --git a/apps/web/src/components/chat/useComposerFocusState.test.tsx b/apps/web/src/components/chat/useComposerFocusState.test.tsx new file mode 100644 index 000000000000..e4bf38430e85 --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocusState.test.tsx @@ -0,0 +1,87 @@ +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; +import { useComposerFocusState } from "./useComposerFocusState"; + +let root: Root; +let composer: ReturnType; +let isResting: boolean; + +function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolean }) { + const state = useComposerFocusState(isMobileViewport); + useLayoutEffect(() => { + composer = state; + isResting = shouldUseRestingComposerLayout({ + isExistingThread: true, + isMobileViewport, + isFocused: state.isComposerFocused, + isScrollCollapsed: state.isComposerScrollCollapsed, + hasExpandedChrome: false, + collapseOnBlur: true, + }); + }); + return null; +} + +beforeEach(async () => { + // The probe has no DOM output, but ReactDOM needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + await act(() => root.render()); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("composer focus state", () => { + it("expands at the timeline end after a tool call takes focus", async () => { + await act(() => composer.setIsComposerFocused(true)); + expect(isResting).toBe(false); + + // A tool disclosure takes focus before the user scrolls through its output. + await act(() => composer.setIsComposerFocused(false)); + expect(isResting).toBe(true); + + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(isResting).toBe(false); + + await act(() => composer.setIsComposerFocused(false)); + expect(isResting).toBe(true); + }); + + it("can collapse again on the next scroll after returning to the end", async () => { + await act(() => composer.setIsComposerScrollCollapsed(true)); + expect(isResting).toBe(true); + + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(isResting).toBe(false); + + await act(() => composer.setIsComposerScrollCollapsed(true)); + expect(isResting).toBe(true); + }); + + it("does not expand the phone composer when the timeline reaches the end", async () => { + await act(() => root.render()); + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(composer.isComposerFocused).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/useComposerFocusState.ts b/apps/web/src/components/chat/useComposerFocusState.ts new file mode 100644 index 000000000000..c8858b33fa6b --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocusState.ts @@ -0,0 +1,23 @@ +import { useCallback, useState } from "react"; + +export function useComposerFocusState(isMobileViewport: boolean) { + const [isComposerFocused, setIsComposerFocused] = useState(false); + const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); + + const restoreAfterTimelineReachedEnd = useCallback(() => { + setIsComposerScrollCollapsed(false); + // Restore the expanded layout after a timeline control takes focus too. + // This state holds the layout open without moving DOM focus to the editor. + if (!isMobileViewport) { + setIsComposerFocused(true); + } + }, [isMobileViewport]); + + return { + isComposerFocused, + setIsComposerFocused, + isComposerScrollCollapsed, + setIsComposerScrollCollapsed, + restoreAfterTimelineReachedEnd, + }; +} From 387caece0ad2b5914332b2cf8ec8496e3a6b40bb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 14:14:11 -0700 Subject: [PATCH 2/2] fix(web): restore composer after standalone tool output closes --- .../components/chat/MessagesTimeline.test.tsx | 16 +++++++++++----- .../web/src/components/chat/MessagesTimeline.tsx | 12 +++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index eab827b01df0..6e3cd38a346f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -240,9 +240,14 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { - it.each([true, false])( - "restores the composer after closing tool output only at the end: %s", - async (isAtEnd) => { + it.each([ + { toolLifecycleStatus: "inProgress", isAtEnd: true }, + { toolLifecycleStatus: "inProgress", isAtEnd: false }, + { toolLifecycleStatus: "completed", isAtEnd: true }, + { toolLifecycleStatus: "completed", isAtEnd: false }, + ] as const)( + "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd", + async ({ toolLifecycleStatus, isAtEnd }) => { const frames = new Map(); let nextFrame = 0; vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { @@ -279,7 +284,7 @@ describe("MessagesTimeline", () => { return ( { createdAt: MESSAGE_CREATED_AT, label: "Run command", tone: "tool", - toolLifecycleStatus: "inProgress", + toolLifecycleStatus, + detail: "Command output", }, }, ]} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7a1f46346558..1a5ad6a279dc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1738,7 +1738,11 @@ const WorkGroupSection = memo(function WorkGroupSection({ isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { - const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); + const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); + const onToggleStandaloneEntry = useCallback( + (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), + [anchorKey, onToggleWorkEntry], + ); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), [groupedEntries, isExpandedToolGroup], @@ -1766,6 +1770,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ workspaceRoot={workspaceRoot} isExpandedToolGroupEntry={false} displayLabel={displayLabel} + onToggleEntry={onToggleStandaloneEntry} /> ))}
@@ -3068,6 +3073,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; // Before any hooks: spawn CTA rows render their own component. @@ -3080,6 +3086,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot={workspaceRoot} isExpandedToolGroupEntry={isExpandedToolGroupEntry} displayLabel={displayLabel} + onToggleEntry={props.onToggleEntry} /> ); }); @@ -3089,6 +3096,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); @@ -3102,6 +3110,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { groupView.onToggleEntry(!next); if (next) groupView.state.expandedEntries.add(workEntry.id); else groupView.state.expandedEntries.delete(workEntry.id); + } else { + props.onToggleEntry?.(!next); } setExpanded(next); };