@agent-native/toolkit 0.21.3 → 0.22.0-nightly-20260926081002
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/agent-native.eject.json +11 -0
- package/dist/agentkit.d.ts +5 -1
- package/dist/agentkit.d.ts.map +1 -1
- package/dist/agentkit.js +2 -0
- package/dist/agentkit.js.map +1 -1
- package/dist/app-shell/PromptHome.d.ts +26 -0
- package/dist/app-shell/PromptHome.d.ts.map +1 -0
- package/dist/app-shell/PromptHome.js +19 -0
- package/dist/app-shell/PromptHome.js.map +1 -0
- package/dist/app-shell/PromptHome.spec.d.ts +2 -0
- package/dist/app-shell/PromptHome.spec.d.ts.map +1 -0
- package/dist/app-shell/PromptHome.spec.js +206 -0
- package/dist/app-shell/PromptHome.spec.js.map +1 -0
- package/dist/app-shell/TemplateLibraryGrid.d.ts +48 -0
- package/dist/app-shell/TemplateLibraryGrid.d.ts.map +1 -0
- package/dist/app-shell/TemplateLibraryGrid.js +20 -0
- package/dist/app-shell/TemplateLibraryGrid.js.map +1 -0
- package/dist/app-shell/TemplatePreviewDialog.d.ts +41 -0
- package/dist/app-shell/TemplatePreviewDialog.d.ts.map +1 -0
- package/dist/app-shell/TemplatePreviewDialog.js +50 -0
- package/dist/app-shell/TemplatePreviewDialog.js.map +1 -0
- package/dist/app-shell/TemplatePreviewDialog.spec.d.ts +2 -0
- package/dist/app-shell/TemplatePreviewDialog.spec.d.ts.map +1 -0
- package/dist/app-shell/TemplatePreviewDialog.spec.js +258 -0
- package/dist/app-shell/TemplatePreviewDialog.spec.js.map +1 -0
- package/dist/app-shell/index.d.ts +3 -0
- package/dist/app-shell/index.d.ts.map +1 -1
- package/dist/app-shell/index.js +3 -0
- package/dist/app-shell/index.js.map +1 -1
- package/dist/app-shell/prompt-home.css +159 -0
- package/dist/composer/ComposerContextMenu.d.ts +48 -0
- package/dist/composer/ComposerContextMenu.d.ts.map +1 -0
- package/dist/composer/ComposerContextMenu.js +295 -0
- package/dist/composer/ComposerContextMenu.js.map +1 -0
- package/dist/composer/ComposerContextMenu.spec.d.ts +2 -0
- package/dist/composer/ComposerContextMenu.spec.d.ts.map +1 -0
- package/dist/composer/ComposerContextMenu.spec.js +270 -0
- package/dist/composer/ComposerContextMenu.spec.js.map +1 -0
- package/dist/composer/ComposerContextMenuSearch.d.ts +11 -0
- package/dist/composer/ComposerContextMenuSearch.d.ts.map +1 -0
- package/dist/composer/ComposerContextMenuSearch.js +42 -0
- package/dist/composer/ComposerContextMenuSearch.js.map +1 -0
- package/dist/composer/ComposerContextPicker.d.ts +7 -0
- package/dist/composer/ComposerContextPicker.d.ts.map +1 -0
- package/dist/composer/ComposerContextPicker.js +71 -0
- package/dist/composer/ComposerContextPicker.js.map +1 -0
- package/dist/composer/ComposerContextPicker.spec.d.ts +2 -0
- package/dist/composer/ComposerContextPicker.spec.d.ts.map +1 -0
- package/dist/composer/ComposerContextPicker.spec.js +454 -0
- package/dist/composer/ComposerContextPicker.spec.js.map +1 -0
- package/dist/composer/ComposerContextPickerDialog.d.ts +8 -0
- package/dist/composer/ComposerContextPickerDialog.d.ts.map +1 -0
- package/dist/composer/ComposerContextPickerDialog.js +101 -0
- package/dist/composer/ComposerContextPickerDialog.js.map +1 -0
- package/dist/composer/ComposerContextPickerDialog.spec.d.ts +2 -0
- package/dist/composer/ComposerContextPickerDialog.spec.d.ts.map +1 -0
- package/dist/composer/ComposerContextPickerDialog.spec.js +329 -0
- package/dist/composer/ComposerContextPickerDialog.spec.js.map +1 -0
- package/dist/composer/ComposerContextSearchInput.d.ts +7 -0
- package/dist/composer/ComposerContextSearchInput.d.ts.map +1 -0
- package/dist/composer/ComposerContextSearchInput.js +18 -0
- package/dist/composer/ComposerContextSearchInput.js.map +1 -0
- package/dist/composer/PromptComposer.context.spec.d.ts +2 -0
- package/dist/composer/PromptComposer.context.spec.d.ts.map +1 -0
- package/dist/composer/PromptComposer.context.spec.js +383 -0
- package/dist/composer/PromptComposer.context.spec.js.map +1 -0
- package/dist/composer/PromptComposer.d.ts +19 -5
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +30 -25
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/composer/PromptComposer.spec.js +24 -1
- package/dist/composer/PromptComposer.spec.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +13 -2
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +89 -32
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/context-items.d.ts +12 -0
- package/dist/composer/context-items.d.ts.map +1 -0
- package/dist/composer/context-items.js +46 -0
- package/dist/composer/context-items.js.map +1 -0
- package/dist/composer/context-items.spec.d.ts +2 -0
- package/dist/composer/context-items.spec.d.ts.map +1 -0
- package/dist/composer/context-items.spec.js +43 -0
- package/dist/composer/context-items.spec.js.map +1 -0
- package/dist/composer/index.d.ts +4 -1
- package/dist/composer/index.d.ts.map +1 -1
- package/dist/composer/index.js +3 -1
- package/dist/composer/index.js.map +1 -1
- package/dist/composer/runtime-adapters.d.ts +6 -1
- package/dist/composer/runtime-adapters.d.ts.map +1 -1
- package/dist/composer/runtime-adapters.js.map +1 -1
- package/dist/composer/useComposerContextPicker.d.ts +111 -0
- package/dist/composer/useComposerContextPicker.d.ts.map +1 -0
- package/dist/composer/useComposerContextPicker.js +299 -0
- package/dist/composer/useComposerContextPicker.js.map +1 -0
- package/dist/design-system/default-adapter.d.ts.map +1 -1
- package/dist/design-system/default-adapter.js +13 -7
- package/dist/design-system/default-adapter.js.map +1 -1
- package/dist/design-system/default-adapter.spec.js +13 -0
- package/dist/design-system/default-adapter.spec.js.map +1 -1
- package/dist/design-system/types.d.ts +4 -1
- package/dist/design-system/types.d.ts.map +1 -1
- package/dist/styles.css +1 -0
- package/dist/ui/command.d.ts +8 -2
- package/dist/ui/command.d.ts.map +1 -1
- package/dist/ui/command.js +9 -2
- package/dist/ui/command.js.map +1 -1
- package/dist/ui/command.spec.js +20 -1
- package/dist/ui/command.spec.js.map +1 -1
- package/package.json +2 -1
- package/src/agentkit.ts +26 -0
- package/src/app-shell/PromptHome.spec.tsx +404 -0
- package/src/app-shell/PromptHome.tsx +111 -0
- package/src/app-shell/TemplateLibraryGrid.tsx +212 -0
- package/src/app-shell/TemplatePreviewDialog.spec.tsx +473 -0
- package/src/app-shell/TemplatePreviewDialog.tsx +224 -0
- package/src/app-shell/index.ts +3 -0
- package/src/app-shell/prompt-home.css +159 -0
- package/src/composer/ComposerContextMenu.spec.tsx +343 -0
- package/src/composer/ComposerContextMenu.tsx +610 -0
- package/src/composer/ComposerContextMenuSearch.tsx +69 -0
- package/src/composer/ComposerContextPicker.spec.tsx +520 -0
- package/src/composer/ComposerContextPicker.tsx +280 -0
- package/src/composer/ComposerContextPickerDialog.spec.tsx +405 -0
- package/src/composer/ComposerContextPickerDialog.tsx +274 -0
- package/src/composer/ComposerContextSearchInput.tsx +48 -0
- package/src/composer/PromptComposer.context.spec.tsx +577 -0
- package/src/composer/PromptComposer.spec.ts +24 -1
- package/src/composer/PromptComposer.tsx +60 -26
- package/src/composer/TiptapComposer.tsx +198 -48
- package/src/composer/context-items.spec.ts +68 -0
- package/src/composer/context-items.ts +72 -0
- package/src/composer/index.ts +27 -0
- package/src/composer/runtime-adapters.tsx +6 -1
- package/src/composer/useComposerContextPicker.ts +441 -0
- package/src/design-system/default-adapter.spec.tsx +23 -0
- package/src/design-system/default-adapter.tsx +52 -23
- package/src/design-system/types.ts +4 -1
- package/src/styles.css +1 -0
- package/src/ui/command.spec.tsx +33 -1
- package/src/ui/command.tsx +52 -9
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { IconArrowLeft, IconCheck } from "@tabler/icons-react";
|
|
2
|
+
|
|
3
|
+
import { Button } from "../ui/button.js";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenuGroup,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
} from "../ui/dropdown-menu.js";
|
|
9
|
+
import { Skeleton } from "../ui/skeleton.js";
|
|
10
|
+
import { ComposerContextMenuSearch } from "./ComposerContextMenuSearch.js";
|
|
11
|
+
import {
|
|
12
|
+
useComposerContextPicker,
|
|
13
|
+
type ComposerContextPickerConfig,
|
|
14
|
+
} from "./useComposerContextPicker.js";
|
|
15
|
+
export type {
|
|
16
|
+
ComposerContextPickerConfig,
|
|
17
|
+
ComposerContextPickerItem,
|
|
18
|
+
ComposerContextPickerRequest,
|
|
19
|
+
ComposerContextPickerResult,
|
|
20
|
+
ComposerContextPickerSelection,
|
|
21
|
+
ComposerContextPickerFooterAction,
|
|
22
|
+
} from "./useComposerContextPicker.js";
|
|
23
|
+
|
|
24
|
+
export function ComposerContextPicker({
|
|
25
|
+
config,
|
|
26
|
+
onClose,
|
|
27
|
+
}: {
|
|
28
|
+
config: ComposerContextPickerConfig;
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const {
|
|
32
|
+
t,
|
|
33
|
+
stage,
|
|
34
|
+
link,
|
|
35
|
+
setLink,
|
|
36
|
+
location,
|
|
37
|
+
cursors,
|
|
38
|
+
move,
|
|
39
|
+
back,
|
|
40
|
+
select,
|
|
41
|
+
submitLink,
|
|
42
|
+
loading,
|
|
43
|
+
error,
|
|
44
|
+
items,
|
|
45
|
+
hasMore,
|
|
46
|
+
retry,
|
|
47
|
+
selecting,
|
|
48
|
+
actionError,
|
|
49
|
+
setActionError,
|
|
50
|
+
result,
|
|
51
|
+
} = useComposerContextPicker({ config, onClose });
|
|
52
|
+
const check = (selected: boolean) => (
|
|
53
|
+
<span className="ms-auto size-4 shrink-0" aria-hidden="true">
|
|
54
|
+
{selected ? <IconCheck className="size-4" /> : null}
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const footer = config.footerAction;
|
|
59
|
+
const footerContent = footer ? (
|
|
60
|
+
<>
|
|
61
|
+
{footer.icon}
|
|
62
|
+
{footer.label}
|
|
63
|
+
</>
|
|
64
|
+
) : null;
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<ComposerContextMenuSearch
|
|
68
|
+
leading={
|
|
69
|
+
stage === "results" && config.link ? (
|
|
70
|
+
<Button
|
|
71
|
+
type="button"
|
|
72
|
+
variant="ghost"
|
|
73
|
+
size="icon"
|
|
74
|
+
aria-label={t("agentChat.composer.contextBack", {
|
|
75
|
+
defaultValue: "Back",
|
|
76
|
+
})}
|
|
77
|
+
onClick={back}
|
|
78
|
+
>
|
|
79
|
+
<IconArrowLeft size={16} />
|
|
80
|
+
</Button>
|
|
81
|
+
) : undefined
|
|
82
|
+
}
|
|
83
|
+
placeholder={
|
|
84
|
+
stage === "link" ? config.link!.placeholder : config.searchPlaceholder
|
|
85
|
+
}
|
|
86
|
+
invalid={stage === "link" && Boolean(actionError)}
|
|
87
|
+
value={stage === "link" ? link : location.search}
|
|
88
|
+
onValueChange={(value) => {
|
|
89
|
+
if (stage === "link") {
|
|
90
|
+
setLink(value);
|
|
91
|
+
setActionError(undefined);
|
|
92
|
+
} else {
|
|
93
|
+
cursors.current = [undefined];
|
|
94
|
+
move({ search: value, page: 1, url: location.url });
|
|
95
|
+
}
|
|
96
|
+
}}
|
|
97
|
+
onSubmit={stage === "link" ? submitLink : undefined}
|
|
98
|
+
/>
|
|
99
|
+
<DropdownMenuGroup
|
|
100
|
+
className="max-h-64 overflow-y-auto"
|
|
101
|
+
aria-busy={stage === "results" && (loading || selecting)}
|
|
102
|
+
>
|
|
103
|
+
{stage === "link" ? (
|
|
104
|
+
<>
|
|
105
|
+
{actionError && (
|
|
106
|
+
<>
|
|
107
|
+
<div role="alert" className="p-3 text-sm text-destructive">
|
|
108
|
+
{actionError.message}
|
|
109
|
+
</div>
|
|
110
|
+
{actionError.retry && (
|
|
111
|
+
<DropdownMenuItem
|
|
112
|
+
disabled={selecting}
|
|
113
|
+
onSelect={(event) => {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
actionError.retry?.();
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{t("agentChat.common.retry", { defaultValue: "Retry" })}
|
|
119
|
+
</DropdownMenuItem>
|
|
120
|
+
)}
|
|
121
|
+
</>
|
|
122
|
+
)}
|
|
123
|
+
<DropdownMenuItem
|
|
124
|
+
onSelect={(event) => {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
submitLink();
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{config.link!.submitLabel}
|
|
130
|
+
</DropdownMenuItem>
|
|
131
|
+
</>
|
|
132
|
+
) : (
|
|
133
|
+
<>
|
|
134
|
+
{error ? (
|
|
135
|
+
<>
|
|
136
|
+
<div role="alert" className="p-3 text-sm text-destructive">
|
|
137
|
+
{error}
|
|
138
|
+
</div>
|
|
139
|
+
{retry && (
|
|
140
|
+
<DropdownMenuItem
|
|
141
|
+
disabled={loading || selecting}
|
|
142
|
+
onSelect={(event) => {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
retry();
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{t("agentChat.common.retry", { defaultValue: "Retry" })}
|
|
148
|
+
</DropdownMenuItem>
|
|
149
|
+
)}
|
|
150
|
+
</>
|
|
151
|
+
) : loading ? (
|
|
152
|
+
<div
|
|
153
|
+
role="status"
|
|
154
|
+
aria-label={t("agentChat.composer.contextPending", {
|
|
155
|
+
defaultValue: "Loading context…",
|
|
156
|
+
})}
|
|
157
|
+
className="grid gap-1 p-1"
|
|
158
|
+
>
|
|
159
|
+
<Skeleton className="h-8 w-full" />
|
|
160
|
+
<Skeleton className="h-8 w-full" />
|
|
161
|
+
<Skeleton className="h-8 w-full" />
|
|
162
|
+
</div>
|
|
163
|
+
) : (
|
|
164
|
+
<>
|
|
165
|
+
{!items.length && (
|
|
166
|
+
<div
|
|
167
|
+
role="status"
|
|
168
|
+
className="p-3 text-xs text-muted-foreground"
|
|
169
|
+
>
|
|
170
|
+
{config.emptyMessage ??
|
|
171
|
+
t("agentChat.composer.noContextResults", {
|
|
172
|
+
defaultValue: "No matching context.",
|
|
173
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
{config.clearSelection && (
|
|
177
|
+
<DropdownMenuItem
|
|
178
|
+
role="menuitemcheckbox"
|
|
179
|
+
aria-checked={!config.selectedIds?.length}
|
|
180
|
+
disabled={selecting}
|
|
181
|
+
onSelect={(event) => {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
void select();
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{config.clearSelection.label}
|
|
187
|
+
{check(!config.selectedIds?.length)}
|
|
188
|
+
</DropdownMenuItem>
|
|
189
|
+
)}
|
|
190
|
+
{items.map((item) => (
|
|
191
|
+
<DropdownMenuItem
|
|
192
|
+
key={item.id}
|
|
193
|
+
role="menuitemcheckbox"
|
|
194
|
+
aria-checked={
|
|
195
|
+
config.selectedIds?.includes(item.id) === true
|
|
196
|
+
}
|
|
197
|
+
disabled={item.disabled || selecting}
|
|
198
|
+
onSelect={(event) => {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
void select(item);
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<span className="min-w-0 flex-1 truncate">
|
|
204
|
+
{item.title}
|
|
205
|
+
</span>
|
|
206
|
+
{check(config.selectedIds?.includes(item.id) === true)}
|
|
207
|
+
</DropdownMenuItem>
|
|
208
|
+
))}
|
|
209
|
+
</>
|
|
210
|
+
)}
|
|
211
|
+
{(location.page > 1 || hasMore) && (
|
|
212
|
+
<>
|
|
213
|
+
{location.page > 1 && (
|
|
214
|
+
<DropdownMenuItem
|
|
215
|
+
disabled={loading || selecting}
|
|
216
|
+
onSelect={(event) => {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
move({
|
|
219
|
+
...location,
|
|
220
|
+
page: location.page - 1,
|
|
221
|
+
cursor: cursors.current[location.page - 2],
|
|
222
|
+
});
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
{t("agentChat.composer.contextPrevious", {
|
|
226
|
+
defaultValue: "Previous",
|
|
227
|
+
})}
|
|
228
|
+
</DropdownMenuItem>
|
|
229
|
+
)}
|
|
230
|
+
{hasMore && (
|
|
231
|
+
<DropdownMenuItem
|
|
232
|
+
disabled={loading || selecting || Boolean(error)}
|
|
233
|
+
onSelect={(event) => {
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
cursors.current = [
|
|
236
|
+
...cursors.current.slice(0, location.page),
|
|
237
|
+
result?.nextCursor,
|
|
238
|
+
];
|
|
239
|
+
move({
|
|
240
|
+
...location,
|
|
241
|
+
page: location.page + 1,
|
|
242
|
+
cursor: result?.nextCursor,
|
|
243
|
+
});
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{t("agentChat.composer.contextNext", {
|
|
247
|
+
defaultValue: "Next",
|
|
248
|
+
})}
|
|
249
|
+
</DropdownMenuItem>
|
|
250
|
+
)}
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</DropdownMenuGroup>
|
|
256
|
+
{footer && (
|
|
257
|
+
<>
|
|
258
|
+
<DropdownMenuSeparator />
|
|
259
|
+
<DropdownMenuGroup>
|
|
260
|
+
{footer.renderLink && !footer.disabled && !selecting ? (
|
|
261
|
+
<DropdownMenuItem asChild onSelect={onClose}>
|
|
262
|
+
{footer.renderLink(footerContent)}
|
|
263
|
+
</DropdownMenuItem>
|
|
264
|
+
) : (
|
|
265
|
+
<DropdownMenuItem
|
|
266
|
+
disabled={footer.disabled || selecting}
|
|
267
|
+
onSelect={(event) => {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
void select("footer");
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{footerContent}
|
|
273
|
+
</DropdownMenuItem>
|
|
274
|
+
)}
|
|
275
|
+
</DropdownMenuGroup>
|
|
276
|
+
</>
|
|
277
|
+
)}
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { TooltipProvider } from "../ui/tooltip.js";
|
|
8
|
+
import {
|
|
9
|
+
ComposerContextMenu,
|
|
10
|
+
type ComposerContextPickerConfig,
|
|
11
|
+
} from "./ComposerContextMenu.js";
|
|
12
|
+
|
|
13
|
+
let container: HTMLDivElement;
|
|
14
|
+
let root: ReturnType<typeof createRoot>;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
17
|
+
vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(
|
|
18
|
+
() => {},
|
|
19
|
+
);
|
|
20
|
+
container = document.createElement("div");
|
|
21
|
+
document.body.append(container);
|
|
22
|
+
root = createRoot(container);
|
|
23
|
+
});
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await act(async () => root.unmount());
|
|
26
|
+
container.remove();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
});
|
|
30
|
+
const frames = [
|
|
31
|
+
{ id: "file-a:1", title: "First frame", url: "https://example.com/a" },
|
|
32
|
+
{ id: "file-b:1", title: "Second frame", url: "https://example.com/b" },
|
|
33
|
+
];
|
|
34
|
+
function multiple() {
|
|
35
|
+
return {
|
|
36
|
+
presentation: {
|
|
37
|
+
type: "dialog" as const,
|
|
38
|
+
mode: "multiple" as const,
|
|
39
|
+
onAttach: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
},
|
|
41
|
+
searchPlaceholder: "Search frames",
|
|
42
|
+
scopeKey: "account-a",
|
|
43
|
+
refreshKey: 0,
|
|
44
|
+
link: {
|
|
45
|
+
label: "Source URL",
|
|
46
|
+
placeholder: "https://example.com/file",
|
|
47
|
+
submitLabel: "Unused legacy label",
|
|
48
|
+
validate: (url: string) =>
|
|
49
|
+
url.includes("/supported/") ? undefined : "Use a supported source URL",
|
|
50
|
+
},
|
|
51
|
+
load: vi.fn().mockResolvedValue({ items: frames, hasMore: false }),
|
|
52
|
+
} satisfies ComposerContextPickerConfig;
|
|
53
|
+
}
|
|
54
|
+
async function render(
|
|
55
|
+
config: ComposerContextPickerConfig,
|
|
56
|
+
onParentSubmit?: () => void,
|
|
57
|
+
) {
|
|
58
|
+
await act(async () =>
|
|
59
|
+
root.render(
|
|
60
|
+
<TooltipProvider>
|
|
61
|
+
<form
|
|
62
|
+
onSubmit={(event) => {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
onParentSubmit?.();
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<input aria-label="Prompt draft" defaultValue="Keep my draft" />
|
|
68
|
+
<ComposerContextMenu
|
|
69
|
+
items={[
|
|
70
|
+
{
|
|
71
|
+
id: "category",
|
|
72
|
+
label: "Design",
|
|
73
|
+
children: [
|
|
74
|
+
{ id: "link", label: "Attach source", picker: config },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
]}
|
|
78
|
+
/>
|
|
79
|
+
</form>
|
|
80
|
+
</TooltipProvider>,
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
async function tick() {
|
|
85
|
+
await act(async () => {
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function row(text: string) {
|
|
90
|
+
const target = Array.from(
|
|
91
|
+
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
92
|
+
).find((element) => element.textContent === text);
|
|
93
|
+
expect(target, text).toBeDefined();
|
|
94
|
+
return target!;
|
|
95
|
+
}
|
|
96
|
+
const dialog = () => document.querySelector<HTMLElement>('[role="dialog"]');
|
|
97
|
+
const trigger = () =>
|
|
98
|
+
container.querySelector<HTMLButtonElement>(
|
|
99
|
+
'button[aria-label="Add context"]',
|
|
100
|
+
)!;
|
|
101
|
+
function button(text: string) {
|
|
102
|
+
const target = Array.from(
|
|
103
|
+
dialog()!.querySelectorAll<HTMLButtonElement>("button"),
|
|
104
|
+
).find((element) => element.textContent === text);
|
|
105
|
+
expect(target, text).toBeDefined();
|
|
106
|
+
return target!;
|
|
107
|
+
}
|
|
108
|
+
async function open() {
|
|
109
|
+
await act(async () =>
|
|
110
|
+
trigger().dispatchEvent(
|
|
111
|
+
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
await act(async () => row("Add context").click());
|
|
115
|
+
await act(async () => row("Design").click());
|
|
116
|
+
expect(row("Attach source").hasAttribute("aria-haspopup")).toBe(false);
|
|
117
|
+
await act(async () => row("Attach source").click());
|
|
118
|
+
await tick();
|
|
119
|
+
expect(dialog()).not.toBeNull();
|
|
120
|
+
}
|
|
121
|
+
async function type(value: string, selector = 'input[type="url"]') {
|
|
122
|
+
const input = dialog()!.querySelector<HTMLInputElement>(selector)!;
|
|
123
|
+
await act(async () => {
|
|
124
|
+
Object.getOwnPropertyDescriptor(
|
|
125
|
+
HTMLInputElement.prototype,
|
|
126
|
+
"value",
|
|
127
|
+
)!.set!.call(input, value);
|
|
128
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async function click(text: string) {
|
|
132
|
+
await act(async () => button(text).click());
|
|
133
|
+
await tick();
|
|
134
|
+
}
|
|
135
|
+
async function choose(title: string) {
|
|
136
|
+
const label = Array.from(
|
|
137
|
+
dialog()!.querySelectorAll<HTMLLabelElement>("label"),
|
|
138
|
+
).find((element) => element.textContent === title)!;
|
|
139
|
+
const checkbox = document.getElementById(label.htmlFor)!;
|
|
140
|
+
await act(async () => checkbox.click());
|
|
141
|
+
return checkbox;
|
|
142
|
+
}
|
|
143
|
+
async function browse() {
|
|
144
|
+
await type("https://example.com/supported/file");
|
|
145
|
+
await click("Continue");
|
|
146
|
+
}
|
|
147
|
+
function deferred<T>() {
|
|
148
|
+
let resolve!: (value: T) => void;
|
|
149
|
+
let reject!: (cause: Error) => void;
|
|
150
|
+
const promise = new Promise<T>((yes, no) => {
|
|
151
|
+
resolve = yes;
|
|
152
|
+
reject = no;
|
|
153
|
+
});
|
|
154
|
+
return { promise, resolve, reject };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe("shared context URL dialog", () => {
|
|
158
|
+
it("isolates Continue and Attach form submissions from the portaled parent composer", async () => {
|
|
159
|
+
const config = multiple();
|
|
160
|
+
const parent = vi.fn();
|
|
161
|
+
config.presentation.onAttach.mockResolvedValue(false);
|
|
162
|
+
await render(config, parent);
|
|
163
|
+
await open();
|
|
164
|
+
await type("https://example.com/supported/file");
|
|
165
|
+
await act(async () => dialog()!.querySelector("form")!.requestSubmit());
|
|
166
|
+
expect(config.load).toHaveBeenCalledOnce();
|
|
167
|
+
expect(parent).not.toHaveBeenCalled();
|
|
168
|
+
await choose("First frame");
|
|
169
|
+
await act(async () => dialog()!.querySelector("form")!.requestSubmit());
|
|
170
|
+
expect(config.presentation.onAttach).toHaveBeenCalledOnce();
|
|
171
|
+
expect(parent).not.toHaveBeenCalled();
|
|
172
|
+
expect(
|
|
173
|
+
container.querySelector<HTMLInputElement>('[aria-label="Prompt draft"]')!
|
|
174
|
+
.value,
|
|
175
|
+
).toBe("Keep my draft");
|
|
176
|
+
});
|
|
177
|
+
it("opens only from an ordinary menu action, focuses URL after menu close and restores plus on cancel", async () => {
|
|
178
|
+
const config = multiple();
|
|
179
|
+
await render(config);
|
|
180
|
+
expect(dialog()).toBeNull();
|
|
181
|
+
await open();
|
|
182
|
+
expect(document.querySelector('[role="menu"]')).toBeNull();
|
|
183
|
+
expect(dialog()!.textContent).toContain("Attach source");
|
|
184
|
+
expect(document.activeElement).toBe(
|
|
185
|
+
dialog()!.querySelector('input[type="url"]'),
|
|
186
|
+
);
|
|
187
|
+
expect(dialog()!.querySelectorAll("form")).toHaveLength(1);
|
|
188
|
+
expect(config.load).not.toHaveBeenCalled();
|
|
189
|
+
await click("Cancel");
|
|
190
|
+
expect(dialog()).toBeNull();
|
|
191
|
+
expect(document.activeElement).toBe(trigger());
|
|
192
|
+
expect(
|
|
193
|
+
container.querySelector<HTMLInputElement>('[aria-label="Prompt draft"]')!
|
|
194
|
+
.value,
|
|
195
|
+
).toBe("Keep my draft");
|
|
196
|
+
await open();
|
|
197
|
+
expect(
|
|
198
|
+
dialog()!.querySelector<HTMLInputElement>('input[type="url"]')!.value,
|
|
199
|
+
).toBe("");
|
|
200
|
+
});
|
|
201
|
+
it("makes blank Enter inert and validates URL and host restrictions before any provider call", async () => {
|
|
202
|
+
const config = multiple();
|
|
203
|
+
await render(config);
|
|
204
|
+
await open();
|
|
205
|
+
expect(button("Continue").disabled).toBe(true);
|
|
206
|
+
await act(async () =>
|
|
207
|
+
dialog()!
|
|
208
|
+
.querySelector("form")!
|
|
209
|
+
.dispatchEvent(
|
|
210
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
expect(dialog()!.querySelector('[role="alert"]')).toBeNull();
|
|
214
|
+
expect(dialog()!.textContent).not.toContain("Enter a link.");
|
|
215
|
+
for (const url of [
|
|
216
|
+
"not-a-url",
|
|
217
|
+
"javascript:alert(1)",
|
|
218
|
+
"ftp://example.com/supported/file",
|
|
219
|
+
"https://user:secret@example.com/supported/file",
|
|
220
|
+
"https://example.com/unsupported",
|
|
221
|
+
]) {
|
|
222
|
+
await type(url);
|
|
223
|
+
expect(button("Continue").disabled).toBe(true);
|
|
224
|
+
await act(async () =>
|
|
225
|
+
dialog()!
|
|
226
|
+
.querySelector("form")!
|
|
227
|
+
.dispatchEvent(
|
|
228
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
expect(config.load).not.toHaveBeenCalled();
|
|
233
|
+
expect(config.presentation.onAttach).not.toHaveBeenCalled();
|
|
234
|
+
await browse();
|
|
235
|
+
expect(config.load).toHaveBeenCalledOnce();
|
|
236
|
+
});
|
|
237
|
+
it("stages checkboxes, excludes already attached IDs without conflating files and attaches one frozen batch", async () => {
|
|
238
|
+
const config = multiple();
|
|
239
|
+
await render({ ...config, selectedIds: ["file-a:1"] });
|
|
240
|
+
await open();
|
|
241
|
+
await browse();
|
|
242
|
+
expect(button("Attach").disabled).toBe(true);
|
|
243
|
+
const existing = await choose("First frame");
|
|
244
|
+
expect(existing.getAttribute("aria-checked")).toBe("true");
|
|
245
|
+
expect(existing.hasAttribute("disabled")).toBe(true);
|
|
246
|
+
await choose("Second frame");
|
|
247
|
+
expect(config.presentation.onAttach).not.toHaveBeenCalled();
|
|
248
|
+
await click("Attach");
|
|
249
|
+
expect(config.presentation.onAttach).toHaveBeenCalledOnce();
|
|
250
|
+
const [items, request] = config.presentation.onAttach.mock.calls[0];
|
|
251
|
+
expect(items).toEqual([frames[1]]);
|
|
252
|
+
expect(Object.isFrozen(items)).toBe(true);
|
|
253
|
+
expect(Object.isFrozen(items[0])).toBe(true);
|
|
254
|
+
expect(request).toMatchObject({
|
|
255
|
+
url: "https://example.com/supported/file",
|
|
256
|
+
page: 1,
|
|
257
|
+
signal: expect.any(AbortSignal),
|
|
258
|
+
});
|
|
259
|
+
expect(dialog()).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
it("retains staged choices across pages, search and ordinary refresh", async () => {
|
|
262
|
+
const config = multiple();
|
|
263
|
+
config.load.mockImplementation(async ({ page }: { page: number }) => ({
|
|
264
|
+
items: [frames[page === 2 ? 1 : 0]],
|
|
265
|
+
hasMore: page === 1,
|
|
266
|
+
nextCursor: "second",
|
|
267
|
+
}));
|
|
268
|
+
await render(config);
|
|
269
|
+
await open();
|
|
270
|
+
await browse();
|
|
271
|
+
await choose("First frame");
|
|
272
|
+
await click("Next");
|
|
273
|
+
await choose("Second frame");
|
|
274
|
+
await type("one", 'input[type="search"]');
|
|
275
|
+
await render({ ...config, refreshKey: 1 });
|
|
276
|
+
await click("Attach");
|
|
277
|
+
expect(config.presentation.onAttach.mock.calls[0][0]).toEqual(frames);
|
|
278
|
+
expect(
|
|
279
|
+
config.load.mock.calls.some(([request]) => request.cursor === "second"),
|
|
280
|
+
).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
it("shows provider errors, retries without losing the URL, and suppresses late cancelled reads", async () => {
|
|
283
|
+
const config = multiple();
|
|
284
|
+
const late = deferred<{ items: typeof frames; hasMore: boolean }>();
|
|
285
|
+
config.load
|
|
286
|
+
.mockRejectedValueOnce(new Error("Source access denied"))
|
|
287
|
+
.mockReturnValueOnce(late.promise);
|
|
288
|
+
await render(config);
|
|
289
|
+
await open();
|
|
290
|
+
await browse();
|
|
291
|
+
expect(dialog()!.querySelector('[role="alert"]')!.textContent).toBe(
|
|
292
|
+
"Source access denied",
|
|
293
|
+
);
|
|
294
|
+
expect(dialog()!.querySelector('[role="status"]')).toBeNull();
|
|
295
|
+
await click("Retry");
|
|
296
|
+
const request = config.load.mock.calls.at(-1)![0];
|
|
297
|
+
expect(request.url).toBe("https://example.com/supported/file");
|
|
298
|
+
await click("Cancel");
|
|
299
|
+
expect(request.signal.aborted).toBe(true);
|
|
300
|
+
await act(async () => late.resolve({ items: frames, hasMore: false }));
|
|
301
|
+
expect(dialog()).toBeNull();
|
|
302
|
+
});
|
|
303
|
+
it("aborts a pending batch on identity change and does not close a newer dialog", async () => {
|
|
304
|
+
const config = multiple();
|
|
305
|
+
const pending = deferred<void>();
|
|
306
|
+
config.presentation.onAttach.mockReturnValue(pending.promise);
|
|
307
|
+
await render(config);
|
|
308
|
+
await open();
|
|
309
|
+
await browse();
|
|
310
|
+
await choose("First frame");
|
|
311
|
+
await click("Attach");
|
|
312
|
+
const request = config.presentation.onAttach.mock.calls[0][1];
|
|
313
|
+
expect(button("Attach").disabled).toBe(true);
|
|
314
|
+
await render({ ...config, scopeKey: "account-b" });
|
|
315
|
+
await tick();
|
|
316
|
+
expect(dialog()).toBeNull();
|
|
317
|
+
expect(request.signal.aborted).toBe(true);
|
|
318
|
+
await open();
|
|
319
|
+
await act(async () => pending.resolve());
|
|
320
|
+
expect(dialog()).not.toBeNull();
|
|
321
|
+
expect(
|
|
322
|
+
dialog()!.querySelector<HTMLInputElement>('input[type="url"]')!.value,
|
|
323
|
+
).toBe("");
|
|
324
|
+
});
|
|
325
|
+
it("backs out of a pending list and ignores its stale response", async () => {
|
|
326
|
+
const config = multiple();
|
|
327
|
+
const pending = deferred<{ items: typeof frames; hasMore: boolean }>();
|
|
328
|
+
config.load.mockReturnValue(pending.promise);
|
|
329
|
+
await render(config);
|
|
330
|
+
await open();
|
|
331
|
+
await browse();
|
|
332
|
+
const request = config.load.mock.calls[0][0];
|
|
333
|
+
await click("Back");
|
|
334
|
+
expect(request.signal.aborted).toBe(true);
|
|
335
|
+
await act(async () => pending.resolve({ items: frames, hasMore: false }));
|
|
336
|
+
expect(dialog()!.querySelector('[role="checkbox"]')).toBeNull();
|
|
337
|
+
expect(document.activeElement).toBe(
|
|
338
|
+
dialog()!.querySelector('input[type="url"]'),
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
it("retries failed batches and respects false without clearing staged selection", async () => {
|
|
342
|
+
const config = multiple();
|
|
343
|
+
config.presentation.onAttach
|
|
344
|
+
.mockRejectedValueOnce(new Error("Cannot attach yet"))
|
|
345
|
+
.mockResolvedValueOnce(false);
|
|
346
|
+
await render(config);
|
|
347
|
+
await open();
|
|
348
|
+
await browse();
|
|
349
|
+
await choose("First frame");
|
|
350
|
+
await click("Attach");
|
|
351
|
+
expect(dialog()!.textContent).toContain("Cannot attach yet");
|
|
352
|
+
await click("Retry");
|
|
353
|
+
expect(dialog()).not.toBeNull();
|
|
354
|
+
expect(config.presentation.onAttach).toHaveBeenCalledTimes(2);
|
|
355
|
+
expect(button("Attach").disabled).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
it("uses URL-only mode without a list, keeps caret navigation, and restores focus on Escape", async () => {
|
|
358
|
+
const select = vi.fn().mockResolvedValue(false);
|
|
359
|
+
const load = vi.fn();
|
|
360
|
+
await render({
|
|
361
|
+
presentation: { type: "dialog", mode: "url" },
|
|
362
|
+
onSelect: select,
|
|
363
|
+
load,
|
|
364
|
+
searchPlaceholder: "Unused",
|
|
365
|
+
link: {
|
|
366
|
+
label: "Website URL",
|
|
367
|
+
placeholder: "https://example.com",
|
|
368
|
+
submitLabel: "Unused",
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
await open();
|
|
372
|
+
expect(button("Attach").disabled).toBe(true);
|
|
373
|
+
await type("https://example.com/page");
|
|
374
|
+
const input =
|
|
375
|
+
dialog()!.querySelector<HTMLInputElement>('input[type="url"]')!;
|
|
376
|
+
for (const key of ["ArrowLeft", "ArrowRight"])
|
|
377
|
+
await act(async () =>
|
|
378
|
+
input.dispatchEvent(
|
|
379
|
+
new KeyboardEvent("keydown", { key, bubbles: true }),
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
expect(dialog()).not.toBeNull();
|
|
383
|
+
await click("Attach");
|
|
384
|
+
expect(load).not.toHaveBeenCalled();
|
|
385
|
+
expect(select).toHaveBeenCalledWith(
|
|
386
|
+
{
|
|
387
|
+
id: "https://example.com/page",
|
|
388
|
+
title: "https://example.com/page",
|
|
389
|
+
url: "https://example.com/page",
|
|
390
|
+
},
|
|
391
|
+
expect.objectContaining({
|
|
392
|
+
url: "https://example.com/page",
|
|
393
|
+
signal: expect.any(AbortSignal),
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
await act(async () =>
|
|
397
|
+
input.dispatchEvent(
|
|
398
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
await tick();
|
|
402
|
+
expect(dialog()).toBeNull();
|
|
403
|
+
expect(document.activeElement).toBe(trigger());
|
|
404
|
+
});
|
|
405
|
+
});
|