@iloveagents/foundry-web-ui 0.13.0 → 0.14.1

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.
@@ -13,9 +13,14 @@ export interface ChatContentProps {
13
13
  */
14
14
  starterSuggestions?: string[];
15
15
  /**
16
- * Show the page pin button in the composer. Defaults to false so generic
17
- * starter apps only show attachment, input, and send controls. Apps with
18
- * page-aware workflows can opt in explicitly.
16
+ * Show the page pin button in the composer.
17
+ *
18
+ * Defaults to TRUE. It defaulted to false, and nothing in the shell ever
19
+ * passed it, so the affordance shipped switched off everywhere: on a
20
+ * document or a view there was no way to put the thing you were looking
21
+ * at into the conversation. Pinning the current page is the core gesture
22
+ * of a page-aware chat, not an opt-in extra — an app that genuinely wants
23
+ * a bare composer opts OUT.
19
24
  */
20
25
  showPinButton?: boolean;
21
26
  }
@@ -11,10 +11,8 @@ import { ContextPins } from "./context-bar.js";
11
11
  import { ComposerSubmitBridge } from "./composer-submit-bridge.js";
12
12
  import { ChatComposerBannerSlot } from "./chat-slots.js";
13
13
  import { useChatBubbleStore } from "../lib/chat-bubble-store.js";
14
- import { MarkdownText } from "./markdown-text.js";
15
- import { ReasoningMessagePartComponent } from "./reasoning-part.js";
16
14
  import { ReasoningEffortPicker } from "./reasoning-effort-picker.js";
17
- import { ToolFallback } from "./tool-fallback.js";
15
+ import { ChatMessageParts } from "./chat-message-parts.js";
18
16
  import { LoadingIndicator, LoadingBar } from "./loading-indicator.js";
19
17
  import { MessageTimestamp } from "./message-timestamp.js";
20
18
  import { MessageError } from "./message-error.js";
@@ -24,11 +22,15 @@ const UserMessage = () => {
24
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, {}) })] })] }) }));
25
23
  };
26
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" }) }) }) }));
27
- 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(MessagePrimitive.Parts, { components: {
28
- Text: MarkdownText,
29
- Reasoning: ReasoningMessagePartComponent,
30
- tools: { Fallback: ToolFallback },
31
- } }) }), _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
+ };
32
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" }) }) })] }));
33
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" }) })] })] }) }) }) }));
34
36
  const ComposerActionButton = () => {
@@ -113,7 +115,7 @@ const DropZone = ({ children }) => {
113
115
  * Reusable chat content — used by ChatPage and ChatBubble.
114
116
  * Renders inside a ThreadPrimitive.Root context.
115
117
  */
116
- export function ChatContent({ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS, showPinButton = false, } = {}) {
118
+ export function ChatContent({ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS, showPinButton = true, } = {}) {
117
119
  const isExpanded = useChatBubbleStore((s) => s.isExpanded);
118
120
  const collapseChat = useChatBubbleStore((s) => s.collapse);
119
121
  const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
@@ -9,8 +9,8 @@ import { ContextPins } from "./context-bar.js";
9
9
  import { ComposerContextBadges, SentContextBadges } from "./context-badges.js";
10
10
  import { ComposerSubmitBridge } from "./composer-submit-bridge.js";
11
11
  import { ChatComposerBannerSlot, ChatLauncherBadgeSlot } from "./chat-slots.js";
12
- import { ReasoningMessagePartComponent } from "./reasoning-part.js";
13
- import { MarkdownText } from "./markdown-text.js";
12
+ import { ChatMessageParts } from "./chat-message-parts.js";
13
+ import { ReasoningEffortPicker } from "./reasoning-effort-picker.js";
14
14
  import { LoadingIndicator } from "./loading-indicator.js";
15
15
  import { MessageTimestamp } from "./message-timestamp.js";
16
16
  import { MessageError } from "./message-error.js";
@@ -20,10 +20,7 @@ import { useNewConversation } from "../lib/use-new-conversation.js";
20
20
  import { RAIL_WIDTH, useSidebarStore } from "../lib/sidebar-store.js";
21
21
  const CHAT_BUBBLE_INSET = 8;
22
22
  function BubbleAssistantMessage() {
23
- return (_jsxs(MessagePrimitive.Root, { className: "w-full mb-2 group", children: [_jsx("div", { className: "text-foreground text-sm leading-relaxed flex flex-col gap-2", children: _jsx(MessagePrimitive.Parts, { components: {
24
- Text: MarkdownText,
25
- Reasoning: ReasoningMessagePartComponent,
26
- } }) }), _jsx(MessageError, { compact: true }), _jsxs("div", { className: "flex h-7 items-center gap-2 mt-1", children: [_jsxs(ActionBarPrimitive.Root, { hideWhenRunning: true, className: cn("pointer-events-none flex items-center gap-1 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", className: "size-7", children: [_jsx(MessagePrimitive.If, { copied: false, children: _jsx(Copy, { className: "size-3" }) }), _jsx(MessagePrimitive.If, { copied: true, children: _jsx(Check, { className: "size-3 text-primary" }) })] }) }), _jsx(ActionBarPrimitive.Reload, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Retry", size: "icon", className: "size-7", children: _jsx(RefreshCw, { className: "size-3" }) }) })] }), _jsx(MessageTimestamp, {})] })] }));
23
+ return (_jsxs(MessagePrimitive.Root, { className: "w-full mb-2 group", children: [_jsx("div", { className: "text-foreground text-sm leading-relaxed flex flex-col gap-2", children: _jsx(ChatMessageParts, {}) }), _jsx(MessageError, { compact: true }), _jsxs("div", { className: "flex h-7 items-center gap-2 mt-1", children: [_jsxs(ActionBarPrimitive.Root, { hideWhenRunning: true, className: cn("pointer-events-none flex items-center gap-1 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", className: "size-7", children: [_jsx(MessagePrimitive.If, { copied: false, children: _jsx(Copy, { className: "size-3" }) }), _jsx(MessagePrimitive.If, { copied: true, children: _jsx(Check, { className: "size-3 text-primary" }) })] }) }), _jsx(ActionBarPrimitive.Reload, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Retry", size: "icon", className: "size-7", children: _jsx(RefreshCw, { className: "size-3" }) }) })] }), _jsx(MessageTimestamp, {})] })] }));
27
24
  }
28
25
  function BubbleUserMessage() {
29
26
  const messageId = useAuiState((s) => s.message.id);
@@ -99,10 +96,10 @@ export function ChatBubble() {
99
96
  return (_jsxs(_Fragment, { children: [!isOpen && (_jsx("div", { "aria-hidden": "true", className: cn("pointer-events-none fixed bottom-0 left-0 z-40 hidden md:block", "border-r border-sidebar-border", "bg-gradient-to-b from-sidebar/0 via-sidebar/88 to-sidebar/96", "supports-[backdrop-filter]:backdrop-blur"), style: {
100
97
  width: isSidebarOpen ? sidebarWidth : RAIL_WIDTH,
101
98
  height: 64,
102
- } })), !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, 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
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
103
100
  ? { left: dragPos.x, top: dragPos.y }
104
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: {
105
102
  UserMessage: BubbleUserMessage,
106
103
  AssistantMessage: BubbleAssistantMessage,
107
- } }), _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 })] }), _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: 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("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" }) }) }) })] })] }) })] })] })] }))] }));
108
105
  }
@@ -8,8 +8,15 @@ import { ThemeToggle } from "./theme-toggle.js";
8
8
  import { UserMenu } from "./user-menu.js";
9
9
  import { useSidebarStore } from "../lib/sidebar-store.js";
10
10
  import { useNewConversation } from "../lib/use-new-conversation.js";
11
+ import { useChatBubbleStore } from "../lib/chat-bubble-store.js";
11
12
  export const ChatHeader = () => {
12
- const handleNewConversation = useNewConversation();
13
+ const isExpanded = useChatBubbleStore((s) => s.isExpanded);
14
+ // In expanded mode the default "navigate to /" would land on the chat
15
+ // route, and the shell collapses expanded chat there — so New Thread
16
+ // silently threw the user out of the mode they were working in AND off
17
+ // the page they were reading. Expanded starts the thread in place, the
18
+ // same contract the bubble already uses.
19
+ const handleNewConversation = useNewConversation(isExpanded ? { navigateToChat: false, preserveNavigation: true } : undefined);
13
20
  const toggleMobile = useSidebarStore((s) => s.toggleMobile);
14
21
  const isSidebarOpen = useSidebarStore((s) => s.isOpen);
15
22
  return (_jsx("div", { className: cn("shrink-0", "bg-background border-b border-border"), children: _jsxs("div", { className: "px-4 py-3 flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(TooltipIconButton, { tooltip: "Open sidebar", onClick: toggleMobile, className: "md:hidden", children: _jsx(Menu, { className: "size-5" }) }), _jsx(Link, { to: "/", className: cn("flex items-center gap-2.5 hover:opacity-80 transition-opacity", isSidebarOpen && "md:hidden"), children: _jsx(AppBrand, { labelClassName: "text-sm font-semibold text-foreground" }) })] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ThemeToggle, {}), _jsx(TooltipIconButton, { tooltip: "New Thread", size: "icon", onClick: handleNewConversation, children: _jsx(SquarePen, { className: "size-4 text-primary" }) }), _jsx(UserMenu, {})] })] }) }));
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The parts of an assistant message — ONE renderer, used by every chat
3
+ * surface.
4
+ *
5
+ * The bubble and the expanded chat used to configure `MessagePrimitive.Parts`
6
+ * separately, and had drifted: the expanded chat passed a `tools` fallback and
7
+ * rendered full tool cards, the bubble passed none and so rendered assistant-ui's
8
+ * default. Same conversation, two different transcripts depending on which
9
+ * window you happened to have open. Composing this component instead of
10
+ * hand-rolling a parts config is what keeps them honest — there is now only
11
+ * one place to drift from.
12
+ *
13
+ * Adjacent tool calls fold into one {@link ToolRunGroup} — a single summary row
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.
21
+ */
22
+ export declare function ChatMessageParts(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { MessagePrimitive, groupPartByType } from "@assistant-ui/react";
3
+ import { MarkdownText } from "./markdown-text.js";
4
+ import { ReasoningMessagePartComponent } from "./reasoning-part.js";
5
+ import { ToolFallback } from "./tool-fallback.js";
6
+ import { ToolRunGroup } from "./tool-run-group.js";
7
+ /**
8
+ * Coalesce adjacent tool calls into one `group-tool` node. Everything else
9
+ * stays ungrouped and renders as its own leaf.
10
+ */
11
+ const groupBy = groupPartByType({ "tool-call": ["group-tool"] });
12
+ /**
13
+ * The parts of an assistant message — ONE renderer, used by every chat
14
+ * surface.
15
+ *
16
+ * The bubble and the expanded chat used to configure `MessagePrimitive.Parts`
17
+ * separately, and had drifted: the expanded chat passed a `tools` fallback and
18
+ * rendered full tool cards, the bubble passed none and so rendered assistant-ui's
19
+ * default. Same conversation, two different transcripts depending on which
20
+ * window you happened to have open. Composing this component instead of
21
+ * hand-rolling a parts config is what keeps them honest — there is now only
22
+ * one place to drift from.
23
+ *
24
+ * Adjacent tool calls fold into one {@link ToolRunGroup} — a single summary row
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.
32
+ */
33
+ export function ChatMessageParts() {
34
+ return (_jsx(MessagePrimitive.GroupedParts, { groupBy: groupBy, indicator: "never", children: ({ part, children }) => {
35
+ switch (part.type) {
36
+ case "group-tool":
37
+ return (_jsx(ToolRunGroup, { indices: part.indices, isRunning: part.status?.type === "running", children: children }));
38
+ case "text":
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, {}) }));
43
+ case "reasoning":
44
+ return _jsx(ReasoningMessagePartComponent, { text: part.text });
45
+ case "tool-call":
46
+ return part.toolUI ?? _jsx(ToolFallback, { ...part });
47
+ default:
48
+ return null;
49
+ }
50
+ } }));
51
+ }
@@ -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
+ }
@@ -46,5 +46,5 @@ export function ReasoningEffortPicker({ className }) {
46
46
  const triggerLabel = effort === "default" && resolved
47
47
  ? `${REASONING_EFFORT_LABELS.default} · ${REASONING_EFFORT_LABELS[resolved]}`
48
48
  : REASONING_EFFORT_LABELS[effort];
49
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", 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", { children: triggerLabel }), _jsx(ChevronDown, { className: "size-3.5" })] }) }), _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)))] })] }));
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)))] })] }));
50
50
  }
@@ -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
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * One row standing in for a run of adjacent tool calls, with the cards under
4
+ * it behind a disclosure.
5
+ *
6
+ * A turn that calls eight tools used to render eight bordered cards with no
7
+ * way to fold them away. The run is now one summary line — "8 tools · done" —
8
+ * over a group the reader can collapse when the machinery is in the way.
9
+ *
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.
19
+ *
20
+ * RED only when the whole run failed. One failure among eight successful
21
+ * calls is not a red turn — the agent recovered, and painting the summary red
22
+ * would report a broken run where there was a working one. A partial failure
23
+ * still says so, in the count, without the alarm.
24
+ */
25
+ export declare function ToolRunGroup({ indices, isRunning, children, }: {
26
+ indices: readonly number[];
27
+ isRunning: boolean;
28
+ children: ReactNode;
29
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useAuiState } from "@assistant-ui/react";
4
+ import { Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react";
5
+ import { MessageDisclosure } from "./message-disclosure.js";
6
+ import { _detectErrorResult } from "./tool-fallback.js";
7
+ /**
8
+ * One row standing in for a run of adjacent tool calls, with the cards under
9
+ * it behind a disclosure.
10
+ *
11
+ * A turn that calls eight tools used to render eight bordered cards with no
12
+ * way to fold them away. The run is now one summary line — "8 tools · done" —
13
+ * over a group the reader can collapse when the machinery is in the way.
14
+ *
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.
24
+ *
25
+ * RED only when the whole run failed. One failure among eight successful
26
+ * calls is not a red turn — the agent recovered, and painting the summary red
27
+ * would report a broken run where there was a working one. A partial failure
28
+ * still says so, in the count, without the alarm.
29
+ */
30
+ export function ToolRunGroup({ indices, isRunning, children, }) {
31
+ const { total, failed } = useToolRunOutcome(indices);
32
+ // Every call in the run failed — the only state that earns the alarm colour.
33
+ const allFailed = total > 0 && failed === total;
34
+ const [isOpen, setIsOpen] = useState(false);
35
+ const label = isRunning
36
+ ? `Using ${total === 1 ? "1 tool" : `${total} tools`}…`
37
+ : failed > 0
38
+ ? `${total === 1 ? "1 tool" : `${total} tools`} · ${failed} failed`
39
+ : `${total === 1 ? "1 tool" : `${total} tools`} · done`;
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 }) }));
43
+ }
44
+ function useToolRunOutcome(indices) {
45
+ const parts = useAuiState((s) => s.message.parts);
46
+ let failed = 0;
47
+ for (const index of indices) {
48
+ const part = parts?.[index];
49
+ if (!part)
50
+ continue;
51
+ const status = part.status;
52
+ if (status?.type === "incomplete" || _detectErrorResult(part.result))
53
+ failed++;
54
+ }
55
+ return { total: indices.length, failed };
56
+ }
@@ -2,8 +2,27 @@
2
2
  * usePageTools — hook for pages to register client tools with the agent.
3
3
  *
4
4
  * Call this in any page component to declare tools available on that page.
5
- * Tools are registered on mount and removed on unmount, so the agent
6
- * only sees tools relevant to the current page.
5
+ * Tools are registered on mount and removed on unmount, so the agent only
6
+ * sees tools relevant to the current page.
7
+ *
8
+ * **The set may change while the page is mounted.** A page's capabilities are
9
+ * state, not a mount-time constant: a document open read-only cannot accept a
10
+ * live edit, a form with no dirty fields cannot submit. Registering once meant
11
+ * the catalogue advertised tools the surface could not perform, and the model —
12
+ * correctly trusting the catalogue — called them and dead-ended on an error.
13
+ * Offer a tool only while it works, and the agent picks the route that does:
14
+ *
15
+ * ```tsx
16
+ * usePageTools([
17
+ * fileInfoTool,
18
+ * ...(liveEditAvailable ? [replaceSelectionTool] : []),
19
+ * ]);
20
+ * ```
21
+ *
22
+ * Re-registration keys on the schema the model sees (names, descriptions,
23
+ * parameters) — a fresh array literal with identical schemas does not thrash
24
+ * the store. `execute` is always dispatched to the CURRENT render's closure, so
25
+ * tools read live state directly instead of through refs.
7
26
  *
8
27
  * @example
9
28
  * ```tsx
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
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.13.0",
74
- "@iloveagents/foundry-web-primitives": "^0.13.0"
73
+ "@iloveagents/foundry-agent": "^0.14.1",
74
+ "@iloveagents/foundry-web-primitives": "^0.14.1"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@ag-ui/client": "^0.0.52",