@iloveagents/foundry-web-ui 0.20.1 → 0.21.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.
@@ -5,6 +5,7 @@ import { TooltipProvider } from "../ui/tooltip.js";
5
5
  import { AGUIAdapterSDK } from "../lib/ag-ui-adapter.js";
6
6
  import { FileAttachmentAdapter } from "../lib/attachment-adapter.js";
7
7
  import { useAttachmentAdapterStore } from "../lib/attachment-registry.js";
8
+ import { useVoiceAdapterStore } from "../lib/voice-adapter-registry.js";
8
9
  import { ShowDocumentToolUI } from "./show-document-tool-ui.js";
9
10
  import { ClientToolExecutor } from "./client-tool-executor.js";
10
11
  const AGUIAdapterContext = createContext(null);
@@ -27,7 +28,7 @@ export function AGUIRuntimeProvider(props) {
27
28
  const [resetKey, setResetKey] = useState(0);
28
29
  const resetThread = useCallback(() => setResetKey((p) => p + 1), []);
29
30
  const innerKey = `${props.threadId ?? "fresh"}:${resetKey}`;
30
- return (_jsx(AGUIRuntimeInner, { ...props, resetThread: resetThread }, innerKey));
31
+ return _jsx(AGUIRuntimeInner, { ...props, resetThread: resetThread }, innerKey);
31
32
  }
32
33
  function AGUIRuntimeInner({ children, fetchFn, threadId, urlMatch, historyAdapterFactory, resetThread, }) {
33
34
  const adapter = useMemo(() => new AGUIAdapterSDK("/api/agent",
@@ -61,6 +62,13 @@ function AGUIRuntimeInner({ children, fetchFn, threadId, urlMatch, historyAdapte
61
62
  // factory + urlMatch here.
62
63
  // eslint-disable-next-line react-hooks/exhaustive-deps
63
64
  }, [historyAdapterFactory, urlMatch]);
65
+ // Voice is opt-in and host-supplied (see ``registerVoiceAdapter``).
66
+ // Both slots stay absent unless a module registered one, so a runtime
67
+ // without voice is byte-for-byte the runtime that existed before:
68
+ // assistant-ui reports the capability as unavailable and every voice
69
+ // affordance hides itself.
70
+ const voiceAdapter = useVoiceAdapterStore((s) => s.voice);
71
+ const speechAdapter = useVoiceAdapterStore((s) => s.speech);
64
72
  const runtime = useLocalRuntime(adapter, {
65
73
  adapters: {
66
74
  attachments: attachmentAdapter,
@@ -69,6 +77,8 @@ function AGUIRuntimeInner({ children, fetchFn, threadId, urlMatch, historyAdapte
69
77
  // assistant-ui calls .load() on mount to seed past messages and
70
78
  // .append() after every turn.
71
79
  ...(historyAdapter ? { history: historyAdapter } : {}),
80
+ ...(voiceAdapter ? { voice: voiceAdapter } : {}),
81
+ ...(speechAdapter ? { speech: speechAdapter } : {}),
72
82
  },
73
83
  });
74
84
  return (_jsx(AGUIAdapterContext.Provider, { value: { adapter, resetThread }, children: _jsx(AssistantRuntimeProvider, { runtime: runtime, children: _jsxs(TooltipProvider, { children: [_jsx(ShowDocumentToolUI, {}), _jsx(ClientToolExecutor, {}), children] }) }) }));
@@ -23,9 +23,19 @@ export interface ChatContentProps {
23
23
  * a bare composer opts OUT.
24
24
  */
25
25
  showPinButton?: boolean;
26
+ /**
27
+ * Composer placeholder. Defaults to `DEFAULT_COMPOSER_PLACEHOLDER`.
28
+ */
29
+ placeholder?: string;
26
30
  }
31
+ /**
32
+ * "Message Assistant..." named the machine and told the user nothing. The
33
+ * invitation is what belongs here: it says the box takes anything, which is
34
+ * the one thing a first-time user does not know.
35
+ */
36
+ export declare const DEFAULT_COMPOSER_PLACEHOLDER = "Ask anything";
27
37
  /**
28
38
  * Reusable chat content — used by ChatPage and ChatBubble.
29
39
  * Renders inside a ThreadPrimitive.Root context.
30
40
  */
31
- export declare function ChatContent({ starterSuggestions, showPinButton, }?: ChatContentProps): import("react/jsx-runtime").JSX.Element;
41
+ export declare function ChatContent({ starterSuggestions, showPinButton, placeholder, }?: ChatContentProps): import("react/jsx-runtime").JSX.Element;
@@ -1,15 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useRef, useCallback, useEffect } from "react";
3
3
  import { ThreadPrimitive, ComposerPrimitive, MessagePrimitive, ActionBarPrimitive, BranchPickerPrimitive, useAui, useAuiState, useThreadViewport, } from "@assistant-ui/react";
4
- import { ArrowUp, Copy, Check, RefreshCw, PencilIcon, ChevronLeft, ChevronRight, ChevronDown, Square, Bot, Paperclip, Minimize2, PanelRightClose, PanelRightOpen, } from "lucide-react";
4
+ import { ArrowUp, Copy, Check, RefreshCw, PencilIcon, ChevronLeft, ChevronRight, ChevronDown, Square, Bot, Minimize2, PanelRightClose, PanelRightOpen, } from "lucide-react";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
6
  import { Button } from "@iloveagents/foundry-web-primitives";
7
7
  import { TooltipIconButton } from "./tooltip-icon-button.js";
8
8
  import { userAttachmentComponents, composerAttachmentComponents } from "./chat-attachments.js";
9
9
  import { SentContextBadges, ComposerContextBadges } from "./context-badges.js";
10
+ import { ComposerAddMenu } from "./composer-add-menu.js";
10
11
  import { ContextPins } from "./context-bar.js";
11
12
  import { ComposerSubmitBridge } from "./composer-submit-bridge.js";
12
- import { ChatComposerBannerSlot } from "./chat-slots.js";
13
+ import { ChatComposerActionsSlot, ChatComposerBannerSlot, useChatThreadSurface, useComposerActionsOwnSend, } from "./chat-slots.js";
13
14
  import { useChatBubbleStore } from "../lib/chat-bubble-store.js";
14
15
  import { ReasoningEffortPicker } from "./reasoning-effort-picker.js";
15
16
  import { ChatMessageParts } from "./chat-message-parts.js";
@@ -34,7 +35,10 @@ const AssistantMessage = () => {
34
35
  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" }) }) })] }));
35
36
  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" }) })] })] }) }) }) }));
36
37
  const ComposerActionButton = () => {
37
- return (_jsxs(_Fragment, { children: [_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-10 flex-shrink-0", "aria-label": "Stop generating", children: _jsx(Square, { className: "size-4" }) }) }) }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "icon", className: "rounded-full size-10 flex-shrink-0", "aria-label": "Send message", children: _jsx(ArrowUp, { className: "size-5" }) }) }) })] }));
38
+ // With nothing typed, a module-supplied control (a voice button) may hold
39
+ // the send corner instead of a Send that has nothing to send.
40
+ const actionsOwnSend = useComposerActionsOwnSend();
41
+ return (_jsxs(_Fragment, { children: [_jsx(ThreadPrimitive.If, { running: false, children: _jsx(ReasoningEffortPicker, {}) }), _jsx(ChatComposerActionsSlot, {}), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(ComposerPrimitive.Cancel, { asChild: true, children: _jsx(Button, { variant: "secondary", size: "icon", className: "rounded-full size-10 flex-shrink-0", "aria-label": "Stop generating", children: _jsx(Square, { className: "size-4" }) }) }) }), !actionsOwnSend && (_jsx(ThreadPrimitive.If, { running: false, children: _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "icon", className: "rounded-full size-10 flex-shrink-0", "aria-label": "Send message", children: _jsx(ArrowUp, { className: "size-5" }) }) }) }))] }));
38
42
  };
39
43
  /**
40
44
  * Default starter prompts shown on empty state. Override per-app by passing
@@ -111,22 +115,33 @@ const DropZone = ({ children }) => {
111
115
  }, [aui, showDropError]);
112
116
  return (_jsxs("div", { className: "relative flex flex-col flex-1 overflow-hidden", onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, children: [children, isDragging && (_jsx("div", { className: cn("absolute inset-0 z-50 flex items-center justify-center pointer-events-none", "bg-background/80 backdrop-blur-sm", "border-2 border-dashed border-primary rounded-xl"), children: _jsx("p", { className: "text-lg text-primary font-medium", children: "Drop files here" }) })), dropError && (_jsx("div", { role: "alert", className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-50 max-w-[90%]", "rounded-md border border-destructive/50 bg-background px-3 py-2", "text-sm text-destructive shadow-md pointer-events-none truncate"), children: dropError }))] }));
113
117
  };
118
+ /**
119
+ * "Message Assistant..." named the machine and told the user nothing. The
120
+ * invitation is what belongs here: it says the box takes anything, which is
121
+ * the one thing a first-time user does not know.
122
+ */
123
+ export const DEFAULT_COMPOSER_PLACEHOLDER = "Ask anything";
114
124
  /**
115
125
  * Reusable chat content — used by ChatPage and ChatBubble.
116
126
  * Renders inside a ThreadPrimitive.Root context.
117
127
  */
118
- export function ChatContent({ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS, showPinButton = true, } = {}) {
128
+ export function ChatContent({ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS, showPinButton = true, placeholder = DEFAULT_COMPOSER_PLACEHOLDER, } = {}) {
129
+ const ThreadSurface = useChatThreadSurface();
119
130
  const isExpanded = useChatBubbleStore((s) => s.isExpanded);
120
131
  const collapseChat = useChatBubbleStore((s) => s.collapse);
121
132
  const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
122
133
  const togglePagePanel = useChatBubbleStore((s) => s.togglePagePanel);
123
- return (_jsxs(ThreadPrimitive.Root, { className: cn("flex flex-col flex-1", "bg-background text-foreground", "overflow-hidden"), children: [isExpanded && (_jsxs("div", { className: "shrink-0 flex items-center justify-between px-4 py-2 border-b border-border", children: [_jsxs("span", { className: "text-sm font-medium text-foreground flex items-center gap-2", children: [_jsx(Bot, { className: "size-4 text-primary" }), "Chat"] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(TooltipIconButton, { tooltip: showPagePanel ? "Hide page panel" : "Show page panel", size: "icon", onClick: togglePagePanel, className: "hidden xl:inline-flex", children: showPagePanel ? (_jsx(PanelRightClose, { className: "size-4" })) : (_jsx(PanelRightOpen, { className: "size-4" })) }), _jsx(TooltipIconButton, { tooltip: "Minimize to bubble", size: "icon", onClick: collapseChat, children: _jsx(Minimize2, { className: "size-4" }) })] })] })), _jsxs(DropZone, { children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs(ThreadPrimitive.Viewport, { className: "absolute inset-0 overflow-y-auto bg-background", children: [_jsx(ThreadPrimitive.Empty, { children: _jsxs("div", { className: cn("flex min-h-full flex-col", "items-center justify-center", "px-4"), children: [_jsx(Bot, { className: "size-16 mb-6 text-primary/70" }), _jsx("h1", { className: "text-[2rem] font-normal text-foreground text-center mb-6", children: "How can I help you today?" }), starterSuggestions.length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-2 max-w-lg", children: starterSuggestions.map((prompt) => (_jsx(ThreadPrimitive.Suggestion, { prompt: prompt, send: true, asChild: true, children: _jsx(Button, { variant: "outline", className: "text-sm", children: prompt }) }, prompt))) }))] }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "mx-auto max-w-3xl px-4 pt-8 pb-4", children: [_jsx(ThreadPrimitive.Messages, { components: {
134
+ return (_jsxs(ThreadPrimitive.Root, { className: cn("flex flex-col flex-1", "bg-background text-foreground", "overflow-hidden"), children: [isExpanded && (_jsxs("div", { className: "shrink-0 flex items-center justify-between px-4 py-2 border-b border-border", children: [_jsxs("span", { className: "text-sm font-medium text-foreground flex items-center gap-2", children: [_jsx(Bot, { className: "size-4 text-primary" }), "Chat"] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(TooltipIconButton, { tooltip: showPagePanel ? "Hide page panel" : "Show page panel", size: "icon", onClick: togglePagePanel, className: "hidden xl:inline-flex", children: showPagePanel ? (_jsx(PanelRightClose, { className: "size-4" })) : (_jsx(PanelRightOpen, { className: "size-4" })) }), _jsx(TooltipIconButton, { tooltip: "Minimize to bubble", size: "icon", onClick: collapseChat, children: _jsx(Minimize2, { className: "size-4" }) })] })] })), _jsxs(DropZone, { children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [ThreadSurface && (
135
+ // `overflow-hidden`, not scroll: a surface owns its own layout,
136
+ // and a full-bleed one (an avatar cropped to the frame) must be
137
+ // able to reach the edges rather than sit in a scroll box.
138
+ _jsx("div", { className: "absolute inset-0 z-10 overflow-hidden bg-background", children: _jsx(ThreadSurface, {}) })), _jsxs(ThreadPrimitive.Viewport, { className: cn("absolute inset-0 overflow-y-auto bg-background", ThreadSurface && "invisible"), "aria-hidden": ThreadSurface ? true : undefined, children: [_jsx(ThreadPrimitive.Empty, { children: _jsxs("div", { className: cn("flex min-h-full flex-col", "items-center justify-center", "px-4"), children: [_jsx(Bot, { className: "size-16 mb-6 text-primary/70" }), _jsx("h1", { className: "text-[2rem] font-normal text-foreground text-center mb-6", children: "How can I help you today?" }), starterSuggestions.length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-2 max-w-lg", children: starterSuggestions.map((prompt) => (_jsx(ThreadPrimitive.Suggestion, { prompt: prompt, send: true, asChild: true, children: _jsx(Button, { variant: "outline", className: "text-sm", children: prompt }) }, prompt))) }))] }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "mx-auto max-w-3xl px-4 pt-8 pb-4", children: [_jsx(ThreadPrimitive.Messages, { components: {
124
139
  UserMessage,
125
140
  EditComposer,
126
141
  AssistantMessage,
127
- } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(ScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 bg-background", children: [_jsx(ChatComposerBannerSlot, {}), _jsx(ThreadPrimitive.If, { running: false, children: _jsx("div", { className: "border-t border-border" }) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingBar, {}) }), _jsx("div", { className: "mx-auto max-w-3xl px-4 py-4", children: _jsxs(ComposerPrimitive.Root, { className: cn(
142
+ } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), !ThreadSurface && (_jsx(ThreadPrimitive.If, { empty: false, children: _jsx(ScrollToBottomButton, {}) }))] }), _jsxs("div", { className: "shrink-0 bg-background", children: [_jsx(ChatComposerBannerSlot, {}), _jsx(ThreadPrimitive.If, { running: false, children: _jsx("div", { className: "border-t border-border" }) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingBar, {}) }), _jsx("div", { className: "mx-auto max-w-3xl px-4 py-4", children: _jsxs(ComposerPrimitive.Root, { className: cn(
128
143
  // Query container for the controls inside, so they size
129
144
  // against the composer rather than the viewport. This one
130
145
  // is wide, so the effort picker keeps its full label.
131
- "@container", "flex flex-col gap-2", "rounded-3xl border border-input", "bg-background shadow-sm", "px-4 py-2", "focus-within:border-primary/50 focus-within:shadow-md", "transition-all duration-300"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsx(ComposerPrimitive.Attachments, { components: composerAttachmentComponents }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message Assistant...", rows: 1, className: cn("w-full bg-transparent", "pt-2 text-base", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-32") }), _jsxs("div", { className: "flex items-center gap-2 pb-1", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", variant: "ghost", className: "shrink-0", children: _jsx(Paperclip, { className: "size-5" }) }) }), _jsx(ContextPins, { showPinButton: showPinButton })] }), _jsx("div", { className: "flex-1" }), _jsx(ComposerActionButton, {})] })] }) })] })] })] }));
146
+ "@container", "flex flex-col gap-2", "rounded-3xl border border-input", "bg-background shadow-sm", "px-4 py-2", "focus-within:border-primary/50 focus-within:shadow-md", "transition-all duration-300"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsx(ComposerPrimitive.Attachments, { components: composerAttachmentComponents }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 @md:flex-nowrap", children: [_jsxs("div", { className: "order-2 flex shrink-0 items-center gap-1 @md:order-1", children: [_jsx(ComposerAddMenu, { showPin: showPinButton }), _jsx(ContextPins, {})] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: placeholder, rows: 1, className: cn("order-1 w-full bg-transparent", "py-2 text-base", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-32", "@md:order-2 @md:w-auto @md:flex-1") }), _jsx("div", { className: "order-3 ml-auto flex shrink-0 items-center gap-2 @md:ml-0", children: _jsx(ComposerActionButton, {}) })] })] }) })] })] })] }));
132
147
  }
@@ -1,14 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useRef, useState } from "react";
3
3
  import { ThreadPrimitive, ComposerPrimitive, MessagePrimitive, ActionBarPrimitive, useAuiState, useThreadViewport, } from "@assistant-ui/react";
4
- import { Sparkles, X, Maximize2, SquarePen, ArrowUp, Square, ChevronDown, Copy, Check, Paperclip, RefreshCw, } from "lucide-react";
4
+ import { Sparkles, X, Maximize2, SquarePen, ArrowUp, Square, ChevronDown, Copy, Check, RefreshCw, } from "lucide-react";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
6
  import { Button } from "@iloveagents/foundry-web-primitives";
7
7
  import { TooltipIconButton } from "./tooltip-icon-button.js";
8
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
- import { ChatComposerBannerSlot, ChatLauncherBadgeSlot } from "./chat-slots.js";
11
+ import { ComposerAddMenu } from "./composer-add-menu.js";
12
+ import { DEFAULT_COMPOSER_PLACEHOLDER } from "./assistant-chat.js";
13
+ import { ChatComposerActionsSlot, ChatComposerBannerSlot, ChatLauncherBadgeSlot, useChatThreadSurface, useComposerActionsOwnSend, } from "./chat-slots.js";
12
14
  import { ChatMessageParts } from "./chat-message-parts.js";
13
15
  import { ReasoningEffortPicker } from "./reasoning-effort-picker.js";
14
16
  import { LoadingIndicator } from "./loading-indicator.js";
@@ -34,6 +36,7 @@ function BubbleScrollToBottomButton() {
34
36
  return (_jsx(Button, { variant: "outline", size: "icon", className: "absolute bottom-2 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md size-7", onClick: () => scrollToBottom({ behavior: "smooth" }), children: _jsx(ChevronDown, { className: "size-3" }) }));
35
37
  }
36
38
  export function ChatBubble() {
39
+ const ThreadSurface = useChatThreadSurface();
37
40
  const isOpen = useChatBubbleStore((s) => s.isOpen);
38
41
  const isExpanded = useChatBubbleStore((s) => s.isExpanded);
39
42
  const openBubble = useChatBubbleStore((s) => s.open);
@@ -45,6 +48,8 @@ export function ChatBubble() {
45
48
  const pageLabel = useAppStore((s) => s.navContext.label);
46
49
  const currentPage = useAppStore((s) => s.currentPage);
47
50
  const threadActive = useAppStore((s) => s.threadActive);
51
+ // With nothing typed, a module-supplied control may hold the send corner.
52
+ const actionsOwnSend = useComposerActionsOwnSend();
48
53
  // Draggable position: null = use default CSS anchored with equal left/bottom inset.
49
54
  const [dragPos, setDragPos] = useState(null);
50
55
  const dragStart = useRef(null);
@@ -93,17 +98,17 @@ export function ChatBubble() {
93
98
  // Hide bubble on chat page (already full-screen) and in expanded mode
94
99
  if (currentPage === "/" || isExpanded)
95
100
  return null;
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: {
97
- width: isSidebarOpen ? sidebarWidth : RAIL_WIDTH,
98
- height: 64,
99
- } })), !isOpen && (_jsxs("div", { className: "fixed z-50 group", style: { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, children: [_jsx("button", { type: "button", onClick: openBubble, className: cn("size-10 rounded-full", "bg-primary text-primary-foreground shadow-lg", "flex items-center justify-center", "hover:bg-primary/90 transition-all duration-200", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"), "aria-label": "Ask the AI", children: _jsx(Sparkles, { className: "size-4" }) }), _jsx(ChatLauncherBadgeSlot, {})] })), isOpen && (_jsxs("div", { ref: panelRef, "data-chat-surface": "bubble", className: cn("fixed z-50", "rounded-2xl border border-border", "bg-background shadow-2xl", "flex flex-col overflow-hidden", "w-95 h-150"), style: dragPos
100
- ? { left: dragPos.x, top: dragPos.y }
101
- : { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, children: [_jsxs("div", { className: cn("flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0", "cursor-grab active:cursor-grabbing select-none"), style: { touchAction: "none" }, onPointerDown: onHeaderPointerDown, onPointerMove: onHeaderPointerMove, onPointerUp: onHeaderPointerUp, onLostPointerCapture: onHeaderLostPointerCapture, children: [_jsxs("div", { className: "flex flex-col min-w-0", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-sm", children: [_jsx(Sparkles, { className: "size-3.5 text-primary shrink-0" }), _jsx("span", { className: "font-semibold text-foreground truncate", children: pageLabel })] }), _jsx("span", { className: "text-xs text-muted-foreground", children: threadActive ? "Active thread" : "New conversation" })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsx(TooltipIconButton, { tooltip: "New Thread", size: "icon", className: "size-7", onClick: startNewThread, children: _jsx(SquarePen, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Expand chat", size: "icon", className: "size-7", onClick: expandChat, children: _jsx(Maximize2, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Close", size: "icon", className: "size-7", onClick: handleClose, children: _jsx(X, { className: "size-4" }) })] })] }), _jsxs(ThreadPrimitive.Root, { className: "flex flex-col flex-1 overflow-hidden", children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs(ThreadPrimitive.Viewport, { className: "absolute inset-0 overflow-y-auto", children: [_jsx(ThreadPrimitive.Empty, { children: _jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsx("p", { className: "text-sm text-muted-foreground text-center", children: "Ask the agent about this page" }) }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "px-4 pt-4 pb-3", children: [_jsx(ThreadPrimitive.Messages, { components: {
102
- UserMessage: BubbleUserMessage,
103
- AssistantMessage: BubbleAssistantMessage,
104
- } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(BubbleScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 border-t border-border", children: [_jsx(ChatComposerBannerSlot, {}), _jsx("div", { className: "px-3 py-3", children: _jsxs(ComposerPrimitive.Root, { className: cn(
105
- // Query container for the controls inside: the bubble is
106
- // a narrow panel in a wide window, so they have to size
107
- // against THIS box, not the viewport.
108
- "@container", "flex flex-col gap-1", "rounded-2xl border border-input", "bg-background shadow-sm", "px-3 py-2", "focus-within:border-primary/50", "transition-all duration-200"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", size: "icon", className: "size-7 shrink-0", children: _jsx(Paperclip, { className: "size-3.5" }) }) }), _jsx(ContextPins, { compact: true, showPinButton: true })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message...", rows: 1, className: cn("flex-1 bg-transparent", "py-1 text-sm", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-20") }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ReasoningEffortPicker, {}) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(ComposerPrimitive.Cancel, { asChild: true, children: _jsx(Button, { variant: "secondary", size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Stop", children: _jsx(Square, { className: "size-3" }) }) }) }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Send", children: _jsx(ArrowUp, { className: "size-4" }) }) }) })] })] }) })] })] })] }))] }));
101
+ return (_jsxs(_Fragment, { children: [_jsx(ComposerSubmitBridge, {}), _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: {
102
+ width: isSidebarOpen ? sidebarWidth : RAIL_WIDTH,
103
+ height: 64,
104
+ } })), !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
105
+ ? { left: dragPos.x, top: dragPos.y }
106
+ : { 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: [ThreadSurface && (_jsx("div", { className: "absolute inset-0 z-10 overflow-hidden bg-background", children: _jsx(ThreadSurface, {}) })), _jsxs(ThreadPrimitive.Viewport, { className: cn("absolute inset-0 overflow-y-auto", ThreadSurface && "invisible"), "aria-hidden": ThreadSurface ? true : undefined, children: [_jsx(ThreadPrimitive.Empty, { children: _jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsx("p", { className: "text-sm text-muted-foreground text-center", children: "Ask the agent about this page" }) }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "px-4 pt-4 pb-3", children: [_jsx(ThreadPrimitive.Messages, { components: {
107
+ UserMessage: BubbleUserMessage,
108
+ AssistantMessage: BubbleAssistantMessage,
109
+ } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: !ThreadSurface && _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(
110
+ // Query container for the controls inside: the bubble is
111
+ // a narrow panel in a wide window, so they have to size
112
+ // against THIS box, not the viewport.
113
+ "@container", "flex flex-col gap-1", "rounded-2xl border border-input", "bg-background shadow-sm", "px-3 py-2", "focus-within:border-primary/50", "transition-all duration-200"), children: [_jsx(ComposerContextBadges, {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex shrink-0 items-center gap-0.5", children: [_jsx(ComposerAddMenu, { compact: true }), _jsx(ContextPins, { compact: true })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: DEFAULT_COMPOSER_PLACEHOLDER, 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(ChatComposerActionsSlot, {}), _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" }) }) }) }), !actionsOwnSend && (_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" }) }) }) }))] })] }) })] })] })] }))] })] }));
109
114
  }
@@ -18,11 +18,35 @@
18
18
  * wrapper (a positioned ``group`` container around the round button),
19
19
  * so a badge can position itself absolutely over the button and use
20
20
  * ``group-hover:`` styles for a hover flyout.
21
+ * - ``composerActions`` — in the composer's trailing control row, just
22
+ * before Send, in BOTH the full chat and the floating bubble panel.
23
+ * For controls that are an alternative *way to send* rather than
24
+ * content about the thread — a push-to-talk mic, say. Leading-edge
25
+ * controls (attach, context pins) stay owned by the composer itself.
26
+ *
27
+ * - ``threadSurface`` — rendered *instead of* the message viewport, for a
28
+ * mode that replaces the conversation rather than adding to it. A live
29
+ * voice call is the case it exists for: while you are talking, the
30
+ * transcript scrolling past is not what you are looking at. Unlike the
31
+ * other slots this one is set and cleared at runtime — registering a
32
+ * component IS the takeover, clearing it hands the thread back.
33
+ *
34
+ * A module that registers ``composerActions`` may also claim the Send
35
+ * position while the composer is empty (``composerActionsReplaceSend``).
36
+ * That is the shape a voice control wants: nothing has been typed, so Send
37
+ * has nothing to do, and the corner belongs to the other way of asking.
38
+ * Type one character and Send comes back. Opt-in, because a slot holding
39
+ * something unrelated must not make Send disappear.
21
40
  */
22
41
  import type { ComponentType } from "react";
23
42
  interface ChatSlotsState {
24
43
  composerBanner: ComponentType | null;
25
44
  launcherBadge: ComponentType | null;
45
+ composerActions: ComponentType | null;
46
+ /** Let ``composerActions`` stand in for Send while the composer is empty. */
47
+ composerActionsReplaceSend: boolean;
48
+ /** Replaces the message viewport entirely while set. */
49
+ threadSurface: ComponentType | null;
26
50
  }
27
51
  export declare const useChatSlotsStore: import("zustand").UseBoundStore<import("zustand").StoreApi<ChatSlotsState>>;
28
52
  /**
@@ -34,4 +58,15 @@ export declare const useChatSlotsStore: import("zustand").UseBoundStore<import("
34
58
  export declare function registerChatSlots(slots: Partial<ChatSlotsState>): void;
35
59
  export declare function ChatComposerBannerSlot(): import("react/jsx-runtime").JSX.Element | null;
36
60
  export declare function ChatLauncherBadgeSlot(): import("react/jsx-runtime").JSX.Element | null;
61
+ export declare function ChatComposerActionsSlot(): import("react/jsx-runtime").JSX.Element | null;
62
+ /**
63
+ * True when Send should stand aside for the ``composerActions`` slot: a
64
+ * module claimed the position and there is nothing to send.
65
+ */
66
+ export declare function useComposerActionsOwnSend(): boolean;
67
+ /**
68
+ * The module-supplied surface that replaces the message viewport, or null
69
+ * when the thread should render normally.
70
+ */
71
+ export declare function useChatThreadSurface(): ComponentType | null;
37
72
  export {};
@@ -1,8 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { create } from "zustand";
3
+ import { useAuiState } from "@assistant-ui/react";
3
4
  export const useChatSlotsStore = create(() => ({
4
5
  composerBanner: null,
5
6
  launcherBadge: null,
7
+ composerActions: null,
8
+ composerActionsReplaceSend: false,
9
+ threadSurface: null,
6
10
  }));
7
11
  /**
8
12
  * Register chat slot components. Later calls overwrite only the keys
@@ -16,6 +20,13 @@ export function registerChatSlots(slots) {
16
20
  next.composerBanner = slots.composerBanner;
17
21
  if (slots.launcherBadge !== undefined)
18
22
  next.launcherBadge = slots.launcherBadge;
23
+ if (slots.composerActions !== undefined)
24
+ next.composerActions = slots.composerActions;
25
+ if (slots.composerActionsReplaceSend !== undefined) {
26
+ next.composerActionsReplaceSend = slots.composerActionsReplaceSend;
27
+ }
28
+ if (slots.threadSurface !== undefined)
29
+ next.threadSurface = slots.threadSurface;
19
30
  useChatSlotsStore.setState(next);
20
31
  }
21
32
  export function ChatComposerBannerSlot() {
@@ -26,3 +37,23 @@ export function ChatLauncherBadgeSlot() {
26
37
  const Badge = useChatSlotsStore((s) => s.launcherBadge);
27
38
  return Badge ? _jsx(Badge, {}) : null;
28
39
  }
40
+ export function ChatComposerActionsSlot() {
41
+ const Actions = useChatSlotsStore((s) => s.composerActions);
42
+ return Actions ? _jsx(Actions, {}) : null;
43
+ }
44
+ /**
45
+ * True when Send should stand aside for the ``composerActions`` slot: a
46
+ * module claimed the position and there is nothing to send.
47
+ */
48
+ export function useComposerActionsOwnSend() {
49
+ const claimed = useChatSlotsStore((s) => s.composerActionsReplaceSend && !!s.composerActions);
50
+ const isEmpty = useAuiState((s) => s.optional.composer?.isEmpty ?? true);
51
+ return claimed && isEmpty;
52
+ }
53
+ /**
54
+ * The module-supplied surface that replaces the message viewport, or null
55
+ * when the thread should render normally.
56
+ */
57
+ export function useChatThreadSurface() {
58
+ return useChatSlotsStore((s) => s.threadSurface);
59
+ }
@@ -2,5 +2,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { SearchX } from "lucide-react";
3
3
  import { cn } from "@iloveagents/foundry-web-primitives";
4
4
  export function CollectionEmptyState({ icon: Icon = SearchX, title, description, action, className, }) {
5
- return (_jsxs("div", { className: cn("flex min-h-[18rem] flex-col items-center justify-center rounded-[1.75rem] bg-muted/18 px-6 py-10 text-center ring-1 ring-border/35", className), children: [Icon ? (_jsx("div", { className: "mb-4 flex size-14 items-center justify-center rounded-2xl bg-background/85 text-primary shadow-[0_8px_24px_-18px_rgb(15_23_42/0.35)]", children: _jsx(Icon, { className: "size-6" }) })) : null, _jsx("div", { className: "text-base font-semibold text-foreground", children: title }), description ? _jsx("p", { className: "mt-2 max-w-xl text-sm leading-6 text-muted-foreground", children: description }) : null, action ? _jsx("div", { className: "mt-5", children: action }) : null] }));
5
+ return (_jsxs("div", { className: cn("flex min-h-[18rem] flex-col items-center justify-center rounded-[1.75rem] bg-muted/18 px-6 py-10 text-center ring-1 ring-border/35", className), children: [Icon ? (_jsx("div", { className: "mb-4 flex size-14 items-center justify-center rounded-2xl bg-background/85 text-primary shadow-[0_8px_24px_-18px_rgb(15_23_42/0.35)]", children: _jsx(Icon, { className: "size-6" }) })) : null, _jsx("div", { className: "text-base font-semibold text-foreground", children: title }), description ? (_jsx("p", { className: "mt-2 max-w-xl text-sm leading-6 text-muted-foreground", children: description })) : null, action ? _jsx("div", { className: "mt-5", children: action }) : null] }));
6
6
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The composer's leading "+" — one affordance for everything you can add to
3
+ * a turn.
4
+ *
5
+ * It replaces a row of single-purpose icons (paperclip, pin, and whatever
6
+ * came next). That row grows every time the product learns a new trick, and
7
+ * each icon costs the user a guess about what it does; a labelled menu costs
8
+ * one click and reads itself. Pinning in particular is a power feature that
9
+ * does not deserve permanent residency next to the text field.
10
+ *
11
+ * Attaching still goes through `ComposerPrimitive.AddAttachment`, so the
12
+ * file picker keeps whatever `accept` filter the registered attachment
13
+ * adapter declares — reimplementing the picker here would drift from it.
14
+ */
15
+ import type { FC } from "react";
16
+ export interface ComposerAddMenuProps {
17
+ /** Bubble-composer sizing. */
18
+ compact?: boolean;
19
+ /**
20
+ * Offer "Pin this page". A bare composer with no page behind it (or a host
21
+ * that does not use page context) opts out.
22
+ */
23
+ showPin?: boolean;
24
+ }
25
+ export declare const ComposerAddMenu: FC<ComposerAddMenuProps>;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ComposerPrimitive } from "@assistant-ui/react";
3
+ import { Paperclip, Pin, PinOff, Plus } from "lucide-react";
4
+ import { Button, cn } from "@iloveagents/foundry-web-primitives";
5
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu.js";
6
+ import { usePagePin } from "./context-bar.js";
7
+ export const ComposerAddMenu = ({ compact = false, showPin = true }) => {
8
+ const { isPinned, toggle } = usePagePin();
9
+ const icon = compact ? "size-3.5" : "size-4";
10
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": "Add to this message", className: cn("shrink-0 rounded-full", compact ? "size-7" : "size-9"), children: _jsx(Plus, { className: compact ? "size-4" : "size-5" }) }) }), _jsxs(DropdownMenuContent, { align: "start", side: "top", className: "w-52", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsxs(DropdownMenuItem, { children: [_jsx(Paperclip, { className: icon }), "Attach file"] }) }), showPin && (_jsxs(DropdownMenuItem, { onSelect: (event) => {
11
+ // Keep the menu anchored while the pin badge appears, so the
12
+ // user sees the result of what they just clicked.
13
+ event.preventDefault();
14
+ toggle();
15
+ }, children: [isPinned ? _jsx(PinOff, { className: icon }) : _jsx(Pin, { className: icon }), isPinned ? "Unpin this page" : "Pin this page"] }))] })] }));
16
+ };
@@ -1 +1,26 @@
1
+ /**
2
+ * Sends text a module submitted on the user's behalf — a spoken turn, most of
3
+ * the time — through the ordinary composer.
4
+ *
5
+ * The composer stays editable while a module is using it, so the user's own
6
+ * draft can be sitting in it when a turn arrives. Sending would take that with
7
+ * it, so the text is saved and put back.
8
+ *
9
+ * **Attachments are NOT preserved**, and that is a decision rather than an
10
+ * oversight. There is no way to send without them — `SendOptions` is
11
+ * `{ startRun, steer }` — so preserving them means lifting them out of the
12
+ * composer, sending, and adding them back. That is asynchronous, through a
13
+ * host-supplied adapter, and it produced four separate defects in short order:
14
+ * a cancellation check that dropped them mid-sequence, two turns interleaving
15
+ * their save/restore, a stalled upload wedging every later turn, and a
16
+ * timed-out operation landing on a later turn instead. Each fix was correct
17
+ * and each one exposed the next.
18
+ *
19
+ * So this stays synchronous, which is what makes it easy to be sure about: no
20
+ * queue, no timeout, no window in which a second turn can interleave. A file
21
+ * attached but not yet sent goes with the spoken turn, exactly as it did
22
+ * before any of this — a real limitation, and a smaller one than the failures
23
+ * that came with trying to remove it. It is worth revisiting if the composer
24
+ * ever grows a way to send without attachments.
25
+ */
1
26
  export declare function ComposerSubmitBridge(): null;
@@ -1,17 +1,51 @@
1
1
  import { useEffect } from "react";
2
2
  import { useAui } from "@assistant-ui/react";
3
3
  import { useComposerSubmitStore } from "../lib/composer-submit-store.js";
4
+ /**
5
+ * Sends text a module submitted on the user's behalf — a spoken turn, most of
6
+ * the time — through the ordinary composer.
7
+ *
8
+ * The composer stays editable while a module is using it, so the user's own
9
+ * draft can be sitting in it when a turn arrives. Sending would take that with
10
+ * it, so the text is saved and put back.
11
+ *
12
+ * **Attachments are NOT preserved**, and that is a decision rather than an
13
+ * oversight. There is no way to send without them — `SendOptions` is
14
+ * `{ startRun, steer }` — so preserving them means lifting them out of the
15
+ * composer, sending, and adding them back. That is asynchronous, through a
16
+ * host-supplied adapter, and it produced four separate defects in short order:
17
+ * a cancellation check that dropped them mid-sequence, two turns interleaving
18
+ * their save/restore, a stalled upload wedging every later turn, and a
19
+ * timed-out operation landing on a later turn instead. Each fix was correct
20
+ * and each one exposed the next.
21
+ *
22
+ * So this stays synchronous, which is what makes it easy to be sure about: no
23
+ * queue, no timeout, no window in which a second turn can interleave. A file
24
+ * attached but not yet sent goes with the spoken turn, exactly as it did
25
+ * before any of this — a real limitation, and a smaller one than the failures
26
+ * that came with trying to remove it. It is worth revisiting if the composer
27
+ * ever grows a way to send without attachments.
28
+ */
4
29
  export function ComposerSubmitBridge() {
5
30
  const aui = useAui();
6
31
  const pending = useComposerSubmitStore((s) => s.pending);
7
32
  useEffect(() => {
8
33
  if (!pending)
9
34
  return;
35
+ // Claim first, send second. Several bridges may be mounted at once, and
36
+ // StrictMode runs this effect twice; both would otherwise send the same
37
+ // text before either had cleared it.
38
+ if (!useComposerSubmitStore.getState().claim(pending.id))
39
+ return;
40
+ const draft = aui.composer.getState().text;
10
41
  // send() starts the run for the queued text; steering/queueing semantics
11
42
  // are handled by the composer client (assistant-ui >= 0.15).
12
43
  aui.composer.setText(pending.text);
13
44
  aui.composer.send();
14
- useComposerSubmitStore.getState().consume(pending.id);
45
+ // send() clears the composer, so this restores the user's own text into an
46
+ // empty box rather than appending it to the turn just sent.
47
+ if (draft)
48
+ aui.composer.setText(draft);
15
49
  }, [aui, pending]);
16
50
  return null;
17
51
  }
@@ -37,5 +37,14 @@ interface ContextPinsProps {
37
37
  * @internal Exported for unit tests; not part of the public API.
38
38
  */
39
39
  export declare function getNavigablePath(item: ContextItem): string | null;
40
+ /**
41
+ * The "is this page pinned, and how do I flip that" logic, shared by the
42
+ * pin badge here and the composer's add-menu. Two call sites deciding
43
+ * independently what "pinned" means is how they end up disagreeing.
44
+ */
45
+ export declare function usePagePin(): {
46
+ isPinned: boolean;
47
+ toggle: () => void;
48
+ };
40
49
  export declare const ContextPins: FC<ContextPinsProps>;
41
50
  export {};
@@ -57,22 +57,49 @@ export function getNavigablePath(item) {
57
57
  }
58
58
  return null;
59
59
  }
60
- export const ContextPins = ({ showPinButton = false, compact = false, flyoutDirection = "up", }) => {
60
+ /**
61
+ * The "is this page pinned, and how do I flip that" logic, shared by the
62
+ * pin badge here and the composer's add-menu. Two call sites deciding
63
+ * independently what "pinned" means is how they end up disagreeing.
64
+ */
65
+ export function usePagePin() {
61
66
  const items = useAppStore((s) => s.contextItems);
62
67
  const removeItem = useAppStore((s) => s.removeContextItem);
63
68
  const addContextItem = useAppStore((s) => s.addContextItem);
64
69
  const currentPage = useAppStore((s) => s.currentPage);
65
70
  const pageLabel = useAppStore((s) => s.navContext.label);
66
71
  const navMeta = useAppStore((s) => s.navContext.meta);
72
+ const currentPagePin = items.find((i) => i.persistence === "persistent" &&
73
+ i.type === "page" &&
74
+ i.payload.kind === "page" &&
75
+ i.payload.path === currentPage);
76
+ return {
77
+ isPinned: !!currentPagePin,
78
+ toggle: () => {
79
+ if (currentPagePin) {
80
+ removeItem(currentPagePin.id);
81
+ return;
82
+ }
83
+ addContextItem({
84
+ type: "page",
85
+ label: pageLabel,
86
+ payload: { kind: "page", path: currentPage, label: pageLabel, meta: navMeta },
87
+ sourcePage: currentPage,
88
+ persistence: "persistent",
89
+ });
90
+ },
91
+ };
92
+ }
93
+ export const ContextPins = ({ showPinButton = false, compact = false, flyoutDirection = "up", }) => {
94
+ const items = useAppStore((s) => s.contextItems);
95
+ const removeItem = useAppStore((s) => s.removeContextItem);
96
+ const currentPage = useAppStore((s) => s.currentPage);
97
+ const { isPinned: isCurrentPagePinned, toggle: handleTogglePin } = usePagePin();
67
98
  const navigate = useNavigate();
68
99
  const [flyoutOpen, setFlyoutOpen] = useState(false);
69
100
  const containerRef = useRef(null);
70
101
  const hoverTimeout = useRef(undefined);
71
102
  const pinned = items.filter((i) => i.persistence === "persistent");
72
- // Check if current page is already pinned
73
- const isCurrentPagePinned = pinned.some((i) => i.type === "page" && i.payload.kind === "page" && i.payload.path === currentPage);
74
- // Find the pin item for current page (to unpin)
75
- const currentPagePin = pinned.find((i) => i.type === "page" && i.payload.kind === "page" && i.payload.path === currentPage);
76
103
  // Close flyout on outside click
77
104
  useEffect(() => {
78
105
  if (!flyoutOpen)
@@ -85,20 +112,6 @@ export const ContextPins = ({ showPinButton = false, compact = false, flyoutDire
85
112
  document.addEventListener("mousedown", handleClick);
86
113
  return () => document.removeEventListener("mousedown", handleClick);
87
114
  }, [flyoutOpen]);
88
- const handleTogglePin = () => {
89
- if (isCurrentPagePinned && currentPagePin) {
90
- removeItem(currentPagePin.id);
91
- }
92
- else {
93
- addContextItem({
94
- type: "page",
95
- label: pageLabel,
96
- payload: { kind: "page", path: currentPage, label: pageLabel, meta: navMeta },
97
- sourcePage: currentPage,
98
- persistence: "persistent",
99
- });
100
- }
101
- };
102
115
  const handleMouseEnter = () => {
103
116
  clearTimeout(hoverTimeout.current);
104
117
  if (pinned.length > 0)
@@ -5,7 +5,8 @@
5
5
  * habit across the platform (and the same set the chat agent reads).
6
6
  */
7
7
  import type { Row, Table } from "@tanstack/react-table";
8
- import type { ReactNode } from "react";
8
+ import { type ReactNode } from "react";
9
+ export declare const SelectionBarSingleLine: import("react").Provider<boolean>;
9
10
  export interface DataTableSelectionBarProps<T> {
10
11
  table: Table<T>;
11
12
  /** Bulk actions for the selected rows. */
@@ -1,14 +1,34 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { X } from "lucide-react";
3
+ import { createContext, useContext } from "react";
3
4
  import { Button, cn } from "@iloveagents/foundry-web-primitives";
5
+ /**
6
+ * Whether the bar has a row to itself or shares one.
7
+ *
8
+ * The toolbar's non-stacked layout puts the bar on the SAME line as the
9
+ * search, in a region that scrolls sideways rather than growing — because a
10
+ * toolbar that gains height on the first tick shoves the table down under
11
+ * the cursor mid-click, which is the whole reason the bar moved into the
12
+ * toolbar. A bar that wraps internally defeats that just as thoroughly as
13
+ * one that wraps externally, and its nested actions container is a second
14
+ * place it can happen (review catch).
15
+ *
16
+ * The container knows the constraint and the bar owns the class names, so
17
+ * the policy travels between them instead of the toolbar reaching in with a
18
+ * descendant selector — which would apply to a host's own `selectionBar`
19
+ * node too, and lose to it on specificity besides.
20
+ */
21
+ const SelectionBarSingleLineContext = createContext(false);
22
+ export const SelectionBarSingleLine = SelectionBarSingleLineContext.Provider;
4
23
  export function DataTableSelectionBar({ table, children, label = "row", plural, className, }) {
5
24
  // Every selected row, not only the ones the current filter shows: a
6
25
  // selection the user cannot see is still a selection, and hiding the bar
7
26
  // would take away the only way to clear it.
8
27
  const rows = table.getSelectedRowModel().rows;
28
+ const singleLine = useContext(SelectionBarSingleLineContext);
9
29
  if (rows.length === 0)
10
30
  return null;
11
- return (_jsxs("div", { "data-selection-bar": "", role: "status", className: cn("flex min-h-8 flex-wrap items-center gap-2 rounded-xl bg-primary/5 px-3 text-sm", className), children: [_jsxs("span", { className: "font-medium", children: [rows.length, " ", rows.length === 1 ? label : (plural ?? `${label}s`), " selected"] }), _jsx("div", { className: "flex flex-wrap items-center gap-2", children: typeof children === "function" ? children(rows) : children }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm",
31
+ return (_jsxs("div", { "data-selection-bar": "", role: "status", className: cn("flex min-h-8 items-center gap-2 rounded-xl bg-primary/5 px-3 text-sm", singleLine ? "flex-nowrap whitespace-nowrap" : "flex-wrap", className), children: [_jsxs("span", { className: "font-medium", children: [rows.length, " ", rows.length === 1 ? label : (plural ?? `${label}s`), " selected"] }), _jsx("div", { "data-selection-actions": "", className: cn("flex items-center gap-2", singleLine ? "flex-nowrap" : "flex-wrap"), children: typeof children === "function" ? children(rows) : children }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm",
12
32
  // `true` = the blank state, not the caller's `initialState`, which
13
33
  // would make "Clear" resurrect rows.
14
34
  onClick: () => table.resetRowSelection(true), className: "ml-auto h-8 rounded-lg px-2.5", children: ["Clear", _jsx(X, { className: "size-4" })] })] }));
@@ -1,9 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Maximize2, Minimize2, Plus, Search, SlidersHorizontal, X } from "lucide-react";
3
3
  import { useState } from "react";
4
4
  import { Button, Input, cn } from "@iloveagents/foundry-web-primitives";
5
5
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../ui/dropdown-menu.js";
6
6
  import { DataTableFacetedFilter } from "./data-table-faceted-filter.js";
7
+ import { SelectionBarSingleLine } from "./data-table-selection-bar.js";
7
8
  import { useDataTableFrame } from "./data-table-frame.js";
8
9
  import { DataTableViewOptions } from "./data-table-view-options.js";
9
10
  import { columnLabel, normalizeFacetValue } from "./facets.js";
@@ -75,7 +76,32 @@ export function DataTableToolbar({ table, facets = [], layout = "auto", collapsi
75
76
  const filtersToggle = canCollapse ? (_jsxs(Button, { type: "button", variant: "outline", size: "sm", "data-filters-toggle": "", "aria-expanded": showFilters, onClick: () => setFiltersOpen((current) => !current), className: cn("h-8 rounded-xl border-border/45 bg-background/65 shadow-none hover:bg-muted/26", activeFilters > 0 && "border-primary/30"), children: [_jsx(SlidersHorizontal, { className: "size-4" }), "Filters", activeFilters > 0 ? (_jsxs("span", { className: "rounded-md bg-muted px-1.5 py-0.5 text-xs font-normal tabular-nums", children: [_jsx("span", { "aria-hidden": "true", children: activeFilters }), _jsxs("span", { className: "sr-only", children: [activeFilters, " active"] })] })) : null] })) : null;
76
77
  const rightGroup = actions || viewOptions || focusButton || filtersToggle ? (_jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [filtersToggle, actions, viewOptions ? _jsx(DataTableViewOptions, { table: table }) : null, focusButton] })) : null;
77
78
  if (!stacked) {
78
- return (_jsxs("div", { className: cn("flex flex-col gap-2", className), children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-2", children: [searchNode, facetNodes, addFilterNode, children, resetNode] }), rightGroup ? _jsx("div", { className: "ml-auto", children: rightGroup }) : null] }), showSelectionBar ? _jsx("div", { "data-selection-row": "", children: selectionBar }) : null] }));
79
+ return (
80
+ // A stable hook for tests and hosts, rather than leaving them to
81
+ // match on Tailwind classes — a selector that silently stops
82
+ // matching is how an assertion starts passing for the wrong reason
83
+ // (review catch).
84
+ _jsx("div", { "data-slot": "data-table-toolbar", className: cn("flex flex-col gap-2", className), children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsxs("div", { "data-toolbar-lead": showSelectionBar ? "selection" : "filters", className: cn("flex min-w-0 flex-1 items-center gap-2",
85
+ // Facets genuinely want rows: six of them in a narrow pane
86
+ // should stack rather than scroll off the side. A selection
87
+ // bar is the opposite — one row by definition. Left wrapping,
88
+ // it drops below the search as soon as the search alone is
89
+ // wider than the space, which grows the toolbar again and is
90
+ // the jump this whole change exists to remove (review catch).
91
+ showSelectionBar ? "flex-nowrap" : "flex-wrap"), children: [searchNode, showSelectionBar ? (
92
+ // The bar shares this row with a search that has a width
93
+ // floor and a right group that does not shrink, so in a
94
+ // narrow pane it gets squeezed. It scrolls rather than
95
+ // wrapping inside itself — the row stays exactly one high.
96
+ //
97
+ // And the scrollbar itself stays out of layout. A classic,
98
+ // space-consuming one — Windows and Linux by default, macOS
99
+ // set to "always show" — appears only once the bar overflows,
100
+ // which is only once something is ticked, so its height lands
101
+ // on the toolbar as the same jump by another route (review
102
+ // catch). `[data-selection-row]` in `styles.css` keeps it out
103
+ // of layout; hiding the widget does not hide the scrolling.
104
+ _jsx("div", { "data-selection-row": "", className: "min-w-0 flex-1 overflow-x-auto", children: _jsx(SelectionBarSingleLine, { value: true, children: selectionBar }) })) : (_jsxs(_Fragment, { children: [facetNodes, addFilterNode, children, resetNode] }))] }), rightGroup ? _jsx("div", { className: "ml-auto", children: rightGroup }) : null] }) }));
79
105
  }
80
- return (_jsxs("div", { className: cn("flex flex-col gap-2", className), children: [_jsxs("div", { className: "flex items-center gap-2", children: [searchNode, rightGroup ? _jsx("div", { className: "ml-auto", children: rightGroup }) : null] }), showSelectionBar ? (_jsx("div", { "data-selection-row": "", children: selectionBar })) : showFilters ? (_jsxs("div", { "data-filter-row": "", className: "flex flex-wrap items-center gap-2", children: [facetNodes, addFilterNode, children, resetNode] })) : null] }));
106
+ return (_jsxs("div", { "data-slot": "data-table-toolbar", className: cn("flex flex-col gap-2", className), children: [_jsxs("div", { className: "flex items-center gap-2", children: [searchNode, rightGroup ? _jsx("div", { className: "ml-auto", children: rightGroup }) : null] }), showSelectionBar ? (_jsx("div", { "data-selection-row": "", children: selectionBar })) : showFilters ? (_jsxs("div", { "data-filter-row": "", className: "flex flex-wrap items-center gap-2", children: [facetNodes, addFilterNode, children, resetNode] })) : null] }));
81
107
  }
@@ -11,7 +11,7 @@ import { cn } from "@iloveagents/foundry-web-primitives";
11
11
  import { useAppStore } from "../lib/app-store.js";
12
12
  import { useChatBubbleStore } from "../lib/chat-bubble-store.js";
13
13
  import { submitComposerText } from "../lib/composer-submit-store.js";
14
- import { resolveSelectionContext, } from "../lib/selection-context.js";
14
+ import { resolveSelectionContext } from "../lib/selection-context.js";
15
15
  const ACTION_POPOVER_HEIGHT = 38;
16
16
  const SIMPLE_POPOVER_WIDTH = 126;
17
17
  const ACTION_POPOVER_WIDTH = 236;
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
3
3
  import { resolveThemeRuntime, } from "../lib/theme-runtime.js";
4
4
  const ThemeRuntimeContext = createContext(null);
5
+ /** True inside a ThemeScope — the outermost one owns the document. */
6
+ const ThemeScopeNestedContext = createContext(false);
5
7
  export function ThemeRuntimeProvider({ layers, mode, children, }) {
6
8
  const fallback = useRef(resolveThemeRuntime([], "system"));
7
9
  const [systemPrefersDark, setSystemPrefersDark] = useState(() => typeof window !== "undefined"
@@ -33,9 +35,149 @@ export function ThemeRuntimeProvider({ layers, mode, children, }) {
33
35
  export function useThemeRuntime() {
34
36
  return useContext(ThemeRuntimeContext) ?? resolveThemeRuntime([], "system");
35
37
  }
38
+ const rootThemeStack = [];
39
+ const rootThemeBase = new Map();
40
+ // What this module last put on the element, per name. Anything else there
41
+ // now came from outside, and outside wins — see `applyTopRootTheme`. Value
42
+ // AND priority: a host re-declaring the same value as `!important` has said
43
+ // something new about that name, and comparing values alone would read it as
44
+ // our own write and quietly drop the flag (review catch).
45
+ const rootThemeWritten = new Map();
46
+ function currentlyOurs(root, name) {
47
+ const written = rootThemeWritten.get(name);
48
+ if (written === undefined)
49
+ return true;
50
+ return (root.style.getPropertyValue(name) === written.value
51
+ && root.style.getPropertyPriority(name) === written.priority);
52
+ }
53
+ function rememberWrite(root, name) {
54
+ // Read back rather than storing what we passed: the browser normalises the
55
+ // value, and a comparison against the un-normalised string would read
56
+ // every one of our own writes as somebody else's.
57
+ rootThemeWritten.set(name, {
58
+ value: root.style.getPropertyValue(name),
59
+ priority: root.style.getPropertyPriority(name),
60
+ });
61
+ }
62
+ function applyTopRootTheme(root) {
63
+ const top = rootThemeStack[rootThemeStack.length - 1];
64
+ for (const [name, base] of rootThemeBase) {
65
+ if (!currentlyOurs(root, name)) {
66
+ // Someone outside this module wrote this name after we did — a host
67
+ // theme manager, a design-mode preview, a test. Their write is newer
68
+ // than anything we have to say about it, so we neither overwrite it
69
+ // now nor restore over it later: the name is theirs from here
70
+ // (review catch).
71
+ continue;
72
+ }
73
+ const next = top?.vars[name];
74
+ if (next !== undefined) {
75
+ root.style.setProperty(name, String(next));
76
+ }
77
+ else if (base.value) {
78
+ // Value AND priority: a host token declared `!important` keeps its
79
+ // flag, or anything else can start overriding a theme it owns.
80
+ root.style.setProperty(name, base.value, base.priority);
81
+ }
82
+ else {
83
+ root.style.removeProperty(name);
84
+ rootThemeWritten.delete(name);
85
+ continue;
86
+ }
87
+ rememberWrite(root, name);
88
+ }
89
+ }
90
+ /**
91
+ * Publish `vars` on the document for `id`, claiming a place in the stack the
92
+ * first time and KEEPING it afterwards.
93
+ *
94
+ * Keeping it is the point: a scope whose theme merely changes — a rerender
95
+ * that hands back a new `variables` object, even a semantically identical
96
+ * one from an inline `layers` array — must not jump over a scope that
97
+ * mounted after it. Re-registering on every dependency change would make
98
+ * the newest write win instead of the newest owner (review catch).
99
+ */
100
+ function writeRootTheme(root, id, vars) {
101
+ for (const name of Object.keys(vars)) {
102
+ if (!rootThemeBase.has(name)) {
103
+ rootThemeBase.set(name, {
104
+ value: root.style.getPropertyValue(name),
105
+ priority: root.style.getPropertyPriority(name),
106
+ });
107
+ }
108
+ }
109
+ const existing = rootThemeStack.find((write) => write.id === id);
110
+ if (existing)
111
+ existing.vars = vars;
112
+ else
113
+ rootThemeStack.push({ id, vars });
114
+ applyTopRootTheme(root);
115
+ }
116
+ /** Give up `id`'s place, wherever in the stack it sits. */
117
+ function releaseRootTheme(root, id) {
118
+ const at = rootThemeStack.findIndex((write) => write.id === id);
119
+ if (at >= 0)
120
+ rootThemeStack.splice(at, 1);
121
+ applyTopRootTheme(root);
122
+ if (rootThemeStack.length === 0) {
123
+ rootThemeBase.clear();
124
+ rootThemeWritten.clear();
125
+ }
126
+ }
36
127
  export function ThemeScope({ children, className, style, }) {
37
128
  const runtime = useThemeRuntime();
38
- return (_jsx("div", { className: className, style: { ...runtime.variables, fontFamily: "var(--font-body)", ...style }, children: children }));
129
+ const nested = useContext(ThemeScopeNestedContext);
130
+ // Variables on this div alone reach only what renders INSIDE it. Radix
131
+ // portals mount into document.body, outside it, so every dropdown,
132
+ // popover, select and tooltip fell back to :root — the base preset —
133
+ // while the page around them wore the customer's theme. `dialog` papered
134
+ // over its own case with a nested ThemeScope; that fixes one portal and
135
+ // leaves the next one to rediscover the bug.
136
+ //
137
+ // The outermost scope publishes to the document element instead, so
138
+ // anything portalled anywhere inherits. Nested scopes keep the div only:
139
+ // they exist to theme a subtree differently, and must not overwrite the
140
+ // document with their own values.
141
+ //
142
+ // Residual, stated because it is a real limit and not an oversight: a
143
+ // NESTED scope's own portalled content still resolves the document's
144
+ // (outermost) theme, because the portal escapes the nested div and the
145
+ // nested scope deliberately does not publish. That is strictly better
146
+ // than before — such content used to get the base `:root` preset rather
147
+ // than any theme at all — and the remedy already exists and is used:
148
+ // wrap the portalled content in a `ThemeScope`, as `ui/dialog.tsx` does
149
+ // with `display: contents`. Context reaches through a portal, so the
150
+ // nested scope's variables land on the portalled subtree (review catch).
151
+ // BEFORE paint, not after. A portal that exists on the very first render —
152
+ // a default-open dialog, an overlay restored during hydration — would
153
+ // otherwise paint once against the base `:root` preset and then correct
154
+ // itself: the mismatched-theme flash this exists to remove, in miniature
155
+ // (review catch).
156
+ // The identity of THIS scope, stable for its whole life. Its place in the
157
+ // stack is claimed once and released once; changing its theme in between
158
+ // must not re-order it (review catch).
159
+ const idRef = useRef(undefined);
160
+ idRef.current ?? (idRef.current = Symbol("theme-scope"));
161
+ useLayoutEffect(() => {
162
+ if (nested || typeof document === "undefined")
163
+ return;
164
+ const root = document.documentElement;
165
+ const id = idRef.current;
166
+ return () => releaseRootTheme(root, id);
167
+ }, [nested]);
168
+ useLayoutEffect(() => {
169
+ if (nested || typeof document === "undefined")
170
+ return;
171
+ // `font-family` rides along as one more name: `<html>` needs it set,
172
+ // not just `--font-body` defined, or portalled content keeps the host's
173
+ // font (review catch).
174
+ const vars = {
175
+ ...runtime.variables,
176
+ "font-family": "var(--font-body)",
177
+ };
178
+ writeRootTheme(document.documentElement, idRef.current, vars);
179
+ }, [nested, runtime.variables]);
180
+ return (_jsx(ThemeScopeNestedContext.Provider, { value: true, children: _jsx("div", { className: className, style: { ...runtime.variables, fontFamily: "var(--font-body)", ...style }, children: children }) }));
39
181
  }
40
182
  export function ThemeDocumentMetadata() {
41
183
  const runtime = useThemeRuntime();
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Loader2, CheckCircle2, XCircle, AlertCircle, ChevronDown } from "lucide-react";
3
3
  import { cn } from "@iloveagents/foundry-web-primitives";
4
- import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "../ui/collapsible.js";
4
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
5
5
  import { useState } from "react";
6
6
  export function ToolCallCard({ icon, title, description, status, action, children, className, }) {
7
7
  const [isOpen, setIsOpen] = useState(false);
package/dist/index.d.ts CHANGED
@@ -5,10 +5,11 @@ export { MarkdownText, markdownComponents } from "./components/markdown-text.js"
5
5
  export { ClientToolExecutor } from "./components/client-tool-executor.js";
6
6
  export { AGUIRuntimeProvider, useAGUIAdapter } from "./components/ag-ui-runtime-provider.js";
7
7
  export type { AGUIChatConversationFactoryArgs, AGUIHistoryAdapterFactory, } from "./components/ag-ui-runtime-provider.js";
8
- export { ChatContent, DEFAULT_STARTER_SUGGESTIONS } from "./components/assistant-chat.js";
8
+ export { ChatContent, DEFAULT_STARTER_SUGGESTIONS, DEFAULT_COMPOSER_PLACEHOLDER, } from "./components/assistant-chat.js";
9
+ export { ComposerAddMenu, type ComposerAddMenuProps } from "./components/composer-add-menu.js";
9
10
  export type { ChatContentProps } from "./components/assistant-chat.js";
10
11
  export { ChatBubble } from "./components/chat-bubble.js";
11
- export { registerChatSlots, useChatSlotsStore, ChatComposerBannerSlot, ChatLauncherBadgeSlot, } from "./components/chat-slots.js";
12
+ export { registerChatSlots, useChatSlotsStore, ChatComposerBannerSlot, ChatLauncherBadgeSlot, ChatComposerActionsSlot, useComposerActionsOwnSend, useChatThreadSurface, } from "./components/chat-slots.js";
12
13
  export { ChatHeader } from "./components/chat-header.js";
13
14
  export { ComposerAttachment, UserMessageAttachment, userAttachmentComponents, composerAttachmentComponents, } from "./components/chat-attachments.js";
14
15
  export { ShowDocumentToolUI } from "./components/show-document-tool-ui.js";
@@ -18,7 +19,7 @@ export { ToolFallback } from "./components/tool-fallback.js";
18
19
  export { ConfirmationCard } from "./components/confirmation-card.js";
19
20
  export { TooltipIconButton } from "./components/tooltip-icon-button.js";
20
21
  export { ComposerContextBadges, SentContextBadges } from "./components/context-badges.js";
21
- export { ContextPins } from "./components/context-bar.js";
22
+ export { ContextPins, usePagePin } from "./components/context-bar.js";
22
23
  export { SelectionPopover } from "./components/selection-popover.js";
23
24
  export { GlobalSelectionPopover } from "./components/global-selection-popover.js";
24
25
  export { LoadingIndicator } from "./components/loading-indicator.js";
@@ -84,6 +85,7 @@ export { useNewConversation } from "./lib/use-new-conversation.js";
84
85
  export { AGUIAdapterSDK } from "./lib/ag-ui-adapter.js";
85
86
  export { FileAttachmentAdapter, resolveMimeType } from "./lib/attachment-adapter.js";
86
87
  export { registerAttachmentAdapter, useAttachmentAdapterStore } from "./lib/attachment-registry.js";
88
+ export { registerVoiceAdapter, registerSpeechAdapter, useVoiceAdapterStore, } from "./lib/voice-adapter-registry.js";
87
89
  export { ReasoningPart, ReasoningMessagePartComponent } from "./components/reasoning-part.js";
88
90
  export { ReasoningEffortPicker } from "./components/reasoning-effort-picker.js";
89
91
  export { AuthProvider } from "./lib/auth-provider.js";
package/dist/index.js CHANGED
@@ -5,9 +5,10 @@ export { ToolPanelLayout } from "./components/tool-panel-layout.js";
5
5
  export { MarkdownText, markdownComponents } from "./components/markdown-text.js";
6
6
  export { ClientToolExecutor } from "./components/client-tool-executor.js";
7
7
  export { AGUIRuntimeProvider, useAGUIAdapter } from "./components/ag-ui-runtime-provider.js";
8
- export { ChatContent, DEFAULT_STARTER_SUGGESTIONS } from "./components/assistant-chat.js";
8
+ export { ChatContent, DEFAULT_STARTER_SUGGESTIONS, DEFAULT_COMPOSER_PLACEHOLDER, } from "./components/assistant-chat.js";
9
+ export { ComposerAddMenu } from "./components/composer-add-menu.js";
9
10
  export { ChatBubble } from "./components/chat-bubble.js";
10
- export { registerChatSlots, useChatSlotsStore, ChatComposerBannerSlot, ChatLauncherBadgeSlot, } from "./components/chat-slots.js";
11
+ export { registerChatSlots, useChatSlotsStore, ChatComposerBannerSlot, ChatLauncherBadgeSlot, ChatComposerActionsSlot, useComposerActionsOwnSend, useChatThreadSurface, } from "./components/chat-slots.js";
11
12
  export { ChatHeader } from "./components/chat-header.js";
12
13
  export { ComposerAttachment, UserMessageAttachment, userAttachmentComponents, composerAttachmentComponents, } from "./components/chat-attachments.js";
13
14
  export { ShowDocumentToolUI } from "./components/show-document-tool-ui.js";
@@ -16,7 +17,7 @@ export { ToolFallback } from "./components/tool-fallback.js";
16
17
  export { ConfirmationCard } from "./components/confirmation-card.js";
17
18
  export { TooltipIconButton } from "./components/tooltip-icon-button.js";
18
19
  export { ComposerContextBadges, SentContextBadges } from "./components/context-badges.js";
19
- export { ContextPins } from "./components/context-bar.js";
20
+ export { ContextPins, usePagePin } from "./components/context-bar.js";
20
21
  export { SelectionPopover } from "./components/selection-popover.js";
21
22
  export { GlobalSelectionPopover } from "./components/global-selection-popover.js";
22
23
  export { LoadingIndicator } from "./components/loading-indicator.js";
@@ -92,6 +93,7 @@ export { useNewConversation } from "./lib/use-new-conversation.js";
92
93
  export { AGUIAdapterSDK } from "./lib/ag-ui-adapter.js";
93
94
  export { FileAttachmentAdapter, resolveMimeType } from "./lib/attachment-adapter.js";
94
95
  export { registerAttachmentAdapter, useAttachmentAdapterStore } from "./lib/attachment-registry.js";
96
+ export { registerVoiceAdapter, registerSpeechAdapter, useVoiceAdapterStore, } from "./lib/voice-adapter-registry.js";
95
97
  export { ReasoningPart, ReasoningMessagePartComponent } from "./components/reasoning-part.js";
96
98
  export { ReasoningEffortPicker } from "./components/reasoning-effort-picker.js";
97
99
  // --- Auth ---
@@ -8,9 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  * The provider expects a fully resolved MSAL configuration.
9
9
  * Missing configuration is treated as an application bootstrap error.
10
10
  */
11
- import { useEffect, useState, } from "react";
11
+ import { useEffect, useState } from "react";
12
12
  import { authStore, initializeMsal, getMsalInstance, } from "@iloveagents/foundry-agent/msal";
13
- export const AuthProvider = ({ config, children, }) => {
13
+ export const AuthProvider = ({ config, children }) => {
14
14
  const [ready, setReady] = useState(false);
15
15
  const [initError, setInitError] = useState(null);
16
16
  const [MsalProvider, setMsalProvider] = useState(null);
@@ -35,7 +35,9 @@ export const AuthProvider = ({ config, children, }) => {
35
35
  setReady(true);
36
36
  }
37
37
  })();
38
- return () => { cancelled = true; };
38
+ return () => {
39
+ cancelled = true;
40
+ };
39
41
  }, [config]);
40
42
  if (!ready)
41
43
  return null;
@@ -55,7 +57,7 @@ export const AuthProvider = ({ config, children, }) => {
55
57
  * Uses inProgress === "none" to avoid redirect loops (best practice from
56
58
  * Microsoft docs — never call loginRedirect while another interaction is active).
57
59
  */
58
- const AuthGuard = ({ useMsalHook, children, }) => {
60
+ const AuthGuard = ({ useMsalHook, children }) => {
59
61
  const { instance, accounts, inProgress } = useMsalHook();
60
62
  const [authenticated, setAuthenticated] = useState(false);
61
63
  useEffect(() => {
@@ -5,7 +5,16 @@ interface PendingComposerSubmit {
5
5
  interface ComposerSubmitState {
6
6
  pending: PendingComposerSubmit | null;
7
7
  submit: (text: string) => void;
8
- consume: (id: number) => void;
8
+ /**
9
+ * Take ownership of a pending submission. True for exactly one caller.
10
+ *
11
+ * More than one bridge can legitimately be mounted — the expanded chat and
12
+ * the floating bubble can both exist, and voice needs a consumer alive even
13
+ * while the bubble is closed. Reading `pending` and clearing it afterwards
14
+ * let every mounted bridge see the same value and send it, so the claim and
15
+ * the clear happen together here instead.
16
+ */
17
+ claim: (id: number) => boolean;
9
18
  }
10
19
  export declare const useComposerSubmitStore: import("zustand").UseBoundStore<import("zustand").StoreApi<ComposerSubmitState>>;
11
20
  export declare function submitComposerText(text: string): void;
@@ -1,6 +1,6 @@
1
1
  import { create } from "zustand";
2
2
  let nextSubmitId = 1;
3
- export const useComposerSubmitStore = create((set) => ({
3
+ export const useComposerSubmitStore = create((set, get) => ({
4
4
  pending: null,
5
5
  submit: (text) => {
6
6
  const trimmed = text.trim();
@@ -8,9 +8,12 @@ export const useComposerSubmitStore = create((set) => ({
8
8
  return;
9
9
  set({ pending: { id: nextSubmitId++, text: trimmed } });
10
10
  },
11
- consume: (id) => set((state) => ({
12
- pending: state.pending?.id === id ? null : state.pending,
13
- })),
11
+ claim: (id) => {
12
+ if (get().pending?.id !== id)
13
+ return false;
14
+ set({ pending: null });
15
+ return true;
16
+ },
14
17
  }));
15
18
  export function submitComposerText(text) {
16
19
  useComposerSubmitStore.getState().submit(text);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Voice adapter registry — host-injectable realtime voice + read-aloud.
3
+ *
4
+ * assistant-ui owns realtime voice natively: ``useLocalRuntime`` accepts a
5
+ * ``RealtimeVoiceAdapter``, the thread runtime merges the transcripts it
6
+ * emits into ``thread.messages``, and ``useVoiceControls()`` /
7
+ * ``useVoiceState()`` / ``useVoiceVolume()`` expose the session to any
8
+ * component. What the runtime does NOT provide is a transport — that is
9
+ * the adapter's whole job, and it is deliberately not something this
10
+ * package implements.
11
+ *
12
+ * Threading an adapter through every chat mount would couple the shell to
13
+ * whichever voice backend a deployment happens to use, so this is a tiny
14
+ * registry instead — the exact pattern of :func:`registerAttachmentAdapter`
15
+ * and ``registerChatSlots``: a module calls :func:`registerVoiceAdapter`
16
+ * once (import time or ``useInit``) and the runtime provider picks it up on
17
+ * its next mount.
18
+ *
19
+ * Nothing here knows about any particular voice service.
20
+ * ``@iloveagents/foundry-web-voice`` registers an Azure Voice Live adapter;
21
+ * a different backend is a different registration and no change here.
22
+ *
23
+ * Two independent slots, because they answer different questions:
24
+ * - ``voice`` — a live, two-way spoken session (``RealtimeVoiceAdapter``).
25
+ * - ``speech`` — one-shot "read this message aloud"
26
+ * (``SpeechSynthesisAdapter``, driven by ``thread.speak(messageId)``).
27
+ * A host may register either, both, or neither. Registering neither leaves
28
+ * the runtime exactly as it was before this file existed: assistant-ui
29
+ * reports the capability as unavailable and the UI hides the affordances.
30
+ */
31
+ import type { RealtimeVoiceAdapter, SpeechSynthesisAdapter } from "@assistant-ui/react";
32
+ interface VoiceAdapterState {
33
+ voice: RealtimeVoiceAdapter | null;
34
+ speech: SpeechSynthesisAdapter | null;
35
+ }
36
+ export declare const useVoiceAdapterStore: import("zustand").UseBoundStore<import("zustand").StoreApi<VoiceAdapterState>>;
37
+ /**
38
+ * Register the realtime voice adapter. ``null`` clears it (the runtime
39
+ * then reports voice as unavailable). Later calls overwrite earlier ones —
40
+ * last registration wins.
41
+ */
42
+ export declare function registerVoiceAdapter(adapter: RealtimeVoiceAdapter | null): void;
43
+ /**
44
+ * Register the read-aloud adapter behind ``thread.speak(messageId)``.
45
+ * ``null`` clears it. Independent of :func:`registerVoiceAdapter` — a host
46
+ * may want read-aloud without a live voice session, or the reverse.
47
+ */
48
+ export declare function registerSpeechAdapter(adapter: SpeechSynthesisAdapter | null): void;
49
+ export {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Voice adapter registry — host-injectable realtime voice + read-aloud.
3
+ *
4
+ * assistant-ui owns realtime voice natively: ``useLocalRuntime`` accepts a
5
+ * ``RealtimeVoiceAdapter``, the thread runtime merges the transcripts it
6
+ * emits into ``thread.messages``, and ``useVoiceControls()`` /
7
+ * ``useVoiceState()`` / ``useVoiceVolume()`` expose the session to any
8
+ * component. What the runtime does NOT provide is a transport — that is
9
+ * the adapter's whole job, and it is deliberately not something this
10
+ * package implements.
11
+ *
12
+ * Threading an adapter through every chat mount would couple the shell to
13
+ * whichever voice backend a deployment happens to use, so this is a tiny
14
+ * registry instead — the exact pattern of :func:`registerAttachmentAdapter`
15
+ * and ``registerChatSlots``: a module calls :func:`registerVoiceAdapter`
16
+ * once (import time or ``useInit``) and the runtime provider picks it up on
17
+ * its next mount.
18
+ *
19
+ * Nothing here knows about any particular voice service.
20
+ * ``@iloveagents/foundry-web-voice`` registers an Azure Voice Live adapter;
21
+ * a different backend is a different registration and no change here.
22
+ *
23
+ * Two independent slots, because they answer different questions:
24
+ * - ``voice`` — a live, two-way spoken session (``RealtimeVoiceAdapter``).
25
+ * - ``speech`` — one-shot "read this message aloud"
26
+ * (``SpeechSynthesisAdapter``, driven by ``thread.speak(messageId)``).
27
+ * A host may register either, both, or neither. Registering neither leaves
28
+ * the runtime exactly as it was before this file existed: assistant-ui
29
+ * reports the capability as unavailable and the UI hides the affordances.
30
+ */
31
+ import { create } from "zustand";
32
+ export const useVoiceAdapterStore = create(() => ({
33
+ voice: null,
34
+ speech: null,
35
+ }));
36
+ /**
37
+ * Register the realtime voice adapter. ``null`` clears it (the runtime
38
+ * then reports voice as unavailable). Later calls overwrite earlier ones —
39
+ * last registration wins.
40
+ */
41
+ export function registerVoiceAdapter(adapter) {
42
+ useVoiceAdapterStore.setState({ voice: adapter });
43
+ }
44
+ /**
45
+ * Register the read-aloud adapter behind ``thread.speak(messageId)``.
46
+ * ``null`` clears it. Independent of :func:`registerVoiceAdapter` — a host
47
+ * may want read-aloud without a live voice session, or the reverse.
48
+ */
49
+ export function registerSpeechAdapter(adapter) {
50
+ useVoiceAdapterStore.setState({ speech: adapter });
51
+ }
package/dist/styles.css CHANGED
@@ -184,6 +184,27 @@
184
184
  box-shadow: 6px 0 6px -6px color-mix(in srgb, var(--foreground) 12%, transparent);
185
185
  }
186
186
 
187
+ /* The opposite call from the list scroller below, for the opposite reason.
188
+ * The toolbar's selection row shares a line with the search and the actions,
189
+ * and its entire contract is that ticking a row never changes the toolbar's
190
+ * height. A classic, space-consuming scrollbar — Windows and Linux by
191
+ * default, macOS set to "always show" — would appear exactly when the bar
192
+ * overflows, which is exactly when something is ticked, and its height would
193
+ * land on the toolbar as the same jump by another route (review catch).
194
+ *
195
+ * So the widget is hidden, not the scrolling: wheel, trackpad, and tabbing
196
+ * to a button past the edge all still reach it. Plain CSS rather than
197
+ * utilities, because a class that a consumer's Tailwind never scans fails
198
+ * silently, and this one has no visible symptom until the platform is one we
199
+ * do not develop on. */
200
+ [data-selection-row] {
201
+ scrollbar-width: none;
202
+ }
203
+ [data-selection-row]::-webkit-scrollbar {
204
+ height: 0;
205
+ display: none;
206
+ }
207
+
187
208
  /* A list you can scroll should say so: keep its scrollbars visible rather
188
209
  * than relying on the overlay ones macOS hides until you already scroll. */
189
210
  [data-list-scroller] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.20.1",
3
+ "version": "0.21.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": [
@@ -71,8 +71,8 @@
71
71
  "react-markdown": "^10.0.0",
72
72
  "remark-gfm": "^4.0.0",
73
73
  "tailwind-merge": "^3.5.0",
74
- "@iloveagents/foundry-agent": "^0.20.1",
75
- "@iloveagents/foundry-web-primitives": "^0.20.1"
74
+ "@iloveagents/foundry-agent": "^0.21.1",
75
+ "@iloveagents/foundry-web-primitives": "^0.21.1"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@ag-ui/client": "^0.0.52",