@iloveagents/foundry-web-graph 0.1.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/adapters/index.d.ts +1 -0
  4. package/dist/adapters/index.js +1 -0
  5. package/dist/adapters/neo4j.d.ts +52 -0
  6. package/dist/adapters/neo4j.js +149 -0
  7. package/dist/assistant-ui/graph-client-tools.d.ts +11 -0
  8. package/dist/assistant-ui/graph-client-tools.js +261 -0
  9. package/dist/assistant-ui/graph-context.d.ts +38 -0
  10. package/dist/assistant-ui/graph-context.js +113 -0
  11. package/dist/assistant-ui/graph-panel-content.d.ts +45 -0
  12. package/dist/assistant-ui/graph-panel-content.js +28 -0
  13. package/dist/assistant-ui/graph-panel.d.ts +11 -0
  14. package/dist/assistant-ui/graph-panel.js +121 -0
  15. package/dist/assistant-ui/graph-provenance.d.ts +17 -0
  16. package/dist/assistant-ui/graph-provenance.js +31 -0
  17. package/dist/assistant-ui/graph-result-seeder.d.ts +73 -0
  18. package/dist/assistant-ui/graph-result-seeder.js +157 -0
  19. package/dist/assistant-ui/graph-tool-registry.d.ts +25 -0
  20. package/dist/assistant-ui/graph-tool-registry.js +15 -0
  21. package/dist/assistant-ui/graph-tool-ui.d.ts +54 -0
  22. package/dist/assistant-ui/graph-tool-ui.js +77 -0
  23. package/dist/assistant-ui/index.d.ts +17 -0
  24. package/dist/assistant-ui/index.js +17 -0
  25. package/dist/assistant-ui/merge-into-panel.d.ts +21 -0
  26. package/dist/assistant-ui/merge-into-panel.js +72 -0
  27. package/dist/assistant-ui/register-graph-panel.d.ts +8 -0
  28. package/dist/assistant-ui/register-graph-panel.js +18 -0
  29. package/dist/caption-placement.d.ts +119 -0
  30. package/dist/caption-placement.js +146 -0
  31. package/dist/graph-canvas-paint.d.ts +90 -0
  32. package/dist/graph-canvas-paint.js +186 -0
  33. package/dist/graph-canvas.d.ts +49 -0
  34. package/dist/graph-canvas.js +533 -0
  35. package/dist/graph-inspector.d.ts +42 -0
  36. package/dist/graph-inspector.js +106 -0
  37. package/dist/graph-legend.d.ts +19 -0
  38. package/dist/graph-legend.js +20 -0
  39. package/dist/graph-notice.d.ts +19 -0
  40. package/dist/graph-notice.js +30 -0
  41. package/dist/graph-table.d.ts +28 -0
  42. package/dist/graph-table.js +57 -0
  43. package/dist/graph-toolbar.d.ts +22 -0
  44. package/dist/graph-toolbar.js +8 -0
  45. package/dist/graph-tooltip.d.ts +4 -0
  46. package/dist/graph-tooltip.js +55 -0
  47. package/dist/graph-view.d.ts +94 -0
  48. package/dist/graph-view.js +338 -0
  49. package/dist/graph-workspace.d.ts +55 -0
  50. package/dist/graph-workspace.js +100 -0
  51. package/dist/index.d.ts +21 -0
  52. package/dist/index.js +21 -0
  53. package/dist/model.d.ts +206 -0
  54. package/dist/model.js +369 -0
  55. package/dist/styles.css +97 -0
  56. package/dist/theme.d.ts +36 -0
  57. package/dist/theme.js +83 -0
  58. package/dist/use-element-size.d.ts +13 -0
  59. package/dist/use-element-size.js +32 -0
  60. package/dist/use-graph-model.d.ts +101 -0
  61. package/dist/use-graph-model.js +164 -0
  62. package/dist/use-graph-styling.d.ts +55 -0
  63. package/dist/use-graph-styling.js +156 -0
  64. package/dist/use-graph-theme.d.ts +11 -0
  65. package/dist/use-graph-theme.js +54 -0
  66. package/dist/use-graph-view-state.d.ts +42 -0
  67. package/dist/use-graph-view-state.js +145 -0
  68. package/package.json +83 -0
@@ -0,0 +1,261 @@
1
+ import { clientToolRegistry } from "@iloveagents/foundry-agent";
2
+ import { useToolPanelStore } from "@iloveagents/foundry-web-ui";
3
+ import { coerceGraphPayload, hasOwn, mergeGraphPayloads, primaryLabel, resolveCaption, safeStringify, } from "../model.js";
4
+ import { findWorkspaceNode, graphWorkspaceStore, isAmbiguous } from "../graph-workspace.js";
5
+ import { GRAPH_PANEL_TYPE, graphPanelContent } from "./graph-panel-content.js";
6
+ /**
7
+ * Client tools that let the agent work IN the open graph, not just hand one
8
+ * over.
9
+ *
10
+ * These execute in the browser, so they act on what the user is actually
11
+ * looking at — the live panel, its current selection, its current search.
12
+ * That is the difference between an agent that can produce a picture and one
13
+ * you can have a conversation with about the picture.
14
+ */
15
+ /**
16
+ * A node as the AGENT should see it — captioned the way the canvas paints it.
17
+ *
18
+ * The payload\'s `legend.captionKey` is a supported part of the contract, and
19
+ * where a producer sets one, resolving without it names the node something
20
+ * that appears nowhere on screen. `findWorkspaceNode` already matches with the
21
+ * legend key, so without this the tool could find a node by the caption the
22
+ * user can see and then report a different name back.
23
+ */
24
+ const describe = (node, legend) => {
25
+ const label = primaryLabel(node);
26
+ const captionKey = legend && hasOwn(legend, label) ? legend[label]?.captionKey : undefined;
27
+ return {
28
+ id: node.id,
29
+ labels: node.labels ?? [],
30
+ caption: resolveCaption(node, captionKey),
31
+ properties: node.properties ?? {},
32
+ };
33
+ };
34
+ // Not `JSON.stringify`: every one of these results carries node properties
35
+ // straight out of the database, and a `bigint` there would throw inside the
36
+ // tool rather than answering it.
37
+ const ok = (value) => safeStringify(value);
38
+ const fail = (message) => JSON.stringify({ error: message });
39
+ function parseArgs(argsJson) {
40
+ try {
41
+ const parsed = JSON.parse(argsJson || "{}");
42
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
43
+ }
44
+ catch {
45
+ return {};
46
+ }
47
+ }
48
+ const showGraph = {
49
+ name: "graph_show",
50
+ description: "Show a graph in the side panel. Updates the graph already open rather than opening a " +
51
+ "second one, and keeps the positions of nodes that were already on screen, so a new " +
52
+ "result reads as a change rather than a new picture. Set merge: true to ADD to what is " +
53
+ "on screen — always do that for an expansion or a write, so the new part appears beside " +
54
+ "what it connects to. Pass the payload a graph tool returned.",
55
+ parameters: {
56
+ type: "object",
57
+ properties: {
58
+ title: { type: "string", description: "Panel heading, e.g. the question this answers." },
59
+ graph: { type: "object", description: "A graph payload (kind: 'foundry.graph')." },
60
+ merge: {
61
+ type: "boolean",
62
+ description: "Add to the graph on screen instead of replacing it. Use for expansions and writes.",
63
+ },
64
+ },
65
+ required: ["graph"],
66
+ },
67
+ execute: async (argsJson) => {
68
+ const args = parseArgs(argsJson);
69
+ const payload = coerceGraphPayload(args.graph);
70
+ if (!payload)
71
+ return fail("That is not a graph payload (expected kind: 'foundry.graph').");
72
+ const workspace = graphWorkspaceStore.getState();
73
+ const merging = args.merge === true && workspace.payload !== null;
74
+ const next = merging ? mergeGraphPayloads(workspace.payload, payload) : payload;
75
+ const title = typeof args.title === "string" && args.title
76
+ ? args.title
77
+ : ((merging ? workspace.title : null) ?? "Graph");
78
+ // Update in place when a graph is already up. `openPanel` would do the
79
+ // same to the store, but going through `setContent` says what is meant:
80
+ // this is the same workspace changing, not a new one — which is also how
81
+ // it behaves on screen, since the renderer instance is reused and the
82
+ // layout reconciles by node id.
83
+ const panel = useToolPanelStore.getState();
84
+ const content = graphPanelContent(title, next);
85
+ if (panel.isOpen && panel.content?.type === GRAPH_PANEL_TYPE)
86
+ panel.setContent(content);
87
+ else
88
+ panel.openPanel(content);
89
+ return ok({
90
+ shown: true,
91
+ merged: merging,
92
+ nodes: next.nodes.length,
93
+ edges: next.edges.length,
94
+ added: merging ? next.nodes.length - workspace.payload.nodes.length : next.nodes.length,
95
+ });
96
+ },
97
+ };
98
+ const selectNode = {
99
+ name: "graph_select_node",
100
+ description: "Select a node in the open graph and centre the view on it, as if the user had clicked it. " +
101
+ "Accepts a node id or a caption such as a name or title. Returns the node's properties.",
102
+ parameters: {
103
+ type: "object",
104
+ properties: {
105
+ node: { type: "string", description: "Node id, or its caption (name/title)." },
106
+ },
107
+ required: ["node"],
108
+ },
109
+ execute: async (argsJson) => {
110
+ const args = parseArgs(argsJson);
111
+ const needle = typeof args.node === "string" ? args.node : "";
112
+ const { handle, payload: openPayload } = graphWorkspaceStore.getState();
113
+ if (!handle)
114
+ return fail("No graph is open. Call graph_show first.");
115
+ const legend = openPayload?.legend;
116
+ const captionOf = (node) => describe(node, legend).caption;
117
+ const match = findWorkspaceNode(needle);
118
+ if (!match)
119
+ return fail(`No node matching "${needle}" is in the open graph.`);
120
+ // Several nodes answer to that text. Picking one would be a confident
121
+ // wrong answer the user cannot see — duplicate captions are ordinary — so
122
+ // hand back the candidates WITH their ids, which is what the agent needs
123
+ // to ask a real question or select precisely.
124
+ if (isAmbiguous(match)) {
125
+ const options = match.ambiguous
126
+ .slice(0, 10)
127
+ .map((n) => `${describe(n, openPayload?.legend).caption} (id ${n.id})`)
128
+ .join(", ");
129
+ return fail(`"${needle}" matches ${match.ambiguous.length} nodes in the open graph: ${options}. ` +
130
+ `Select by id, or ask the user which one they mean.`);
131
+ }
132
+ const node = match;
133
+ // A node can be in the workspace payload and still not be RENDERED — the
134
+ // view caps a large graph to its connected core — and reporting a
135
+ // selection that did not happen sends the agent on to describe something
136
+ // the user cannot see.
137
+ if (!handle.select(node.id)) {
138
+ // Two different reasons a node is not drawn, and they need opposite
139
+ // advice. A legend filter is undone by turning the label back on;
140
+ // narrowing the query cannot bring back something the USER switched off,
141
+ // so telling the agent to narrow sends it somewhere that cannot work.
142
+ const hidden = handle.getViewState().hiddenLabels;
143
+ const label = primaryLabel(node);
144
+ if (hidden.includes(label)) {
145
+ return fail(`"${captionOf(node)}" is in this result but hidden: the "${label}" filter is ` +
146
+ `switched off in the legend. Ask the user to turn it back on to see this node.`);
147
+ }
148
+ return fail(`"${captionOf(node)}" is in this result but not drawn — the view is showing the ` +
149
+ `most connected part of a graph too large for one screen. Narrow the query to bring ` +
150
+ `it in.`);
151
+ }
152
+ // Reveal, not centre: the user framed this graph, and a tool that
153
+ // re-centres on every mention throws that away. Move only if the node is
154
+ // off-screen or behind the details drawer.
155
+ handle.reveal(node.id);
156
+ return ok({ selected: describe(node, legend) });
157
+ },
158
+ };
159
+ const searchGraph = {
160
+ name: "graph_search",
161
+ description: "Highlight nodes in the open graph matching a term, dimming the rest. Searches captions, " +
162
+ "labels and property values. Pass an empty string to clear.",
163
+ parameters: {
164
+ type: "object",
165
+ properties: { query: { type: "string", description: "Text to highlight. Empty clears." } },
166
+ required: ["query"],
167
+ },
168
+ execute: async (argsJson) => {
169
+ const args = parseArgs(argsJson);
170
+ const query = typeof args.query === "string" ? args.query : "";
171
+ const { handle, payload } = graphWorkspaceStore.getState();
172
+ if (!handle || !payload)
173
+ return fail("No graph is open. Call graph_show first.");
174
+ handle.search(query);
175
+ return ok({ query, of: payload.nodes.length });
176
+ },
177
+ };
178
+ const getView = {
179
+ name: "graph_get_view",
180
+ description: "Read what the user is currently looking at: the open graph's title, its node and " +
181
+ "relationship counts, the types present, the selected node, and any active search. Call " +
182
+ "this before answering a question about 'this' node or 'the graph' on screen.",
183
+ parameters: { type: "object", properties: {} },
184
+ execute: async () => {
185
+ const { payload, title, handle } = graphWorkspaceStore.getState();
186
+ if (!payload || !handle)
187
+ return ok({ open: false });
188
+ // Read the VIEW, not the payload. Two different things were being lost by
189
+ // counting the payload: the render cap keeps only the connected core of a
190
+ // graph too big for one screen, and a label switched off in the legend
191
+ // takes its nodes and their relationships off the canvas. Either way the
192
+ // agent was describing a graph the user was not looking at — and could
193
+ // name a node that is not drawn.
194
+ const view = handle.getViewState();
195
+ // Counted into a `Map`, not `{}`. Labels and relationship types come from
196
+ // the payload, which is source-neutral and untrusted — a graph is free to
197
+ // have a label called `constructor` or `toString`, and reading those off
198
+ // an object literal finds an inherited function instead of "not counted
199
+ // yet". The count then either throws or lands in the result as something
200
+ // that is not a number.
201
+ const labels = new Map();
202
+ for (const node of view.nodes) {
203
+ const label = primaryLabel(node);
204
+ labels.set(label, (labels.get(label) ?? 0) + 1);
205
+ }
206
+ const types = new Map();
207
+ for (const edge of view.edges) {
208
+ const type = edge.type ?? "RELATED";
209
+ types.set(type, (types.get(type) ?? 0) + 1);
210
+ }
211
+ return ok({
212
+ open: true,
213
+ title,
214
+ nodeCount: view.nodes.length,
215
+ edgeCount: view.edges.length,
216
+ // The producer's own truncation OR the render cap. Reporting only the
217
+ // former said `false` while the UI was showing a loss notice.
218
+ truncated: (payload.truncated ?? false) || view.lossy,
219
+ labels: Object.fromEntries(labels),
220
+ relationshipTypes: Object.fromEntries(types),
221
+ // Named, so the agent can say "Person is hidden" rather than silently
222
+ // reporting a smaller graph than the query returned.
223
+ hiddenLabels: view.hiddenLabels,
224
+ hiddenNodeCount: payload.nodes.length - view.nodes.length,
225
+ selectedNode: view.selection ? describe(view.selection, payload.legend) : null,
226
+ // A selected node whose label the legend hides stays selected — the
227
+ // drawer is still open and its properties are still what the user is
228
+ // reading — but it is NOT in `nodeCount` or among the drawn nodes. Said
229
+ // plainly here, so the agent can answer "selected, though its label is
230
+ // currently hidden" instead of describing a node this same result says
231
+ // is not on screen.
232
+ selectedNodeHidden: view.selection ? !view.selectionDrawn : false,
233
+ // A relationship can be selected too, and the drawer shows it. Reporting
234
+ // only nodes said "nothing is selected" with an edge plainly selected.
235
+ selectedRelationship: view.selectedEdge
236
+ ? {
237
+ id: view.selectedEdge.id,
238
+ type: view.selectedEdge.type ?? "RELATED",
239
+ source: view.selectedEdge.source,
240
+ target: view.selectedEdge.target,
241
+ properties: view.selectedEdge.properties ?? {},
242
+ }
243
+ : null,
244
+ search: view.query || null,
245
+ });
246
+ },
247
+ };
248
+ export const GRAPH_CLIENT_TOOLS = [showGraph, selectNode, searchGraph, getView];
249
+ /**
250
+ * Register the graph client tools globally.
251
+ *
252
+ * Global rather than page-scoped: the panel is available from every route, so
253
+ * scoping them to one page would make the agent's ability to drive the graph
254
+ * depend on where the user happens to be standing. Re-registering by the same
255
+ * name replaces the entry, so this is safe to call from a `useInit`.
256
+ */
257
+ export function registerGraphClientTools() {
258
+ const { registerGlobal } = clientToolRegistry.getState();
259
+ for (const tool of GRAPH_CLIENT_TOOLS)
260
+ registerGlobal(tool);
261
+ }
@@ -0,0 +1,38 @@
1
+ import { type GraphNode } from "../model.js";
2
+ /**
3
+ * The context key the graph's selection occupies.
4
+ *
5
+ * One key means one item: selecting a different node replaces the previous
6
+ * one rather than stacking, and deselecting clears it. That is exactly what
7
+ * `setContextItem` is for — "context that tracks a piece of UI rather than a
8
+ * user's pin".
9
+ */
10
+ export declare const GRAPH_SELECTION_CONTEXT_KEY = "graph.selection";
11
+ /**
12
+ * Keep the graph's selected node in the chat's context.
13
+ *
14
+ * Without this, "tell me about this node" means the agent has to guess, or
15
+ * the user has to retype what they are already looking at. With it, whatever
16
+ * is selected is simply part of the conversation — and because it is keyed,
17
+ * selecting something else swaps it instead of piling up.
18
+ */
19
+ export declare function syncGraphSelectionContext(node: GraphNode | null, sourcePage: string, captionKey?: string): void;
20
+ /**
21
+ * Pin the selected node so it outlives the selection.
22
+ *
23
+ * Uses the shell's own pin vocabulary — same icons, same `text-primary` when
24
+ * active — because it is the same idea: this stays in the conversation until
25
+ * I remove it. `promoteContextItem` is what turns the keyed, selection-tracking
26
+ * item into a persistent pin (it drops the key), so pinning and then selecting
27
+ * elsewhere leaves the pin standing.
28
+ */
29
+ export declare function GraphContextPinButton({ node, captionKey, }: {
30
+ node: GraphNode;
31
+ /**
32
+ * The payload's caption key for this node's label — the same one the canvas
33
+ * paints with. Unpinning rebuilds the tracking item, and without it the chip
34
+ * the user reads (and the `preview` the agent reads) reverts to a default
35
+ * caption that may appear nowhere on screen.
36
+ */
37
+ captionKey?: string;
38
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,113 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback } from "react";
3
+ import { Pin, PinOff } from "lucide-react";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+ import { useAppStore } from "@iloveagents/foundry-web-ui";
6
+ import { jsonSafe, primaryLabel, resolveCaption } from "../model.js";
7
+ /**
8
+ * The context key the graph's selection occupies.
9
+ *
10
+ * One key means one item: selecting a different node replaces the previous
11
+ * one rather than stacking, and deselecting clears it. That is exactly what
12
+ * `setContextItem` is for — "context that tracks a piece of UI rather than a
13
+ * user's pin".
14
+ */
15
+ export const GRAPH_SELECTION_CONTEXT_KEY = "graph.selection";
16
+ function contextItemFor(node, sourcePage, captionKey) {
17
+ // Captioned with the payload\'s own `legend.captionKey`, like the canvas,
18
+ // the expansion prompt and the client tools. Without it the chip the user
19
+ // reads — and the `preview` the agent reads — can name the selected node
20
+ // something that appears nowhere on screen.
21
+ const caption = resolveCaption(node, captionKey);
22
+ return {
23
+ key: GRAPH_SELECTION_CONTEXT_KEY,
24
+ type: "ref",
25
+ label: `${primaryLabel(node)} · ${caption}`,
26
+ payload: {
27
+ kind: "ref",
28
+ refType: "graph-node",
29
+ refId: node.id,
30
+ preview: caption,
31
+ // The agent gets the node's own data, so it can answer about "this
32
+ // node" without a round trip back through a tool.
33
+ //
34
+ // A JSON-SAFE copy, not the live object. This item is attached to every
35
+ // subsequent turn and serialized by the HTTP agent, so a single native
36
+ // `bigint` in `properties` — which is what the Neo4j driver returns
37
+ // under `useBigInt` — throws inside someone else's `JSON.stringify`.
38
+ // That does not lose a property, it stops the user sending any message
39
+ // at all for as long as the node stays selected. Copying also detaches
40
+ // it from a node object the simulation keeps mutating.
41
+ meta: jsonSafe({ labels: node.labels ?? [], properties: node.properties ?? {} }),
42
+ },
43
+ sourcePage,
44
+ // Ephemeral: it follows the selection. Pinning promotes it, which drops
45
+ // the key and makes it persist.
46
+ persistence: "ephemeral",
47
+ };
48
+ }
49
+ /**
50
+ * Keep the graph's selected node in the chat's context.
51
+ *
52
+ * Without this, "tell me about this node" means the agent has to guess, or
53
+ * the user has to retype what they are already looking at. With it, whatever
54
+ * is selected is simply part of the conversation — and because it is keyed,
55
+ * selecting something else swaps it instead of piling up.
56
+ */
57
+ export function syncGraphSelectionContext(node, sourcePage, captionKey) {
58
+ const { setContextItem, contextItems } = useAppStore.getState();
59
+ if (!node) {
60
+ setContextItem(GRAPH_SELECTION_CONTEXT_KEY, null);
61
+ return;
62
+ }
63
+ // Already pinned? Then the node is in the conversation once, deliberately,
64
+ // and the tracking item would make it twice. `promoteContextItem` drops the
65
+ // key to pin, so nothing stops this from re-adding a keyed copy on the next
66
+ // republish — a route change or a merge — and the agent would be handed the
67
+ // same node under two entries whose properties can disagree after a merge.
68
+ if (contextItems.some((item) => isPinnedGraphNode(item, node.id))) {
69
+ setContextItem(GRAPH_SELECTION_CONTEXT_KEY, null);
70
+ return;
71
+ }
72
+ setContextItem(GRAPH_SELECTION_CONTEXT_KEY, contextItemFor(node, sourcePage, captionKey));
73
+ }
74
+ /** A user\'s pin of this exact node: promoted, so it carries no key. */
75
+ function isPinnedGraphNode(item, nodeId) {
76
+ const payload = item.payload;
77
+ return (item.key === undefined &&
78
+ payload?.kind === "ref" &&
79
+ payload.refType === "graph-node" &&
80
+ payload.refId === nodeId);
81
+ }
82
+ /**
83
+ * Pin the selected node so it outlives the selection.
84
+ *
85
+ * Uses the shell's own pin vocabulary — same icons, same `text-primary` when
86
+ * active — because it is the same idea: this stays in the conversation until
87
+ * I remove it. `promoteContextItem` is what turns the keyed, selection-tracking
88
+ * item into a persistent pin (it drops the key), so pinning and then selecting
89
+ * elsewhere leaves the pin standing.
90
+ */
91
+ export function GraphContextPinButton({ node, captionKey, }) {
92
+ const items = useAppStore((s) => s.contextItems);
93
+ const promoteContextItem = useAppStore((s) => s.promoteContextItem);
94
+ const removeContextItem = useAppStore((s) => s.removeContextItem);
95
+ const tracked = items.find((item) => item.key === GRAPH_SELECTION_CONTEXT_KEY);
96
+ const pinned = items.find((item) => isPinnedGraphNode(item, node.id));
97
+ const toggle = useCallback(() => {
98
+ if (pinned) {
99
+ removeContextItem(pinned.id);
100
+ // Pinning PROMOTED the keyed item, which is how it survives selecting
101
+ // elsewhere — so removing the pin removes the only copy, and the node
102
+ // the user is still looking at drops out of the conversation entirely
103
+ // until they select something else. Unpinning means "stop keeping this
104
+ // one", not "forget what I have selected", so the tracking item comes
105
+ // back.
106
+ syncGraphSelectionContext(node, pinned.sourcePage ?? "", captionKey);
107
+ return;
108
+ }
109
+ if (tracked)
110
+ promoteContextItem(tracked.id);
111
+ }, [node, captionKey, pinned, tracked, promoteContextItem, removeContextItem]);
112
+ return (_jsxs("button", { type: "button", onClick: toggle, title: pinned ? "Unpin from chat" : "Pin to chat", "aria-pressed": !!pinned, className: cn("border-border hover:bg-muted focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-1", pinned ? "text-primary" : "text-muted-foreground"), children: [pinned ? _jsx(PinOff, { className: "size-3.5" }) : _jsx(Pin, { className: "size-3.5" }), pinned ? "Pinned" : "Pin"] }));
113
+ }
@@ -0,0 +1,45 @@
1
+ import { type GraphPayload } from "../model.js";
2
+ /** The `ToolPanelContent.type` a graph panel matches on. */
3
+ export declare const GRAPH_PANEL_TYPE = "graph";
4
+ /**
5
+ * ANY panel content, for the renderer that has to decide whether it is a graph.
6
+ *
7
+ * Declared structurally so this module does not import the store's type — it
8
+ * only ever reads these fields, and a structural shape keeps the dependency
9
+ * one-way. `type` is deliberately wide here: the whole job of
10
+ * `graphPanelPayload` is to be handed something that might not be a graph.
11
+ */
12
+ export interface GraphPanelContentLike {
13
+ title: string;
14
+ content: string;
15
+ type: string;
16
+ data?: unknown;
17
+ }
18
+ /**
19
+ * Content this module BUILT, which is a graph by construction.
20
+ *
21
+ * Separate from the wide shape above so `type` can be the literal, which is
22
+ * what makes it assignable to the host's `ToolPanelContent` union. One type
23
+ * serving both directions forced an `as never` at every `openPanel` and
24
+ * `setContent` call — casts that switch checking off exactly where a shape
25
+ * mismatch with the host would otherwise be caught.
26
+ */
27
+ export interface GraphPanelContent extends GraphPanelContentLike {
28
+ type: typeof GRAPH_PANEL_TYPE;
29
+ }
30
+ /**
31
+ * Build the panel payload for a graph.
32
+ *
33
+ * `data` carries the parsed payload so the renderer never re-parses, and
34
+ * `content` carries the serialization so the panel header's Copy button and the
35
+ * built-in text fallback still do something useful if no renderer matched.
36
+ */
37
+ export declare function graphPanelContent(title: string, payload: GraphPayload): GraphPanelContent;
38
+ /**
39
+ * Recover the payload from panel content.
40
+ *
41
+ * Prefers `data`; falls back to parsing `content` so a panel opened by a
42
+ * generic client tool (`ui_open_panel`, which only knows how to set strings)
43
+ * still renders as a graph.
44
+ */
45
+ export declare function graphPanelPayload(content: GraphPanelContentLike): GraphPayload | null;
@@ -0,0 +1,28 @@
1
+ import { coerceGraphPayload, safeStringify } from "../model.js";
2
+ /** The `ToolPanelContent.type` a graph panel matches on. */
3
+ export const GRAPH_PANEL_TYPE = "graph";
4
+ /**
5
+ * Build the panel payload for a graph.
6
+ *
7
+ * `data` carries the parsed payload so the renderer never re-parses, and
8
+ * `content` carries the serialization so the panel header's Copy button and the
9
+ * built-in text fallback still do something useful if no renderer matched.
10
+ */
11
+ export function graphPanelContent(title, payload) {
12
+ return {
13
+ title,
14
+ type: GRAPH_PANEL_TYPE,
15
+ content: safeStringify(payload, 2),
16
+ data: payload,
17
+ };
18
+ }
19
+ /**
20
+ * Recover the payload from panel content.
21
+ *
22
+ * Prefers `data`; falls back to parsing `content` so a panel opened by a
23
+ * generic client tool (`ui_open_panel`, which only knows how to set strings)
24
+ * still renders as a graph.
25
+ */
26
+ export function graphPanelPayload(content) {
27
+ return coerceGraphPayload(content.data) ?? coerceGraphPayload(content.content);
28
+ }
@@ -0,0 +1,11 @@
1
+ import { type GraphPanelContentLike } from "./graph-panel-content.js";
2
+ /**
3
+ * Full-size graph for the tool panel, connected to the shared workspace.
4
+ *
5
+ * Default-exported and loaded through `React.lazy` from
6
+ * `registerGraphPanelRenderer`, so neither this nor the force engine is in the
7
+ * bundle until a graph panel is actually opened.
8
+ */
9
+ export default function GraphPanel({ content }: {
10
+ content: GraphPanelContentLike;
11
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,121 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef } from "react";
3
+ import { GraphView } from "../graph-view.js";
4
+ import { hasOwn, primaryLabel, resolveCaption } from "../model.js";
5
+ import { GraphEmptyState } from "../graph-notice.js";
6
+ import { graphWorkspaceStore } from "../graph-workspace.js";
7
+ import { submitComposerText, useAppStore, useToolPanelStore } from "@iloveagents/foundry-web-ui";
8
+ import { graphPanelPayload } from "./graph-panel-content.js";
9
+ import { GraphContextPinButton, syncGraphSelectionContext } from "./graph-context.js";
10
+ /**
11
+ * Full-size graph for the tool panel, connected to the shared workspace.
12
+ *
13
+ * Default-exported and loaded through `React.lazy` from
14
+ * `registerGraphPanelRenderer`, so neither this nor the force engine is in the
15
+ * bundle until a graph panel is actually opened.
16
+ */
17
+ export default function GraphPanel({ content }) {
18
+ const handleRef = useRef(null);
19
+ // Memoised on the panel's own content, so an unrelated re-render does not
20
+ // hand `GraphView` a new payload object and re-run the workspace effect.
21
+ const payload = useMemo(() => graphPanelPayload(content), [content]);
22
+ const isOpen = useToolPanelStore((state) => state.isOpen);
23
+ const currentPage = useAppStore((state) => state.currentPage);
24
+ /**
25
+ * Expand through the conversation, not behind it.
26
+ *
27
+ * The panel cannot query the graph — the Cypher tools live on the server, as
28
+ * they must. So a double-click asks the agent, in the chat, the way the user
29
+ * would: the turn is visible, the agent picks the traversal, and the answer
30
+ * merges into the graph on screen. The expansion becomes part of the
31
+ * conversation rather than a side effect nobody can refer back to.
32
+ */
33
+ /**
34
+ * The payload's caption key for this node's label.
35
+ *
36
+ * Every path that NAMES a node — the expansion prompt, the chat-context
37
+ * chip, the client tools — resolves through the same rule the canvas paints
38
+ * by, or the same node ends up with two names and the agent discusses one
39
+ * the user cannot see.
40
+ */
41
+ const captionKeyFor = useCallback((node) => {
42
+ const legend = payload?.legend;
43
+ const label = primaryLabel(node);
44
+ return legend && hasOwn(legend, label) ? legend[label]?.captionKey : undefined;
45
+ }, [payload]);
46
+ const expandNode = useCallback((node) => {
47
+ submitComposerText(`Expand "${resolveCaption(node, captionKeyFor(node))}" (${primaryLabel(node)}) in the graph — node id ${node.id}.`);
48
+ }, [captionKeyFor]);
49
+ // Publish this panel as THE live graph, so the agent's client tools can read
50
+ // what is on screen and drive it. Registering the handle is what lets
51
+ // `graph_select_node` and `graph_search` reach into a mounted view.
52
+ //
53
+ // Gated on `isOpen`, because closing the panel does not unmount it — the
54
+ // tool panel slides off-screen with `translate-x-full` and stays in the
55
+ // tree. Without this the workspace stayed connected to a graph nobody could
56
+ // see, and `graph_get_view` went on describing it.
57
+ useEffect(() => {
58
+ const handle = handleRef.current;
59
+ if (!handle || !payload || !isOpen)
60
+ return;
61
+ const workspace = graphWorkspaceStore.getState();
62
+ workspace.connect(handle, content.title, payload);
63
+ // Republish what the view is already showing. `disconnect` cleared the
64
+ // workspace when the panel closed, but the view was never unmounted — so
65
+ // reopening it leaves the drawer and the search box exactly as they were
66
+ // while `graph_get_view` reports neither, and the selected node is missing
67
+ // from the chat context. The callbacks cannot help: they fire on CHANGE,
68
+ // and from the view's side nothing changed.
69
+ const view = handle.getViewState();
70
+ workspace.setSelection(view.selection);
71
+ workspace.setQuery(view.query);
72
+ workspace.setHiddenLabels(view.hiddenLabels);
73
+ syncGraphSelectionContext(view.selection, `graph:${content.title}`, view.selection ? captionKeyFor(view.selection) : undefined);
74
+ return () => graphWorkspaceStore.getState().disconnect(handle);
75
+ // `currentPage` is a dependency for the same reason `isOpen` is, and it is
76
+ // easy to miss because the panel does not move: `setCurrentPage` clears
77
+ // every keyed and ephemeral context item on navigation, on the reasonable
78
+ // assumption that a keyed item tracks UI the navigation just unmounted.
79
+ // The tool panel is the exception — it belongs to the shell, so the graph
80
+ // and its open drawer survive a route change while the selected node
81
+ // silently drops out of the conversation. Nothing fires to put it back:
82
+ // the selection did not change. Re-running this republishes it, which is
83
+ // the same job it already does for reopening a closed panel.
84
+ }, [content.title, payload, isOpen, currentPage]);
85
+ /**
86
+ * The selected node leaves the conversation when the graph does — and not
87
+ * before.
88
+ *
89
+ * This used to ride in the cleanup above, which made it fire on every
90
+ * payload change: an in-place merge, the thing this panel is built around,
91
+ * dropped the user's selected node out of the chat context even though it
92
+ * was still on screen and still selected. Visibility is its own lifetime.
93
+ */
94
+ useEffect(() => {
95
+ if (isOpen)
96
+ return;
97
+ syncGraphSelectionContext(null, "");
98
+ }, [isOpen]);
99
+ useEffect(() => () => syncGraphSelectionContext(null, ""), []);
100
+ if (!payload) {
101
+ return _jsx(GraphEmptyState, { message: "This panel does not contain a readable graph." });
102
+ }
103
+ // `absolute inset-0`, not `h-full`.
104
+ //
105
+ // The tool panel's content wrapper is `relative flex-1 overflow-y-auto` — a
106
+ // scroll container. A percentage height inside one resolves against the
107
+ // CONTENT box, so `h-full` lets the canvas grow past the visible area: the
108
+ // graph gets correctly fitted into a canvas taller than the panel, and the
109
+ // bottom half is simply below the fold. Pinning to the positioned ancestor
110
+ // makes the canvas exactly the size of what the user can see, which is also
111
+ // what the ResizeObserver needs to re-fit on drag-resize and fullscreen.
112
+ return (_jsx("div", { className: "absolute inset-0 p-3", children: _jsx(GraphView, { data: payload, variant: "full", height: "100%", handleRef: handleRef, onExpandNode: expandNode,
113
+ // The user's clicks and the agent's reads share one piece of state, so
114
+ // "tell me about this node" needs no restating on either side.
115
+ onSelectionChange: (node) => {
116
+ graphWorkspaceStore.getState().setSelection(node);
117
+ // The selected node joins the conversation automatically, so
118
+ // "tell me about this" needs no restating on either side.
119
+ syncGraphSelectionContext(node, `graph:${content.title}`, node ? captionKeyFor(node) : undefined);
120
+ }, inspectorActions: (node) => (_jsx(GraphContextPinButton, { node: node, captionKey: captionKeyFor(node) })), onQueryChange: (query) => graphWorkspaceStore.getState().setQuery(query), onFilterChange: (labels) => graphWorkspaceStore.getState().setHiddenLabels(labels) }) }));
121
+ }
@@ -0,0 +1,17 @@
1
+ import { type GraphPayload } from "../model.js";
2
+ interface GraphProvenanceProps {
3
+ payload: GraphPayload;
4
+ /** Arguments the agent called the tool with — usually where the query lives. */
5
+ args?: unknown;
6
+ className?: string;
7
+ }
8
+ /**
9
+ * What the card shows when expanded: the query that ran, and the result.
10
+ *
11
+ * The card is provenance, not a viewport — the graph belongs in the panel,
12
+ * where it has room. What someone opens a tool card for is "what did it
13
+ * actually do, and do I believe it": the statement, the shape of what came
14
+ * back, and a way to see the data itself.
15
+ */
16
+ export declare function GraphProvenance({ payload, args, className }: GraphProvenanceProps): import("react/jsx-runtime").JSX.Element;
17
+ export {};