@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 [
|
|
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";
|
package/dist/lib/app-store.js
CHANGED
|
@@ -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) =>
|
|
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.
|
|
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.
|
|
80
|
-
"@iloveagents/foundry-web-primitives": "^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",
|