@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.
- package/CHANGELOG.md +28 -0
- package/package.json +3 -3
- package/src/components/__tests__/context-bar.test.ts +112 -0
- package/src/components/assistant-chat.tsx +27 -7
- package/src/components/chat-bubble.tsx +40 -16
- package/src/components/composer-submit-bridge.tsx +19 -0
- package/src/components/context-badges.tsx +25 -13
- package/src/components/context-bar.tsx +38 -2
- package/src/components/global-selection-popover.tsx +165 -34
- package/src/components/markdown-text-citations.test.tsx +108 -0
- package/src/components/markdown-text.tsx +155 -26
- package/src/components/selection-popover.tsx +3 -3
- package/src/components/sidebar.test.tsx +144 -0
- package/src/components/sidebar.tsx +207 -41
- package/src/components/tool-panel.tsx +3 -3
- package/src/components/user-menu.tsx +1 -1
- package/src/index.ts +9 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +42 -2
- package/src/lib/__tests__/app-store.test.ts +42 -0
- package/src/lib/__tests__/selection-context.test.ts +114 -0
- package/src/lib/ag-ui-adapter.ts +44 -2
- package/src/lib/app-store.ts +19 -1
- package/src/lib/composer-submit-store.ts +32 -0
- package/src/lib/nav-config.ts +5 -0
- package/src/lib/selection-context.ts +65 -0
- package/src/ui/dropdown-menu.tsx +5 -1
|
@@ -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: () =>
|
|
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
|
+
|
package/src/lib/ag-ui-adapter.ts
CHANGED
|
@@ -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
|
*
|
package/src/lib/app-store.ts
CHANGED
|
@@ -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
|
|
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
|
+
|
package/src/lib/nav-config.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/dropdown-menu.tsx
CHANGED
|
@@ -36,7 +36,11 @@ export const DropdownMenuItem = forwardRef<
|
|
|
36
36
|
<DropdownMenuPrimitive.Item
|
|
37
37
|
ref={ref}
|
|
38
38
|
className={cn(
|
|
39
|
-
|
|
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,
|