@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.
- package/dist/components/assistant-chat.d.ts +8 -3
- package/dist/components/assistant-chat.js +11 -9
- package/dist/components/chat-bubble.js +5 -8
- package/dist/components/chat-header.js +8 -1
- package/dist/components/chat-message-parts.d.ts +22 -0
- package/dist/components/chat-message-parts.js +51 -0
- package/dist/components/loading-indicator.js +6 -1
- package/dist/components/message-disclosure.d.ts +46 -0
- package/dist/components/message-disclosure.js +30 -0
- package/dist/components/reasoning-effort-picker.js +1 -1
- package/dist/components/reasoning-part.d.ts +4 -0
- package/dist/components/reasoning-part.js +12 -5
- package/dist/components/sidebar.js +28 -10
- package/dist/components/tool-run-group.d.ts +29 -0
- package/dist/components/tool-run-group.js +56 -0
- package/dist/lib/use-page-tools.d.ts +21 -2
- package/dist/lib/use-page-tools.js +0 -0
- package/package.json +3 -3
|
@@ -13,9 +13,14 @@ export interface ChatContentProps {
|
|
|
13
13
|
*/
|
|
14
14
|
starterSuggestions?: string[];
|
|
15
15
|
/**
|
|
16
|
-
* Show the page pin button in the composer.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 {
|
|
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 = () =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 =
|
|
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 {
|
|
13
|
-
import {
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { Brain
|
|
4
|
-
import {
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
74
|
-
"@iloveagents/foundry-web-primitives": "^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",
|