@iloveagents/foundry-web-ui 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,15 @@ const UserMessage = () => {
22
22
  return (_jsx(MessagePrimitive.Root, { className: "w-full mb-2 group", children: _jsxs("div", { className: "flex flex-col items-end gap-1", children: [_jsx(MessagePrimitive.Attachments, { components: userAttachmentComponents }), _jsx(SentContextBadges, { messageId: messageId }), _jsx("div", { className: cn("max-w-[80%]", "bg-muted rounded-2xl", "px-4 py-3", "text-foreground"), children: _jsx(MessagePrimitive.Content, {}) }), _jsxs("div", { className: "flex h-8 items-center justify-end gap-2 pr-2", children: [_jsx(MessageTimestamp, {}), _jsx(BranchPicker, {}), _jsx("div", { className: "flex h-8 w-10 items-center justify-end", children: _jsx(UserActionBar, {}) })] })] }) }));
23
23
  };
24
24
  const UserActionBar = () => (_jsx(ActionBarPrimitive.Root, { hideWhenRunning: true, className: "flex items-center opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(ActionBarPrimitive.Edit, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Edit", size: "icon", children: _jsx(PencilIcon, { className: "size-4" }) }) }) }));
25
- const AssistantMessage = () => (_jsx(MessagePrimitive.Root, { className: "w-full mb-2 group", children: _jsxs("div", { className: "mx-auto max-w-3xl px-4", children: [_jsx("div", { className: "text-foreground leading-relaxed flex flex-col gap-4", children: _jsx(ChatMessageParts, {}) }), _jsx(MessageError, {}), _jsxs("div", { className: "flex items-center gap-2 mt-1", children: [_jsx("div", { className: "flex h-8 w-[4.25rem] items-center", children: _jsx(AssistantActionBar, {}) }), _jsx(BranchPicker, {}), _jsx(MessageTimestamp, {})] })] }) }));
25
+ const AssistantMessage = () => {
26
+ // Until the message has parts there is nothing to copy, retry or branch.
27
+ // `AssistantActionBar` already hides its BUTTONS while running, but the
28
+ // row reserving their height did not — so an empty assistant message put
29
+ // a 36px hole between the question and the "Thinking" line, which read as
30
+ // the indicator floating far away from the message it belongs to.
31
+ const hasParts = useAuiState((s) => (s.message.parts?.length ?? 0) > 0);
32
+ return (_jsx(MessagePrimitive.Root, { className: "w-full mb-2 group", children: _jsxs("div", { className: "mx-auto max-w-3xl px-4", children: [_jsx("div", { className: "text-foreground leading-relaxed flex flex-col gap-4", children: _jsx(ChatMessageParts, {}) }), _jsx(MessageError, {}), hasParts && (_jsxs("div", { className: "flex items-center gap-2 mt-1", children: [_jsx("div", { className: "flex h-8 w-[4.25rem] items-center", children: _jsx(AssistantActionBar, {}) }), _jsx(BranchPicker, {}), _jsx(MessageTimestamp, {})] }))] }) }));
33
+ };
26
34
  const AssistantActionBar = () => (_jsxs(ActionBarPrimitive.Root, { hideWhenRunning: true, className: cn("pointer-events-none flex items-center gap-1 -ml-2 opacity-0 transition-opacity", "group-hover:pointer-events-auto group-hover:opacity-100", "group-focus-within:pointer-events-auto group-focus-within:opacity-100"), children: [_jsx(ActionBarPrimitive.Copy, { asChild: true, children: _jsxs(TooltipIconButton, { tooltip: "Copy", size: "icon", children: [_jsx(MessagePrimitive.If, { copied: false, children: _jsx(Copy, { className: "size-4" }) }), _jsx(MessagePrimitive.If, { copied: true, children: _jsx(Check, { className: "size-4 text-primary" }) })] }) }), _jsx(ActionBarPrimitive.Reload, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Refresh", size: "icon", children: _jsx(RefreshCw, { className: "size-4" }) }) })] }));
27
35
  const EditComposer = () => (_jsx(MessagePrimitive.Root, { className: "w-full mb-2", children: _jsx("div", { className: "flex items-start justify-end", children: _jsx("div", { className: "w-full max-w-[80%]", children: _jsxs(ComposerPrimitive.Root, { className: cn("flex flex-col gap-2", "rounded-2xl border border-input", "bg-background shadow-sm", "px-4 py-3"), children: [_jsx(ComposerPrimitive.Input, { autoFocus: true, className: cn("w-full bg-transparent", "text-base text-foreground", "outline-none resize-none", "placeholder:text-muted-foreground") }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [_jsx(ComposerPrimitive.Cancel, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "sm", children: "Cancel" }) }), _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "sm", children: "Update" }) })] })] }) }) }) }));
28
36
  const ComposerActionButton = () => {
@@ -116,5 +124,9 @@ export function ChatContent({ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS,
116
124
  UserMessage,
117
125
  EditComposer,
118
126
  AssistantMessage,
119
- } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(ScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 bg-background", children: [_jsx(ChatComposerBannerSlot, {}), _jsx(ThreadPrimitive.If, { running: false, children: _jsx("div", { className: "border-t border-border" }) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingBar, {}) }), _jsx("div", { className: "mx-auto max-w-3xl px-4 py-4", children: _jsxs(ComposerPrimitive.Root, { className: cn("flex flex-col gap-2", "rounded-3xl border border-input", "bg-background shadow-sm", "px-4 py-2", "focus-within:border-primary/50 focus-within:shadow-md", "transition-all duration-300"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsx(ComposerPrimitive.Attachments, { components: composerAttachmentComponents }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message Assistant...", rows: 1, className: cn("w-full bg-transparent", "pt-2 text-base", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-32") }), _jsxs("div", { className: "flex items-center gap-2 pb-1", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", variant: "ghost", className: "shrink-0", children: _jsx(Paperclip, { className: "size-5" }) }) }), _jsx(ContextPins, { showPinButton: showPinButton })] }), _jsx("div", { className: "flex-1" }), _jsx(ComposerActionButton, {})] })] }) })] })] })] }));
127
+ } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(ScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 bg-background", children: [_jsx(ChatComposerBannerSlot, {}), _jsx(ThreadPrimitive.If, { running: false, children: _jsx("div", { className: "border-t border-border" }) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingBar, {}) }), _jsx("div", { className: "mx-auto max-w-3xl px-4 py-4", children: _jsxs(ComposerPrimitive.Root, { className: cn(
128
+ // Query container for the controls inside, so they size
129
+ // against the composer rather than the viewport. This one
130
+ // is wide, so the effort picker keeps its full label.
131
+ "@container", "flex flex-col gap-2", "rounded-3xl border border-input", "bg-background shadow-sm", "px-4 py-2", "focus-within:border-primary/50 focus-within:shadow-md", "transition-all duration-300"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsx(ComposerPrimitive.Attachments, { components: composerAttachmentComponents }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message Assistant...", rows: 1, className: cn("w-full bg-transparent", "pt-2 text-base", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-32") }), _jsxs("div", { className: "flex items-center gap-2 pb-1", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", variant: "ghost", className: "shrink-0", children: _jsx(Paperclip, { className: "size-5" }) }) }), _jsx(ContextPins, { showPinButton: showPinButton })] }), _jsx("div", { className: "flex-1" }), _jsx(ComposerActionButton, {})] })] }) })] })] })] }));
120
132
  }
@@ -98,8 +98,12 @@ export function ChatBubble() {
98
98
  height: 64,
99
99
  } })), !isOpen && (_jsxs("div", { className: "fixed z-50 group", style: { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, children: [_jsx("button", { type: "button", onClick: openBubble, className: cn("size-10 rounded-full", "bg-primary text-primary-foreground shadow-lg", "flex items-center justify-center", "hover:bg-primary/90 transition-all duration-200", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"), "aria-label": "Ask the AI", children: _jsx(Sparkles, { className: "size-4" }) }), _jsx(ChatLauncherBadgeSlot, {})] })), isOpen && (_jsxs("div", { ref: panelRef, "data-chat-surface": "bubble", className: cn("fixed z-50", "rounded-2xl border border-border", "bg-background shadow-2xl", "flex flex-col overflow-hidden", "w-95 h-150"), style: dragPos
100
100
  ? { left: dragPos.x, top: dragPos.y }
101
- : { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, children: [_jsxs("div", { className: cn("flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0", "cursor-grab active:cursor-grabbing select-none"), style: { touchAction: "none" }, onPointerDown: onHeaderPointerDown, onPointerMove: onHeaderPointerMove, onPointerUp: onHeaderPointerUp, onLostPointerCapture: onHeaderLostPointerCapture, children: [_jsxs("div", { className: "flex flex-col min-w-0", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-sm", children: [_jsx(Sparkles, { className: "size-3.5 text-primary shrink-0" }), _jsx("span", { className: "font-semibold text-foreground truncate", children: pageLabel })] }), _jsx("span", { className: "text-xs text-muted-foreground", children: threadActive ? "Active thread" : "New conversation" })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsx(TooltipIconButton, { tooltip: "New Thread", size: "icon", className: "size-7", onClick: startNewThread, children: _jsx(SquarePen, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Expand chat", size: "icon", className: "size-7", onClick: expandChat, children: _jsx(Maximize2, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Close", size: "icon", className: "size-7", onClick: handleClose, children: _jsx(X, { className: "size-4" }) })] })] }), _jsxs(ThreadPrimitive.Root, { className: "flex flex-col flex-1 overflow-hidden", children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs(ThreadPrimitive.Viewport, { className: "absolute inset-0 overflow-y-auto", children: [_jsx(ThreadPrimitive.Empty, { children: _jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsx("p", { className: "text-sm text-muted-foreground text-center", children: "Ask the agent about this page" }) }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "px-4 pt-4", children: [_jsx(ThreadPrimitive.Messages, { components: {
101
+ : { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, children: [_jsxs("div", { className: cn("flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0", "cursor-grab active:cursor-grabbing select-none"), style: { touchAction: "none" }, onPointerDown: onHeaderPointerDown, onPointerMove: onHeaderPointerMove, onPointerUp: onHeaderPointerUp, onLostPointerCapture: onHeaderLostPointerCapture, children: [_jsxs("div", { className: "flex flex-col min-w-0", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-sm", children: [_jsx(Sparkles, { className: "size-3.5 text-primary shrink-0" }), _jsx("span", { className: "font-semibold text-foreground truncate", children: pageLabel })] }), _jsx("span", { className: "text-xs text-muted-foreground", children: threadActive ? "Active thread" : "New conversation" })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsx(TooltipIconButton, { tooltip: "New Thread", size: "icon", className: "size-7", onClick: startNewThread, children: _jsx(SquarePen, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Expand chat", size: "icon", className: "size-7", onClick: expandChat, children: _jsx(Maximize2, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Close", size: "icon", className: "size-7", onClick: handleClose, children: _jsx(X, { className: "size-4" }) })] })] }), _jsxs(ThreadPrimitive.Root, { className: "flex flex-col flex-1 overflow-hidden", children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs(ThreadPrimitive.Viewport, { className: "absolute inset-0 overflow-y-auto", children: [_jsx(ThreadPrimitive.Empty, { children: _jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsx("p", { className: "text-sm text-muted-foreground text-center", children: "Ask the agent about this page" }) }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "px-4 pt-4 pb-3", children: [_jsx(ThreadPrimitive.Messages, { components: {
102
102
  UserMessage: BubbleUserMessage,
103
103
  AssistantMessage: BubbleAssistantMessage,
104
- } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(BubbleScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 border-t border-border", children: [_jsx(ChatComposerBannerSlot, {}), _jsx("div", { className: "px-3 py-3", children: _jsxs(ComposerPrimitive.Root, { className: cn("flex flex-col gap-1", "rounded-2xl border border-input", "bg-background shadow-sm", "px-3 py-2", "focus-within:border-primary/50", "transition-all duration-200"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", size: "icon", className: "size-7 shrink-0", children: _jsx(Paperclip, { className: "size-3.5" }) }) }), _jsx(ContextPins, { compact: true, showPinButton: true })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message...", rows: 1, className: cn("flex-1 bg-transparent", "py-1 text-sm", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-20") }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ReasoningEffortPicker, {}) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(ComposerPrimitive.Cancel, { asChild: true, children: _jsx(Button, { variant: "secondary", size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Stop", children: _jsx(Square, { className: "size-3" }) }) }) }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Send", children: _jsx(ArrowUp, { className: "size-4" }) }) }) })] })] }) })] })] })] }))] }));
104
+ } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(BubbleScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 border-t border-border", children: [_jsx(ChatComposerBannerSlot, {}), _jsx("div", { className: "px-3 py-3", children: _jsxs(ComposerPrimitive.Root, { className: cn(
105
+ // Query container for the controls inside: the bubble is
106
+ // a narrow panel in a wide window, so they have to size
107
+ // against THIS box, not the viewport.
108
+ "@container", "flex flex-col gap-1", "rounded-2xl border border-input", "bg-background shadow-sm", "px-3 py-2", "focus-within:border-primary/50", "transition-all duration-200"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", size: "icon", className: "size-7 shrink-0", children: _jsx(Paperclip, { className: "size-3.5" }) }) }), _jsx(ContextPins, { compact: true, showPinButton: true })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message...", rows: 1, className: cn("flex-1 bg-transparent", "py-1 text-sm", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-20") }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ReasoningEffortPicker, {}) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(ComposerPrimitive.Cancel, { asChild: true, children: _jsx(Button, { variant: "secondary", size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Stop", children: _jsx(Square, { className: "size-3" }) }) }) }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Send", children: _jsx(ArrowUp, { className: "size-4" }) }) }) })] })] }) })] })] })] }))] }));
105
109
  }
@@ -11,7 +11,12 @@
11
11
  * one place to drift from.
12
12
  *
13
13
  * Adjacent tool calls fold into one {@link ToolRunGroup} — a single summary row
14
- * over the cards, open by default; see that component for why collapsing is the
15
- * deliberate act rather than the default.
14
+ * over the cards, collapsed; see that component for why.
15
+ *
16
+ * Reading order is fixed rather than chronological: tool runs, then the
17
+ * reasoning that explains them, then the answer. A model normally emits its
18
+ * thinking FIRST, which put the least important row at the top of every
19
+ * message and pushed the answer down. Flex `order` does the moving, so the
20
+ * parts stay where the runtime put them in the DOM.
16
21
  */
17
22
  export declare function ChatMessageParts(): import("react/jsx-runtime").JSX.Element;
@@ -22,8 +22,13 @@ const groupBy = groupPartByType({ "tool-call": ["group-tool"] });
22
22
  * one place to drift from.
23
23
  *
24
24
  * Adjacent tool calls fold into one {@link ToolRunGroup} — a single summary row
25
- * over the cards, open by default; see that component for why collapsing is the
26
- * deliberate act rather than the default.
25
+ * over the cards, collapsed; see that component for why.
26
+ *
27
+ * Reading order is fixed rather than chronological: tool runs, then the
28
+ * reasoning that explains them, then the answer. A model normally emits its
29
+ * thinking FIRST, which put the least important row at the top of every
30
+ * message and pushed the answer down. Flex `order` does the moving, so the
31
+ * parts stay where the runtime put them in the DOM.
27
32
  */
28
33
  export function ChatMessageParts() {
29
34
  return (_jsx(MessagePrimitive.GroupedParts, { groupBy: groupBy, indicator: "never", children: ({ part, children }) => {
@@ -31,7 +36,10 @@ export function ChatMessageParts() {
31
36
  case "group-tool":
32
37
  return (_jsx(ToolRunGroup, { indices: part.indices, isRunning: part.status?.type === "running", children: children }));
33
38
  case "text":
34
- return _jsx(MarkdownText, {});
39
+ // The answer is ordered LAST so the message ends on its point,
40
+ // with the reasoning row (order-1) sitting between the tool runs
41
+ // and it. Tool groups keep the default order-0.
42
+ return (_jsx("div", { className: "order-2", children: _jsx(MarkdownText, {}) }));
35
43
  case "reasoning":
36
44
  return _jsx(ReasoningMessagePartComponent, { text: part.text });
37
45
  case "tool-call":
@@ -31,5 +31,10 @@ export function LoadingIndicator() {
31
31
  else if (streaming.status === "streaming") {
32
32
  label = "Generating response";
33
33
  }
34
- return (_jsx("div", { className: "h-10 px-4", children: _jsxs("div", { className: "flex items-center gap-2 py-2 text-foreground leading-relaxed animate-in fade-in duration-300", children: [_jsx("span", { className: stalled ? "text-sm text-warning" : "text-sm text-muted-foreground", children: label }), _jsxs("div", { className: "flex gap-1", children: [_jsx("span", { className: `size-1.5 rounded-full ${stalled ? "bg-warning/70" : "bg-primary/60"} animate-[pulse_1.4s_ease-in-out_infinite]` }), _jsx("span", { className: `size-1.5 rounded-full ${stalled ? "bg-warning/70" : "bg-primary/60"} animate-[pulse_1.4s_ease-in-out_0.2s_infinite]` }), _jsx("span", { className: `size-1.5 rounded-full ${stalled ? "bg-warning/70" : "bg-primary/60"} animate-[pulse_1.4s_ease-in-out_0.4s_infinite]` })] })] }) }));
34
+ return (
35
+ // `px-4` keeps the label on the same left edge as the message parts
36
+ // above it — measured, not guessed: the rows sit at x=469 and so does
37
+ // this. The old `h-10` did NOT belong: a fixed 40px block on top of the
38
+ // parts' own gap-4 left a visible hole under the last tool row.
39
+ _jsx("div", { className: "min-h-6 px-4", children: _jsxs("div", { className: "flex items-center gap-2 py-0.5 text-foreground leading-relaxed animate-in fade-in duration-300", children: [_jsx("span", { className: stalled ? "text-sm text-warning" : "text-sm text-muted-foreground", children: label }), _jsx("span", { "aria-hidden": "true", className: `size-1.5 rounded-full ${stalled ? "bg-warning/70" : "bg-primary/60"} animate-[pulse_1.4s_ease-in-out_infinite]` })] }) }));
35
40
  }
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * The one row shape every foldaway inside an assistant message uses.
4
+ *
5
+ * Reasoning and tool runs are the same affordance — a quiet line the reader
6
+ * can open when curious — but they were built twice and had drifted into two
7
+ * different controls stacked on top of each other: one chevron pointing down
8
+ * on the RIGHT of its label, another pointing right on the LEFT of its own,
9
+ * different paddings, different hover treatments, different default states.
10
+ * Read together in a transcript they looked like two unrelated widgets.
11
+ *
12
+ * So the shape lives here once. A row is: disclosure caret, the part's own
13
+ * icon, its label — left-aligned in that order, on a single baseline, with
14
+ * identical metrics. The caret is the FIRST thing in the row because it is
15
+ * the control; the icon that follows says what kind of thing is folded away.
16
+ *
17
+ * Everything is understated on purpose. These rows sit between the user's
18
+ * question and the answer, and they are machinery, not content: muted until
19
+ * hovered, no border, no fill. The body indents under the label behind a
20
+ * hairline rule, so an open row reads as a margin note rather than a second
21
+ * message.
22
+ */
23
+ export declare function MessageDisclosure({ icon, label, open, onOpenChange, tone, placement, ariaLabel, children, ...rest }: {
24
+ /** The part's own icon — a brain, a wrench, a status glyph. */
25
+ icon: ReactNode;
26
+ label: ReactNode;
27
+ open: boolean;
28
+ onOpenChange: (open: boolean) => void;
29
+ /** ``destructive`` is for a run where everything failed; nothing else. */
30
+ tone?: "muted" | "destructive";
31
+ /**
32
+ * ``after-tools`` sits the row between the tool runs and the answer,
33
+ * wherever the model actually emitted it — reasoning belongs under the
34
+ * work it explains and above the conclusion it produced. (The message
35
+ * text is ordered after it; see `ChatMessageParts`.)
36
+ *
37
+ * It is a flex `order`, so the row moves VISUALLY while staying put in the
38
+ * DOM. That is exactly why such a row carries a different attribute: the
39
+ * compaction below is a DOM-sibling rule, and a row that is first in the
40
+ * DOM but second on screen would otherwise hand its negative margin to
41
+ * whichever row really is on top.
42
+ */
43
+ placement?: "inline" | "after-tools";
44
+ ariaLabel?: string;
45
+ children: ReactNode;
46
+ } & Record<`data-${string}`, unknown>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronRight } from "lucide-react";
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
5
+ /**
6
+ * The one row shape every foldaway inside an assistant message uses.
7
+ *
8
+ * Reasoning and tool runs are the same affordance — a quiet line the reader
9
+ * can open when curious — but they were built twice and had drifted into two
10
+ * different controls stacked on top of each other: one chevron pointing down
11
+ * on the RIGHT of its label, another pointing right on the LEFT of its own,
12
+ * different paddings, different hover treatments, different default states.
13
+ * Read together in a transcript they looked like two unrelated widgets.
14
+ *
15
+ * So the shape lives here once. A row is: disclosure caret, the part's own
16
+ * icon, its label — left-aligned in that order, on a single baseline, with
17
+ * identical metrics. The caret is the FIRST thing in the row because it is
18
+ * the control; the icon that follows says what kind of thing is folded away.
19
+ *
20
+ * Everything is understated on purpose. These rows sit between the user's
21
+ * question and the answer, and they are machinery, not content: muted until
22
+ * hovered, no border, no fill. The body indents under the label behind a
23
+ * hairline rule, so an open row reads as a margin note rather than a second
24
+ * message.
25
+ */
26
+ export function MessageDisclosure({ icon, label, open, onOpenChange, tone = "muted", placement = "inline", ariaLabel, children, ...rest }) {
27
+ return (_jsxs(Collapsible, { open: open, onOpenChange: onOpenChange, ...(placement === "after-tools"
28
+ ? { "data-message-disclosure-reordered": true }
29
+ : { "data-message-disclosure": true }), className: cn("w-full [[data-message-disclosure]_+_&]:-mt-3", placement === "after-tools" && "order-1"), ...rest, children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-disclosure-trigger": true, "aria-label": ariaLabel, className: cn("group/disclosure flex w-full items-center gap-1.5 rounded-md", "px-1.5 py-0.5 text-left text-xs transition-colors", "text-muted-foreground hover:bg-muted hover:text-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", tone === "destructive" && "text-destructive hover:text-destructive"), children: [_jsx(ChevronRight, { "aria-hidden": "true", className: cn("size-3.5 shrink-0 opacity-60 transition-transform duration-150", "group-hover/disclosure:opacity-100", open && "rotate-90") }), _jsx("span", { className: "flex size-3.5 shrink-0 items-center justify-center", children: icon }), _jsx("span", { className: "truncate", children: label })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "mt-1.5 ml-3 border-l border-border pl-3", children: children }) })] }));
30
+ }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useStore } from "zustand";
3
3
  import { Check, ChevronDown } from "lucide-react";
4
4
  import { reasoningDefaultStore, reasoningEffortStore, REASONING_EFFORT_LABELS, } from "@iloveagents/foundry-agent";
@@ -43,8 +43,16 @@ export function ReasoningEffortPicker({ className }) {
43
43
  // The trigger is the only thing visible without opening the menu, so it
44
44
  // carries the resolved level too — "Auto · Medium" rather than a bare
45
45
  // "Auto" that leaves the active level a mystery.
46
- const triggerLabel = effort === "default" && resolved
47
- ? `${REASONING_EFFORT_LABELS.default} · ${REASONING_EFFORT_LABELS[resolved]}`
46
+ //
47
+ // Split into MODE and LEVEL because a narrow composer cannot afford
48
+ // both. The level is the half worth keeping: it is what changes cost
49
+ // and latency, and "which mode" is nearly always the default. See the
50
+ // render below.
51
+ const showsMode = effort === "default" && resolved !== null;
52
+ const modeLabel = REASONING_EFFORT_LABELS.default;
53
+ const levelLabel = showsMode
54
+ ? REASONING_EFFORT_LABELS[resolved]
48
55
  : REASONING_EFFORT_LABELS[effort];
49
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-reasoning-effort-trigger": true, className: cn("flex items-center gap-1 rounded-md px-2 py-1", "text-xs text-muted-foreground hover:text-foreground hover:bg-muted", "transition-colors", className), "aria-label": `Intelligence: ${triggerLabel}`, children: [_jsx("span", { className: "whitespace-nowrap", children: triggerLabel }), _jsx(ChevronDown, { className: "size-3.5 shrink-0" })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", children: [_jsx(DropdownMenuLabel, { className: "text-xs font-normal text-muted-foreground", children: "Intelligence" }), OPTIONS.map((opt) => (_jsxs(DropdownMenuItem, { onSelect: () => setEffort(opt.value), className: "flex items-start gap-2", children: [_jsx(Check, { className: cn("size-4 mt-0.5 shrink-0", effort === opt.value ? "opacity-100" : "opacity-0") }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm", children: REASONING_EFFORT_LABELS[opt.value] }), _jsx("span", { className: "text-xs text-muted-foreground", children: opt.value === "default" ? autoHint(resolved, scope) : opt.hint })] })] }, opt.value)))] })] }));
56
+ const triggerLabel = showsMode ? `${modeLabel} · ${levelLabel}` : levelLabel;
57
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-reasoning-effort-trigger": true, className: cn("flex items-center gap-1 rounded-md px-2 py-1", "text-xs text-muted-foreground hover:text-foreground hover:bg-muted", "transition-colors", className), "aria-label": `Intelligence: ${triggerLabel}`, title: `Intelligence: ${triggerLabel}`, children: [_jsxs("span", { className: "whitespace-nowrap", children: [showsMode && _jsxs("span", { className: "@max-[24rem]:hidden", children: [modeLabel, " \u00B7 "] }), levelLabel] }), _jsx(ChevronDown, { className: "size-3.5 shrink-0" })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", children: [_jsx(DropdownMenuLabel, { className: "text-xs font-normal text-muted-foreground", children: "Intelligence" }), OPTIONS.map((opt) => (_jsxs(DropdownMenuItem, { onSelect: () => setEffort(opt.value), className: "flex items-start gap-2", children: [_jsx(Check, { className: cn("size-4 mt-0.5 shrink-0", effort === opt.value ? "opacity-100" : "opacity-0") }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm", children: REASONING_EFFORT_LABELS[opt.value] }), _jsx("span", { className: "text-xs text-muted-foreground", children: opt.value === "default" ? autoHint(resolved, scope) : opt.hint })] })] }, opt.value)))] })] }));
50
58
  }
@@ -5,6 +5,10 @@
5
5
  * Collapsed by default and deliberately understated: reasoning is context for
6
6
  * a curious reader, not the response. It stays visible after the run so a
7
7
  * reader can check *why* an answer came out the way it did.
8
+ *
9
+ * The row shape comes from `MessageDisclosure`, shared with the tool run
10
+ * group — these two sit next to each other in a message and used to be built
11
+ * separately, which is how they ended up with their carets on opposite sides.
8
12
  */
9
13
  export declare function ReasoningPart({ text }: {
10
14
  text: string;
@@ -1,8 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
- import { Brain, ChevronDown } from "lucide-react";
4
- import { cn } from "@iloveagents/foundry-web-primitives";
5
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
3
+ import { Brain } from "lucide-react";
4
+ import { MessageDisclosure } from "./message-disclosure.js";
6
5
  /**
7
6
  * Renders a `reasoning` message part — the model's own thinking, streamed
8
7
  * over AG-UI's `REASONING_*` events and kept separate from the answer.
@@ -10,12 +9,20 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/colla
10
9
  * Collapsed by default and deliberately understated: reasoning is context for
11
10
  * a curious reader, not the response. It stays visible after the run so a
12
11
  * reader can check *why* an answer came out the way it did.
12
+ *
13
+ * The row shape comes from `MessageDisclosure`, shared with the tool run
14
+ * group — these two sit next to each other in a message and used to be built
15
+ * separately, which is how they ended up with their carets on opposite sides.
13
16
  */
14
17
  export function ReasoningPart({ text }) {
15
18
  const [isOpen, setIsOpen] = useState(false);
16
19
  if (!text.trim())
17
20
  return null;
18
- return (_jsxs(Collapsible, { open: isOpen, onOpenChange: setIsOpen, className: "w-full", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: cn("flex items-center gap-1.5 text-xs text-muted-foreground", "hover:text-foreground transition-colors"), "aria-label": isOpen ? "Hide reasoning" : "Show reasoning", children: [_jsx(Brain, { className: "size-3.5" }), _jsx("span", { children: "Reasoning" }), _jsx(ChevronDown, { className: cn("size-3.5 transition-transform", isOpen && "rotate-180") })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: cn("mt-1.5 whitespace-pre-wrap border-l-2 border-border pl-3", "text-xs text-muted-foreground"), children: text }) })] }));
21
+ return (_jsx(MessageDisclosure, { icon: _jsx(Brain, { className: "size-3.5" }), label: "Reasoning", open: isOpen, onOpenChange: setIsOpen,
22
+ // Under the work, above the conclusion: the model usually emits its
23
+ // thinking first, but a reader wants "what it did", then "why", and
24
+ // the answer last — the message should end on its point.
25
+ placement: "after-tools", ariaLabel: isOpen ? "Hide reasoning" : "Show reasoning", children: _jsx("div", { className: "whitespace-pre-wrap text-xs text-muted-foreground", children: text }) }));
19
26
  }
20
27
  /** Adapter for assistant-ui's `Reasoning` part slot. */
21
28
  export const ReasoningMessagePartComponent = ({ text }) => (_jsx(ReasoningPart, { text: text }));
@@ -99,6 +99,11 @@ const SIDEBAR_GROUP_HEADER_PADDING =
99
99
  // Bumping size AND weight together stacked too much visual mass
100
100
  // against the already-wide uppercase tracking — felt shouty.
101
101
  const SIDEBAR_GROUP_HEADER_LABEL_CLASS = "text-xs font-semibold uppercase tracking-wider";
102
+ /** Body padding for a row that is NOT in a tree. Flat rows render no
103
+ * disclosure cell, so reserving its column just pushes them right of the
104
+ * sidebar's other flat chrome — which is what made Activity look indented
105
+ * under New Thread. */
106
+ const NAV_FLAT_BODY_CLASS = "pl-3";
102
107
  function getNavDepthOffsets(depth) {
103
108
  return NAV_DEPTH_OFFSETS[depth] ?? NAV_DEPTH_OFFSETS[NAV_DEPTH_OFFSETS.length - 1];
104
109
  }
@@ -422,10 +427,10 @@ function ContainerDropZone({ dnd }) {
422
427
  * row underneath; the branch chevron keeps its own, since positioned
423
428
  * elements paint above the static button and win the hit test.
424
429
  */
425
- function NavTwisty({ hasChildren, isExpanded, onToggle, label, isActive, depth, }) {
430
+ function NavTwisty({ hasChildren, isExpanded, onToggle, label, isActive, depth, inTree = true, }) {
426
431
  const cell = cn(NAV_TWISTY_CLASS, getNavDepthOffsets(depth).column);
427
432
  if (!hasChildren) {
428
- return (_jsx("span", { "data-nav-twisty-cell": true, "aria-hidden": "true", className: cn(cell, "pointer-events-none"), children: _jsx("span", { className: cn("size-1 rounded-full", isActive ? "bg-primary/70" : "bg-sidebar-foreground/30") }) }));
433
+ return (_jsx("span", { "data-nav-twisty-cell": true, "aria-hidden": "true", className: cn(cell, "pointer-events-none"), children: inTree && (_jsx("span", { className: cn("size-1 rounded-full", isActive ? "bg-primary/70" : "bg-sidebar-foreground/30") })) }));
429
434
  }
430
435
  return (_jsx("span", { "data-nav-twisty-cell": true, className: cell, children: _jsx("button", { type: "button", "data-nav-twisty": true, "aria-expanded": isExpanded, "aria-label": isExpanded ? `Collapse ${label}` : `Expand ${label}`, onClick: (e) => {
431
436
  e.preventDefault();
@@ -602,7 +607,7 @@ function NestedFolderItem({ item, depth }) {
602
607
  } }, child.to + child.label));
603
608
  }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
604
609
  }
605
- function CollapsibleNavItem({ item }) {
610
+ function CollapsibleNavItem({ item, inTree = true }) {
606
611
  const navigate = useNavigate();
607
612
  const currentPath = useLocation().pathname;
608
613
  const setNavContext = useAppStore((s) => s.setNavContext);
@@ -629,7 +634,7 @@ function CollapsibleNavItem({ item }) {
629
634
  !isDisabled &&
630
635
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
631
636
  !isDisabled &&
632
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren, isExpanded: isExpanded, onToggle: hasChildren ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
637
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren, inTree: inTree, isExpanded: isExpanded, onToggle: hasChildren ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
633
638
  if (isDisabled)
634
639
  return;
635
640
  setNavContext({
@@ -644,7 +649,7 @@ function CollapsibleNavItem({ item }) {
644
649
  if (hasChildren)
645
650
  setManualOverride(true);
646
651
  navigate(item.to);
647
- }, className: cn("flex items-center gap-2 flex-1 min-w-0 py-2 text-sm text-left", getNavDepthOffsets(0).body, "pr-3 transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
652
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0 py-2 text-sm text-left", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS, "pr-3 transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
648
653
  ? "text-sidebar-foreground/45 cursor-default"
649
654
  : selected
650
655
  ? ACTIVE_NAV_TEXT_CLASS
@@ -658,11 +663,11 @@ function CollapsibleNavItem({ item }) {
658
663
  return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
659
664
  }
660
665
  if (isDisabled) {
661
- return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", getNavDepthOffsets(0).body,
666
+ return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
662
667
  // Transparent border for box-model parity — see the NavLink variant.
663
- "rounded-lg border border-transparent pr-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
668
+ "rounded-lg border border-transparent pr-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, inTree: inTree, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
664
669
  }
665
- return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("relative flex items-center gap-2 w-full", getNavDepthOffsets(0).body,
670
+ return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("relative flex items-center gap-2 w-full", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
666
671
  // The transparent border is NOT decoration: it is what makes this
667
672
  // row's box model match every other variant. An absolutely
668
673
  // positioned twisty resolves against the PADDING box, so a row
@@ -670,7 +675,7 @@ function CollapsibleNavItem({ item }) {
670
675
  // siblings' and the column goes ragged.
671
676
  "rounded-xl border border-transparent pr-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
672
677
  !isFileDragOver &&
673
- "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
678
+ "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
674
679
  ? ACTIVE_NAV_BADGE_CLASS
675
680
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] })) }));
676
681
  }
@@ -829,6 +834,14 @@ function findFirstStatusDot(items) {
829
834
  * — toggling one doesn't re-render siblings.
830
835
  */
831
836
  function NavGroupSection({ group }) {
837
+ // Does anything in THIS group branch? The leaf dot exists to say "this
838
+ // row has nothing under it", which is only information where a sibling
839
+ // might — a tree. In a flat group (New Thread, Activity, a list of
840
+ // chats) nothing branches, so a dot on every row marks a distinction
841
+ // that does not exist and reads as a bullet. The disclosure cell still
842
+ // renders either way, so flat rows keep the same label column as tree
843
+ // rows and the sidebar stays on one grid.
844
+ const groupHasBranches = group.items.some((item) => Boolean(item.children && item.children.length > 0));
832
845
  // hideLabel + collapsible are mutually exclusive — a hidden label
833
846
  // can't host a chevron. The collapsible flag is the opt-in; legacy
834
847
  // groups stay uncollapsable so behaviour is unchanged.
@@ -881,7 +894,12 @@ function NavGroupSection({ group }) {
881
894
  _jsxs("div", { className: cn("flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx(NavLink, { to: group.to, end: true, className: "min-w-0 flex-1", children: ({ isActive }) => (_jsx("span", { className: cn("block", SIDEBAR_GROUP_HEADER_LABEL_CLASS, isActive
882
895
  ? "text-sidebar-selected-foreground"
883
896
  : "text-sidebar-foreground/65 hover:text-sidebar-foreground"), children: group.label })) }), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : (_jsxs("div", { className: cn("flex items-center", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx("span", { className: cn("text-sidebar-foreground/65", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label })] })) })), !collapsed &&
884
- group.items.map((item) => (_jsx(CollapsibleNavItem, { item: item }, item.to + item.label)))] }));
897
+ group.items.map((item) => (_jsx(CollapsibleNavItem, { item: item,
898
+ // A leaf dot distinguishes "nothing under this" from a sibling
899
+ // that branches. Decided per GROUP: Workspaces has branches, so
900
+ // its leaves are marked; Chats has none, so a dot on every row
901
+ // would mark a distinction that does not exist.
902
+ inTree: groupHasBranches }, item.to + item.label)))] }));
885
903
  }
886
904
  /**
887
905
  * The "+" dropdown shown next to a group label when the group declares
@@ -7,9 +7,15 @@ import type { ReactNode } from "react";
7
7
  * way to fold them away. The run is now one summary line — "8 tools · done" —
8
8
  * over a group the reader can collapse when the machinery is in the way.
9
9
  *
10
- * OPEN by default: the detail is what a developer watching an agent work
11
- * actually wants, and a transcript that hides every step by default reads as
12
- * though nothing happened. Collapsing is the deliberate act, not expanding.
10
+ * COLLAPSED by default. This was open at first, on the reasoning that a
11
+ * developer watching an agent work wants the detail but in a real
12
+ * transcript the cards dominate the answer they were supposed to support,
13
+ * and the reader scrolls past machinery to reach the point. The summary line
14
+ * already carries what matters at a glance (how many calls, and whether they
15
+ * worked); opening it is the deliberate act.
16
+ *
17
+ * The row shape is shared with the reasoning part via `MessageDisclosure`, so
18
+ * the two foldaways in a message read as one family instead of two widgets.
13
19
  *
14
20
  * RED only when the whole run failed. One failure among eight successful
15
21
  * calls is not a red turn — the agent recovered, and painting the summary red
@@ -1,9 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { useAuiState } from "@assistant-ui/react";
4
- import { ChevronRight, Loader2, CheckCircle2, XCircle, AlertCircle, Wrench } from "lucide-react";
5
- import { cn } from "@iloveagents/foundry-web-primitives";
6
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
4
+ import { Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react";
5
+ import { MessageDisclosure } from "./message-disclosure.js";
7
6
  import { _detectErrorResult } from "./tool-fallback.js";
8
7
  /**
9
8
  * One row standing in for a run of adjacent tool calls, with the cards under
@@ -13,9 +12,15 @@ import { _detectErrorResult } from "./tool-fallback.js";
13
12
  * way to fold them away. The run is now one summary line — "8 tools · done" —
14
13
  * over a group the reader can collapse when the machinery is in the way.
15
14
  *
16
- * OPEN by default: the detail is what a developer watching an agent work
17
- * actually wants, and a transcript that hides every step by default reads as
18
- * though nothing happened. Collapsing is the deliberate act, not expanding.
15
+ * COLLAPSED by default. This was open at first, on the reasoning that a
16
+ * developer watching an agent work wants the detail but in a real
17
+ * transcript the cards dominate the answer they were supposed to support,
18
+ * and the reader scrolls past machinery to reach the point. The summary line
19
+ * already carries what matters at a glance (how many calls, and whether they
20
+ * worked); opening it is the deliberate act.
21
+ *
22
+ * The row shape is shared with the reasoning part via `MessageDisclosure`, so
23
+ * the two foldaways in a message read as one family instead of two widgets.
19
24
  *
20
25
  * RED only when the whole run failed. One failure among eight successful
21
26
  * calls is not a red turn — the agent recovered, and painting the summary red
@@ -26,26 +31,16 @@ export function ToolRunGroup({ indices, isRunning, children, }) {
26
31
  const { total, failed } = useToolRunOutcome(indices);
27
32
  // Every call in the run failed — the only state that earns the alarm colour.
28
33
  const allFailed = total > 0 && failed === total;
29
- const [isOpen, setIsOpen] = useState(true);
34
+ const [isOpen, setIsOpen] = useState(false);
30
35
  const label = isRunning
31
36
  ? `Using ${total === 1 ? "1 tool" : `${total} tools`}…`
32
37
  : failed > 0
33
38
  ? `${total === 1 ? "1 tool" : `${total} tools`} · ${failed} failed`
34
39
  : `${total === 1 ? "1 tool" : `${total} tools`} · done`;
35
- return (_jsxs(Collapsible, { open: isOpen, onOpenChange: setIsOpen, className: "w-full", "data-tool-run-group": true, children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-tool-run-trigger": true, className: cn("flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-xs", "text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", allFailed && "text-destructive hover:text-destructive"), children: [_jsx(ChevronRight, { className: cn("size-3.5 shrink-0 transition-transform", isOpen && "rotate-90") }), isRunning ? (_jsx(Loader2, { className: "size-3.5 shrink-0 animate-spin text-primary" })) : allFailed ? (_jsx(XCircle, { className: "size-3.5 shrink-0 text-destructive" })) : failed > 0 ? (
36
- // Partial: the run produced a result, some of it the hard way.
37
- _jsx(AlertCircle, { className: "size-3.5 shrink-0 text-muted-foreground" })) : (_jsx(CheckCircle2, { className: "size-3.5 shrink-0 text-primary" })), _jsx(Wrench, { className: "size-3 shrink-0 opacity-60" }), _jsx("span", { className: "truncate", children: label })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "mt-1.5 flex flex-col gap-2", children: children }) })] }));
40
+ return (_jsx(MessageDisclosure, { open: isOpen, onOpenChange: setIsOpen, tone: allFailed ? "destructive" : "muted", icon: isRunning ? (_jsx(Loader2, { className: "size-3.5 animate-spin text-primary" })) : allFailed ? (_jsx(XCircle, { className: "size-3.5 text-destructive" })) : failed > 0 ? (
41
+ // Partial: the run produced a result, some of it the hard way.
42
+ _jsx(AlertCircle, { className: "size-3.5 text-muted-foreground" })) : (_jsx(CheckCircle2, { className: "size-3.5 text-primary" })), label: label, "data-tool-run-group": true, children: _jsx("div", { className: "flex flex-col gap-2", children: children }) }));
38
43
  }
39
- /**
40
- * How many calls this run made, and how many of them failed.
41
- *
42
- * The group node the primitive hands us carries only the LAST part's status,
43
- * which is the wrong signal here: a run whose final call succeeded after an
44
- * earlier one failed would summarise as green. So read the parts themselves
45
- * and count. A failed tool call shows up two ways — a transport-level
46
- * `incomplete` status, or a `complete` round-trip whose payload describes an
47
- * error (a Python `@tool` raising, an MCP `isError`) — and both count.
48
- */
49
44
  function useToolRunOutcome(indices) {
50
45
  const parts = useAuiState((s) => s.message.parts);
51
46
  let failed = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "license": "MIT",
5
5
  "description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
6
6
  "keywords": [
@@ -70,8 +70,8 @@
70
70
  "tailwind-merge": "^3.5.0",
71
71
  "react-markdown": "^10.0.0",
72
72
  "remark-gfm": "^4.0.0",
73
- "@iloveagents/foundry-agent": "^0.14.0",
74
- "@iloveagents/foundry-web-primitives": "^0.14.0"
73
+ "@iloveagents/foundry-agent": "^0.14.2",
74
+ "@iloveagents/foundry-web-primitives": "^0.14.2"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@ag-ui/client": "^0.0.52",