@iloveagents/foundry-web-ui 0.32.4 → 0.33.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.
@@ -0,0 +1,16 @@
1
+ import type { AGUIAdapterSDK } from "../lib/ag-ui-adapter.js";
2
+ /**
3
+ * The AG-UI runtime a component is rendered in.
4
+ *
5
+ * Its own module, importing nothing at runtime but React, so a composer
6
+ * control can ask which thread it belongs to without pulling in the
7
+ * provider — and with it the tool UIs, adapters and theme it mounts.
8
+ */
9
+ export interface AGUIContextValue {
10
+ adapter: AGUIAdapterSDK;
11
+ resetThread: () => void;
12
+ selectRetainedThread: (threadId: string) => boolean;
13
+ }
14
+ export declare const AGUIAdapterContext: import("react").Context<AGUIContextValue | null>;
15
+ /** The thread this component is rendered for, or `null` outside a runtime. */
16
+ export declare function useCurrentThreadId(): string | null;
@@ -0,0 +1,6 @@
1
+ import { createContext, useContext } from "react";
2
+ export const AGUIAdapterContext = createContext(null);
3
+ /** The thread this component is rendered for, or `null` outside a runtime. */
4
+ export function useCurrentThreadId() {
5
+ return useContext(AGUIAdapterContext)?.adapter.threadId ?? null;
6
+ }
@@ -1,5 +1,4 @@
1
1
  import type { ThreadHistoryAdapter } from "@assistant-ui/react";
2
- import { AGUIAdapterSDK } from "../lib/ag-ui-adapter.js";
3
2
  /**
4
3
  * Per-mount inputs the host shell passes when it wants a chat
5
4
  * conversation history wired to the runtime. Mirrors
@@ -57,12 +56,7 @@ interface AGUIRuntimeProviderProps {
57
56
  */
58
57
  historyAdapterFactory?: AGUIHistoryAdapterFactory;
59
58
  }
60
- interface AGUIContextValue {
61
- adapter: AGUIAdapterSDK;
62
- resetThread: () => void;
63
- selectRetainedThread: (threadId: string) => boolean;
64
- }
65
- export declare function useAGUIAdapter(): AGUIContextValue;
59
+ export declare function useAGUIAdapter(): import("./ag-ui-adapter-context.js").AGUIContextValue;
66
60
  /** Whether this runtime owns visible UI effects; standalone consumers remain active. */
67
61
  export declare function useIsForegroundChat(): boolean;
68
62
  export declare function AGUIRuntimeProvider(props: AGUIRuntimeProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -1,15 +1,15 @@
1
1
  import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useState, useCallback, useMemo, useRef, useLayoutEffect, useEffect, } from "react";
2
+ import { useContext, useState, useCallback, useMemo, useRef, useLayoutEffect, useEffect, } from "react";
3
3
  import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react";
4
4
  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
8
  import { useVoiceAdapterStore } from "../lib/voice-adapter-registry.js";
9
+ import { AGUIAdapterContext } from "./ag-ui-adapter-context.js";
9
10
  import { ShowDocumentToolUI } from "./show-document-tool-ui.js";
10
11
  import { ClientToolExecutor } from "./client-tool-executor.js";
11
12
  import { useChatRuns, isChatRunActive, selectChatRun, warnBeforeChatUnload, disposeChatRuns, } from "../lib/chat-runs-store.js";
12
- const AGUIAdapterContext = createContext(null);
13
13
  export function useAGUIAdapter() {
14
14
  const context = useContext(AGUIAdapterContext);
15
15
  if (!context) {
@@ -6,6 +6,7 @@ import { summarizeChatContext } from "../lib/chat-context-summary.js";
6
6
  import { Popover, PopoverTrigger, PopoverContent } from "../ui/popover.js";
7
7
  import { ChatContextDetailsSlot } from "./chat-slots.js";
8
8
  import { ChatContextItems } from "./chat-context-items.js";
9
+ import { ConversationMemory, useConversationMemory } from "./conversation-memory.js";
9
10
  import { usePagePin } from "./context-bar.js";
10
11
  import { PinStateIcon } from "./pin-state-icon.js";
11
12
  function readContext() {
@@ -14,6 +15,7 @@ function readContext() {
14
15
  /** The displayed context and the agent request use the same projection. */
15
16
  export function ChatContext({ centered = false }) {
16
17
  const [open, setOpen] = useState(false);
18
+ const memory = useConversationMemory();
17
19
  const [context, setContext] = useState(readContext);
18
20
  const items = useAppStore((s) => s.contextItems);
19
21
  const { isPinned, toggle } = usePagePin();
@@ -74,7 +76,7 @@ export function ChatContext({ centered = false }) {
74
76
  };
75
77
  }, []);
76
78
  const Icon = context.field ? TextCursorInput : context.focused ? Maximize2 : FileText;
77
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-chat-context": true, "aria-label": `Message context: ${context.label}${detail ? `, ${detail}` : ""}${additionalCount ? `, ${additionalCount} more` : ""}`, className: `mb-2 flex min-h-9 min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary [@media(pointer:coarse)]:min-h-11 ${centered ? "mx-auto w-fit max-w-full" : "w-full"}`, children: [_jsx(Icon, { className: "size-3.5 shrink-0" }), _jsxs("span", { className: "min-w-0 flex-1 truncate", "aria-live": "polite", children: ["With ", _jsx("span", { className: "font-medium text-foreground", children: context.label }), detail && (_jsxs(_Fragment, { children: [" ", _jsx("span", { "aria-hidden": true, children: "\u203A" }), " ", _jsx("span", { className: "font-medium text-foreground", children: detail })] }))] }), additionalCount > 0 && (_jsxs("span", { className: "shrink-0 tabular-nums", "aria-hidden": true, children: ["+", additionalCount] })), _jsx(ChevronDown, { className: "size-3.5 shrink-0" })] }) }), _jsxs(PopoverContent, { align: centered ? "center" : "start", side: "top", collisionPadding: 12, "aria-label": "Context and actions", className: "w-[22rem] max-w-[calc(100vw-1.5rem)] max-h-[min(36rem,var(--radix-popover-content-available-height))] overflow-y-auto overscroll-contain space-y-3 p-3 text-sm", children: [_jsxs("div", { className: "px-2", children: [_jsx("h2", { className: "text-xs font-medium text-muted-foreground", children: "Current context" }), _jsxs("p", { className: "mt-1 break-words font-medium", children: [context.label, detail && ` › ${detail}`] })] }), _jsx("p", { className: "px-2 text-xs leading-relaxed text-muted-foreground", children: detail
79
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-chat-context": true, "aria-label": `Message context: ${context.label}${detail ? `, ${detail}` : ""}${additionalCount ? `, ${additionalCount} more` : ""}${memory?.full ? ", conversation memory full" : memory?.nearlyFull ? ", conversation memory nearly full" : ""}`, className: `mb-2 flex min-h-9 min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary [@media(pointer:coarse)]:min-h-11 ${centered ? "mx-auto w-fit max-w-full" : "w-full"}`, children: [_jsx(Icon, { className: "size-3.5 shrink-0" }), _jsxs("span", { className: "min-w-0 flex-1 truncate", "aria-live": "polite", children: ["With ", _jsx("span", { className: "font-medium text-foreground", children: context.label }), detail && (_jsxs(_Fragment, { children: [" ", _jsx("span", { "aria-hidden": true, children: "\u203A" }), " ", _jsx("span", { className: "font-medium text-foreground", children: detail })] }))] }), additionalCount > 0 && (_jsxs("span", { className: "shrink-0 tabular-nums", "aria-hidden": true, children: ["+", additionalCount] })), memory?.nearlyFull && (_jsx("span", { "data-memory-dot": true, title: "Conversation memory nearly full", className: `size-1.5 shrink-0 rounded-full ${memory.full ? "bg-destructive" : "bg-warning"}` })), _jsx(ChevronDown, { className: "size-3.5 shrink-0" })] }) }), _jsxs(PopoverContent, { align: centered ? "center" : "start", side: "top", collisionPadding: 12, "aria-label": "Context and actions", className: "w-[22rem] max-w-[calc(100vw-1.5rem)] max-h-[min(36rem,var(--radix-popover-content-available-height))] overflow-y-auto overscroll-contain space-y-3 p-3 text-sm", children: [_jsxs("div", { className: "px-2", children: [_jsx("h2", { className: "text-xs font-medium text-muted-foreground", children: "Current context" }), _jsxs("p", { className: "mt-1 break-words font-medium", children: [context.label, detail && ` › ${detail}`] })] }), _jsx("p", { className: "px-2 text-xs leading-relaxed text-muted-foreground", children: detail
78
80
  ? "This selection stays available when you move into chat."
79
- : "The current page follows your navigation. Select text or a field to work more specifically." }), context.selectedText && (_jsx("blockquote", { className: "max-h-32 overflow-y-auto border-l-2 border-border pl-3 text-xs", children: context.selectedText })), !context.focused && context.label !== "Your spaces" && (_jsxs("button", { type: "button", "aria-pressed": isPinned, onClick: toggle, className: "flex min-h-9 w-full items-center gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted", children: [_jsx(PinStateIcon, { pinned: isPinned, className: "size-4 shrink-0 text-muted-foreground" }), isPinned ? "Unpin this page" : "Keep this page in context"] })), _jsx(ChatContextItems, { items: items, onNavigate: () => setOpen(false) }), _jsx(ChatContextDetailsSlot, { onDismiss: () => setOpen(false) }), context.references.length > 0 && (_jsxs("div", { className: "border-t border-border px-2 pt-3 text-xs", children: [_jsx("h3", { className: "font-medium", children: "Also open" }), _jsx("ul", { className: "mt-1 space-y-1 text-muted-foreground", children: context.references.map((label, index) => (_jsx("li", { className: "truncate", children: label }, `${label}-${index}`))) })] }))] })] }));
81
+ : "The current page follows your navigation. Select text or a field to work more specifically." }), context.selectedText && (_jsx("blockquote", { className: "max-h-32 overflow-y-auto border-l-2 border-border pl-3 text-xs", children: context.selectedText })), !context.focused && context.label !== "Your spaces" && (_jsxs("button", { type: "button", "aria-pressed": isPinned, onClick: toggle, className: "flex min-h-9 w-full items-center gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted", children: [_jsx(PinStateIcon, { pinned: isPinned, className: "size-4 shrink-0 text-muted-foreground" }), isPinned ? "Unpin this page" : "Keep this page in context"] })), _jsx(ChatContextItems, { items: items, onNavigate: () => setOpen(false) }), _jsx(ChatContextDetailsSlot, { onDismiss: () => setOpen(false) }), memory && _jsx(ConversationMemory, { usage: memory }), context.references.length > 0 && (_jsxs("div", { className: "border-t border-border px-2 pt-3 text-xs", children: [_jsx("h3", { className: "font-medium", children: "Also open" }), _jsx("ul", { className: "mt-1 space-y-1 text-muted-foreground", children: context.references.map((label, index) => (_jsx("li", { className: "truncate", children: label }, `${label}-${index}`))) })] }))] })] }));
80
82
  }
@@ -0,0 +1,25 @@
1
+ export interface ConversationMemoryUsage {
2
+ used: number;
3
+ limit: number;
4
+ ratio: number;
5
+ nearlyFull: boolean;
6
+ full: boolean;
7
+ /**
8
+ * Whether the server compacts at this ceiling. Only a server that states
9
+ * its own fill and ceiling with each call does; a window the host merely
10
+ * stated says how much fits, not what happens when it is full.
11
+ */
12
+ compacts: boolean;
13
+ }
14
+ /**
15
+ * How much of this conversation's memory is in use, or `null` while unknown.
16
+ *
17
+ * Known only when a fill and its ceiling are both stated in the same units:
18
+ * preferably by the server with each model call's usage, else the model's
19
+ * input count against a window the host stated.
20
+ */
21
+ export declare function useConversationMemory(): ConversationMemoryUsage | null;
22
+ /** The "Conversation memory" section of the context card. */
23
+ export declare function ConversationMemory({ usage }: {
24
+ usage: ConversationMemoryUsage;
25
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useStore } from "zustand";
3
+ import { contextUsageStore } from "@iloveagents/foundry-agent";
4
+ import { Info } from "lucide-react";
5
+ import { cn } from "@iloveagents/foundry-web-primitives";
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip.js";
7
+ import { useCurrentThreadId } from "./ag-ui-adapter-context.js";
8
+ /** From here the chat is close to the point where older content is summarized. */
9
+ const NEARLY_FULL = 0.9;
10
+ /** At the ceiling: the next message compacts the chat. */
11
+ const FULL = 0.98;
12
+ function formatTokens(n) {
13
+ if (n >= 10000)
14
+ return `${Math.round(n / 1000)}k`;
15
+ if (n >= 1000)
16
+ return `${(n / 1000).toFixed(1)}k`;
17
+ return String(n);
18
+ }
19
+ /**
20
+ * How much of this conversation's memory is in use, or `null` while unknown.
21
+ *
22
+ * Known only when a fill and its ceiling are both stated in the same units:
23
+ * preferably by the server with each model call's usage, else the model's
24
+ * input count against a window the host stated.
25
+ */
26
+ export function useConversationMemory() {
27
+ const threadId = useCurrentThreadId();
28
+ const windowTokens = useStore(contextUsageStore, (s) => s.windowTokens);
29
+ const usage = useStore(contextUsageStore, (s) => (threadId ? s.byThread[threadId] : undefined));
30
+ if (!usage)
31
+ return null;
32
+ // Without the server's own figure: what the model was sent plus what it
33
+ // answered, which is now part of the conversation too.
34
+ const used = usage.contextLimit
35
+ ? (usage.contextUsed ?? 0)
36
+ : usage.inputTokens + (usage.outputTokens ?? 0);
37
+ const limit = usage.contextLimit ?? windowTokens;
38
+ if (!limit)
39
+ return null;
40
+ const ratio = Math.min(used / limit, 1);
41
+ return {
42
+ used: Math.min(used, limit),
43
+ limit,
44
+ ratio,
45
+ nearlyFull: ratio >= NEARLY_FULL,
46
+ full: ratio >= FULL,
47
+ compacts: usage.contextLimit != null,
48
+ };
49
+ }
50
+ function explanation(usage) {
51
+ if (!usage.compacts)
52
+ return usage.full
53
+ ? "This chat is full: the model cannot take in more of it. Start a new chat to continue."
54
+ : "How much of what the model can take in this chat uses. For a fresh topic, start a new chat.";
55
+ if (usage.full)
56
+ return "This chat is full. It will be compacted with your next message: earlier parts are summarized to make room.";
57
+ if (usage.nearlyFull)
58
+ return "This chat is nearly full. When it fills, earlier parts are summarized to make room; for a fresh topic, start a new chat.";
59
+ return "When this fills, the chat is compacted: earlier parts are summarized to make room.";
60
+ }
61
+ /** The "Conversation memory" section of the context card. */
62
+ export function ConversationMemory({ usage }) {
63
+ const percent = Math.round(usage.ratio * 100);
64
+ const about = explanation(usage);
65
+ return (_jsxs("section", { "aria-label": "Conversation memory", "data-conversation-memory": usage.full ? "full" : usage.nearlyFull ? "nearly-full" : "ok", className: "border-t border-border px-2 pt-3 text-xs", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("h3", { className: "font-medium", children: "Conversation memory" }), _jsx(TooltipProvider, { delayDuration: 150, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": about, className: "inline-flex size-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: _jsx(Info, { className: "size-3", "aria-hidden": true }) }) }), _jsx(TooltipContent, { side: "top", className: "max-w-60 leading-relaxed", children: about })] }) })] }), _jsxs("span", { className: "tabular-nums text-muted-foreground", children: [formatTokens(usage.used), " of ", formatTokens(usage.limit)] })] }), _jsx("div", { role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "aria-label": `${percent}% of conversation memory used`, className: "mt-2 h-1.5 overflow-hidden rounded-full bg-muted", children: _jsx("div", { className: cn("h-full rounded-full transition-[width]", usage.full ? "bg-destructive" : usage.nearlyFull ? "bg-warning" : "bg-primary"), style: { width: `${percent}%` } }) })] }));
66
+ }
@@ -31,6 +31,9 @@ export function LoadingIndicator() {
31
31
  else if (streaming.status === "streaming") {
32
32
  label = "Generating response";
33
33
  }
34
+ else if (streaming.status === "working" && streaming.label) {
35
+ label = streaming.label;
36
+ }
34
37
  return (
35
38
  // `px-4` keeps the label on the same left edge as the message parts
36
39
  // above it — measured, not guessed: the rows sit at x=469 and so does
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ 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, useIsForegroundChat, } from "./components/ag-ui-runtime-provider.js";
8
+ export { useCurrentThreadId } from "./components/ag-ui-adapter-context.js";
8
9
  export type { AGUIChatConversationFactoryArgs, AGUIHistoryAdapterFactory, } from "./components/ag-ui-runtime-provider.js";
9
10
  export { ChatContent, DEFAULT_STARTER_SUGGESTIONS, DEFAULT_COMPOSER_PLACEHOLDER, } from "./components/assistant-chat.js";
10
11
  export { ComposerAddMenu, type ComposerAddMenuProps } from "./components/composer-add-menu.js";
@@ -89,6 +90,7 @@ export { registerAttachmentAdapter, useAttachmentAdapterStore } from "./lib/atta
89
90
  export { registerVoiceAdapter, registerSpeechAdapter, useVoiceAdapterStore, } from "./lib/voice-adapter-registry.js";
90
91
  export { ReasoningPart, ReasoningMessagePartComponent } from "./components/reasoning-part.js";
91
92
  export { ReasoningEffortPicker } from "./components/reasoning-effort-picker.js";
93
+ export { ConversationMemory, useConversationMemory, type ConversationMemoryUsage, } from "./components/conversation-memory.js";
92
94
  export { AuthProvider } from "./lib/auth-provider.js";
93
95
  export { createUiId } from "./lib/create-ui-id.js";
94
96
  export { useChatRuns, isChatRunActive, removeChatRun, type ChatRun, } from "./lib/chat-runs-store.js";
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { ToolPanelLayout } from "./components/tool-panel-layout.js";
6
6
  export { MarkdownText, markdownComponents } from "./components/markdown-text.js";
7
7
  export { ClientToolExecutor } from "./components/client-tool-executor.js";
8
8
  export { AGUIRuntimeProvider, useAGUIAdapter, useIsForegroundChat, } from "./components/ag-ui-runtime-provider.js";
9
+ export { useCurrentThreadId } from "./components/ag-ui-adapter-context.js";
9
10
  export { ChatContent, DEFAULT_STARTER_SUGGESTIONS, DEFAULT_COMPOSER_PLACEHOLDER, } from "./components/assistant-chat.js";
10
11
  export { ComposerAddMenu } from "./components/composer-add-menu.js";
11
12
  export { ChatBubble } from "./components/chat-bubble.js";
@@ -97,6 +98,7 @@ export { registerAttachmentAdapter, useAttachmentAdapterStore } from "./lib/atta
97
98
  export { registerVoiceAdapter, registerSpeechAdapter, useVoiceAdapterStore, } from "./lib/voice-adapter-registry.js";
98
99
  export { ReasoningPart, ReasoningMessagePartComponent } from "./components/reasoning-part.js";
99
100
  export { ReasoningEffortPicker } from "./components/reasoning-effort-picker.js";
101
+ export { ConversationMemory, useConversationMemory, } from "./components/conversation-memory.js";
100
102
  // --- Auth ---
101
103
  // `AuthProvider` is the React MSAL bootstrap (uses `@azure/msal-react`).
102
104
  // Token store, config, fetch wrapper all live in `@iloveagents/foundry-agent/msal`.
@@ -17,7 +17,7 @@
17
17
  * @see https://www.assistant-ui.com/docs/runtimes/custom/local
18
18
  */
19
19
  import { mergeChatState } from "./merge-chat-state.js";
20
- import { AGUIRunner, clientToolRegistry } from "@iloveagents/foundry-agent";
20
+ import { AGUIRunner, clientToolRegistry, contextUsageStore } from "@iloveagents/foundry-agent";
21
21
  import { tokenFetch } from "@iloveagents/foundry-agent/msal";
22
22
  import { useAppStore } from "./app-store.js";
23
23
  import { useDevStore } from "./dev-store.js";
@@ -175,6 +175,14 @@ export class AGUIAdapterSDK {
175
175
  case "heartbeat":
176
176
  // Server liveness ping — already recorded via touchSignal above.
177
177
  break;
178
+ case "usage":
179
+ contextUsageStore.getState().record(this.threadId, {
180
+ inputTokens: event.inputTokens,
181
+ outputTokens: event.outputTokens ?? null,
182
+ contextUsed: event.contextUsed ?? null,
183
+ contextLimit: event.contextLimit ?? null,
184
+ });
185
+ break;
178
186
  case "request-sent":
179
187
  if (import.meta.env.DEV) {
180
188
  useDevStore.getState().captureRequest(event.input);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Keep rendering machinery out of the agent state projection.
3
+ *
4
+ * Navigation metadata is written by the UI for the UI: a nav group's `meta`
5
+ * may carry the items a panel renders, with their icon components and click
6
+ * handlers. The projection built from it is `structuredClone`d on every run
7
+ * and sent to the agent, and a component or a function cannot be cloned —
8
+ * the run then fails in the browser before a request is made, with nothing
9
+ * in any server log.
10
+ *
11
+ * The rule: a value that would make the clone throw marks the object holding
12
+ * it as UI, and that object is left out whole — except the projected record
13
+ * itself, a container, which only loses those entries. A half-stripped nav item
14
+ * (a label with its icon and click handler removed) would still tell the agent
15
+ * something the author never meant it to hear. Everything a clone already
16
+ * accepted passes through unchanged, so no projection that works today
17
+ * changes shape.
18
+ */
19
+ /** Project a record to what may safely be cloned and sent to the agent. */
20
+ export declare function toAgentData(value: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Keep rendering machinery out of the agent state projection.
3
+ *
4
+ * Navigation metadata is written by the UI for the UI: a nav group's `meta`
5
+ * may carry the items a panel renders, with their icon components and click
6
+ * handlers. The projection built from it is `structuredClone`d on every run
7
+ * and sent to the agent, and a component or a function cannot be cloned —
8
+ * the run then fails in the browser before a request is made, with nothing
9
+ * in any server log.
10
+ *
11
+ * The rule: a value that would make the clone throw marks the object holding
12
+ * it as UI, and that object is left out whole — except the projected record
13
+ * itself, a container, which only loses those entries. A half-stripped nav item
14
+ * (a label with its icon and click handler removed) would still tell the agent
15
+ * something the author never meant it to hear. Everything a clone already
16
+ * accepted passes through unchanged, so no projection that works today
17
+ * changes shape.
18
+ */
19
+ const OMIT = Symbol("omit");
20
+ /**
21
+ * Built-ins `structuredClone` copies natively (typed arrays via `isView`).
22
+ * Not `Error`: a clone copies its `cause`, which can hold anything.
23
+ */
24
+ const CLONEABLE_TAGS = new Set([
25
+ "[object Date]",
26
+ "[object RegExp]",
27
+ "[object ArrayBuffer]",
28
+ "[object Blob]",
29
+ "[object File]",
30
+ "[object FileList]",
31
+ "[object ImageData]",
32
+ "[object Boolean]",
33
+ "[object Number]",
34
+ "[object String]",
35
+ "[object BigInt]",
36
+ ]);
37
+ function isUiValue(value) {
38
+ if (typeof value === "function" || typeof value === "symbol")
39
+ return true;
40
+ // React elements and component objects (memo / forwardRef / lazy) carry a
41
+ // symbol-valued `$$typeof`. Plain data may use the key too; only the
42
+ // symbol marks React.
43
+ return (typeof value === "object" &&
44
+ value !== null &&
45
+ typeof value.$$typeof === "symbol");
46
+ }
47
+ function project(value, depth, seen) {
48
+ if (isUiValue(value))
49
+ return OMIT;
50
+ if (value === null || typeof value !== "object")
51
+ return value;
52
+ // Cycles only: a depth cap would cut off deep data that clones fine today.
53
+ if (seen.has(value))
54
+ return OMIT;
55
+ seen.add(value);
56
+ try {
57
+ // A clone copies collections by cloning every entry, so an entry holding
58
+ // UI would still make it throw: walk them like any other container.
59
+ if (value instanceof Map) {
60
+ const out = new Map();
61
+ for (const [k, v] of value) {
62
+ const key = project(k, depth + 1, seen);
63
+ const val = project(v, depth + 1, seen);
64
+ if (key !== OMIT && val !== OMIT)
65
+ out.set(key, val);
66
+ }
67
+ return value.size > 0 && out.size === 0 ? OMIT : out;
68
+ }
69
+ if (value instanceof Set) {
70
+ const out = new Set();
71
+ for (const v of value) {
72
+ const val = project(v, depth + 1, seen);
73
+ if (val !== OMIT)
74
+ out.add(val);
75
+ }
76
+ return value.size > 0 && out.size === 0 ? OMIT : out;
77
+ }
78
+ if (!Array.isArray(value) && !isRecord(value)) {
79
+ // Built-ins a clone copies natively keep doing so. Anything else that
80
+ // is not a plain object by tag — a DOM node held by a ref, a WeakMap, a
81
+ // Promise — makes the clone throw. A class instance tags as a plain
82
+ // object, and a clone copies its own fields, so it is walked like one.
83
+ const tag = Object.prototype.toString.call(value);
84
+ if (CLONEABLE_TAGS.has(tag) || ArrayBuffer.isView(value))
85
+ return value;
86
+ if (tag !== "[object Object]")
87
+ return OMIT;
88
+ }
89
+ if (Array.isArray(value)) {
90
+ const out = value.map((item) => project(item, depth + 1, seen)).filter((v) => v !== OMIT);
91
+ return value.length > 0 && out.length === 0 ? OMIT : out;
92
+ }
93
+ const entries = Object.entries(value);
94
+ // The projected record itself is a container (a page's meta, a group's
95
+ // meta): its UI entries are dropped, the rest kept. Below it, an object
96
+ // holding a component or a handler IS a UI object, and goes whole.
97
+ if (depth > 0 && entries.some(([, v]) => isUiValue(v)))
98
+ return OMIT;
99
+ const out = {};
100
+ for (const [key, v] of entries) {
101
+ const projected = project(v, depth + 1, seen);
102
+ // defineProperty, not assignment: a "__proto__" key from JSON data is
103
+ // an own property, and assigning it would set the prototype instead.
104
+ if (projected !== OMIT) {
105
+ Object.defineProperty(out, key, {
106
+ value: projected,
107
+ enumerable: true,
108
+ writable: true,
109
+ configurable: true,
110
+ });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ finally {
116
+ seen.delete(value);
117
+ }
118
+ }
119
+ function isRecord(value) {
120
+ const proto = Object.getPrototypeOf(value);
121
+ return proto === Object.prototype || proto === null;
122
+ }
123
+ /** Project a record to what may safely be cloned and sent to the agent. */
124
+ export function toAgentData(value) {
125
+ const projected = project(value, 0, new WeakSet());
126
+ return projected === OMIT ? {} : projected;
127
+ }
@@ -9,6 +9,7 @@ import { collectFocusContext } from "./focus-context.js";
9
9
  * - Strict AG-UI state projection sent to the agent on every request
10
10
  */
11
11
  import { create } from "zustand";
12
+ import { toAgentData } from "./agent-data.js";
12
13
  const MAX_ITEMS = 10;
13
14
  const MAX_LABEL_LENGTH = 500;
14
15
  const MAX_CONTEXT_VALUE_LENGTH = 2000;
@@ -207,10 +208,7 @@ export const useAppStore = create((set, get) => ({
207
208
  ? item.label.slice(0, MAX_LABEL_LENGTH) + "..."
208
209
  : item.label;
209
210
  set({
210
- contextItems: [
211
- ...contextItems,
212
- { ...item, label, id: createUiId(), createdAt: Date.now() },
213
- ],
211
+ contextItems: [...contextItems, { ...item, label, id: createUiId(), createdAt: Date.now() }],
214
212
  });
215
213
  },
216
214
  setContextItem: (key, item) => {
@@ -277,11 +275,13 @@ export const useAppStore = create((set, get) => ({
277
275
  navigation: {
278
276
  group: s.navContext.group,
279
277
  groupDescription: s.navContext.groupDescription,
280
- groupMeta: s.navContext.groupMeta,
278
+ // Nav metadata is written for the UI and may hold what a panel
279
+ // renders — components, handlers. Only data reaches the agent.
280
+ groupMeta: toAgentData(s.navContext.groupMeta),
281
281
  page: s.currentPage,
282
282
  label: s.navContext.label,
283
283
  description: s.navContext.description,
284
- meta: focusedSurface ? { ...s.navContext.meta, focusedSurface } : s.navContext.meta,
284
+ meta: toAgentData(focusedSurface ? { ...s.navContext.meta, focusedSurface } : s.navContext.meta),
285
285
  contextInstructions: focusedSurface
286
286
  ? [
287
287
  s.navContext.contextInstructions,
@@ -292,7 +292,7 @@ export const useAppStore = create((set, get) => ({
292
292
  : s.navContext.contextInstructions,
293
293
  },
294
294
  selectedItemId: s.selectedItemId,
295
- content: s.content,
295
+ content: toAgentData(s.content),
296
296
  highlights: s.highlights,
297
297
  contextItems: s.contextItems.map(({ type, label, payload, sourcePage }) => {
298
298
  switch (payload.kind) {
@@ -313,7 +313,7 @@ export const useAppStore = create((set, get) => ({
313
313
  refId: payload.refId,
314
314
  preview: payload.preview?.slice(0, 100),
315
315
  target: compactReferenceTarget(payload.target),
316
- meta: payload.meta,
316
+ meta: payload.meta ? toAgentData(payload.meta) : payload.meta,
317
317
  targetPath: typeof payload.target?.path === "string"
318
318
  ? payload.target.path
319
319
  : typeof payload.meta?.targetPath === "string"
@@ -1,5 +1,5 @@
1
1
  import { create } from "zustand";
2
- import { agentStateStore, citationStore, streamingStatusStore, } from "@iloveagents/foundry-agent";
2
+ import { agentStateStore, citationStore, contextUsageStore, streamingStatusStore, } from "@iloveagents/foundry-agent";
3
3
  export const isChatRunActive = (run) => run.status === "running" || run.status === "waiting";
4
4
  /** Browser-session activity. Message persistence remains owned by the history adapter. */
5
5
  export const useChatRuns = create(() => ({ foregroundId: null, runs: {}, deletedThreads: {} }));
@@ -48,6 +48,7 @@ export function removeChatRun(threadId) {
48
48
  return { runs, deletedThreads: { ...state.deletedThreads, [threadId]: true } };
49
49
  });
50
50
  citationStore.getState().clear(threadId);
51
+ contextUsageStore.getState().forget(threadId);
51
52
  signals.delete(threadId);
52
53
  agentStates.delete(threadId);
53
54
  appProjections.delete(threadId);
@@ -164,6 +165,7 @@ export function disposeChatRuns() {
164
165
  agentStates.clear();
165
166
  appProjections.clear();
166
167
  citationStore.setState({ results: [], byThread: {}, threadId: null });
168
+ contextUsageStore.getState().clear();
167
169
  useChatRuns.setState({ foregroundId: null, runs: {}, deletedThreads: {} });
168
170
  streamingStatusStore.setState({
169
171
  streamingStatus: { status: "idle" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.32.4",
3
+ "version": "0.33.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": [
@@ -80,8 +80,8 @@
80
80
  "recharts": "^3.10.1",
81
81
  "remark-gfm": "^4.0.0",
82
82
  "tailwind-merge": "^3.5.0",
83
- "@iloveagents/foundry-agent": "^0.32.4",
84
- "@iloveagents/foundry-web-primitives": "^0.32.4"
83
+ "@iloveagents/foundry-agent": "^0.33.0",
84
+ "@iloveagents/foundry-web-primitives": "^0.33.0"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@ag-ui/client": "^0.0.52",