@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,577 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
PromptComposer,
|
|
9
|
+
type PromptComposerFile,
|
|
10
|
+
type PromptComposerProps,
|
|
11
|
+
} from "./PromptComposer.js";
|
|
12
|
+
import {
|
|
13
|
+
ComposerRuntimeAdaptersProvider,
|
|
14
|
+
type AgentChatContextItem,
|
|
15
|
+
} from "./runtime-adapters.js";
|
|
16
|
+
import type { TiptapComposerHandle } from "./TiptapComposer.js";
|
|
17
|
+
|
|
18
|
+
let container: HTMLDivElement;
|
|
19
|
+
let root: Root;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
23
|
+
container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
root = createRoot(container);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
act(() => root.unmount());
|
|
30
|
+
container.remove();
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("controlled composer context", () => {
|
|
35
|
+
it("uses the shared upload menu without host entries and retains the explicit hidden mode", async () => {
|
|
36
|
+
await mount();
|
|
37
|
+
const trigger = container.querySelector<HTMLButtonElement>(
|
|
38
|
+
'button[aria-label="Add context"]',
|
|
39
|
+
)!;
|
|
40
|
+
expect(trigger).not.toBeNull();
|
|
41
|
+
await act(async () =>
|
|
42
|
+
trigger.dispatchEvent(
|
|
43
|
+
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
const menu = document.querySelector('[role="menu"]')!;
|
|
47
|
+
expect(menu.querySelector('[role="searchbox"]')).not.toBeNull();
|
|
48
|
+
expect(menu.textContent).toBe("Upload File");
|
|
49
|
+
await act(async () =>
|
|
50
|
+
document.dispatchEvent(
|
|
51
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
await mount({ plusMenuMode: "hidden" });
|
|
55
|
+
expect(
|
|
56
|
+
container.querySelector('button[aria-label="Add context"]'),
|
|
57
|
+
).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
async function mount(props: Partial<PromptComposerProps> = {}) {
|
|
60
|
+
const composerRef = React.createRef<TiptapComposerHandle>();
|
|
61
|
+
const onSubmit = vi.fn();
|
|
62
|
+
await act(async () => {
|
|
63
|
+
root.render(
|
|
64
|
+
<PromptComposer
|
|
65
|
+
composerRef={composerRef}
|
|
66
|
+
onSubmit={onSubmit}
|
|
67
|
+
initialText="Keep the editable draft"
|
|
68
|
+
initialTextKey="quick-start"
|
|
69
|
+
showModelSelector={false}
|
|
70
|
+
modelStatusChecksEnabled={false}
|
|
71
|
+
includeDefaultSlashSkills={false}
|
|
72
|
+
voiceEnabled={false}
|
|
73
|
+
{...props}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
return { composerRef, onSubmit };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function attachFile() {
|
|
81
|
+
const file = new File(["example"], "reference.pdf", {
|
|
82
|
+
type: "application/pdf",
|
|
83
|
+
});
|
|
84
|
+
const input =
|
|
85
|
+
container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
|
86
|
+
Object.defineProperty(input, "files", {
|
|
87
|
+
configurable: true,
|
|
88
|
+
value: [file],
|
|
89
|
+
});
|
|
90
|
+
await act(async () =>
|
|
91
|
+
input.dispatchEvent(new Event("change", { bubbles: true })),
|
|
92
|
+
);
|
|
93
|
+
return file;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it.each(["host", "provider"] as const)(
|
|
97
|
+
"%s submission gating allows staging and blocks every send path until ready",
|
|
98
|
+
async (gate) => {
|
|
99
|
+
const composerRef = React.createRef<TiptapComposerHandle>();
|
|
100
|
+
const onSubmit = vi.fn();
|
|
101
|
+
const onSelect = vi.fn();
|
|
102
|
+
const onRemove = vi.fn();
|
|
103
|
+
const onRetry = vi.fn();
|
|
104
|
+
const onDisabledClick = vi.fn();
|
|
105
|
+
let blocked = true;
|
|
106
|
+
let files: PromptComposerFile[] = [];
|
|
107
|
+
const render = async () => {
|
|
108
|
+
await act(async () => {
|
|
109
|
+
root.render(
|
|
110
|
+
<ComposerRuntimeAdaptersProvider
|
|
111
|
+
adapters={{
|
|
112
|
+
models: {
|
|
113
|
+
useAgentEngineConfigured: () => ({
|
|
114
|
+
state:
|
|
115
|
+
gate === "provider" && blocked ? "missing" : "configured",
|
|
116
|
+
missing: gate === "provider" && blocked,
|
|
117
|
+
}),
|
|
118
|
+
BuilderSetupCard: () => <div data-testid="provider-setup" />,
|
|
119
|
+
},
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<PromptComposer
|
|
123
|
+
composerRef={composerRef}
|
|
124
|
+
onSubmit={onSubmit}
|
|
125
|
+
submissionDisabled={gate === "host" && blocked}
|
|
126
|
+
onDisabledClick={onDisabledClick}
|
|
127
|
+
placeholder="Prepare your prompt"
|
|
128
|
+
showModelSelector={false}
|
|
129
|
+
modelStatusChecksEnabled={gate === "provider"}
|
|
130
|
+
includeDefaultSlashSkills={false}
|
|
131
|
+
voiceEnabled={false}
|
|
132
|
+
onAttachmentsChange={(next) => {
|
|
133
|
+
files = next;
|
|
134
|
+
}}
|
|
135
|
+
contextMenuItems={[
|
|
136
|
+
{ id: "brief", label: "Choose brief", onSelect },
|
|
137
|
+
]}
|
|
138
|
+
contextItems={[
|
|
139
|
+
{
|
|
140
|
+
key: "brief",
|
|
141
|
+
title: "Brief",
|
|
142
|
+
context: "Snapshot",
|
|
143
|
+
status: "error",
|
|
144
|
+
blocksSubmission: false,
|
|
145
|
+
},
|
|
146
|
+
]}
|
|
147
|
+
onRemoveContextItem={onRemove}
|
|
148
|
+
onRetryContextItem={onRetry}
|
|
149
|
+
/>
|
|
150
|
+
</ComposerRuntimeAdaptersProvider>,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
await render();
|
|
155
|
+
const editor = container.querySelector<HTMLElement>(
|
|
156
|
+
'[contenteditable="true"]',
|
|
157
|
+
)!;
|
|
158
|
+
expect(editor).not.toBeNull();
|
|
159
|
+
await act(async () => composerRef.current!.setText("Staged draft"));
|
|
160
|
+
const file = await attachFile();
|
|
161
|
+
expect(files).toEqual([file]);
|
|
162
|
+
const contextButton = container.querySelector<HTMLButtonElement>(
|
|
163
|
+
'button[aria-label="Add context"]',
|
|
164
|
+
)!;
|
|
165
|
+
expect(contextButton.disabled).toBe(false);
|
|
166
|
+
await act(async () =>
|
|
167
|
+
contextButton.dispatchEvent(
|
|
168
|
+
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
const contextGroup = Array.from(
|
|
172
|
+
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
173
|
+
).find((element) => element.textContent === "Add context")!;
|
|
174
|
+
await act(async () => contextGroup.click());
|
|
175
|
+
const option = Array.from(
|
|
176
|
+
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
177
|
+
).find((element) => element.textContent?.includes("Choose brief"))!;
|
|
178
|
+
expect(option).toBeDefined();
|
|
179
|
+
await act(async () => option.click());
|
|
180
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
181
|
+
await act(async () => {
|
|
182
|
+
container
|
|
183
|
+
.querySelector<HTMLButtonElement>(
|
|
184
|
+
'button[aria-label="Retry Brief context"]',
|
|
185
|
+
)!
|
|
186
|
+
.click();
|
|
187
|
+
container
|
|
188
|
+
.querySelector<HTMLButtonElement>(
|
|
189
|
+
'button[aria-label="Remove Brief context"]',
|
|
190
|
+
)!
|
|
191
|
+
.click();
|
|
192
|
+
});
|
|
193
|
+
expect(onRetry).toHaveBeenCalledWith("brief");
|
|
194
|
+
expect(onRemove).toHaveBeenCalledWith("brief");
|
|
195
|
+
expect(onDisabledClick).not.toHaveBeenCalled();
|
|
196
|
+
const send = container.querySelector<HTMLButtonElement>(
|
|
197
|
+
'button[aria-label="Send message"]',
|
|
198
|
+
)!;
|
|
199
|
+
expect(send.disabled).toBe(true);
|
|
200
|
+
await act(async () => {
|
|
201
|
+
send.click();
|
|
202
|
+
editor.dispatchEvent(
|
|
203
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
204
|
+
);
|
|
205
|
+
editor.dispatchEvent(
|
|
206
|
+
new KeyboardEvent("keydown", {
|
|
207
|
+
key: "Enter",
|
|
208
|
+
metaKey: true,
|
|
209
|
+
bubbles: true,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
editor.dispatchEvent(
|
|
213
|
+
new KeyboardEvent("keydown", {
|
|
214
|
+
key: "Enter",
|
|
215
|
+
ctrlKey: true,
|
|
216
|
+
bubbles: true,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
expect(await composerRef.current!.submitWithText("Quick start")).toBe(
|
|
220
|
+
false,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
224
|
+
expect(editor.textContent).toBe("Staged draft");
|
|
225
|
+
expect(files).toEqual([file]);
|
|
226
|
+
blocked = false;
|
|
227
|
+
await render();
|
|
228
|
+
await act(async () => {
|
|
229
|
+
expect(await composerRef.current!.submitWithText("Ready now")).toBe(
|
|
230
|
+
true,
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
expect(onSubmit).toHaveBeenCalledOnce();
|
|
234
|
+
expect(onSubmit.mock.calls[0][0]).toBe("Ready now");
|
|
235
|
+
expect(onSubmit.mock.calls[0][1]).toEqual([file]);
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
it("preserves full-surface disabled semantics independently of the submit-only gate", async () => {
|
|
240
|
+
const { composerRef, onSubmit } = await mount({
|
|
241
|
+
disabled: true,
|
|
242
|
+
contextMenuItems: [
|
|
243
|
+
{ id: "brief", label: "Choose brief", onSelect: vi.fn() },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
expect(container.querySelector('[contenteditable="true"]')).toBeNull();
|
|
247
|
+
expect(
|
|
248
|
+
container.querySelector<HTMLButtonElement>(
|
|
249
|
+
'button[aria-label="Add context"]',
|
|
250
|
+
)!.disabled,
|
|
251
|
+
).toBe(true);
|
|
252
|
+
await act(async () =>
|
|
253
|
+
expect(await composerRef.current!.submitWithText("Quick start")).toBe(
|
|
254
|
+
false,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("quick starts use the current files, model and immutable context without replacing the draft first", async () => {
|
|
261
|
+
const contextItems = [
|
|
262
|
+
{ key: "brief", title: "Brief", context: "Snapshot" },
|
|
263
|
+
];
|
|
264
|
+
let release!: () => void;
|
|
265
|
+
const gate = new Promise<void>((resolve) => {
|
|
266
|
+
release = resolve;
|
|
267
|
+
});
|
|
268
|
+
const onSubmit = vi.fn().mockReturnValue(gate);
|
|
269
|
+
const { composerRef } = await mount({
|
|
270
|
+
contextItems,
|
|
271
|
+
onSubmit,
|
|
272
|
+
showModelSelector: true,
|
|
273
|
+
availableModels: [
|
|
274
|
+
{
|
|
275
|
+
engine: "example",
|
|
276
|
+
label: "Example",
|
|
277
|
+
models: ["example-model"],
|
|
278
|
+
configured: true,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
selectedModel: "example-model",
|
|
282
|
+
selectedEngine: "example",
|
|
283
|
+
selectedEffort: "high",
|
|
284
|
+
onModelChange: vi.fn(),
|
|
285
|
+
});
|
|
286
|
+
const file = await attachFile();
|
|
287
|
+
let submission!: Promise<boolean>;
|
|
288
|
+
await act(async () => {
|
|
289
|
+
submission = composerRef.current!.submitWithText(
|
|
290
|
+
"Create from the reference",
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
expect(onSubmit).toHaveBeenCalledOnce();
|
|
294
|
+
expect(onSubmit.mock.calls[0]).toMatchObject([
|
|
295
|
+
"Create from the reference",
|
|
296
|
+
[file],
|
|
297
|
+
[],
|
|
298
|
+
{
|
|
299
|
+
model: "example-model",
|
|
300
|
+
engine: "example",
|
|
301
|
+
effort: "high",
|
|
302
|
+
contextItems,
|
|
303
|
+
},
|
|
304
|
+
]);
|
|
305
|
+
expect(
|
|
306
|
+
container.querySelector('[contenteditable="true"]')?.textContent,
|
|
307
|
+
).toBe("Keep the editable draft");
|
|
308
|
+
contextItems[0].context = "Later change";
|
|
309
|
+
expect(onSubmit.mock.calls[0][3].contextItems[0].context).toBe("Snapshot");
|
|
310
|
+
await act(async () => {
|
|
311
|
+
release();
|
|
312
|
+
expect(await submission).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
expect(
|
|
315
|
+
container.querySelector('[contenteditable="true"]')?.textContent,
|
|
316
|
+
).toBe("");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("returns false and retains the typed draft and files after a rejected quick start", async () => {
|
|
320
|
+
const onSubmit = vi.fn().mockRejectedValue(new Error("Submit unavailable"));
|
|
321
|
+
let files: PromptComposerFile[] = [];
|
|
322
|
+
const { composerRef } = await mount({
|
|
323
|
+
onSubmit,
|
|
324
|
+
onAttachmentsChange: (next) => {
|
|
325
|
+
files = next;
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
const file = await attachFile();
|
|
329
|
+
await act(async () =>
|
|
330
|
+
expect(await composerRef.current!.submitWithText("Quick start")).toBe(
|
|
331
|
+
false,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
expect(
|
|
335
|
+
container.querySelector('[contenteditable="true"]')?.textContent,
|
|
336
|
+
).toBe("Keep the editable draft");
|
|
337
|
+
expect(files).toEqual([file]);
|
|
338
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
|
|
339
|
+
"Submit unavailable",
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it.each(["pending", "error"] as const)(
|
|
344
|
+
"quick starts cannot bypass %s context",
|
|
345
|
+
async (status) => {
|
|
346
|
+
const { composerRef, onSubmit } = await mount({
|
|
347
|
+
contextItems: [{ key: "brief", title: "Brief", context: "", status }],
|
|
348
|
+
});
|
|
349
|
+
await act(async () =>
|
|
350
|
+
expect(await composerRef.current!.submitWithText("Quick start")).toBe(
|
|
351
|
+
false,
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
355
|
+
expect(
|
|
356
|
+
container.querySelector('[contenteditable="true"]')?.textContent,
|
|
357
|
+
).toBe("Keep the editable draft");
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
it("reports oversize context and keeps the draft instead of truncating or sending", async () => {
|
|
362
|
+
const { composerRef, onSubmit } = await mount({
|
|
363
|
+
contextItems: [
|
|
364
|
+
{ key: "brief", title: "Brief", context: "x".repeat(65536) },
|
|
365
|
+
],
|
|
366
|
+
});
|
|
367
|
+
await act(async () =>
|
|
368
|
+
expect(await composerRef.current!.submitWithText("Quick start")).toBe(
|
|
369
|
+
false,
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
373
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
|
374
|
+
"Context is too large",
|
|
375
|
+
);
|
|
376
|
+
expect(
|
|
377
|
+
container.querySelector('[contenteditable="true"]')?.textContent,
|
|
378
|
+
).toBe("Keep the editable draft");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("a failed provider preflight leaves the draft untouched and returns false", async () => {
|
|
382
|
+
const composerRef = React.createRef<TiptapComposerHandle>();
|
|
383
|
+
const onSubmit = vi.fn();
|
|
384
|
+
const preflight = vi.fn().mockResolvedValue("missing");
|
|
385
|
+
await act(async () =>
|
|
386
|
+
root.render(
|
|
387
|
+
<ComposerRuntimeAdaptersProvider
|
|
388
|
+
adapters={{
|
|
389
|
+
models: {
|
|
390
|
+
useAgentEngineConfigured: () => ({
|
|
391
|
+
state: "unknown",
|
|
392
|
+
missing: false,
|
|
393
|
+
}),
|
|
394
|
+
fetchAgentEngineConfiguredState: preflight,
|
|
395
|
+
},
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
<PromptComposer
|
|
399
|
+
composerRef={composerRef}
|
|
400
|
+
onSubmit={onSubmit}
|
|
401
|
+
initialText="Keep my draft"
|
|
402
|
+
initialTextKey="gate"
|
|
403
|
+
showModelSelector={false}
|
|
404
|
+
includeDefaultSlashSkills={false}
|
|
405
|
+
/>
|
|
406
|
+
</ComposerRuntimeAdaptersProvider>,
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
await act(async () =>
|
|
410
|
+
expect(await composerRef.current!.submitWithText("Quick start")).toBe(
|
|
411
|
+
false,
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
expect(preflight).toHaveBeenCalledOnce();
|
|
415
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
416
|
+
expect(
|
|
417
|
+
container.querySelector('[contenteditable="true"]')?.textContent,
|
|
418
|
+
).toBe("Keep my draft");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it.each(["click", "enter"])(
|
|
422
|
+
"allows %s steering past a nonremovable failed persisted source",
|
|
423
|
+
async (method) => {
|
|
424
|
+
const onSubmit = vi.fn();
|
|
425
|
+
const remove = vi.fn();
|
|
426
|
+
const source: AgentChatContextItem = {
|
|
427
|
+
key: "system-source:qa",
|
|
428
|
+
title: "QA reference",
|
|
429
|
+
context: "systemId=qa; sourceId=qa",
|
|
430
|
+
status: "error",
|
|
431
|
+
statusMessage: "Source read failed",
|
|
432
|
+
removable: false,
|
|
433
|
+
blocksSubmission: false,
|
|
434
|
+
};
|
|
435
|
+
await act(async () => {
|
|
436
|
+
root.render(
|
|
437
|
+
React.createElement(PromptComposer, {
|
|
438
|
+
onSubmit,
|
|
439
|
+
contextItems: [source],
|
|
440
|
+
onRemoveContextItem: remove,
|
|
441
|
+
initialText: "Try a different direction",
|
|
442
|
+
initialTextKey: "nonblocking-source",
|
|
443
|
+
showModelSelector: false,
|
|
444
|
+
modelStatusChecksEnabled: false,
|
|
445
|
+
attachmentsEnabled: false,
|
|
446
|
+
includeDefaultSlashSkills: false,
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
450
|
+
});
|
|
451
|
+
expect(
|
|
452
|
+
container.querySelector(
|
|
453
|
+
'button[aria-label="Remove QA reference context"]',
|
|
454
|
+
),
|
|
455
|
+
).toBeNull();
|
|
456
|
+
expect(
|
|
457
|
+
container
|
|
458
|
+
.querySelector('[data-context-key="system-source:qa"]')
|
|
459
|
+
?.getAttribute("title"),
|
|
460
|
+
).toBe("Source read failed");
|
|
461
|
+
await act(async () => {
|
|
462
|
+
if (method === "click")
|
|
463
|
+
container
|
|
464
|
+
.querySelector<HTMLButtonElement>(
|
|
465
|
+
'button[aria-label="Send message"]',
|
|
466
|
+
)!
|
|
467
|
+
.click();
|
|
468
|
+
else
|
|
469
|
+
container
|
|
470
|
+
.querySelector('[contenteditable="true"]')!
|
|
471
|
+
.dispatchEvent(
|
|
472
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
expect(onSubmit).toHaveBeenCalledOnce();
|
|
476
|
+
expect(onSubmit.mock.calls[0][3].contextItems).toEqual([source]);
|
|
477
|
+
expect(remove).not.toHaveBeenCalled();
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
it("renders context inside the frame, forwards inspection/retry/removal, and blocks click and keyboard submission until ready", async () => {
|
|
481
|
+
const onSubmit = vi.fn();
|
|
482
|
+
const onRemoveContextItem = vi.fn();
|
|
483
|
+
const onInspectContextItem = vi.fn();
|
|
484
|
+
const onRetryContextItem = vi.fn();
|
|
485
|
+
const composerRef = React.createRef<TiptapComposerHandle>();
|
|
486
|
+
const item: AgentChatContextItem = {
|
|
487
|
+
key: "brief",
|
|
488
|
+
title: "Brief",
|
|
489
|
+
context: "Original context",
|
|
490
|
+
status: "pending",
|
|
491
|
+
};
|
|
492
|
+
const render = async () => {
|
|
493
|
+
await act(async () => {
|
|
494
|
+
root.render(
|
|
495
|
+
React.createElement(PromptComposer, {
|
|
496
|
+
contextItems: [item],
|
|
497
|
+
onRemoveContextItem,
|
|
498
|
+
onInspectContextItem,
|
|
499
|
+
onRetryContextItem,
|
|
500
|
+
composerRef,
|
|
501
|
+
onSubmit,
|
|
502
|
+
initialText: "Review",
|
|
503
|
+
initialTextKey: "context-test",
|
|
504
|
+
showModelSelector: false,
|
|
505
|
+
modelStatusChecksEnabled: false,
|
|
506
|
+
attachmentsEnabled: false,
|
|
507
|
+
includeDefaultSlashSkills: false,
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
511
|
+
});
|
|
512
|
+
};
|
|
513
|
+
await render();
|
|
514
|
+
expect(
|
|
515
|
+
container
|
|
516
|
+
.querySelector('[data-context-key="brief"]')
|
|
517
|
+
?.closest('[data-agent-composer-slot="root"]'),
|
|
518
|
+
).not.toBeNull();
|
|
519
|
+
const clickSend = async () => {
|
|
520
|
+
await act(async () =>
|
|
521
|
+
container
|
|
522
|
+
.querySelector<HTMLButtonElement>(
|
|
523
|
+
'button[aria-label="Send message"]',
|
|
524
|
+
)!
|
|
525
|
+
.click(),
|
|
526
|
+
);
|
|
527
|
+
};
|
|
528
|
+
const pressEnter = async () => {
|
|
529
|
+
await act(async () =>
|
|
530
|
+
container
|
|
531
|
+
.querySelector('[contenteditable="true"]')!
|
|
532
|
+
.dispatchEvent(
|
|
533
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
};
|
|
537
|
+
await clickSend();
|
|
538
|
+
await pressEnter();
|
|
539
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
540
|
+
item.status = "error";
|
|
541
|
+
await render();
|
|
542
|
+
await clickSend();
|
|
543
|
+
await pressEnter();
|
|
544
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
545
|
+
const contextRow = container.querySelector('[data-context-key="brief"]')!;
|
|
546
|
+
await act(async () => {
|
|
547
|
+
contextRow.querySelector<HTMLButtonElement>("button")!.click();
|
|
548
|
+
contextRow
|
|
549
|
+
.querySelector<HTMLButtonElement>(
|
|
550
|
+
'button[aria-label="Retry Brief context"]',
|
|
551
|
+
)!
|
|
552
|
+
.click();
|
|
553
|
+
contextRow
|
|
554
|
+
.querySelector<HTMLButtonElement>(
|
|
555
|
+
'button[aria-label="Remove Brief context"]',
|
|
556
|
+
)!
|
|
557
|
+
.click();
|
|
558
|
+
});
|
|
559
|
+
expect(onInspectContextItem).toHaveBeenCalledWith("brief");
|
|
560
|
+
expect(onRetryContextItem).toHaveBeenCalledWith("brief");
|
|
561
|
+
expect(onRemoveContextItem).toHaveBeenCalledWith("brief");
|
|
562
|
+
expect(
|
|
563
|
+
container.querySelector('[data-context-key="brief"]'),
|
|
564
|
+
).not.toBeNull();
|
|
565
|
+
item.status = "ready";
|
|
566
|
+
await render();
|
|
567
|
+
await clickSend();
|
|
568
|
+
expect(onSubmit).toHaveBeenCalledOnce();
|
|
569
|
+
const [text, , references, options] = onSubmit.mock.calls[0];
|
|
570
|
+
expect(text).toBe("Review");
|
|
571
|
+
expect(references).toEqual([]);
|
|
572
|
+
expect(options.contextItems).toEqual([item]);
|
|
573
|
+
item.context = "Changed later";
|
|
574
|
+
expect(options.contextItems[0].context).toBe("Original context");
|
|
575
|
+
expect(Object.isFrozen(options.contextItems[0])).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
buildPromptComposerSubmission,
|
|
@@ -60,6 +60,29 @@ describe("shouldGateComposerForMissingEngine", () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
describe("buildPromptComposerSubmission", () => {
|
|
63
|
+
it("lets hosts extract uploaded text without reading or duplicating it in the prompt", async () => {
|
|
64
|
+
const file = new File(["source"], "component.tsx", { type: "text/plain" });
|
|
65
|
+
const read = vi.spyOn(file, "text");
|
|
66
|
+
const result = await buildPromptComposerSubmission({
|
|
67
|
+
text: "Review",
|
|
68
|
+
inlineTextAttachments: false,
|
|
69
|
+
attachments: [{ file, name: file.name, id: "source", type: "document" }],
|
|
70
|
+
});
|
|
71
|
+
expect(result).toEqual({ text: "Review", files: [file] });
|
|
72
|
+
expect(result.files[0]).toBe(file);
|
|
73
|
+
expect(read).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
it("still inlines synthetic pasted text when ordinary upload inlining is disabled", async () => {
|
|
76
|
+
const file = new File(["Pasted notes"], "pasted-text-example.txt", {
|
|
77
|
+
type: "text/plain",
|
|
78
|
+
});
|
|
79
|
+
const result = await buildPromptComposerSubmission({
|
|
80
|
+
text: "Review",
|
|
81
|
+
inlineTextAttachments: false,
|
|
82
|
+
attachments: [{ file, name: file.name, id: "paste", type: "document" }],
|
|
83
|
+
});
|
|
84
|
+
expect(result).toEqual({ text: "Review\n\nPasted notes", files: [] });
|
|
85
|
+
});
|
|
63
86
|
it("passes images through files only — never inlines base64 into prompt text", async () => {
|
|
64
87
|
// Images are passed to `files` for the host to process through the
|
|
65
88
|
// attachment pipeline. They must NOT be inlined as base64 in `text`
|