@iloveagents/foundry-web-ui 0.7.1 → 0.8.0

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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useRef, useCallback } from "react";
3
- import { ThreadPrimitive, ComposerPrimitive, MessagePrimitive, ActionBarPrimitive, BranchPickerPrimitive, useAuiState, useComposerRuntime, useThreadViewport, } from "@assistant-ui/react";
3
+ import { ThreadPrimitive, ComposerPrimitive, MessagePrimitive, ActionBarPrimitive, BranchPickerPrimitive, useAui, useAuiState, useThreadViewport, } from "@assistant-ui/react";
4
4
  import { ArrowUp, Copy, Check, RefreshCw, PencilIcon, ChevronLeft, ChevronRight, ChevronDown, Square, Bot, Paperclip, Minimize2, PanelRightClose, PanelRightOpen, } from "lucide-react";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
6
  import { Button } from "@iloveagents/foundry-web-primitives";
@@ -12,6 +12,8 @@ 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
14
  import { MarkdownText } from "./markdown-text.js";
15
+ import { ReasoningMessagePartComponent } from "./reasoning-part.js";
16
+ import { ReasoningEffortPicker } from "./reasoning-effort-picker.js";
15
17
  import { ToolFallback } from "./tool-fallback.js";
16
18
  import { LoadingIndicator, LoadingBar } from "./loading-indicator.js";
17
19
  import { MessageTimestamp } from "./message-timestamp.js";
@@ -24,12 +26,13 @@ const UserMessage = () => {
24
26
  const UserActionBar = () => (_jsx(ActionBarPrimitive.Root, { hideWhenRunning: true, className: "flex items-center opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(ActionBarPrimitive.Edit, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Edit", size: "icon", children: _jsx(PencilIcon, { className: "size-4" }) }) }) }));
25
27
  const AssistantMessage = () => (_jsx(MessagePrimitive.Root, { className: "w-full mb-2 group", children: _jsxs("div", { className: "mx-auto max-w-3xl px-4", children: [_jsx("div", { className: "text-foreground leading-relaxed flex flex-col gap-4", children: _jsx(MessagePrimitive.Parts, { components: {
26
28
  Text: MarkdownText,
29
+ Reasoning: ReasoningMessagePartComponent,
27
30
  tools: { Fallback: ToolFallback },
28
31
  } }) }), _jsx(MessageError, {}), _jsxs("div", { className: "flex items-center gap-2 mt-1", children: [_jsx("div", { className: "flex h-8 w-[4.25rem] items-center", children: _jsx(AssistantActionBar, {}) }), _jsx(BranchPicker, {}), _jsx(MessageTimestamp, {})] })] }) }));
29
32
  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" }) }) })] }));
30
33
  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" }) })] })] }) }) }) }));
31
34
  const ComposerActionButton = () => {
32
- return (_jsxs(_Fragment, { children: [_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" }) }) }) })] }));
35
+ return (_jsxs(_Fragment, { children: [_jsx(ThreadPrimitive.If, { running: false, children: _jsx(ReasoningEffortPicker, { className: "mr-1" }) }), _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" }) }) }) })] }));
33
36
  };
34
37
  /**
35
38
  * Default starter prompts shown on empty state. Override per-app by passing
@@ -51,7 +54,7 @@ const ScrollToBottomButton = () => {
51
54
  const DropZone = ({ children }) => {
52
55
  const [isDragging, setIsDragging] = useState(false);
53
56
  const dragCounter = useRef(0);
54
- const composerRuntime = useComposerRuntime();
57
+ const aui = useAui();
55
58
  const handleDragEnter = useCallback((e) => {
56
59
  e.preventDefault();
57
60
  dragCounter.current++;
@@ -73,9 +76,9 @@ const DropZone = ({ children }) => {
73
76
  setIsDragging(false);
74
77
  const files = Array.from(e.dataTransfer.files);
75
78
  for (const file of files) {
76
- await composerRuntime.addAttachment(file);
79
+ await aui.composer.addAttachment(file);
77
80
  }
78
- }, [composerRuntime]);
81
+ }, [aui]);
79
82
  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" }) }))] }));
80
83
  };
81
84
  /**
@@ -9,6 +9,7 @@ import { ContextPins } from "./context-bar.js";
9
9
  import { ComposerContextBadges, SentContextBadges } from "./context-badges.js";
10
10
  import { ComposerSubmitBridge } from "./composer-submit-bridge.js";
11
11
  import { ChatComposerBannerSlot, ChatLauncherBadgeSlot } from "./chat-slots.js";
12
+ import { ReasoningMessagePartComponent } from "./reasoning-part.js";
12
13
  import { MarkdownText } from "./markdown-text.js";
13
14
  import { LoadingIndicator } from "./loading-indicator.js";
14
15
  import { MessageTimestamp } from "./message-timestamp.js";
@@ -21,6 +22,7 @@ const CHAT_BUBBLE_INSET = 8;
21
22
  function BubbleAssistantMessage() {
22
23
  return (_jsxs(MessagePrimitive.Root, { className: "w-full mb-2 group", children: [_jsx("div", { className: "text-foreground text-sm leading-relaxed flex flex-col gap-2", children: _jsx(MessagePrimitive.Parts, { components: {
23
24
  Text: MarkdownText,
25
+ Reasoning: ReasoningMessagePartComponent,
24
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, {})] })] }));
25
27
  }
26
28
  function BubbleUserMessage() {
@@ -1,15 +1,17 @@
1
1
  import { useEffect } from "react";
2
- import { useComposerRuntime } from "@assistant-ui/react";
2
+ import { useAui } from "@assistant-ui/react";
3
3
  import { useComposerSubmitStore } from "../lib/composer-submit-store.js";
4
4
  export function ComposerSubmitBridge() {
5
- const composerRuntime = useComposerRuntime();
5
+ const aui = useAui();
6
6
  const pending = useComposerSubmitStore((s) => s.pending);
7
7
  useEffect(() => {
8
8
  if (!pending)
9
9
  return;
10
- composerRuntime.setText(pending.text);
11
- composerRuntime.send({ startRun: true });
10
+ // send() starts the run for the queued text; steering/queueing semantics
11
+ // are handled by the composer client (assistant-ui >= 0.15).
12
+ aui.composer.setText(pending.text);
13
+ aui.composer.send();
12
14
  useComposerSubmitStore.getState().consume(pending.id);
13
- }, [composerRuntime, pending]);
15
+ }, [aui, pending]);
14
16
  return null;
15
17
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Lets the user choose how hard the model thinks, per conversation.
3
+ *
4
+ * Sits in the composer next to Send. The choice rides on every request as
5
+ * an AG-UI forwarded prop and is read at send time, so switching it
6
+ * mid-conversation applies from the next message on. `Auto` sends nothing
7
+ * and leaves the backend's configured level in place.
8
+ */
9
+ export declare function ReasoningEffortPicker({ className }: {
10
+ className?: string;
11
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useStore } from "zustand";
3
+ import { Check, ChevronDown } from "lucide-react";
4
+ import { reasoningEffortStore, REASONING_EFFORT_LABELS, } from "@iloveagents/foundry-agent";
5
+ import { cn } from "@iloveagents/foundry-web-primitives";
6
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu.js";
7
+ const OPTIONS = [
8
+ { value: "default", hint: "Let the app decide" },
9
+ { value: "low", hint: "Answers sooner, thinks less" },
10
+ { value: "medium", hint: "Good default for most questions" },
11
+ { value: "high", hint: "Slower, best for hard problems" },
12
+ ];
13
+ /**
14
+ * Lets the user choose how hard the model thinks, per conversation.
15
+ *
16
+ * Sits in the composer next to Send. The choice rides on every request as
17
+ * an AG-UI forwarded prop and is read at send time, so switching it
18
+ * mid-conversation applies from the next message on. `Auto` sends nothing
19
+ * and leaves the backend's configured level in place.
20
+ */
21
+ export function ReasoningEffortPicker({ className }) {
22
+ const effort = useStore(reasoningEffortStore, (s) => s.effort);
23
+ const setEffort = useStore(reasoningEffortStore, (s) => s.setEffort);
24
+ 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": `Thinking effort: ${REASONING_EFFORT_LABELS[effort]}`, children: [_jsx("span", { children: REASONING_EFFORT_LABELS[effort] }), _jsx(ChevronDown, { className: "size-3.5" })] }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-56", children: 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", 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.hint })] })] }, opt.value))) })] }));
25
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Renders a `reasoning` message part — the model's own thinking, streamed
3
+ * over AG-UI's `REASONING_*` events and kept separate from the answer.
4
+ *
5
+ * Collapsed by default and deliberately understated: reasoning is context for
6
+ * a curious reader, not the response. It stays visible after the run so a
7
+ * reader can check *why* an answer came out the way it did.
8
+ */
9
+ export declare function ReasoningPart({ text }: {
10
+ text: string;
11
+ }): import("react/jsx-runtime").JSX.Element | null;
12
+ /** Adapter for assistant-ui's `Reasoning` part slot. */
13
+ export declare const ReasoningMessagePartComponent: ({ text }: {
14
+ text: string;
15
+ }) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Brain, ChevronDown } from "lucide-react";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
6
+ /**
7
+ * Renders a `reasoning` message part — the model's own thinking, streamed
8
+ * over AG-UI's `REASONING_*` events and kept separate from the answer.
9
+ *
10
+ * Collapsed by default and deliberately understated: reasoning is context for
11
+ * a curious reader, not the response. It stays visible after the run so a
12
+ * reader can check *why* an answer came out the way it did.
13
+ */
14
+ export function ReasoningPart({ text }) {
15
+ const [isOpen, setIsOpen] = useState(false);
16
+ if (!text.trim())
17
+ return null;
18
+ return (_jsxs(Collapsible, { open: isOpen, onOpenChange: setIsOpen, className: "w-full", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: cn("flex items-center gap-1.5 text-xs text-muted-foreground", "hover:text-foreground transition-colors"), "aria-label": isOpen ? "Hide reasoning" : "Show reasoning", children: [_jsx(Brain, { className: "size-3.5" }), _jsx("span", { children: "Reasoning" }), _jsx(ChevronDown, { className: cn("size-3.5 transition-transform", isOpen && "rotate-180") })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: cn("mt-1.5 whitespace-pre-wrap border-l-2 border-border pl-3", "text-xs text-muted-foreground"), children: text }) })] }));
19
+ }
20
+ /** Adapter for assistant-ui's `Reasoning` part slot. */
21
+ export const ReasoningMessagePartComponent = ({ text }) => (_jsx(ReasoningPart, { text: text }));
package/dist/index.d.ts CHANGED
@@ -40,8 +40,8 @@ export { FormDialog } from "./components/form-dialog.js";
40
40
  export { ConfirmDialog } from "./components/confirm-dialog.js";
41
41
  export { NameDialog } from "./components/name-dialog.js";
42
42
  export { SurfaceCard, surfaceCardVariants } from "./components/surface-card.js";
43
- export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, } from "./ui/tooltip.js";
44
- export { Collapsible, CollapsibleTrigger, CollapsibleContent, } from "./ui/collapsible.js";
43
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip.js";
44
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./ui/collapsible.js";
45
45
  export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, } from "./ui/dropdown-menu.js";
46
46
  export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, } from "./ui/dialog.js";
47
47
  export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent } from "./ui/popover.js";
@@ -66,4 +66,6 @@ export { usePageTools } from "./lib/use-page-tools.js";
66
66
  export { useNewConversation } from "./lib/use-new-conversation.js";
67
67
  export { AGUIAdapterSDK } from "./lib/ag-ui-adapter.js";
68
68
  export { FileAttachmentAdapter } from "./lib/attachment-adapter.js";
69
+ export { ReasoningPart, ReasoningMessagePartComponent } from "./components/reasoning-part.js";
70
+ export { ReasoningEffortPicker } from "./components/reasoning-effort-picker.js";
69
71
  export { AuthProvider } from "./lib/auth-provider.js";
package/dist/index.js CHANGED
@@ -44,8 +44,8 @@ export { SurfaceCard, surfaceCardVariants } from "./components/surface-card.js";
44
44
  // coupling to ThemeScope (theme-runtime-provider.tsx) — they need a
45
45
  // theme-system refactor before they can move to the leaf primitives
46
46
  // package. Tracked for a follow-up under #95/#96.
47
- export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, } from "./ui/tooltip.js";
48
- export { Collapsible, CollapsibleTrigger, CollapsibleContent, } from "./ui/collapsible.js";
47
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip.js";
48
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./ui/collapsible.js";
49
49
  export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, } from "./ui/dropdown-menu.js";
50
50
  export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, } from "./ui/dialog.js";
51
51
  export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent } from "./ui/popover.js";
@@ -74,6 +74,8 @@ export { useNewConversation } from "./lib/use-new-conversation.js";
74
74
  // --- Adapters ---
75
75
  export { AGUIAdapterSDK } from "./lib/ag-ui-adapter.js";
76
76
  export { FileAttachmentAdapter } from "./lib/attachment-adapter.js";
77
+ export { ReasoningPart, ReasoningMessagePartComponent } from "./components/reasoning-part.js";
78
+ export { ReasoningEffortPicker } from "./components/reasoning-effort-picker.js";
77
79
  // --- Auth ---
78
80
  // `AuthProvider` is the React MSAL bootstrap (uses `@azure/msal-react`).
79
81
  // Token store, config, fetch wrapper all live in `@iloveagents/foundry-agent/msal`.
@@ -46,6 +46,10 @@ export class AGUIAdapterSDK {
46
46
  useAppStore.getState().consumeContextForMessage(lastUserMsg.id);
47
47
  }
48
48
  let currentText = "";
49
+ // Reasoning accumulates across the WHOLE run, unlike `currentText` which
50
+ // is per-turn: the collapsed reasoning panel shouldn't lose what the model
51
+ // thought before a tool call just because the runner re-issued the turn.
52
+ let currentReasoning = "";
49
53
  const toolCalls = new Map();
50
54
  let runError = null;
51
55
  // Liveness clock for the working indicator: every runner event (incl.
@@ -64,6 +68,11 @@ export class AGUIAdapterSDK {
64
68
  */
65
69
  const buildResult = (statusOverride) => {
66
70
  const content = [];
71
+ // Reasoning first: it precedes the answer chronologically, and
72
+ // assistant-ui renders parts in array order.
73
+ if (currentReasoning) {
74
+ content.push({ type: "reasoning", text: currentReasoning });
75
+ }
67
76
  for (const [id, tc] of toolCalls) {
68
77
  let parsedArgs = {};
69
78
  try {
@@ -143,6 +152,18 @@ export class AGUIAdapterSDK {
143
152
  currentText += event.delta;
144
153
  shouldYield = true;
145
154
  break;
155
+ case "reasoning-delta":
156
+ currentReasoning += event.delta;
157
+ shouldYield = true;
158
+ break;
159
+ case "reasoning-end":
160
+ shouldYield = true;
161
+ break;
162
+ case "step-started":
163
+ case "step-finished":
164
+ // Progress only — the status store already carries the step name
165
+ // (pushed by the runner alongside these events).
166
+ break;
146
167
  case "text-message-end":
147
168
  shouldYield = true;
148
169
  break;
@@ -1,17 +1,41 @@
1
1
  /**
2
2
  * File attachment adapter for assistant-ui.
3
3
  *
4
- * Reads image files client-side as base64 and produces FileMessagePart content.
5
- * Non-image documents are represented as text metadata so starter apps don't
6
- * send malformed multimodal payloads to providers that require a separate file
7
- * upload or document extraction pipeline.
4
+ * Reads attachments client-side as base64 and produces FileMessagePart content
5
+ * for both images and documents. The AG-UI adapter turns those into `binary`
6
+ * message parts (mimeType + data + filename), which the protocol carries
7
+ * natively and model providers accept as file input.
8
8
  *
9
- * Supported: images (PNG, JPEG, WEBP, GIF). PDFs/DOCX can be attached, but the
10
- * default starter only sends their metadata to the agent.
9
+ * Documents used to degrade to a text stub (`[Attached file: x.pdf; ...]`) so
10
+ * the starter wouldn't send payloads to providers lacking file input. That
11
+ * silently discarded the bytes: the user attaches a PDF, the model receives
12
+ * only its filename, and — having been told a file was attached — goes hunting
13
+ * for a matching document in the backend and reports that it can't read it.
14
+ * Losing user data silently is the worse failure, so documents are sent by
15
+ * default. Pass `{ documentMode: "metadata" }` to restore the stub for agents
16
+ * whose model genuinely can't accept file input.
17
+ *
18
+ * Note for hosts: the bytes ride along on every turn of the live conversation
19
+ * and land in persisted history unless the backend elides them — see
20
+ * lastspace's `_elide_inline_media` for the pattern.
11
21
  */
12
22
  import type { AttachmentAdapter, PendingAttachment, CompleteAttachment, Attachment } from "@assistant-ui/react";
23
+ export interface FileAttachmentAdapterOptions {
24
+ /**
25
+ * How non-image documents (PDF, DOCX) are sent.
26
+ *
27
+ * - `"binary"` (default) — base64 bytes as a file part, so the model can
28
+ * actually read the document.
29
+ * - `"metadata"` — filename/type/size as text only. For agents whose model
30
+ * cannot accept file input; the agent sees that a file was attached but
31
+ * never its contents.
32
+ */
33
+ documentMode?: "binary" | "metadata";
34
+ }
13
35
  export declare class FileAttachmentAdapter implements AttachmentAdapter {
14
36
  accept: string;
37
+ private readonly documentMode;
38
+ constructor(options?: FileAttachmentAdapterOptions);
15
39
  add({ file }: {
16
40
  file: File;
17
41
  }): Promise<PendingAttachment>;
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * File attachment adapter for assistant-ui.
3
3
  *
4
- * Reads image files client-side as base64 and produces FileMessagePart content.
5
- * Non-image documents are represented as text metadata so starter apps don't
6
- * send malformed multimodal payloads to providers that require a separate file
7
- * upload or document extraction pipeline.
4
+ * Reads attachments client-side as base64 and produces FileMessagePart content
5
+ * for both images and documents. The AG-UI adapter turns those into `binary`
6
+ * message parts (mimeType + data + filename), which the protocol carries
7
+ * natively and model providers accept as file input.
8
8
  *
9
- * Supported: images (PNG, JPEG, WEBP, GIF). PDFs/DOCX can be attached, but the
10
- * default starter only sends their metadata to the agent.
9
+ * Documents used to degrade to a text stub (`[Attached file: x.pdf; ...]`) so
10
+ * the starter wouldn't send payloads to providers lacking file input. That
11
+ * silently discarded the bytes: the user attaches a PDF, the model receives
12
+ * only its filename, and — having been told a file was attached — goes hunting
13
+ * for a matching document in the backend and reports that it can't read it.
14
+ * Losing user data silently is the worse failure, so documents are sent by
15
+ * default. Pass `{ documentMode: "metadata" }` to restore the stub for agents
16
+ * whose model genuinely can't accept file input.
17
+ *
18
+ * Note for hosts: the bytes ride along on every turn of the live conversation
19
+ * and land in persisted history unless the backend elides them — see
20
+ * lastspace's `_elide_inline_media` for the pattern.
11
21
  */
12
22
  const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
13
23
  /** Infer MIME type from extension when browser reports empty file.type (common for .docx) */
@@ -39,8 +49,9 @@ function fileToBase64(file) {
39
49
  });
40
50
  }
41
51
  export class FileAttachmentAdapter {
42
- constructor() {
52
+ constructor(options = {}) {
43
53
  this.accept = "image/*,application/pdf,.pdf,.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document";
54
+ this.documentMode = options.documentMode ?? "binary";
44
55
  }
45
56
  async add({ file }) {
46
57
  if (file.size > MAX_FILE_SIZE) {
@@ -58,7 +69,7 @@ export class FileAttachmentAdapter {
58
69
  }
59
70
  async send(attachment) {
60
71
  const mimeType = attachment.contentType ?? "application/octet-stream";
61
- if (!mimeType.startsWith("image/")) {
72
+ if (!mimeType.startsWith("image/") && this.documentMode === "metadata") {
62
73
  return {
63
74
  ...attachment,
64
75
  status: { type: "complete" },
@@ -67,7 +78,9 @@ export class FileAttachmentAdapter {
67
78
  type: "text",
68
79
  text: `[Attached file: ${attachment.name}` +
69
80
  `; type: ${mimeType}` +
70
- `; size: ${formatFileSize(attachment.file.size)}]`,
81
+ `; size: ${formatFileSize(attachment.file.size)}` +
82
+ `. Contents were not sent — this agent is configured for ` +
83
+ `metadata-only attachments, so tell the user you cannot read it.]`,
71
84
  },
72
85
  ],
73
86
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
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": [
@@ -41,7 +41,7 @@
41
41
  "react": "^19.0.0",
42
42
  "react-dom": "^19.0.0",
43
43
  "react-router": "^7.0.0",
44
- "@assistant-ui/react": "^0.12.25",
44
+ "@assistant-ui/react": "^0.15.1",
45
45
  "@ag-ui/client": "^0.0.52",
46
46
  "@ag-ui/core": "^0.0.52",
47
47
  "zustand": "^5.0.0",
@@ -70,13 +70,13 @@
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.7.1",
74
- "@iloveagents/foundry-web-primitives": "^0.7.1"
73
+ "@iloveagents/foundry-agent": "^0.8.0",
74
+ "@iloveagents/foundry-web-primitives": "^0.8.0"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@ag-ui/client": "^0.0.52",
78
78
  "@ag-ui/core": "^0.0.52",
79
- "@assistant-ui/react": "^0.12.25",
79
+ "@assistant-ui/react": "^0.15.1",
80
80
  "@azure/msal-browser": "^5.0.0",
81
81
  "@azure/msal-react": "^5.0.0",
82
82
  "@types/react": "^19.2.2",