@iloveagents/foundry-web-ui 0.1.0 → 0.1.2

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,14 @@
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: {
20
+ contextItems: [] as Array<Record<string, unknown>>,
21
+ navigation: { contextInstructions: null as string | null },
22
+ },
23
+ consumedMessageIds: [] as string[],
24
+ }));
25
+
18
26
  const runMock = vi.fn();
19
27
  const runnerMock = vi.fn().mockImplementation(() => ({
20
28
  threadId: "thread-1",
@@ -46,8 +54,10 @@ vi.mock("../app-store.ts", () => ({
46
54
  useAppStore: {
47
55
  getState: () => ({
48
56
  markThreadActive: () => {},
49
- getAgentState: () => ({ contextItems: [] }),
50
- consumeContextForMessage: () => {},
57
+ getAgentState: () => appStoreMock.agentState,
58
+ consumeContextForMessage: (messageId: string) => {
59
+ appStoreMock.consumedMessageIds.push(messageId);
60
+ },
51
61
  }),
52
62
  },
53
63
  }));
@@ -66,7 +76,9 @@ import { AGUIAdapterSDK } from "../ag-ui-adapter.ts";
66
76
 
67
77
  function makeRunInput() {
68
78
  return {
69
- messages: [{ id: "u-1", role: "user" as const, content: [{ type: "text" as const, text: "hi" }] }],
79
+ messages: [
80
+ { id: "u-1", role: "user" as const, content: [{ type: "text" as const, text: "hi" }] },
81
+ ],
70
82
  abortSignal: undefined as AbortSignal | undefined,
71
83
  };
72
84
  }
@@ -75,6 +87,11 @@ describe("AGUIAdapterSDK", () => {
75
87
  beforeEach(() => {
76
88
  runMock.mockReset();
77
89
  runnerMock.mockClear();
90
+ appStoreMock.agentState = {
91
+ contextItems: [],
92
+ navigation: { contextInstructions: null },
93
+ };
94
+ appStoreMock.consumedMessageIds = [];
78
95
  });
79
96
 
80
97
  it("yields a terminal complete after a text-only run", async () => {
@@ -174,6 +191,80 @@ describe("AGUIAdapterSDK", () => {
174
191
 
175
192
  expect(out[out.length - 1].status).toEqual({ type: "complete", reason: "stop" });
176
193
  });
194
+
195
+ it("forwards app context items through the native AG-UI context field", async () => {
196
+ appStoreMock.agentState = {
197
+ navigation: { contextInstructions: null },
198
+ contextItems: [
199
+ {
200
+ type: "ref",
201
+ refType: "block",
202
+ refId: "entity-1:block-2",
203
+ preview: "A selected table cell",
204
+ targetPath: "$.blocks[?(@.id=='block-2')].rows[0].cells[1]",
205
+ sourcePage: "/spaces/entity-1#block-block-2",
206
+ },
207
+ ],
208
+ };
209
+ runMock.mockImplementation(async function* (): AsyncGenerator<RunnerEvent> {});
210
+
211
+ const adapter = new AGUIAdapterSDK();
212
+ for await (const _ of adapter.run(makeRunInput())) {
213
+ // Drain the adapter.
214
+ }
215
+
216
+ expect(runMock).toHaveBeenCalledTimes(1);
217
+ expect(runMock.mock.calls[0][0].context).toEqual([
218
+ {
219
+ description:
220
+ "Selected block atom. Use targetPath as the exact edit target when the user asks to modify, rewrite, or transform selected content.",
221
+ value: JSON.stringify(appStoreMock.agentState.contextItems[0]),
222
+ },
223
+ ]);
224
+ expect(appStoreMock.consumedMessageIds).toEqual(["u-1"]);
225
+ });
226
+
227
+ it("describes typed JSON ref targets without losing the stable ref handle", async () => {
228
+ appStoreMock.agentState = {
229
+ navigation: { contextInstructions: null },
230
+ contextItems: [
231
+ {
232
+ type: "ref",
233
+ label: "Page Instructions · Test Page",
234
+ refType: "entity-field",
235
+ refId: "entity-1:instructions.agent_instructions",
236
+ preview: "Current: old\nProposed: new",
237
+ targetPath: "$.instructions.agent_instructions",
238
+ target: {
239
+ kind: "json-field",
240
+ path: "$.instructions.agent_instructions",
241
+ label: "Page Instructions",
242
+ scope: "entity-field",
243
+ containerId: "entity-1",
244
+ containerLabel: "Test Page",
245
+ currentValue: "old",
246
+ proposedValue: "new",
247
+ changeIds: ["change-1"],
248
+ },
249
+ sourcePage: "/spaces/entity-1",
250
+ },
251
+ ],
252
+ };
253
+ runMock.mockImplementation(async function* (): AsyncGenerator<RunnerEvent> {});
254
+
255
+ const adapter = new AGUIAdapterSDK();
256
+ for await (const _ of adapter.run(makeRunInput())) {
257
+ // Drain the adapter.
258
+ }
259
+
260
+ expect(runMock.mock.calls[0][0].context).toEqual([
261
+ {
262
+ description:
263
+ "Selected entity-field Page Instructions. Use targetPath as the exact JSON edit target when the user asks to modify, rewrite, or transform this reference.",
264
+ value: JSON.stringify(appStoreMock.agentState.contextItems[0]),
265
+ },
266
+ ]);
267
+ });
177
268
  });
178
269
 
179
270
  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"));
@@ -297,6 +339,39 @@ describe("app-store", () => {
297
339
  expect(projection.navigation.groupMeta).toEqual({ orgId: "org-1" });
298
340
  expect(projection.navigation.group).toBe("Contracts");
299
341
  });
342
+
343
+ it("compacts reference target values within the documented context limit", () => {
344
+ const longValue = "x".repeat(2_001);
345
+
346
+ store().addContextItem({
347
+ type: "ref",
348
+ label: "Long field",
349
+ sourcePage: "/spaces/entity-1",
350
+ persistence: "ephemeral",
351
+ payload: {
352
+ kind: "ref",
353
+ refType: "entity-field",
354
+ refId: "entity-1",
355
+ target: {
356
+ path: "$.instructions.page",
357
+ label: "Page Instructions",
358
+ currentValue: longValue,
359
+ proposedValue: longValue,
360
+ },
361
+ },
362
+ });
363
+
364
+ const projection = store().getAgentState();
365
+ const target = projection.contextItems[0].target as {
366
+ currentValue: string;
367
+ proposedValue: string;
368
+ };
369
+
370
+ expect(target.currentValue).toHaveLength(2_000);
371
+ expect(target.currentValue.endsWith("...")).toBe(true);
372
+ expect(target.proposedValue).toHaveLength(2_000);
373
+ expect(target.proposedValue.endsWith("...")).toBe(true);
374
+ });
300
375
  });
301
376
 
302
377
  describe("resetAll", () => {
@@ -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,10 @@ import type {
24
24
  TextMessagePart,
25
25
  ToolCallMessagePart,
26
26
  } from "@assistant-ui/react";
27
- import type { Message as AGUIMessage } from "@ag-ui/core";
28
- import {
29
- AGUIRunner,
30
- clientToolRegistry,
31
- streamingStatusStore,
32
- } from "@iloveagents/foundry-agent";
27
+ import type { Context, Message as AGUIMessage } from "@ag-ui/core";
28
+ import { AGUIRunner, clientToolRegistry, streamingStatusStore } from "@iloveagents/foundry-agent";
33
29
  import { tokenFetch } from "@iloveagents/foundry-agent/msal";
34
- import { useAppStore } from "./app-store.ts";
30
+ import { useAppStore, type AgentStateProjection } from "./app-store.ts";
35
31
  import { useDevStore } from "./dev-store.ts";
36
32
 
37
33
  interface ToolCallSnapshot {
@@ -45,10 +41,7 @@ interface ToolCallSnapshot {
45
41
  export class AGUIAdapterSDK implements ChatModelAdapter {
46
42
  private readonly runner: AGUIRunner;
47
43
 
48
- constructor(
49
- url: string = "/api/agent",
50
- options?: { threadId?: string; fetchFn?: typeof fetch },
51
- ) {
44
+ constructor(url: string = "/api/agent", options?: { threadId?: string; fetchFn?: typeof fetch }) {
52
45
  this.runner = new AGUIRunner({
53
46
  url,
54
47
  threadId: options?.threadId,
@@ -71,6 +64,7 @@ export class AGUIAdapterSDK implements ChatModelAdapter {
71
64
  // ephemeral context off the last user message.
72
65
  useAppStore.getState().markThreadActive();
73
66
  const appState = useAppStore.getState().getAgentState();
67
+ const context = buildAGUIContext(appState);
74
68
  const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
75
69
  if (lastUserMsg) {
76
70
  useAppStore.getState().consumeContextForMessage(lastUserMsg.id);
@@ -90,9 +84,7 @@ export class AGUIAdapterSDK implements ChatModelAdapter {
90
84
  * generator returns. assistant-ui leaves the message stuck in the
91
85
  * loading state if the last yield's status is `running`.
92
86
  */
93
- const buildResult = (
94
- statusOverride?: ChatModelRunResult["status"],
95
- ): ChatModelRunResult => {
87
+ const buildResult = (statusOverride?: ChatModelRunResult["status"]): ChatModelRunResult => {
96
88
  const content: (ToolCallMessagePart | TextMessagePart)[] = [];
97
89
 
98
90
  for (const [id, tc] of toolCalls) {
@@ -136,6 +128,7 @@ export class AGUIAdapterSDK implements ChatModelAdapter {
136
128
  for await (const event of this.runner.run({
137
129
  messages: aguiMessages,
138
130
  state: appState as unknown as Record<string, unknown>,
131
+ context,
139
132
  registry: clientToolRegistry.getState(),
140
133
  abortSignal,
141
134
  })) {
@@ -263,6 +256,63 @@ export class AGUIAdapterSDK implements ChatModelAdapter {
263
256
  }
264
257
  }
265
258
 
259
+ function buildAGUIContext(appState: AgentStateProjection): Context[] {
260
+ return appState.contextItems.map((item, index) => ({
261
+ description: describeContextItem(item, index),
262
+ value: stringifyContextItem(item),
263
+ }));
264
+ }
265
+
266
+ function describeContextItem(item: Record<string, unknown>, index: number): string {
267
+ const type = typeof item.type === "string" ? item.type : "context";
268
+ const label = typeof item.label === "string" ? item.label : "";
269
+
270
+ if (type === "ref") {
271
+ const refType = typeof item.refType === "string" ? item.refType : "reference";
272
+ const target = isRecord(item.target) ? item.target : undefined;
273
+ const targetPath =
274
+ typeof item.targetPath === "string"
275
+ ? item.targetPath
276
+ : typeof target?.path === "string"
277
+ ? target.path
278
+ : undefined;
279
+ const targetLabel = typeof target?.label === "string" ? target.label : undefined;
280
+ const targetScope = typeof target?.scope === "string" ? target.scope : undefined;
281
+ if (targetPath) {
282
+ if (!targetLabel && !targetScope) {
283
+ return `Selected ${refType} atom. Use targetPath as the exact edit target when the user asks to modify, rewrite, or transform selected content.`;
284
+ }
285
+ const targetText = targetLabel || targetScope || "JSON atom";
286
+ return `Selected ${refType} ${targetText}. Use targetPath as the exact JSON edit target when the user asks to modify, rewrite, or transform this reference.`;
287
+ }
288
+ return `Selected ${refType} reference. Use refId as the edit target when the user asks to modify, rewrite, or transform selected content.`;
289
+ }
290
+
291
+ if (type === "selection") {
292
+ return "Selected text from the current UI. Use it as the focus when the user asks about, rewrites, or modifies selected content.";
293
+ }
294
+
295
+ if (type === "page") {
296
+ return `Pinned page context${label ? `: ${label}` : ""}.`;
297
+ }
298
+
299
+ return `Context item ${index + 1}${label ? `: ${label}` : ""}.`;
300
+ }
301
+
302
+ function stringifyContextItem(item: Record<string, unknown>): string {
303
+ try {
304
+ return JSON.stringify(item);
305
+ } catch {
306
+ const type = typeof item.type === "string" ? item.type : "context";
307
+ const label = typeof item.label === "string" ? item.label : "";
308
+ return JSON.stringify({ type, label });
309
+ }
310
+ }
311
+
312
+ function isRecord(value: unknown): value is Record<string, unknown> {
313
+ return typeof value === "object" && value !== null && !Array.isArray(value);
314
+ }
315
+
266
316
  /**
267
317
  * Convert assistant-ui's `Message[]` into AG-UI's wire format.
268
318
  *
@@ -33,6 +33,25 @@ export interface ReferencePayload {
33
33
  refId: string;
34
34
  /** Optional display text (~100 chars) */
35
35
  preview?: string;
36
+ /**
37
+ * Optional exact JSON target inside the referenced domain object.
38
+ *
39
+ * This keeps the existing refType/refId contract stable for deep links
40
+ * while allowing feature modules to target one schema-declared field,
41
+ * table cell, JSON atom, or app-defined custom value precisely.
42
+ */
43
+ target?: {
44
+ kind?: string;
45
+ path?: string;
46
+ label?: string;
47
+ scope?: string;
48
+ containerId?: string;
49
+ containerLabel?: string;
50
+ currentValue?: unknown;
51
+ proposedValue?: unknown;
52
+ changeIds?: string[];
53
+ meta?: Record<string, unknown>;
54
+ };
36
55
  /** Domain-specific data the agent can use */
37
56
  meta?: Record<string, unknown>;
38
57
  }
@@ -82,6 +101,8 @@ export interface AgentStateProjection {
82
101
 
83
102
  const MAX_ITEMS = 10;
84
103
  const MAX_LABEL_LENGTH = 500;
104
+ const MAX_CONTEXT_VALUE_LENGTH = 2_000;
105
+ const TRUNCATION_SUFFIX = "...";
85
106
 
86
107
  const DEFAULT_NAV_CONTEXT: NavContext = {
87
108
  group: null,
@@ -93,6 +114,43 @@ const DEFAULT_NAV_CONTEXT: NavContext = {
93
114
  contextInstructions: null,
94
115
  };
95
116
 
117
+ function refDedupKey(payload: ReferencePayload): string {
118
+ const selectionScope =
119
+ typeof payload.meta?.selectionScope === "string" ? payload.meta.selectionScope : "block";
120
+ const targetPath =
121
+ typeof payload.target?.path === "string"
122
+ ? payload.target.path
123
+ : typeof payload.meta?.targetPath === "string"
124
+ ? payload.meta.targetPath
125
+ : undefined;
126
+
127
+ if (selectionScope === "atom" && targetPath) {
128
+ return `${payload.refType}:${payload.refId}:atom:${targetPath}`;
129
+ }
130
+ if (selectionScope === "text") {
131
+ return `${payload.refType}:${payload.refId}:text:${payload.preview ?? ""}`;
132
+ }
133
+ return `${payload.refType}:${payload.refId}:block`;
134
+ }
135
+
136
+ function compactContextValue(value: unknown): unknown {
137
+ if (typeof value === "string" && value.length > MAX_CONTEXT_VALUE_LENGTH) {
138
+ return `${value.slice(0, MAX_CONTEXT_VALUE_LENGTH - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`;
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function compactReferenceTarget(
144
+ target: ReferencePayload["target"],
145
+ ): ReferencePayload["target"] {
146
+ if (!target) return undefined;
147
+ return {
148
+ ...target,
149
+ currentValue: compactContextValue(target.currentValue),
150
+ proposedValue: compactContextValue(target.proposedValue),
151
+ };
152
+ }
153
+
96
154
  interface AppState {
97
155
  // Thread state
98
156
  threadActive: boolean;
@@ -188,7 +246,7 @@ export const useAppStore = create<AppState>((set, get) => ({
188
246
  if (item.type === "ref" && existing.type === "ref") {
189
247
  const a = item.payload as ReferencePayload;
190
248
  const b = existing.payload as ReferencePayload;
191
- return a.refId === b.refId;
249
+ return refDedupKey(a) === refDedupKey(b);
192
250
  }
193
251
  // selection / note: dedup by label + sourcePage
194
252
  return existing.label === item.label && existing.sourcePage === item.sourcePage;
@@ -267,9 +325,18 @@ export const useAppStore = create<AppState>((set, get) => ({
267
325
  case "ref":
268
326
  return {
269
327
  type,
328
+ label: label.slice(0, 200),
270
329
  refType: payload.refType,
271
330
  refId: payload.refId,
272
331
  preview: payload.preview?.slice(0, 100),
332
+ target: compactReferenceTarget(payload.target),
333
+ meta: payload.meta,
334
+ targetPath:
335
+ typeof payload.target?.path === "string"
336
+ ? payload.target.path
337
+ : typeof payload.meta?.targetPath === "string"
338
+ ? payload.meta.targetPath
339
+ : undefined,
273
340
  sourcePage,
274
341
  };
275
342
  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
+ }
@@ -595,8 +595,8 @@ export const FOUNDRY_VIOLET_THEME: ThemeDefinition = {
595
595
  radius: "0.875rem",
596
596
  },
597
597
  branding: {
598
- appName: "LastSpace.ai",
599
- appTitle: "LastSpace.ai",
598
+ appName: "eomi",
599
+ appTitle: "eomi",
600
600
  },
601
601
  };
602
602