@classic-homes/agent-chat 0.7.0 → 0.9.0
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/README.md +3 -0
- package/dist/components/AgentChat.svelte +55 -6
- package/dist/components/AgentChat.svelte.d.ts +14 -3
- package/dist/components/AgentChat.svelte.d.ts.map +1 -1
- package/dist/components/Composer.svelte +6 -3
- package/dist/components/Composer.svelte.d.ts +2 -0
- package/dist/components/Composer.svelte.d.ts.map +1 -1
- package/dist/components/MessageList.svelte +37 -28
- package/dist/components/MessageList.svelte.d.ts.map +1 -1
- package/dist/components/UserMessage.svelte +14 -4
- package/dist/components/UserMessage.svelte.d.ts.map +1 -1
- package/dist/components/a11y.svelte.test.d.ts +2 -0
- package/dist/components/a11y.svelte.test.d.ts.map +1 -0
- package/dist/components/a11y.svelte.test.js +172 -0
- package/dist/components/agent-picker.svelte.test.d.ts +2 -0
- package/dist/components/agent-picker.svelte.test.d.ts.map +1 -0
- package/dist/components/agent-picker.svelte.test.js +84 -0
- package/dist/components/composer.svelte.test.js +2 -2
- package/dist/session.svelte.d.ts +2 -0
- package/dist/session.svelte.d.ts.map +1 -1
- package/dist/session.svelte.js +5 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -52,6 +52,9 @@ agent-api origin that serves `/frame`; artifacts and MCP-UI resources only ever
|
|
|
52
52
|
(`ComposerPrompt.text` may be a loader, e.g. fetching the template).
|
|
53
53
|
- `ToolTimeline`: tool calls with status and duration, delegations nested; `toolLabels` maps
|
|
54
54
|
tool names to readable ones (`AgentChat` passes both through).
|
|
55
|
+
- Agent picker: pass `agents` (from `/api/agents`); with more than one, a new conversation offers
|
|
56
|
+
a picker in the composer until its first message. The conversation keeps that agent; `header`
|
|
57
|
+
receives `{ agent, agentName }` and `onagentchange` reports it.
|
|
55
58
|
- `Welcome`: the empty conversation, with a greeting (`userName`), a capability line and up to
|
|
56
59
|
four `suggestions` that send when chosen. `AgentChat` shows it unless the host passes an
|
|
57
60
|
`empty` snippet, a loading skeleton while an existing conversation's transcript arrives, and
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { AgentApiClient } from '@classic-homes/agent-client';
|
|
3
3
|
import type {
|
|
4
4
|
AgentKey,
|
|
5
|
+
AgentSummary,
|
|
5
6
|
ClientKind,
|
|
6
7
|
DocumentContext,
|
|
7
8
|
FileRef,
|
|
@@ -13,9 +14,11 @@
|
|
|
13
14
|
cn,
|
|
14
15
|
Icon,
|
|
15
16
|
ResizablePanes,
|
|
17
|
+
Select,
|
|
16
18
|
Sheet,
|
|
17
19
|
Skeleton,
|
|
18
20
|
} from '@classic-homes/theme-svelte';
|
|
21
|
+
import { onMount } from 'svelte';
|
|
19
22
|
import { MediaQuery } from 'svelte/reactivity';
|
|
20
23
|
import { releaseStuckBodyLock } from '../body-lock.js';
|
|
21
24
|
import type { ComposerPrompt } from '../composer-prompts.js';
|
|
@@ -34,7 +37,15 @@
|
|
|
34
37
|
conversationId?: string | null;
|
|
35
38
|
/** Backend mode from `/api/me`; shows the mock banner before any turn runs. */
|
|
36
39
|
mode?: BackendMode;
|
|
40
|
+
/** Agent for a new conversation; a loaded conversation keeps its own. */
|
|
37
41
|
agent?: AgentKey;
|
|
42
|
+
/**
|
|
43
|
+
* Agents the user may address (`/api/agents`). With more than one, a new conversation offers
|
|
44
|
+
* a picker in the composer until its first message; the agent is fixed after that.
|
|
45
|
+
*/
|
|
46
|
+
agents?: AgentSummary[];
|
|
47
|
+
/** The agent of this conversation changed (picked, or learned from a loaded conversation). */
|
|
48
|
+
onagentchange?: (agent: AgentKey) => void;
|
|
38
49
|
clientKind?: ClientKind;
|
|
39
50
|
variant?: 'page' | 'panel';
|
|
40
51
|
getContext?: () => DocumentContext | null | undefined;
|
|
@@ -63,8 +74,8 @@
|
|
|
63
74
|
welcomeTitle?: string;
|
|
64
75
|
/** Replaces the welcome's capability line. */
|
|
65
76
|
welcomeDescription?: string;
|
|
66
|
-
/** Rendered above the transcript, e.g. a `ChatHeader
|
|
67
|
-
header?: import('svelte').Snippet;
|
|
77
|
+
/** Rendered above the transcript, e.g. a `ChatHeader`; receives the conversation's agent. */
|
|
78
|
+
header?: import('svelte').Snippet<[{ agent: AgentKey; agentName?: string }]>;
|
|
68
79
|
context?: import('svelte').Snippet;
|
|
69
80
|
/** Replaces the default `Welcome` on an empty conversation. */
|
|
70
81
|
empty?: import('svelte').Snippet;
|
|
@@ -76,6 +87,8 @@
|
|
|
76
87
|
conversationId = null,
|
|
77
88
|
mode,
|
|
78
89
|
agent = 'classic-agent',
|
|
90
|
+
agents,
|
|
91
|
+
onagentchange,
|
|
79
92
|
clientKind,
|
|
80
93
|
variant = 'page',
|
|
81
94
|
getContext,
|
|
@@ -163,6 +176,19 @@
|
|
|
163
176
|
const last = session.messages[session.messages.length - 1];
|
|
164
177
|
if (last?.role === 'assistant') void session.retry(last.requestId);
|
|
165
178
|
}
|
|
179
|
+
const agentName = $derived(agents?.find((a) => a.key === session.agent)?.name);
|
|
180
|
+
const pickAgent = $derived(
|
|
181
|
+
(agents?.length ?? 0) > 1 && !conversationId && session.messages.length === 0,
|
|
182
|
+
);
|
|
183
|
+
let reportedAgent: AgentKey | undefined;
|
|
184
|
+
$effect(() => {
|
|
185
|
+
const current = session.agent;
|
|
186
|
+
if (current !== reportedAgent) {
|
|
187
|
+
reportedAgent = current;
|
|
188
|
+
onagentchange?.(current);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
166
192
|
// An existing conversation whose transcript has not arrived yet: show a skeleton, not the
|
|
167
193
|
// welcome (which would flash before the messages).
|
|
168
194
|
const pending = $derived(
|
|
@@ -172,6 +198,17 @@
|
|
|
172
198
|
!session.loadError &&
|
|
173
199
|
session.messages.length === 0),
|
|
174
200
|
);
|
|
201
|
+
// A new chat starts in the composer.
|
|
202
|
+
onMount(() => {
|
|
203
|
+
if (!conversationId) composer?.focus();
|
|
204
|
+
});
|
|
205
|
+
/** Run a turn started from a control that goes away (suggestion, Regenerate, Edit), then put
|
|
206
|
+
* the keyboard back in the composer instead of on the page body. */
|
|
207
|
+
function thenFocus(run: Promise<void>): void {
|
|
208
|
+
composer?.focus();
|
|
209
|
+
void run.finally(() => composer?.focus());
|
|
210
|
+
}
|
|
211
|
+
|
|
175
212
|
function reload(): void {
|
|
176
213
|
if (conversationId) void session.load(conversationId);
|
|
177
214
|
}
|
|
@@ -181,6 +218,17 @@
|
|
|
181
218
|
}
|
|
182
219
|
</script>
|
|
183
220
|
|
|
221
|
+
{#snippet agentPicker()}
|
|
222
|
+
<Select
|
|
223
|
+
value={session.agent}
|
|
224
|
+
onValueChange={(value) => (session.agent = value as AgentKey)}
|
|
225
|
+
options={(agents ?? []).map((a) => ({ value: a.key, label: a.name }))}
|
|
226
|
+
aria-label="Agent"
|
|
227
|
+
disabled={session.status !== 'idle'}
|
|
228
|
+
class="h-8 w-auto min-w-36 border-0 bg-transparent text-xs shadow-none hover:bg-muted"
|
|
229
|
+
/>
|
|
230
|
+
{/snippet}
|
|
231
|
+
|
|
184
232
|
{#snippet emptyState()}
|
|
185
233
|
{#if pending}
|
|
186
234
|
<div
|
|
@@ -213,14 +261,14 @@
|
|
|
213
261
|
{suggestions}
|
|
214
262
|
compact={variant === 'panel'}
|
|
215
263
|
disabled={session.status !== 'idle'}
|
|
216
|
-
onselect={(text) =>
|
|
264
|
+
onselect={(text) => thenFocus(send(text))}
|
|
217
265
|
/>
|
|
218
266
|
{/if}
|
|
219
267
|
{/snippet}
|
|
220
268
|
|
|
221
269
|
{#snippet chat()}
|
|
222
270
|
<div class="flex h-full min-w-0 flex-1 flex-col">
|
|
223
|
-
{#if header}{@render header()}{/if}
|
|
271
|
+
{#if header}{@render header({ agent: session.agent, agentName })}{/if}
|
|
224
272
|
<ModeBanner mode={session.mode} />
|
|
225
273
|
{#if session.loadError}
|
|
226
274
|
<div class="mx-auto w-full max-w-3xl px-4 pt-3">
|
|
@@ -254,8 +302,8 @@
|
|
|
254
302
|
ontool={(name, params) => ontoolrequest?.(name, params)}
|
|
255
303
|
{onnotify}
|
|
256
304
|
onretry={retry}
|
|
257
|
-
onregenerate={(requestId) =>
|
|
258
|
-
onedit={(requestId, text) =>
|
|
305
|
+
onregenerate={(requestId) => thenFocus(session.regenerate(requestId))}
|
|
306
|
+
onedit={(requestId, text) => thenFocus(session.edit(requestId, text))}
|
|
259
307
|
onrate={session.conversationId
|
|
260
308
|
? (requestId, rating, comment) =>
|
|
261
309
|
session
|
|
@@ -277,6 +325,7 @@
|
|
|
277
325
|
onsend={send}
|
|
278
326
|
oncancel={() => session.cancel()}
|
|
279
327
|
{context}
|
|
328
|
+
tools={pickAgent ? agentPicker : undefined}
|
|
280
329
|
/>
|
|
281
330
|
</div>
|
|
282
331
|
{/snippet}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentApiClient } from '@classic-homes/agent-client';
|
|
2
|
-
import type { AgentKey, ClientKind, DocumentContext } from '@classic-homes/agent-contracts';
|
|
2
|
+
import type { AgentKey, AgentSummary, ClientKind, DocumentContext } from '@classic-homes/agent-contracts';
|
|
3
3
|
import type { BackendMode } from '@classic-homes/agent-contracts';
|
|
4
4
|
import type { ComposerPrompt } from '../composer-prompts.js';
|
|
5
5
|
import type { WelcomeSuggestion } from '../welcome.js';
|
|
@@ -10,7 +10,15 @@ interface Props {
|
|
|
10
10
|
conversationId?: string | null;
|
|
11
11
|
/** Backend mode from `/api/me`; shows the mock banner before any turn runs. */
|
|
12
12
|
mode?: BackendMode;
|
|
13
|
+
/** Agent for a new conversation; a loaded conversation keeps its own. */
|
|
13
14
|
agent?: AgentKey;
|
|
15
|
+
/**
|
|
16
|
+
* Agents the user may address (`/api/agents`). With more than one, a new conversation offers
|
|
17
|
+
* a picker in the composer until its first message; the agent is fixed after that.
|
|
18
|
+
*/
|
|
19
|
+
agents?: AgentSummary[];
|
|
20
|
+
/** The agent of this conversation changed (picked, or learned from a loaded conversation). */
|
|
21
|
+
onagentchange?: (agent: AgentKey) => void;
|
|
14
22
|
clientKind?: ClientKind;
|
|
15
23
|
variant?: 'page' | 'panel';
|
|
16
24
|
getContext?: () => DocumentContext | null | undefined;
|
|
@@ -39,8 +47,11 @@ interface Props {
|
|
|
39
47
|
welcomeTitle?: string;
|
|
40
48
|
/** Replaces the welcome's capability line. */
|
|
41
49
|
welcomeDescription?: string;
|
|
42
|
-
/** Rendered above the transcript, e.g. a `ChatHeader
|
|
43
|
-
header?: import('svelte').Snippet
|
|
50
|
+
/** Rendered above the transcript, e.g. a `ChatHeader`; receives the conversation's agent. */
|
|
51
|
+
header?: import('svelte').Snippet<[{
|
|
52
|
+
agent: AgentKey;
|
|
53
|
+
agentName?: string;
|
|
54
|
+
}]>;
|
|
44
55
|
context?: import('svelte').Snippet;
|
|
45
56
|
/** Replaces the default `Welcome` on an empty conversation. */
|
|
46
57
|
empty?: import('svelte').Snippet;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AgentChat.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AgentChat.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,KAAK,EACR,QAAQ,EACR,UAAU,EACV,eAAe,EAEhB,MAAM,gCAAgC,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"AgentChat.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AgentChat.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,KAAK,EACR,QAAQ,EACR,YAAY,EACZ,UAAU,EACV,eAAe,EAEhB,MAAM,gCAAgC,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAclE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAQrD,UAAU,KAAK;IACb,MAAM,EAAE,cAAc,CAAC;IACvB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,+EAA+E;IAC/E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,yEAAyE;IACzE,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,8FAA8F;IAC9F,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,CAAC;IAC1C,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,eAAe,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,qBAAqB,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD,gFAAgF;IAChF,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,IAAI,CAAC;IACX,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,oFAAoF;IACpF,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAClC,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8CAA8C;IAC9C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,6FAA6F;IAC7F,MAAM,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC,CAAC;QAAE,KAAK,EAAE,QAAQ,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IAC7E,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAkPH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
onremovefile?: (fileId: string) => void;
|
|
28
28
|
/** Slot for a host-provided context chip (page context toggle). */
|
|
29
29
|
context?: import('svelte').Snippet;
|
|
30
|
+
/** Toolbar controls before the context chip (e.g. the agent picker). */
|
|
31
|
+
tools?: import('svelte').Snippet;
|
|
30
32
|
}
|
|
31
33
|
let {
|
|
32
34
|
disabled = false,
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
oncancel,
|
|
41
43
|
onremovefile,
|
|
42
44
|
context,
|
|
45
|
+
tools,
|
|
43
46
|
}: Props = $props();
|
|
44
47
|
|
|
45
48
|
const uid = $props.id();
|
|
@@ -248,9 +251,7 @@
|
|
|
248
251
|
{placeholder}
|
|
249
252
|
aria-label="Message"
|
|
250
253
|
maxlength={LIMITS.messageChars}
|
|
251
|
-
role={prompts?.length ? 'combobox' : undefined}
|
|
252
254
|
aria-autocomplete={prompts?.length ? 'list' : undefined}
|
|
253
|
-
aria-expanded={prompts?.length ? menuOpen : undefined}
|
|
254
255
|
aria-controls={menuOpen ? `${uid}-prompts` : undefined}
|
|
255
256
|
aria-activedescendant={menuOpen ? `${uid}-prompt-${active}` : undefined}
|
|
256
257
|
{disabled}
|
|
@@ -272,6 +273,7 @@
|
|
|
272
273
|
<Icon name="paperclip" size={18} />
|
|
273
274
|
</Button>
|
|
274
275
|
{/if}
|
|
276
|
+
{#if tools}<div class="min-w-0">{@render tools()}</div>{/if}
|
|
275
277
|
{#if context}<div class="min-w-0">{@render context()}</div>{/if}
|
|
276
278
|
<div class="ml-auto flex items-center gap-2">
|
|
277
279
|
{#if showCount}
|
|
@@ -334,7 +336,8 @@
|
|
|
334
336
|
>
|
|
335
337
|
<ul id={`${uid}-prompts`} role="listbox" aria-label="Saved prompts" class="grid gap-0.5">
|
|
336
338
|
{#each matches as prompt, i (prompt.id)}
|
|
337
|
-
<!-- Keyboard choice happens in the textarea (
|
|
339
|
+
<!-- Keyboard choice happens in the textarea (textbox + aria-activedescendant; a textarea
|
|
340
|
+
may not take role=combobox). -->
|
|
338
341
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
339
342
|
<li
|
|
340
343
|
id={`${uid}-prompt-${i}`}
|
|
@@ -15,6 +15,8 @@ interface Props {
|
|
|
15
15
|
onremovefile?: (fileId: string) => void;
|
|
16
16
|
/** Slot for a host-provided context chip (page context toggle). */
|
|
17
17
|
context?: import('svelte').Snippet;
|
|
18
|
+
/** Toolbar controls before the context chip (e.g. the agent picker). */
|
|
19
|
+
tools?: import('svelte').Snippet;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* The message box: an autosizing textarea in a card with attach, the host's context chip and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Composer.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Composer.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAG9D,OAAO,EAA8B,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAIvF,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1C,oEAAoE;IACpE,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"Composer.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Composer.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAG9D,OAAO,EAA8B,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAIvF,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1C,oEAAoE;IACpE,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;AA6PH;;;;GAIG;AACH,QAAA,MAAM,QAAQ;iBAhOO,IAAI;qBAgO6B,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
|
@@ -113,6 +113,7 @@
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
const RENDER_TAIL = 6;
|
|
116
117
|
const separators = $derived(daySeparators(messages));
|
|
117
118
|
// Only show day labels when the transcript spans more than one day.
|
|
118
119
|
const multiDay = $derived(separators.size > 1);
|
|
@@ -150,35 +151,43 @@
|
|
|
150
151
|
<span class="h-px flex-1 bg-border"></span>
|
|
151
152
|
</div>
|
|
152
153
|
{/if}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{fileUrl}
|
|
159
|
-
{toolLabels}
|
|
160
|
-
{onnavigate}
|
|
161
|
-
{onopenartifact}
|
|
162
|
-
{onprompt}
|
|
163
|
-
{ontool}
|
|
164
|
-
{onnotify}
|
|
165
|
-
onretry={i === messages.length - 1 ? onretry : undefined}
|
|
166
|
-
oncontinue={i === messages.length - 1 && !streaming ? oncontinue : undefined}
|
|
167
|
-
onregenerate={latest &&
|
|
168
|
-
onregenerate &&
|
|
169
|
-
i === messages.length - 1 &&
|
|
170
|
-
message.role === 'assistant' &&
|
|
171
|
-
message.status === 'done'
|
|
172
|
-
? () => onregenerate(latest)
|
|
154
|
+
<!-- Long transcripts: let the browser skip layout and paint of turns far above the
|
|
155
|
+
fold (the last few always render, they change while streaming). -->
|
|
156
|
+
<div
|
|
157
|
+
class={i < messages.length - RENDER_TAIL
|
|
158
|
+
? '[contain-intrinsic-size:auto_12rem] [content-visibility:auto]'
|
|
173
159
|
: undefined}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
160
|
+
>
|
|
161
|
+
<MessageItem
|
|
162
|
+
{message}
|
|
163
|
+
last={i === messages.length - 1}
|
|
164
|
+
streaming={streaming && i === messages.length - 1 && message.role === 'assistant'}
|
|
165
|
+
{sandboxOrigin}
|
|
166
|
+
{fileUrl}
|
|
167
|
+
{toolLabels}
|
|
168
|
+
{onnavigate}
|
|
169
|
+
{onopenartifact}
|
|
170
|
+
{onprompt}
|
|
171
|
+
{ontool}
|
|
172
|
+
{onnotify}
|
|
173
|
+
onretry={i === messages.length - 1 ? onretry : undefined}
|
|
174
|
+
oncontinue={i === messages.length - 1 && !streaming ? oncontinue : undefined}
|
|
175
|
+
onregenerate={latest &&
|
|
176
|
+
onregenerate &&
|
|
177
|
+
i === messages.length - 1 &&
|
|
178
|
+
message.role === 'assistant' &&
|
|
179
|
+
message.status === 'done'
|
|
180
|
+
? () => onregenerate(latest)
|
|
181
|
+
: undefined}
|
|
182
|
+
onrate={onrate && message.role === 'assistant'
|
|
183
|
+
? (rating: TurnRating | null, comment?: string) =>
|
|
184
|
+
onrate(message.requestId, rating, comment)
|
|
185
|
+
: undefined}
|
|
186
|
+
onedit={latest && onedit && i === messages.length - 2
|
|
187
|
+
? (text: string) => onedit(latest, text)
|
|
188
|
+
: undefined}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
182
191
|
{/each}
|
|
183
192
|
</div>
|
|
184
193
|
{/if}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessageList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageList.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK/C,UAAU,KAAK;IACb,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACtE,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,uDAAuD;IACvD,YAAY,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,yCAAyC;IACzC,MAAM,CAAC,EAAE,CACP,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,UAAU,GAAG,IAAI,EACzB,OAAO,CAAC,EAAE,MAAM,KACb,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,mDAAmD;IACnD,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;
|
|
1
|
+
{"version":3,"file":"MessageList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageList.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK/C,UAAU,KAAK;IACb,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACtE,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,uDAAuD;IACvD,YAAY,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,yCAAyC;IACzC,MAAM,CAAC,EAAE,CACP,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,UAAU,GAAG,IAAI,EACzB,OAAO,CAAC,EAAE,MAAM,KACb,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,mDAAmD;IACnD,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;AAwJH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
let editing = $state(false);
|
|
23
23
|
let draft = $state('');
|
|
24
24
|
let field = $state<HTMLTextAreaElement | null>(null);
|
|
25
|
+
let root = $state<HTMLDivElement | null>(null);
|
|
26
|
+
/** Leaving the editor without sending returns focus to its Edit button. */
|
|
27
|
+
async function cancel(): Promise<void> {
|
|
28
|
+
editing = false;
|
|
29
|
+
await tick();
|
|
30
|
+
root?.querySelector<HTMLButtonElement>('button[aria-label="Edit message"]')?.focus();
|
|
31
|
+
}
|
|
25
32
|
async function startEdit(): Promise<void> {
|
|
26
33
|
draft = message.text;
|
|
27
34
|
editing = true;
|
|
@@ -39,7 +46,7 @@
|
|
|
39
46
|
if (event.isComposing || event.keyCode === 229) return;
|
|
40
47
|
if (event.key === 'Escape') {
|
|
41
48
|
event.preventDefault();
|
|
42
|
-
|
|
49
|
+
void cancel();
|
|
43
50
|
} else if (event.key === 'Enter' && !event.shiftKey) {
|
|
44
51
|
event.preventDefault();
|
|
45
52
|
save();
|
|
@@ -47,7 +54,11 @@
|
|
|
47
54
|
}
|
|
48
55
|
</script>
|
|
49
56
|
|
|
50
|
-
<div
|
|
57
|
+
<div
|
|
58
|
+
bind:this={root}
|
|
59
|
+
class="group/message flex flex-col items-end gap-1 px-4 py-2"
|
|
60
|
+
data-role="user"
|
|
61
|
+
>
|
|
51
62
|
{#if message.attachments.length || message.contextLabel}
|
|
52
63
|
<ul class="flex max-w-[85%] flex-wrap justify-end gap-1.5" aria-label="Attached">
|
|
53
64
|
{#if message.contextLabel}
|
|
@@ -90,8 +101,7 @@
|
|
|
90
101
|
{onkeydown}
|
|
91
102
|
/>
|
|
92
103
|
<div class="flex justify-end gap-2">
|
|
93
|
-
<Button type="button" variant="ghost" size="sm" onclick={() => (
|
|
94
|
-
>Cancel</Button
|
|
104
|
+
<Button type="button" variant="ghost" size="sm" onclick={() => void cancel()}>Cancel</Button
|
|
95
105
|
>
|
|
96
106
|
<Button type="submit" size="sm" disabled={!draft.trim()}>Send</Button>
|
|
97
107
|
</div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"UserMessage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/UserMessage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK/C,UAAU,KAAK;IACb,OAAO,EAAE,WAAW,CAAC;IACrB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,+DAA+D;IAC/D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC;
|
|
1
|
+
{"version":3,"file":"UserMessage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/UserMessage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK/C,UAAU,KAAK;IACb,OAAO,EAAE,WAAW,CAAC;IACrB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,+DAA+D;IAC/D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC;AAsFH;;;GAGG;AACH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a11y.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/a11y.svelte.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import axe from 'axe-core';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import AgentChat from './AgentChat.svelte';
|
|
6
|
+
import ConversationSidebar from './ConversationSidebar.svelte';
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
cleanup();
|
|
9
|
+
document.body.removeAttribute('style');
|
|
10
|
+
});
|
|
11
|
+
/** axe in jsdom: no layout, so colour contrast is checked separately (tokens), not here. */
|
|
12
|
+
async function violations(root = document.body) {
|
|
13
|
+
const result = await axe.run(root, {
|
|
14
|
+
rules: { 'color-contrast': { enabled: false }, region: { enabled: false } },
|
|
15
|
+
});
|
|
16
|
+
return result.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.html).join(' | ')}`);
|
|
17
|
+
}
|
|
18
|
+
const scope = { agent: 'classic-agent', runId: 'run-1' };
|
|
19
|
+
function fakeClient() {
|
|
20
|
+
return {
|
|
21
|
+
fileUrl: (id) => `#${id}`,
|
|
22
|
+
uploadFile: vi.fn(),
|
|
23
|
+
cancel: vi.fn(),
|
|
24
|
+
resumeTurn: vi.fn(),
|
|
25
|
+
sendFeedback: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
getConversation: vi.fn(),
|
|
27
|
+
streamChat: vi.fn(async (req, opts) => {
|
|
28
|
+
[
|
|
29
|
+
{ type: 'start', conversationId: 'c-1', mode: 'live' },
|
|
30
|
+
{ type: 'tool_call', callId: 't1', name: 'hotsheet/list_communities', argsPreview: '{}' },
|
|
31
|
+
{ type: 'tool_result', callId: 't1', status: 'ok', durationMs: 640, summary: '24 rows' },
|
|
32
|
+
{ type: 'delta', text: '| A | B |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst a = 1;\n```\n' },
|
|
33
|
+
{
|
|
34
|
+
type: 'artifact',
|
|
35
|
+
...scope,
|
|
36
|
+
artifactId: 'a1',
|
|
37
|
+
op: 'open',
|
|
38
|
+
mediaType: 'text/markdown',
|
|
39
|
+
title: 'Memo',
|
|
40
|
+
},
|
|
41
|
+
{ type: 'artifact', ...scope, artifactId: 'a1', op: 'delta', text: '# Memo' },
|
|
42
|
+
{ type: 'artifact', ...scope, artifactId: 'a1', op: 'close' },
|
|
43
|
+
{ type: 'done', finishReason: 'stop' },
|
|
44
|
+
].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
|
|
45
|
+
return { kind: 'done' };
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const base = { sandboxOrigin: 'https://frame.test' };
|
|
50
|
+
describe('accessibility (axe)', () => {
|
|
51
|
+
it('has no violations on the welcome screen with the agent picker', async () => {
|
|
52
|
+
render(AgentChat, {
|
|
53
|
+
props: {
|
|
54
|
+
...base,
|
|
55
|
+
client: fakeClient(),
|
|
56
|
+
userName: 'Pat',
|
|
57
|
+
suggestions: [{ label: 'Open lots', description: 'By community', icon: 'home' }],
|
|
58
|
+
agents: [
|
|
59
|
+
{ key: 'classic-agent', name: 'Classic Agent' },
|
|
60
|
+
{ key: 'elevation-studio', name: 'Elevation Studio' },
|
|
61
|
+
],
|
|
62
|
+
prompts: [{ id: 'p1', label: 'Weekly recap', text: 'Recap' }],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
expect(await violations()).toEqual([]);
|
|
66
|
+
// With the saved-prompt menu open.
|
|
67
|
+
await userEvent.type(screen.getByRole('textbox', { name: 'Message' }), '/');
|
|
68
|
+
await screen.findByRole('listbox', { name: 'Saved prompts' });
|
|
69
|
+
expect(await violations()).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
it('has no violations on a failed turn with Try again, and while editing', async () => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
const client = fakeClient();
|
|
74
|
+
client.streamChat.mockImplementationOnce(async (req, opts) => {
|
|
75
|
+
[
|
|
76
|
+
{ type: 'start', conversationId: 'c-1', mode: 'live' },
|
|
77
|
+
{ type: 'error', code: 'upstream_error', message: 'Model failed.', retryable: true },
|
|
78
|
+
].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
|
|
79
|
+
return { kind: 'done' };
|
|
80
|
+
});
|
|
81
|
+
render(AgentChat, { props: { ...base, client: client } });
|
|
82
|
+
await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
|
|
83
|
+
await screen.findByRole('button', { name: /Try again/ });
|
|
84
|
+
expect(await violations()).toEqual([]);
|
|
85
|
+
await user.click(screen.getByRole('button', { name: /Try again/ }));
|
|
86
|
+
// The retried answer opens its artifact in a (modal) sheet; close it first.
|
|
87
|
+
await screen.findByRole('dialog', { name: 'Memo' });
|
|
88
|
+
await user.keyboard('{Escape}');
|
|
89
|
+
await waitFor(() => expect(document.body.style.pointerEvents).not.toBe('none'));
|
|
90
|
+
await user.click(await screen.findByRole('button', { name: 'Edit message' }));
|
|
91
|
+
await screen.findByRole('textbox', { name: 'Edited message' });
|
|
92
|
+
expect(await violations()).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
it('has no violations on a finished turn with tools, code, a table and an artifact', async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
render(AgentChat, { props: { ...base, client: fakeClient(), variant: 'panel' } });
|
|
97
|
+
await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
|
|
98
|
+
await screen.findByRole('button', { name: /Open Memo|Memo/ });
|
|
99
|
+
// The artifact opened in a sheet; close it to check the transcript itself.
|
|
100
|
+
await user.keyboard('{Escape}');
|
|
101
|
+
expect(await violations()).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
it('has no violations in the conversation rail', async () => {
|
|
104
|
+
const now = new Date().toISOString();
|
|
105
|
+
render(ConversationSidebar, {
|
|
106
|
+
props: {
|
|
107
|
+
conversations: [
|
|
108
|
+
{
|
|
109
|
+
conversationId: 'c1',
|
|
110
|
+
agent: 'classic-agent',
|
|
111
|
+
title: 'Lot 12',
|
|
112
|
+
createdAt: now,
|
|
113
|
+
updatedAt: now,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
conversationId: 'c2',
|
|
117
|
+
agent: 'classic-agent',
|
|
118
|
+
title: 'Running',
|
|
119
|
+
createdAt: now,
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
activeRequestId: '11111111-1111-4111-8111-111111111111',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
selectedId: 'c1',
|
|
125
|
+
onrename: vi.fn(),
|
|
126
|
+
ondelete: vi.fn(),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
expect(await violations()).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
/** Answers "ok" with no artifact (an artifact opens a modal sheet, which takes focus). */
|
|
133
|
+
function plainAnswers(client) {
|
|
134
|
+
client.streamChat.mockImplementation(async (req, opts) => {
|
|
135
|
+
[
|
|
136
|
+
{ type: 'start', conversationId: 'c-1', mode: 'live' },
|
|
137
|
+
{ type: 'delta', text: 'ok' },
|
|
138
|
+
{ type: 'done', finishReason: 'stop' },
|
|
139
|
+
].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
|
|
140
|
+
return { kind: 'done' };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
describe('focus', () => {
|
|
144
|
+
it('starts a new chat in the composer and returns there after a suggestion', async () => {
|
|
145
|
+
const user = userEvent.setup();
|
|
146
|
+
const client = fakeClient();
|
|
147
|
+
plainAnswers(client);
|
|
148
|
+
render(AgentChat, {
|
|
149
|
+
props: { ...base, client: client, suggestions: [{ label: 'Open lots' }] },
|
|
150
|
+
});
|
|
151
|
+
const box = screen.getByRole('textbox', { name: 'Message' });
|
|
152
|
+
await waitFor(() => expect(document.activeElement).toBe(box));
|
|
153
|
+
await user.click(screen.getByRole('button', { name: 'Open lots' }));
|
|
154
|
+
await waitFor(() => expect(document.activeElement).toBe(box));
|
|
155
|
+
});
|
|
156
|
+
it('returns to the Edit button when an edit is cancelled', async () => {
|
|
157
|
+
const user = userEvent.setup();
|
|
158
|
+
const client = fakeClient();
|
|
159
|
+
plainAnswers(client);
|
|
160
|
+
render(AgentChat, { props: { ...base, client: client } });
|
|
161
|
+
await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
|
|
162
|
+
await user.click(await screen.findByRole('button', { name: 'Edit message' }));
|
|
163
|
+
await user.keyboard('{Escape}');
|
|
164
|
+
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Edit message' })));
|
|
165
|
+
});
|
|
166
|
+
it('does not steal focus when opening an existing conversation', () => {
|
|
167
|
+
const client = fakeClient();
|
|
168
|
+
client.getConversation.mockReturnValue(new Promise(() => { }));
|
|
169
|
+
render(AgentChat, { props: { ...base, client: client, conversationId: 'c-9' } });
|
|
170
|
+
expect(document.activeElement).toBe(document.body);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-picker.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/agent-picker.svelte.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { ChatSession } from '../session.svelte.js';
|
|
5
|
+
import AgentChat from './AgentChat.svelte';
|
|
6
|
+
afterEach(() => cleanup());
|
|
7
|
+
const agents = [
|
|
8
|
+
{ key: 'classic-agent', name: 'Classic Agent' },
|
|
9
|
+
{ key: 'elevation-studio', name: 'Elevation Studio' },
|
|
10
|
+
];
|
|
11
|
+
function fakeClient() {
|
|
12
|
+
return {
|
|
13
|
+
fileUrl: (id) => `#${id}`,
|
|
14
|
+
uploadFile: vi.fn(),
|
|
15
|
+
cancel: vi.fn(),
|
|
16
|
+
resumeTurn: vi.fn(),
|
|
17
|
+
sendFeedback: vi.fn(),
|
|
18
|
+
getConversation: vi.fn().mockResolvedValue({
|
|
19
|
+
conversation: { conversationId: 'c-9', agent: 'elevation-studio' },
|
|
20
|
+
messages: [],
|
|
21
|
+
files: [],
|
|
22
|
+
}),
|
|
23
|
+
streamChat: vi.fn(async (req, opts) => {
|
|
24
|
+
[
|
|
25
|
+
{ type: 'start', conversationId: 'c-1', mode: 'live' },
|
|
26
|
+
{ type: 'delta', text: 'Hello' },
|
|
27
|
+
{ type: 'done', finishReason: 'stop' },
|
|
28
|
+
].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
|
|
29
|
+
return { kind: 'done' };
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const base = { sandboxOrigin: 'https://frame.test' };
|
|
34
|
+
describe('ChatSession agent', () => {
|
|
35
|
+
it('sends the chosen agent and adopts a loaded conversation’s agent', async () => {
|
|
36
|
+
const client = fakeClient();
|
|
37
|
+
const session = new ChatSession({ client: client });
|
|
38
|
+
expect(session.agent).toBe('classic-agent');
|
|
39
|
+
session.agent = 'elevation-studio';
|
|
40
|
+
await session.send('hi');
|
|
41
|
+
expect(client.streamChat.mock.calls[0][0]).toMatchObject({ agent: 'elevation-studio' });
|
|
42
|
+
const other = new ChatSession({ client: client, conversationId: 'c-9' });
|
|
43
|
+
await other.load('c-9');
|
|
44
|
+
expect(other.agent).toBe('elevation-studio');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('AgentChat agent picker', () => {
|
|
48
|
+
it('offers a picker for a new conversation only, and hides it after the first message', async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
const client = fakeClient();
|
|
51
|
+
const onagentchange = vi.fn();
|
|
52
|
+
render(AgentChat, {
|
|
53
|
+
props: { ...base, client: client, agents, onagentchange },
|
|
54
|
+
});
|
|
55
|
+
expect(onagentchange).toHaveBeenLastCalledWith('classic-agent');
|
|
56
|
+
const picker = screen.getByRole('button', { name: 'Agent' });
|
|
57
|
+
await user.click(picker);
|
|
58
|
+
await user.click(await screen.findByRole('option', { name: 'Elevation Studio' }));
|
|
59
|
+
await waitFor(() => expect(onagentchange).toHaveBeenLastCalledWith('elevation-studio'));
|
|
60
|
+
await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
|
|
61
|
+
await screen.findAllByText('Hello');
|
|
62
|
+
expect(client.streamChat.mock.calls[0][0]).toMatchObject({ agent: 'elevation-studio' });
|
|
63
|
+
expect(screen.queryByRole('button', { name: 'Agent' })).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('has no picker with a single agent or on an existing conversation', async () => {
|
|
66
|
+
const { unmount } = render(AgentChat, {
|
|
67
|
+
props: { ...base, client: fakeClient(), agents: agents.slice(0, 1) },
|
|
68
|
+
});
|
|
69
|
+
expect(screen.queryByRole('button', { name: 'Agent' })).toBeNull();
|
|
70
|
+
unmount();
|
|
71
|
+
const onagentchange = vi.fn();
|
|
72
|
+
render(AgentChat, {
|
|
73
|
+
props: {
|
|
74
|
+
...base,
|
|
75
|
+
client: fakeClient(),
|
|
76
|
+
agents,
|
|
77
|
+
conversationId: 'c-9',
|
|
78
|
+
onagentchange,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
expect(screen.queryByRole('button', { name: 'Agent' })).toBeNull();
|
|
82
|
+
await waitFor(() => expect(onagentchange).toHaveBeenLastCalledWith('elevation-studio'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -59,7 +59,7 @@ describe('Composer', () => {
|
|
|
59
59
|
it('inserts a saved prompt from the / menu with the keyboard', async () => {
|
|
60
60
|
const user = userEvent.setup();
|
|
61
61
|
render(Composer, { props: { prompts } });
|
|
62
|
-
const box = screen.getByRole('
|
|
62
|
+
const box = screen.getByRole('textbox', { name: 'Message' });
|
|
63
63
|
await user.type(box, '/lot');
|
|
64
64
|
const listbox = await screen.findByRole('listbox', { name: 'Saved prompts' });
|
|
65
65
|
expect(listbox.textContent).toContain('Lot report');
|
|
@@ -70,7 +70,7 @@ describe('Composer', () => {
|
|
|
70
70
|
it('closes the menu on Escape and keeps the draft', async () => {
|
|
71
71
|
const user = userEvent.setup();
|
|
72
72
|
render(Composer, { props: { prompts } });
|
|
73
|
-
const box = screen.getByRole('
|
|
73
|
+
const box = screen.getByRole('textbox', { name: 'Message' });
|
|
74
74
|
await user.type(box, '/');
|
|
75
75
|
await screen.findByRole('listbox');
|
|
76
76
|
await user.keyboard('{ArrowDown}{Escape}');
|
package/dist/session.svelte.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export declare class ChatSession {
|
|
|
35
35
|
/** The conversation whose transcript `messages` holds (loaded or started here); null before. */
|
|
36
36
|
loadedConversationId: string | null;
|
|
37
37
|
mode: "mock" | "live" | undefined;
|
|
38
|
+
/** Who answers. Set before the first message; a loaded conversation keeps its own agent. */
|
|
39
|
+
agent: string;
|
|
38
40
|
loadError: string | undefined;
|
|
39
41
|
pendingFiles: {
|
|
40
42
|
fileId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.svelte.d.ts","sourceRoot":"","sources":["../src/lib/session.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAElE,OAAO,KAAK,EACV,QAAQ,EACR,WAAW,EACX,SAAS,EAET,UAAU,EACV,eAAe,EACf,OAAO,EACP,UAAU,EACX,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAML,KAAK,gBAAgB,EACrB,KAAK,WAAW,EACjB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,cAAc,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,mGAAmG;IACnG,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,eAAe,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,qBAAqB,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CAClG;AAED,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,SAAS,GAAG,WAAW,CAAC;AAE7D;;;GAGG;AACH,qBAAa,WAAW;IACtB,QAAQ,gBAA6B;IACrC,MAAM,gBAAiC;IACvC,cAAc,gBAA+B;IAC7C,gGAAgG;IAChG,oBAAoB,gBAA+B;IACnD,IAAI,8BAA8C;IAClD,SAAS,qBAAyC;IAClD,YAAY;;;;;;QAAyB;IAErC,OAAO,CAAC,UAAU,CAA8B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;gBAEjC,OAAO,EAAE,kBAAkB;
|
|
1
|
+
{"version":3,"file":"session.svelte.d.ts","sourceRoot":"","sources":["../src/lib/session.svelte.ts"],"names":[],"mappings":"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"}
|
package/dist/session.svelte.js
CHANGED
|
@@ -11,6 +11,8 @@ export class ChatSession {
|
|
|
11
11
|
/** The conversation whose transcript `messages` holds (loaded or started here); null before. */
|
|
12
12
|
loadedConversationId = $state(null);
|
|
13
13
|
mode = $state(undefined);
|
|
14
|
+
/** Who answers. Set before the first message; a loaded conversation keeps its own agent. */
|
|
15
|
+
agent = $state('classic-agent');
|
|
14
16
|
loadError = $state(undefined);
|
|
15
17
|
pendingFiles = $state([]);
|
|
16
18
|
controller;
|
|
@@ -19,6 +21,7 @@ export class ChatSession {
|
|
|
19
21
|
this.options = options;
|
|
20
22
|
this.conversationId = options.conversationId ?? null;
|
|
21
23
|
this.mode = options.mode;
|
|
24
|
+
this.agent = options.agent ?? 'classic-agent';
|
|
22
25
|
}
|
|
23
26
|
get streaming() {
|
|
24
27
|
return this.status === 'streaming';
|
|
@@ -51,6 +54,7 @@ export class ChatSession {
|
|
|
51
54
|
? { feedback: detail.feedback[m.id.replace(/^a-/, '')] }
|
|
52
55
|
: {}),
|
|
53
56
|
});
|
|
57
|
+
this.agent = detail.conversation.agent;
|
|
54
58
|
const active = detail.conversation.activeRequestId;
|
|
55
59
|
this.status = 'idle';
|
|
56
60
|
if (active)
|
|
@@ -75,7 +79,7 @@ export class ChatSession {
|
|
|
75
79
|
this.pendingFiles = [];
|
|
76
80
|
const request = {
|
|
77
81
|
requestId,
|
|
78
|
-
agent: this.
|
|
82
|
+
agent: this.agent,
|
|
79
83
|
message,
|
|
80
84
|
...(this.conversationId ? { conversationId: this.conversationId } : {}),
|
|
81
85
|
...(context ? { context } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classic-homes/agent-chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Svelte 5 chat UI for Classic Agent: session state, message list, composer, tool timeline, artifacts and MCP-UI sandbox frames. Consumed by the Classic App.",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"dist"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@classic-homes/agent-client": "0.
|
|
21
|
-
"@classic-homes/agent-contracts": "0.
|
|
20
|
+
"@classic-homes/agent-client": "0.9.0",
|
|
21
|
+
"@classic-homes/agent-contracts": "0.9.0",
|
|
22
22
|
"dompurify": "3.4.17"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"@sveltejs/vite-plugin-svelte": "7.3.1",
|
|
32
32
|
"@testing-library/svelte": "5.4.2",
|
|
33
33
|
"@testing-library/user-event": "14.6.1",
|
|
34
|
+
"axe-core": "4.13.0",
|
|
34
35
|
"jsdom": "30.1.3",
|
|
35
36
|
"svelte": "5.57.3",
|
|
36
37
|
"svelte-check": "4.7.7",
|