@iloveagents/foundry-web-ui 0.1.0 → 0.1.1

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.
@@ -15,6 +15,11 @@
15
15
  import { describe, expect, it, vi, beforeEach } from "vitest";
16
16
  import type { RunnerEvent } from "@iloveagents/foundry-agent";
17
17
 
18
+ const appStoreMock = vi.hoisted(() => ({
19
+ agentState: { contextItems: [] as Array<Record<string, unknown>> },
20
+ consumedMessageIds: [] as string[],
21
+ }));
22
+
18
23
  const runMock = vi.fn();
19
24
  const runnerMock = vi.fn().mockImplementation(() => ({
20
25
  threadId: "thread-1",
@@ -46,8 +51,10 @@ vi.mock("../app-store.ts", () => ({
46
51
  useAppStore: {
47
52
  getState: () => ({
48
53
  markThreadActive: () => {},
49
- getAgentState: () => ({ contextItems: [] }),
50
- consumeContextForMessage: () => {},
54
+ getAgentState: () => appStoreMock.agentState,
55
+ consumeContextForMessage: (messageId: string) => {
56
+ appStoreMock.consumedMessageIds.push(messageId);
57
+ },
51
58
  }),
52
59
  },
53
60
  }));
@@ -75,6 +82,8 @@ describe("AGUIAdapterSDK", () => {
75
82
  beforeEach(() => {
76
83
  runMock.mockReset();
77
84
  runnerMock.mockClear();
85
+ appStoreMock.agentState = { contextItems: [] };
86
+ appStoreMock.consumedMessageIds = [];
78
87
  });
79
88
 
80
89
  it("yields a terminal complete after a text-only run", async () => {
@@ -174,6 +183,37 @@ describe("AGUIAdapterSDK", () => {
174
183
 
175
184
  expect(out[out.length - 1].status).toEqual({ type: "complete", reason: "stop" });
176
185
  });
186
+
187
+ it("forwards app context items through the native AG-UI context field", async () => {
188
+ appStoreMock.agentState = {
189
+ contextItems: [
190
+ {
191
+ type: "ref",
192
+ refType: "block",
193
+ refId: "entity-1:block-2",
194
+ preview: "A selected table cell",
195
+ targetPath: "$.blocks[?(@.id=='block-2')].rows[0].cells[1]",
196
+ sourcePage: "/spaces/entity-1#block-block-2",
197
+ },
198
+ ],
199
+ };
200
+ runMock.mockImplementation(async function* (): AsyncGenerator<RunnerEvent> {});
201
+
202
+ const adapter = new AGUIAdapterSDK();
203
+ for await (const _ of adapter.run(makeRunInput())) {
204
+ // Drain the adapter.
205
+ }
206
+
207
+ expect(runMock).toHaveBeenCalledTimes(1);
208
+ expect(runMock.mock.calls[0][0].context).toEqual([
209
+ {
210
+ description:
211
+ "Selected block atom. Use targetPath as the exact edit target when the user asks to modify, rewrite, or transform selected content.",
212
+ value: JSON.stringify(appStoreMock.agentState.contextItems[0]),
213
+ },
214
+ ]);
215
+ expect(appStoreMock.consumedMessageIds).toEqual(["u-1"]);
216
+ });
177
217
  });
178
218
 
179
219
  interface ChatModelRunResultLike {
@@ -54,6 +54,27 @@ const refItem = (refId = "entity-1:block-1", preview = "Introduction") => ({
54
54
  persistence: "persistent" as const,
55
55
  });
56
56
 
57
+ const scopedRefItem = (
58
+ selectionScope: "block" | "text" | "atom",
59
+ preview = "Introduction",
60
+ targetPath?: string,
61
+ ) => ({
62
+ type: "ref" as const,
63
+ label: preview,
64
+ payload: {
65
+ kind: "ref" as const,
66
+ refType: "block",
67
+ refId: "entity-1:block-1",
68
+ preview,
69
+ meta: {
70
+ selectionScope,
71
+ ...(targetPath ? { targetPath } : {}),
72
+ },
73
+ },
74
+ sourcePage: "/spaces/entity-1#block-block-1",
75
+ persistence: selectionScope === "block" ? ("persistent" as const) : ("ephemeral" as const),
76
+ });
77
+
57
78
  describe("app-store", () => {
58
79
  beforeEach(reset);
59
80
 
@@ -108,6 +129,27 @@ describe("app-store", () => {
108
129
  expect(store().contextItems).toHaveLength(1);
109
130
  });
110
131
 
132
+ it("allows scoped refs for the same block alongside the durable block ref", () => {
133
+ store().addContextItem(scopedRefItem("block", "Whole block"));
134
+ store().addContextItem(scopedRefItem("text", "Specific phrase"));
135
+ store().addContextItem(
136
+ scopedRefItem("atom", "Table cell", "$.blocks[?(@.id=='block-1')].rows[0].cells[0]"),
137
+ );
138
+ expect(store().contextItems).toHaveLength(3);
139
+ });
140
+
141
+ it("deduplicates scoped refs by their actual scope key", () => {
142
+ store().addContextItem(scopedRefItem("text", "Specific phrase"));
143
+ store().addContextItem(scopedRefItem("text", "Specific phrase"));
144
+ store().addContextItem(
145
+ scopedRefItem("atom", "Table cell", "$.blocks[?(@.id=='block-1')].rows[0].cells[0]"),
146
+ );
147
+ store().addContextItem(
148
+ scopedRefItem("atom", "Updated table cell", "$.blocks[?(@.id=='block-1')].rows[0].cells[0]"),
149
+ );
150
+ expect(store().contextItems).toHaveLength(2);
151
+ });
152
+
111
153
  it("allows ref items with different refIds", () => {
112
154
  store().addContextItem(refItem("e1:b1"));
113
155
  store().addContextItem(refItem("e1:b2"));
@@ -0,0 +1,114 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ registerSelectionContextResolver,
4
+ resolveSelectionContext,
5
+ type SelectionContextResolver,
6
+ } from "../selection-context.ts";
7
+
8
+ const unregisters: Array<() => void> = [];
9
+
10
+ function register(resolver: SelectionContextResolver) {
11
+ const unregister = registerSelectionContextResolver(resolver);
12
+ unregisters.push(unregister);
13
+ return unregister;
14
+ }
15
+
16
+ function input(text = "selected text") {
17
+ const node = document.createElement("span");
18
+ const range = document.createRange();
19
+ range.selectNodeContents(node);
20
+ return {
21
+ text,
22
+ anchorNode: node,
23
+ focusNode: node,
24
+ range,
25
+ sourcePage: "Current page",
26
+ };
27
+ }
28
+
29
+ describe("selection-context", () => {
30
+ afterEach(() => {
31
+ for (const unregister of unregisters.splice(0)) unregister();
32
+ document.body.innerHTML = "";
33
+ });
34
+
35
+ it("falls back to a plain text selection context", () => {
36
+ const resolved = resolveSelectionContext(input("hello"));
37
+
38
+ expect(resolved.item).toMatchObject({
39
+ type: "selection",
40
+ label: "hello",
41
+ payload: { kind: "text", text: "hello" },
42
+ persistence: "ephemeral",
43
+ });
44
+ expect(resolved.supportsModify).toBeUndefined();
45
+ });
46
+
47
+ it("lets modules resolve richer selection context and opt into Modify", () => {
48
+ register({
49
+ id: "test-rich-selection",
50
+ resolve: (selection) => ({
51
+ item: {
52
+ type: "ref",
53
+ label: `Field · ${selection.text}`,
54
+ payload: {
55
+ kind: "ref",
56
+ refType: "block",
57
+ refId: "entity:block",
58
+ preview: selection.text,
59
+ meta: { selectionScope: "atom", targetPath: "$.blocks[0].field" },
60
+ },
61
+ sourcePage: "/spaces/entity#block-block",
62
+ persistence: "ephemeral",
63
+ },
64
+ supportsModify: true,
65
+ }),
66
+ });
67
+
68
+ const resolved = resolveSelectionContext(input("cell"));
69
+
70
+ expect(resolved.item).toMatchObject({
71
+ type: "ref",
72
+ label: "Field · cell",
73
+ payload: {
74
+ kind: "ref",
75
+ refType: "block",
76
+ refId: "entity:block",
77
+ meta: { targetPath: "$.blocks[0].field" },
78
+ },
79
+ });
80
+ expect(resolved.supportsModify).toBe(true);
81
+ });
82
+
83
+ it("uses resolver priority before falling through to lower priority resolvers", () => {
84
+ register({
85
+ id: "low",
86
+ priority: 1,
87
+ resolve: (selection) => ({
88
+ item: {
89
+ type: "selection",
90
+ label: `low ${selection.text}`,
91
+ payload: { kind: "text", text: selection.text },
92
+ sourcePage: selection.sourcePage,
93
+ persistence: "ephemeral",
94
+ },
95
+ }),
96
+ });
97
+ register({
98
+ id: "high",
99
+ priority: 100,
100
+ resolve: (selection) => ({
101
+ item: {
102
+ type: "selection",
103
+ label: `high ${selection.text}`,
104
+ payload: { kind: "text", text: selection.text },
105
+ sourcePage: selection.sourcePage,
106
+ persistence: "ephemeral",
107
+ },
108
+ }),
109
+ });
110
+
111
+ expect(resolveSelectionContext(input("x")).item.label).toBe("high x");
112
+ });
113
+ });
114
+
@@ -24,14 +24,14 @@ import type {
24
24
  TextMessagePart,
25
25
  ToolCallMessagePart,
26
26
  } from "@assistant-ui/react";
27
- import type { Message as AGUIMessage } from "@ag-ui/core";
27
+ import type { Context, Message as AGUIMessage } from "@ag-ui/core";
28
28
  import {
29
29
  AGUIRunner,
30
30
  clientToolRegistry,
31
31
  streamingStatusStore,
32
32
  } from "@iloveagents/foundry-agent";
33
33
  import { tokenFetch } from "@iloveagents/foundry-agent/msal";
34
- import { useAppStore } from "./app-store.ts";
34
+ import { useAppStore, type AgentStateProjection } from "./app-store.ts";
35
35
  import { useDevStore } from "./dev-store.ts";
36
36
 
37
37
  interface ToolCallSnapshot {
@@ -71,6 +71,7 @@ export class AGUIAdapterSDK implements ChatModelAdapter {
71
71
  // ephemeral context off the last user message.
72
72
  useAppStore.getState().markThreadActive();
73
73
  const appState = useAppStore.getState().getAgentState();
74
+ const context = buildAGUIContext(appState);
74
75
  const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
75
76
  if (lastUserMsg) {
76
77
  useAppStore.getState().consumeContextForMessage(lastUserMsg.id);
@@ -136,6 +137,7 @@ export class AGUIAdapterSDK implements ChatModelAdapter {
136
137
  for await (const event of this.runner.run({
137
138
  messages: aguiMessages,
138
139
  state: appState as unknown as Record<string, unknown>,
140
+ context,
139
141
  registry: clientToolRegistry.getState(),
140
142
  abortSignal,
141
143
  })) {
@@ -263,6 +265,46 @@ export class AGUIAdapterSDK implements ChatModelAdapter {
263
265
  }
264
266
  }
265
267
 
268
+ function buildAGUIContext(appState: AgentStateProjection): Context[] {
269
+ return appState.contextItems.map((item, index) => ({
270
+ description: describeContextItem(item, index),
271
+ value: stringifyContextItem(item),
272
+ }));
273
+ }
274
+
275
+ function describeContextItem(item: Record<string, unknown>, index: number): string {
276
+ const type = typeof item.type === "string" ? item.type : "context";
277
+ const label = typeof item.label === "string" ? item.label : "";
278
+
279
+ if (type === "ref") {
280
+ const refType = typeof item.refType === "string" ? item.refType : "reference";
281
+ if (typeof item.targetPath === "string") {
282
+ return `Selected ${refType} atom. Use targetPath as the exact edit target when the user asks to modify, rewrite, or transform selected content.`;
283
+ }
284
+ return `Selected ${refType} reference. Use refId as the edit target when the user asks to modify, rewrite, or transform selected content.`;
285
+ }
286
+
287
+ if (type === "selection") {
288
+ return "Selected text from the current UI. Use it as the focus when the user asks about, rewrites, or modifies selected content.";
289
+ }
290
+
291
+ if (type === "page") {
292
+ return `Pinned page context${label ? `: ${label}` : ""}.`;
293
+ }
294
+
295
+ return `Context item ${index + 1}${label ? `: ${label}` : ""}.`;
296
+ }
297
+
298
+ function stringifyContextItem(item: Record<string, unknown>): string {
299
+ try {
300
+ return JSON.stringify(item);
301
+ } catch {
302
+ const type = typeof item.type === "string" ? item.type : "context";
303
+ const label = typeof item.label === "string" ? item.label : "";
304
+ return JSON.stringify({ type, label });
305
+ }
306
+ }
307
+
266
308
  /**
267
309
  * Convert assistant-ui's `Message[]` into AG-UI's wire format.
268
310
  *
@@ -93,6 +93,21 @@ const DEFAULT_NAV_CONTEXT: NavContext = {
93
93
  contextInstructions: null,
94
94
  };
95
95
 
96
+ function refDedupKey(payload: ReferencePayload): string {
97
+ const selectionScope =
98
+ typeof payload.meta?.selectionScope === "string" ? payload.meta.selectionScope : "block";
99
+ const targetPath =
100
+ typeof payload.meta?.targetPath === "string" ? payload.meta.targetPath : undefined;
101
+
102
+ if (selectionScope === "atom" && targetPath) {
103
+ return `${payload.refType}:${payload.refId}:atom:${targetPath}`;
104
+ }
105
+ if (selectionScope === "text") {
106
+ return `${payload.refType}:${payload.refId}:text:${payload.preview ?? ""}`;
107
+ }
108
+ return `${payload.refType}:${payload.refId}:block`;
109
+ }
110
+
96
111
  interface AppState {
97
112
  // Thread state
98
113
  threadActive: boolean;
@@ -188,7 +203,7 @@ export const useAppStore = create<AppState>((set, get) => ({
188
203
  if (item.type === "ref" && existing.type === "ref") {
189
204
  const a = item.payload as ReferencePayload;
190
205
  const b = existing.payload as ReferencePayload;
191
- return a.refId === b.refId;
206
+ return refDedupKey(a) === refDedupKey(b);
192
207
  }
193
208
  // selection / note: dedup by label + sourcePage
194
209
  return existing.label === item.label && existing.sourcePage === item.sourcePage;
@@ -270,6 +285,9 @@ export const useAppStore = create<AppState>((set, get) => ({
270
285
  refType: payload.refType,
271
286
  refId: payload.refId,
272
287
  preview: payload.preview?.slice(0, 100),
288
+ meta: payload.meta,
289
+ targetPath:
290
+ typeof payload.meta?.targetPath === "string" ? payload.meta.targetPath : undefined,
273
291
  sourcePage,
274
292
  };
275
293
  default:
@@ -0,0 +1,32 @@
1
+ import { create } from "zustand";
2
+
3
+ interface PendingComposerSubmit {
4
+ id: number;
5
+ text: string;
6
+ }
7
+
8
+ interface ComposerSubmitState {
9
+ pending: PendingComposerSubmit | null;
10
+ submit: (text: string) => void;
11
+ consume: (id: number) => void;
12
+ }
13
+
14
+ let nextSubmitId = 1;
15
+
16
+ export const useComposerSubmitStore = create<ComposerSubmitState>((set) => ({
17
+ pending: null,
18
+ submit: (text) => {
19
+ const trimmed = text.trim();
20
+ if (!trimmed) return;
21
+ set({ pending: { id: nextSubmitId++, text: trimmed } });
22
+ },
23
+ consume: (id) =>
24
+ set((state) => ({
25
+ pending: state.pending?.id === id ? null : state.pending,
26
+ })),
27
+ }));
28
+
29
+ export function submitComposerText(text: string): void {
30
+ useComposerSubmitStore.getState().submit(text);
31
+ }
32
+
@@ -55,6 +55,11 @@ export interface NavItem {
55
55
  dimmed?: boolean;
56
56
  /** Small text badge after the label (e.g., "managed", "locked"). */
57
57
  badge?: string;
58
+ /** Small status dot after the label for quiet state hints such as pending review impact. */
59
+ statusDot?: {
60
+ tone?: "neutral" | "info" | "low-impact" | "medium-impact" | "high-impact" | "pending";
61
+ label?: string;
62
+ };
58
63
  /** Page description sent to the agent for context */
59
64
  description?: string;
60
65
  /** Arbitrary metadata sent to the agent (e.g., workspace IDs, entity types) */
@@ -0,0 +1,65 @@
1
+ import type { ContextItem } from "./app-store.ts";
2
+
3
+ export type SelectionContextItem = Omit<ContextItem, "id" | "createdAt">;
4
+
5
+ export interface SelectionContextInput {
6
+ text: string;
7
+ anchorNode: Node;
8
+ focusNode: Node | null;
9
+ range: Range;
10
+ sourcePage: string;
11
+ }
12
+
13
+ export interface SelectionContextResult {
14
+ item: SelectionContextItem;
15
+ supportsModify?: boolean;
16
+ formatModifyPrompt?: (prompt: string) => string;
17
+ }
18
+
19
+ export interface SelectionContextResolver {
20
+ id: string;
21
+ priority?: number;
22
+ resolve: (input: SelectionContextInput) => SelectionContextResult | null;
23
+ }
24
+
25
+ const resolvers = new Map<string, SelectionContextResolver>();
26
+
27
+ function defaultSelectionContext(input: SelectionContextInput): SelectionContextResult {
28
+ return {
29
+ item: {
30
+ type: "selection",
31
+ label: input.text,
32
+ payload: { kind: "text", text: input.text },
33
+ sourcePage: input.sourcePage,
34
+ persistence: "ephemeral",
35
+ },
36
+ };
37
+ }
38
+
39
+ export function registerSelectionContextResolver(
40
+ resolver: SelectionContextResolver,
41
+ ): () => void {
42
+ resolvers.set(resolver.id, resolver);
43
+ return () => {
44
+ if (resolvers.get(resolver.id) === resolver) {
45
+ resolvers.delete(resolver.id);
46
+ }
47
+ };
48
+ }
49
+
50
+ export function resolveSelectionContext(input: SelectionContextInput): SelectionContextResult {
51
+ const sortedResolvers = [...resolvers.values()].sort(
52
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0),
53
+ );
54
+
55
+ for (const resolver of sortedResolvers) {
56
+ try {
57
+ const resolved = resolver.resolve(input);
58
+ if (resolved) return resolved;
59
+ } catch {
60
+ // A module resolver must never break the generic selection flow.
61
+ }
62
+ }
63
+
64
+ return defaultSelectionContext(input);
65
+ }
@@ -36,7 +36,11 @@ export const DropdownMenuItem = forwardRef<
36
36
  <DropdownMenuPrimitive.Item
37
37
  ref={ref}
38
38
  className={cn(
39
- "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
39
+ // ``gap-2`` is the same icon-to-text spacing the framework's
40
+ // Button primitive uses, so a ``<lucide-icon /> Label`` pattern
41
+ // inside any menu item renders consistently everywhere without
42
+ // the consumer needing to remember the override.
43
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none",
40
44
  "transition-colors focus:bg-accent focus:text-accent-foreground",
41
45
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
42
46
  className,