fix: keep agent action groups collapsed by default (closes #13) (#14)

This commit is contained in:
jfilipiuk
2026-06-22 23:52:30 +02:00
committed by GitHub
parent d9935a9198
commit 0e00f21f3b
+62 -31
View File
@@ -93,16 +93,18 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
return items.some((item) => item.message.id === lastMessageId);
}, [autoApprove, actionRequests.length, lastMessageId, items]);
// Start open while live or while waiting for an approval, otherwise honor
// the preference.
const [open, setOpen] = useState<boolean>(() =>
isStreaming || hasPendingApproval ? true : !defaultCollapsed
);
// Honor the user's preference for the initial state. Per issue #13 the
// body stays collapsed during runs AND while an approval is pending —
// approvals surface via the preview slot below, which renders the single
// approval-bearing message without expanding the whole timeline.
const [open, setOpen] = useState<boolean>(() => !defaultCollapsed);
const wasStreamingRef = useRef(isStreaming);
// Auto-collapse on settle: only when streaming just ended AND no approval is
// Auto-collapse on settle: when streaming just ended AND no approval is
// pending AND user is at the bottom (so a scrolled-up reader isn't jumped).
// Settings opt-out wins.
// Folds groups the user expanded mid-run to peek. We don't auto-collapse
// on approval resolution: the body is never forced open by approvals, so
// if it IS open, the user opened it intentionally — let them keep it.
useEffect(() => {
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = isStreaming;
@@ -117,13 +119,6 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
}
}, [isStreaming, hasPendingApproval, defaultCollapsed, isAtBottom]);
// Force-open whenever live or pending approval kicks in mid-life — a new
// turn starting after a previous one finished, or an interrupt arriving on a
// group the user had manually collapsed. Hiding either is the wrong default.
useEffect(() => {
if (isStreaming || hasPendingApproval) setOpen(true);
}, [isStreaming, hasPendingApproval]);
const count = items.length;
const toolName = lastToolName(items);
const headerText = isStreaming
@@ -155,6 +150,43 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
)}
<span className="truncate">{headerText}</span>
</button>
{/* Approval preview: when the group is collapsed and an approval is
pending, render JUST the last item (which carries the action
requests) outside the body so the user can act without expanding
the whole timeline. When the group is open, this slot is empty
and the same item renders inside the body — one instance at a
time, no duplication. */}
{!open &&
hasPendingApproval &&
lastMessageId !== undefined &&
(() => {
const previewItem = items.find((i) => i.message.id === lastMessageId);
if (!previewItem) return null;
const messageUi = ui?.filter(
(u: any) => u.metadata?.message_id === previewItem.message.id
);
return (
<div className="mt-2 space-y-2 border-l-2 border-border pl-3">
<ChatMessage
message={previewItem.message}
toolCalls={previewItem.toolCalls}
isLoading={isLoading}
isStreaming={isStreaming}
actionRequests={actionRequests}
submittedActionRequestKeys={submittedActionRequestKeys}
onActionRequestSubmitted={onActionRequestSubmitted}
reviewConfigsMap={reviewConfigsMap ?? undefined}
ui={messageUi}
stream={stream}
onResumeInterrupt={onResumeInterrupt}
graphId={graphId}
onEditMessage={onEditMessage}
autoApprove={autoApprove}
subAgentSteps={subAgentSteps}
/>
</div>
);
})()}
{open && (
<div className="mt-2 space-y-2 border-l-2 border-border pl-3">
{/* Children render below; the bottom collapse button comes after the
@@ -196,23 +228,22 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
</React.Fragment>
);
})}
{/* Bottom collapse button — easy reach after scrolling through a long
group. Hidden while streaming or while waiting for approval, since
the section is force-open in those states anyway. */}
{!isStreaming && !hasPendingApproval && (
<button
type="button"
onClick={() => setOpen(false)}
className="flex w-full items-center justify-center gap-1.5 rounded-md py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Collapse ${count} action${count === 1 ? "" : "s"}`}
>
<ChevronUp
aria-hidden="true"
className="size-3.5"
/>
Collapse
</button>
)}
{/* Bottom collapse button — easy reach after scrolling through a
long group. Collapse always sticks now (no force-open during
streaming, approval force-open is one-shot), so this is safe
to render whenever the body is visible. */}
<button
type="button"
onClick={() => setOpen(false)}
className="flex w-full items-center justify-center gap-1.5 rounded-md py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Collapse ${count} action${count === 1 ? "" : "s"}`}
>
<ChevronUp
aria-hidden="true"
className="size-3.5"
/>
Collapse
</button>
</div>
)}
</div>