@iloveagents/foundry-web-ui 0.24.0 → 0.25.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.
@@ -10,7 +10,7 @@ export type ToolCallStatus = {
10
10
  type: "requires-action";
11
11
  reason?: string;
12
12
  };
13
- interface ToolCallCardProps {
13
+ export interface ToolCallCardProps {
14
14
  icon?: ReactNode;
15
15
  title: string;
16
16
  description?: string;
@@ -22,6 +22,17 @@ interface ToolCallCardProps {
22
22
  };
23
23
  children?: ReactNode;
24
24
  className?: string;
25
+ /** Start expanded. Ignored when `open` is supplied. */
26
+ defaultOpen?: boolean;
27
+ /**
28
+ * Controlled expansion.
29
+ *
30
+ * A card whose body is expensive to mount — a live force simulation, a media
31
+ * player — needs to know whether it is expanded so it can defer that cost,
32
+ * and needs to react when the user opens it. Left undefined, the card manages
33
+ * its own state exactly as before.
34
+ */
35
+ open?: boolean;
36
+ onOpenChange?: (open: boolean) => void;
25
37
  }
26
- export declare function ToolCallCard({ icon, title, description, status, action, children, className, }: ToolCallCardProps): import("react/jsx-runtime").JSX.Element;
27
- export {};
38
+ export declare function ToolCallCard({ icon, title, description, status, action, children, className, defaultOpen, open, onOpenChange, }: ToolCallCardProps): import("react/jsx-runtime").JSX.Element;
@@ -3,8 +3,15 @@ import { Loader2, CheckCircle2, XCircle, AlertCircle, ChevronDown } from "lucide
3
3
  import { cn } from "@iloveagents/foundry-web-primitives";
4
4
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
5
5
  import { useState } from "react";
6
- export function ToolCallCard({ icon, title, description, status, action, children, className, }) {
7
- const [isOpen, setIsOpen] = useState(false);
6
+ export function ToolCallCard({ icon, title, description, status, action, children, className, defaultOpen = false, open, onOpenChange, }) {
7
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
8
+ const isControlled = open !== undefined;
9
+ const isOpen = isControlled ? open : uncontrolledOpen;
10
+ const setIsOpen = (next) => {
11
+ if (!isControlled)
12
+ setUncontrolledOpen(next);
13
+ onOpenChange?.(next);
14
+ };
8
15
  const isSuccess = status.type === "complete";
9
16
  const isError = status.type === "incomplete" && status.reason === "error";
10
17
  const isRunning = status.type === "running";
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { ChatHeader } from "./components/chat-header.js";
14
14
  export { ComposerAttachment, UserMessageAttachment, userAttachmentComponents, composerAttachmentComponents, } from "./components/chat-attachments.js";
15
15
  export { ShowDocumentToolUI } from "./components/show-document-tool-ui.js";
16
16
  export { ToolCallCard } from "./components/tool-call-card.js";
17
- export type { ToolCallStatus } from "./components/tool-call-card.js";
17
+ export type { ToolCallStatus, ToolCallCardProps } from "./components/tool-call-card.js";
18
18
  export { ToolFallback } from "./components/tool-fallback.js";
19
19
  export { ConfirmationCard } from "./components/confirmation-card.js";
20
20
  export { TooltipIconButton } from "./components/tooltip-icon-button.js";
@@ -50,6 +50,94 @@ function compactReferenceTarget(target) {
50
50
  proposedValue: compactContextValue(target.proposedValue),
51
51
  };
52
52
  }
53
+ /**
54
+ * Structural comparison that survives what a `payload` actually carries.
55
+ *
56
+ * Compared by WALKING the two values, not by serialising them. A payload is
57
+ * often a database row, and a `bigint` is ordinary there — the Neo4j driver
58
+ * returns them natively — which rules out `JSON.stringify` twice over. It
59
+ * throws on a bigint outright, and the obvious repair (a replacer that tags
60
+ * one) cannot be made correct: `10n` as `"10"` collides with the string
61
+ * `"10"`, as `"10n"` with the string `"10n"`, as `{__bigint:"10"}` with a
62
+ * payload field that happens to be that object. `payload` is arbitrary, so
63
+ * every encoding can be imitated by data shaped like the encoding, and a
64
+ * collision here makes a real change read as "unchanged" — leaving stale
65
+ * context attached to every later message.
66
+ *
67
+ * Comparing types directly has nothing to imitate: `typeof 10n` is not
68
+ * `typeof "10"`. It is also key-order independent (which the serialising
69
+ * version never actually was, despite its name) and cheaper, since it stops at
70
+ * the first difference instead of building two strings.
71
+ *
72
+ * Cycles are tracked as PAIRS. A single visited-set marks an object on first
73
+ * sight, so the second appearance of a shared but acyclic sub-object reads as
74
+ * a cycle — and two values differing only there would compare equal.
75
+ */
76
+ function isDate(value) {
77
+ return Object.prototype.toString.call(value) === "[object Date]";
78
+ }
79
+ /** Own enumerable keys describe this object completely. */
80
+ function isPlainObject(value) {
81
+ const proto = Object.getPrototypeOf(value);
82
+ return proto === Object.prototype || proto === null;
83
+ }
84
+ function deepEqual(a, b, seen) {
85
+ if (Object.is(a, b))
86
+ return true;
87
+ if (typeof a !== typeof b)
88
+ return false;
89
+ if (typeof a !== "object" || a === null || b === null)
90
+ return false;
91
+ const left = a;
92
+ const right = b;
93
+ const pairs = seen ?? new Map();
94
+ const already = pairs.get(left);
95
+ // Assumed equal while in flight: two structures that recurse identically are
96
+ // equal, and re-entering would not terminate.
97
+ if (already?.has(right))
98
+ return true;
99
+ if (already)
100
+ already.add(right);
101
+ else
102
+ pairs.set(left, new Set([right]));
103
+ if (Array.isArray(left) || Array.isArray(right)) {
104
+ if (!Array.isArray(left) || !Array.isArray(right))
105
+ return false;
106
+ if (left.length !== right.length)
107
+ return false;
108
+ return left.every((item, index) => deepEqual(item, right[index], pairs));
109
+ }
110
+ // Enumerable keys only describe a PLAIN object. A `Date`, `Map`, `Set` or
111
+ // `RegExp` keeps its state internally and has none, so comparing key sets
112
+ // said two different dates were the same value — and this guard's entire job
113
+ // is deciding whether something changed. `Date` is worth spelling out
114
+ // because it is what a host or an adapter actually puts in a property; every
115
+ // other exotic object falls through to "not equal unless identical", which
116
+ // is the safe direction: a spurious change costs one redundant update, a
117
+ // missed one loses the user's data silently.
118
+ if (isDate(left) || isDate(right)) {
119
+ return isDate(left) && isDate(right) && Object.is(left.getTime(), right.getTime());
120
+ }
121
+ if (!isPlainObject(left) || !isPlainObject(right))
122
+ return false;
123
+ const leftKeys = Object.keys(left);
124
+ const rightKeys = new Set(Object.keys(right));
125
+ if (leftKeys.length !== rightKeys.size)
126
+ return false;
127
+ return leftKeys.every((key) => rightKeys.has(key) &&
128
+ deepEqual(left[key], right[key], pairs));
129
+ }
130
+ /**
131
+ * Do two keyed context items carry the same value?
132
+ *
133
+ * Compares everything the consumer sees, ignoring the identity fields the
134
+ * store owns (`id`, `createdAt`). `payload` is arbitrary — a selected row, a
135
+ * graph node and its properties — so it is compared structurally.
136
+ */
137
+ function sameContextValue(current, next) {
138
+ const { id: _id, createdAt: _createdAt, ...rest } = current;
139
+ return deepEqual(rest, next);
140
+ }
53
141
  export const useAppStore = create((set, get) => ({
54
142
  // Thread state
55
143
  threadActive: false,
@@ -135,11 +223,18 @@ export const useAppStore = create((set, get) => ({
135
223
  const label = item.label.length > MAX_LABEL_LENGTH
136
224
  ? `${item.label.slice(0, MAX_LABEL_LENGTH)}...`
137
225
  : item.label;
226
+ // Setting the same value twice is not a change. Without this the method
227
+ // mints a fresh `id` and a fresh array every call, so a UI that mirrors a
228
+ // selection into context — re-asserting the same item as it re-renders —
229
+ // churns the store, re-renders every subscriber, and can drive itself
230
+ // into an update loop. The null branch above already declines to write
231
+ // when nothing changes; this is the same rule for the write path.
232
+ const current = get().contextItems.find((existing) => existing.key === key);
233
+ const next = { ...item, key, label };
234
+ if (current && sameContextValue(current, next))
235
+ return;
138
236
  set({
139
- contextItems: [
140
- ...withoutKey,
141
- { ...item, key, label, id: crypto.randomUUID(), createdAt: Date.now() },
142
- ],
237
+ contextItems: [...withoutKey, { ...next, id: crypto.randomUUID(), createdAt: Date.now() }],
143
238
  });
144
239
  },
145
240
  removeContextItem: (id) => set({ contextItems: get().contextItems.filter((i) => i.id !== id) }),
@@ -12,16 +12,34 @@ export interface PdfHighlightRef {
12
12
  export interface ToolPanelContent {
13
13
  title: string;
14
14
  content: string;
15
- type: "markdown" | "text" | "page" | "pdf";
15
+ type: "markdown" | "text" | "page" | "pdf" | "graph";
16
16
  /** PDF highlights for citation overlays (only when type === "pdf") */
17
17
  pdfHighlights?: PdfHighlightRef[];
18
18
  /** Initial page to scroll to (only when type === "pdf") */
19
19
  initialPage?: number;
20
20
  /** SPACES entity ID — enables pin and navigate actions in panel header */
21
21
  entityId?: string;
22
+ /**
23
+ * Structured payload for renderers that need more than a string — a graph,
24
+ * a chart, a table. `content` is still expected to carry a serialization, so
25
+ * the header's Copy action and the built-in text fallback keep working when
26
+ * no renderer matches.
27
+ */
28
+ data?: unknown;
22
29
  }
23
30
  /** Pluggable content renderer for the tool panel — registered by feature modules. */
24
31
  export interface PanelRendererEntry {
32
+ /**
33
+ * Stable identity. When present, re-registering replaces the previous entry
34
+ * with the same id instead of appending a second copy.
35
+ *
36
+ * Registration usually happens in a module's `useInit`, which runs again on
37
+ * every remount — a StrictMode double-render, a route change, a hot reload.
38
+ * Without an id those calls stack up, `renderers` grows without bound, and
39
+ * `find()` keeps matching the oldest (now stale) closure. Entries without an
40
+ * id keep the original append behaviour.
41
+ */
42
+ id?: string;
25
43
  /** Check if this renderer handles the given content */
26
44
  match: (content: ToolPanelContent) => boolean;
27
45
  /** Lazy component to render. Receives content as prop. */
@@ -55,7 +55,18 @@ export const useToolPanelStore = create((set, get) => ({
55
55
  isFullscreen: false,
56
56
  renderers: [],
57
57
  openPanel: (content) => set({ isOpen: true, content }),
58
- registerRenderer: (entry) => set((state) => ({ renderers: [...state.renderers, entry] })),
58
+ registerRenderer: (entry) => set((state) => {
59
+ if (entry.id === undefined)
60
+ return { renderers: [...state.renderers, entry] };
61
+ const index = state.renderers.findIndex((existing) => existing.id === entry.id);
62
+ if (index === -1)
63
+ return { renderers: [...state.renderers, entry] };
64
+ // Replace in place: registration order decides which renderer wins a
65
+ // match, so re-registering must not silently reprioritise it.
66
+ const renderers = [...state.renderers];
67
+ renderers[index] = entry;
68
+ return { renderers };
69
+ }),
59
70
  closePanel: () => set({ isOpen: false, isFullscreen: false }),
60
71
  setContent: (content) => set({ content }),
61
72
  setPanelWidth: (width) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.24.0",
3
+ "version": "0.25.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": [
@@ -76,8 +76,8 @@
76
76
  "recharts": "^3.10.1",
77
77
  "remark-gfm": "^4.0.0",
78
78
  "tailwind-merge": "^3.5.0",
79
- "@iloveagents/foundry-agent": "^0.24.0",
80
- "@iloveagents/foundry-web-primitives": "^0.24.0"
79
+ "@iloveagents/foundry-agent": "^0.25.0",
80
+ "@iloveagents/foundry-web-primitives": "^0.25.0"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@ag-ui/client": "^0.0.52",