@classic-homes/agent-chat 0.8.0 → 0.10.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 CHANGED
@@ -43,6 +43,8 @@ agent-api origin that serves `/frame`; artifacts and MCP-UI resources only ever
43
43
  `AgentChat` composes the rest; each is also exported for other layouts:
44
44
 
45
45
  - `ChatHeader`, `ConversationSidebar`: title and conversation actions; the history rail.
46
+ `ChatHeader`'s menu opens `ShortcutsDialog` (the chat's `CHAT_SHORTCUTS` plus the host's
47
+ `shortcuts`, e.g. a panel toggle). Hosts implement their own app-level shortcuts.
46
48
  - `MessageList`: transcript with day separators and a jump-to-latest button.
47
49
  - `MessageItem` → `UserMessage` / `AssistantMessage`, with `MessageActions` (copy, time,
48
50
  `MessageFeedback` thumbs up/down with an optional note, Regenerate and Edit on the latest turn),
@@ -18,6 +18,7 @@
18
18
  Sheet,
19
19
  Skeleton,
20
20
  } from '@classic-homes/theme-svelte';
21
+ import { onMount } from 'svelte';
21
22
  import { MediaQuery } from 'svelte/reactivity';
22
23
  import { releaseStuckBodyLock } from '../body-lock.js';
23
24
  import type { ComposerPrompt } from '../composer-prompts.js';
@@ -197,6 +198,17 @@
197
198
  !session.loadError &&
198
199
  session.messages.length === 0),
199
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
+
200
212
  function reload(): void {
201
213
  if (conversationId) void session.load(conversationId);
202
214
  }
@@ -249,7 +261,7 @@
249
261
  {suggestions}
250
262
  compact={variant === 'panel'}
251
263
  disabled={session.status !== 'idle'}
252
- onselect={(text) => void send(text)}
264
+ onselect={(text) => thenFocus(send(text))}
253
265
  />
254
266
  {/if}
255
267
  {/snippet}
@@ -290,8 +302,8 @@
290
302
  ontool={(name, params) => ontoolrequest?.(name, params)}
291
303
  {onnotify}
292
304
  onretry={retry}
293
- onregenerate={(requestId) => void session.regenerate(requestId)}
294
- onedit={(requestId, text) => void session.edit(requestId, text)}
305
+ onregenerate={(requestId) => thenFocus(session.regenerate(requestId))}
306
+ onedit={(requestId, text) => thenFocus(session.edit(requestId, text))}
295
307
  onrate={session.conversationId
296
308
  ? (requestId, rating, comment) =>
297
309
  session
@@ -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,YAAY,EACZ,UAAU,EACV,eAAe,EAEhB,MAAM,gCAAgC,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAalE,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;AAsOH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,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"}
@@ -1,8 +1,9 @@
1
1
  <!--
2
2
  @component
3
3
  Header for a chat: conversation title, agent badge and the conversation menu (rename,
4
- open full page, delete). Pass it to `AgentChat` through the `header` snippet. Actions only
5
- show when their handler is set; rename and delete also need a `conversationId`.
4
+ open full page, delete, keyboard shortcuts). Pass it to `AgentChat` through the `header`
5
+ snippet. Actions only show when their handler is set; rename and delete also need a
6
+ `conversationId`. "Keyboard shortcuts" always shows and lists the host's `shortcuts` first.
6
7
  -->
7
8
  <script lang="ts">
8
9
  import {
@@ -14,7 +15,9 @@
14
15
  type DropdownMenuItem,
15
16
  } from '@classic-homes/theme-svelte';
16
17
  import type { Snippet } from 'svelte';
18
+ import type { ChatShortcut } from '../shortcuts.js';
17
19
  import ConversationDialogs, { type ConversationRef } from './ConversationDialogs.svelte';
20
+ import ShortcutsDialog from './ShortcutsDialog.svelte';
18
21
 
19
22
  interface Props {
20
23
  title?: string;
@@ -29,6 +32,8 @@
29
32
  onrename?: (conversationId: string, title: string) => void | Promise<void>;
30
33
  ondelete?: (conversationId: string) => void | Promise<void>;
31
34
  onerror?: (message: string) => void;
35
+ /** Host shortcuts for the "Keyboard shortcuts" dialog, e.g. opening the panel. */
36
+ shortcuts?: ChatShortcut[];
32
37
  /** Extra controls before the menu. */
33
38
  actions?: Snippet;
34
39
  class?: string;
@@ -43,12 +48,14 @@
43
48
  onrename,
44
49
  ondelete,
45
50
  onerror,
51
+ shortcuts,
46
52
  actions,
47
53
  class: className,
48
54
  }: Props = $props();
49
55
 
50
56
  let renaming = $state<ConversationRef | null>(null);
51
57
  let deleting = $state<ConversationRef | null>(null);
58
+ let showingShortcuts = $state(false);
52
59
  let headerEl = $state<HTMLElement | null>(null);
53
60
  let returnFocus = $state<HTMLElement | null>(null);
54
61
  /** Remember the menu trigger before a dialog takes focus from the closing menu. */
@@ -77,6 +84,12 @@
77
84
  onSelect: () => open(() => (deleting = current)),
78
85
  });
79
86
  }
87
+ if (list.length) list.push({ type: 'separator' });
88
+ list.push({
89
+ label: 'Keyboard shortcuts',
90
+ icon: 'keyboard',
91
+ onSelect: () => open(() => (showingShortcuts = true)),
92
+ });
80
93
  return list;
81
94
  });
82
95
  </script>
@@ -104,19 +117,18 @@
104
117
  ><Icon name="plus" size={18} /></Button
105
118
  >
106
119
  {/if}
107
- {#if items.length}
108
- <DropdownMenu
109
- {items}
110
- align="end"
111
- triggerVariant="ghost"
112
- triggerSize="icon"
113
- triggerClass="size-8"
114
- triggerLabel="Conversation options"
115
- showChevron={false}
116
- >
117
- {#snippet trigger()}<Icon name="more-horizontal" size={18} />{/snippet}
118
- </DropdownMenu>
119
- {/if}
120
+ <DropdownMenu
121
+ {items}
122
+ align="end"
123
+ triggerVariant="ghost"
124
+ triggerSize="icon"
125
+ triggerClass="size-8"
126
+ triggerLabel="Conversation options"
127
+ showChevron={false}
128
+ >
129
+ {#snippet trigger()}<Icon name="more-horizontal" size={18} />{/snippet}
130
+ </DropdownMenu>
120
131
  </header>
121
132
 
122
133
  <ConversationDialogs bind:renaming bind:deleting {onrename} {ondelete} {onerror} {returnFocus} />
134
+ <ShortcutsDialog bind:open={showingShortcuts} {shortcuts} {returnFocus} />
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { ChatShortcut } from '../shortcuts.js';
2
3
  interface Props {
3
4
  title?: string;
4
5
  conversationId?: string | null;
@@ -12,14 +13,17 @@ interface Props {
12
13
  onrename?: (conversationId: string, title: string) => void | Promise<void>;
13
14
  ondelete?: (conversationId: string) => void | Promise<void>;
14
15
  onerror?: (message: string) => void;
16
+ /** Host shortcuts for the "Keyboard shortcuts" dialog, e.g. opening the panel. */
17
+ shortcuts?: ChatShortcut[];
15
18
  /** Extra controls before the menu. */
16
19
  actions?: Snippet;
17
20
  class?: string;
18
21
  }
19
22
  /**
20
23
  * Header for a chat: conversation title, agent badge and the conversation menu (rename,
21
- * open full page, delete). Pass it to `AgentChat` through the `header` snippet. Actions only
22
- * show when their handler is set; rename and delete also need a `conversationId`.
24
+ * open full page, delete, keyboard shortcuts). Pass it to `AgentChat` through the `header`
25
+ * snippet. Actions only show when their handler is set; rename and delete also need a
26
+ * `conversationId`. "Keyboard shortcuts" always shows and lists the host's `shortcuts` first.
23
27
  */
24
28
  declare const ChatHeader: import("svelte").Component<Props, {}, "">;
25
29
  type ChatHeader = ReturnType<typeof ChatHeader>;
@@ -1 +1 @@
1
- {"version":3,"file":"ChatHeader.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ChatHeader.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAIpC,UAAU,KAAK;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8EH;;;;GAIG;AACH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"ChatHeader.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ChatHeader.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAKlD,UAAU,KAAK;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,kFAAkF;IAClF,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAuFH;;;;;GAKG;AACH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -251,9 +251,7 @@
251
251
  {placeholder}
252
252
  aria-label="Message"
253
253
  maxlength={LIMITS.messageChars}
254
- role={prompts?.length ? 'combobox' : undefined}
255
254
  aria-autocomplete={prompts?.length ? 'list' : undefined}
256
- aria-expanded={prompts?.length ? menuOpen : undefined}
257
255
  aria-controls={menuOpen ? `${uid}-prompts` : undefined}
258
256
  aria-activedescendant={menuOpen ? `${uid}-prompt-${active}` : undefined}
259
257
  {disabled}
@@ -338,7 +336,8 @@
338
336
  >
339
337
  <ul id={`${uid}-prompts`} role="listbox" aria-label="Saved prompts" class="grid gap-0.5">
340
338
  {#each matches as prompt, i (prompt.id)}
341
- <!-- Keyboard choice happens in the textarea (combobox + aria-activedescendant). -->
339
+ <!-- Keyboard choice happens in the textarea (textbox + aria-activedescendant; a textarea
340
+ may not take role=combobox). -->
342
341
  <!-- svelte-ignore a11y_click_events_have_key_events -->
343
342
  <li
344
343
  id={`${uid}-prompt-${i}`}
@@ -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
- <MessageItem
154
- {message}
155
- last={i === messages.length - 1}
156
- streaming={streaming && i === messages.length - 1 && message.role === 'assistant'}
157
- {sandboxOrigin}
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
- onrate={onrate && message.role === 'assistant'
175
- ? (rating: TurnRating | null, comment?: string) =>
176
- onrate(message.requestId, rating, comment)
177
- : undefined}
178
- onedit={latest && onedit && i === messages.length - 2
179
- ? (text: string) => onedit(latest, text)
180
- : undefined}
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;AAkJH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
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"}
@@ -0,0 +1,56 @@
1
+ <!--
2
+ @component
3
+ Lists the chat's keyboard shortcuts (`CHAT_SHORTCUTS`) and the host's own (`shortcuts`, shown
4
+ first). Opened from the `ChatHeader` menu; focus returns to `returnFocus` on close.
5
+ -->
6
+ <script lang="ts">
7
+ import { Dialog, Kbd } from '@classic-homes/theme-svelte';
8
+ import { releaseStuckBodyLock } from '../body-lock.js';
9
+ import { CHAT_SHORTCUTS, type ChatShortcut } from '../shortcuts.js';
10
+
11
+ interface Props {
12
+ open?: boolean;
13
+ /** Host shortcuts, e.g. opening the agent panel or starting a new chat. */
14
+ shortcuts?: ChatShortcut[];
15
+ returnFocus?: HTMLElement | null;
16
+ }
17
+ let { open = $bindable(false), shortcuts = [], returnFocus = null }: Props = $props();
18
+
19
+ const groups = $derived(
20
+ [
21
+ { title: 'App', items: shortcuts },
22
+ { title: 'Chat', items: [...CHAT_SHORTCUTS] },
23
+ ].filter((g) => g.items.length),
24
+ );
25
+ </script>
26
+
27
+ <Dialog
28
+ bind:open
29
+ title="Keyboard shortcuts"
30
+ size="sm"
31
+ onCloseAutoFocus={(event: Event) => {
32
+ if (returnFocus?.isConnected) {
33
+ event.preventDefault();
34
+ returnFocus.focus();
35
+ }
36
+ releaseStuckBodyLock();
37
+ }}
38
+ >
39
+ <div class="grid gap-4">
40
+ {#each groups as group (group.title)}
41
+ <section>
42
+ <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
43
+ {group.title}
44
+ </h3>
45
+ <dl class="divide-y divide-border text-sm">
46
+ {#each group.items as s (s.label)}
47
+ <div class="flex items-center justify-between gap-4 py-1.5">
48
+ <dt>{s.label}</dt>
49
+ <dd class="shrink-0"><Kbd keys={s.keys} size="sm" /></dd>
50
+ </div>
51
+ {/each}
52
+ </dl>
53
+ </section>
54
+ {/each}
55
+ </div>
56
+ </Dialog>
@@ -0,0 +1,15 @@
1
+ import { type ChatShortcut } from '../shortcuts.js';
2
+ interface Props {
3
+ open?: boolean;
4
+ /** Host shortcuts, e.g. opening the agent panel or starting a new chat. */
5
+ shortcuts?: ChatShortcut[];
6
+ returnFocus?: HTMLElement | null;
7
+ }
8
+ /**
9
+ * Lists the chat's keyboard shortcuts (`CHAT_SHORTCUTS`) and the host's own (`shortcuts`, shown
10
+ * first). Opened from the `ChatHeader` menu; focus returns to `returnFocus` on close.
11
+ */
12
+ declare const ShortcutsDialog: import("svelte").Component<Props, {}, "open">;
13
+ type ShortcutsDialog = ReturnType<typeof ShortcutsDialog>;
14
+ export default ShortcutsDialog;
15
+ //# sourceMappingURL=ShortcutsDialog.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ShortcutsDialog.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ShortcutsDialog.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGlE,UAAU,KAAK;IACb,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,2EAA2E;IAC3E,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;CAClC;AA6CH;;;GAGG;AACH,QAAA,MAAM,eAAe,+CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,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
- editing = false;
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 class="group/message flex flex-col items-end gap-1 px-4 py-2" data-role="user">
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={() => (editing = false)}
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;AA+EH;;;GAGG;AACH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=a11y.svelte.test.d.ts.map
@@ -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,183 @@
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 ChatHeader from './ChatHeader.svelte';
7
+ import ConversationSidebar from './ConversationSidebar.svelte';
8
+ afterEach(() => {
9
+ cleanup();
10
+ document.body.removeAttribute('style');
11
+ });
12
+ /** axe in jsdom: no layout, so colour contrast is checked separately (tokens), not here. */
13
+ async function violations(root = document.body) {
14
+ const result = await axe.run(root, {
15
+ rules: { 'color-contrast': { enabled: false }, region: { enabled: false } },
16
+ });
17
+ return result.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.html).join(' | ')}`);
18
+ }
19
+ const scope = { agent: 'classic-agent', runId: 'run-1' };
20
+ function fakeClient() {
21
+ return {
22
+ fileUrl: (id) => `#${id}`,
23
+ uploadFile: vi.fn(),
24
+ cancel: vi.fn(),
25
+ resumeTurn: vi.fn(),
26
+ sendFeedback: vi.fn().mockResolvedValue(undefined),
27
+ getConversation: vi.fn(),
28
+ streamChat: vi.fn(async (req, opts) => {
29
+ [
30
+ { type: 'start', conversationId: 'c-1', mode: 'live' },
31
+ { type: 'tool_call', callId: 't1', name: 'hotsheet/list_communities', argsPreview: '{}' },
32
+ { type: 'tool_result', callId: 't1', status: 'ok', durationMs: 640, summary: '24 rows' },
33
+ { type: 'delta', text: '| A | B |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst a = 1;\n```\n' },
34
+ {
35
+ type: 'artifact',
36
+ ...scope,
37
+ artifactId: 'a1',
38
+ op: 'open',
39
+ mediaType: 'text/markdown',
40
+ title: 'Memo',
41
+ },
42
+ { type: 'artifact', ...scope, artifactId: 'a1', op: 'delta', text: '# Memo' },
43
+ { type: 'artifact', ...scope, artifactId: 'a1', op: 'close' },
44
+ { type: 'done', finishReason: 'stop' },
45
+ ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
46
+ return { kind: 'done' };
47
+ }),
48
+ };
49
+ }
50
+ const base = { sandboxOrigin: 'https://frame.test' };
51
+ describe('accessibility (axe)', () => {
52
+ it('has no violations on the welcome screen with the agent picker', async () => {
53
+ render(AgentChat, {
54
+ props: {
55
+ ...base,
56
+ client: fakeClient(),
57
+ userName: 'Pat',
58
+ suggestions: [{ label: 'Open lots', description: 'By community', icon: 'home' }],
59
+ agents: [
60
+ { key: 'classic-agent', name: 'Classic Agent' },
61
+ { key: 'elevation-studio', name: 'Elevation Studio' },
62
+ ],
63
+ prompts: [{ id: 'p1', label: 'Weekly recap', text: 'Recap' }],
64
+ },
65
+ });
66
+ expect(await violations()).toEqual([]);
67
+ // With the saved-prompt menu open.
68
+ await userEvent.type(screen.getByRole('textbox', { name: 'Message' }), '/');
69
+ await screen.findByRole('listbox', { name: 'Saved prompts' });
70
+ expect(await violations()).toEqual([]);
71
+ });
72
+ it('has no violations on a failed turn with Try again, and while editing', async () => {
73
+ const user = userEvent.setup();
74
+ const client = fakeClient();
75
+ client.streamChat.mockImplementationOnce(async (req, opts) => {
76
+ [
77
+ { type: 'start', conversationId: 'c-1', mode: 'live' },
78
+ { type: 'error', code: 'upstream_error', message: 'Model failed.', retryable: true },
79
+ ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
80
+ return { kind: 'done' };
81
+ });
82
+ render(AgentChat, { props: { ...base, client: client } });
83
+ await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
84
+ await screen.findByRole('button', { name: /Try again/ });
85
+ expect(await violations()).toEqual([]);
86
+ await user.click(screen.getByRole('button', { name: /Try again/ }));
87
+ // The retried answer opens its artifact in a (modal) sheet; close it first.
88
+ await screen.findByRole('dialog', { name: 'Memo' });
89
+ await user.keyboard('{Escape}');
90
+ await waitFor(() => expect(document.body.style.pointerEvents).not.toBe('none'));
91
+ await user.click(await screen.findByRole('button', { name: 'Edit message' }));
92
+ await screen.findByRole('textbox', { name: 'Edited message' });
93
+ expect(await violations()).toEqual([]);
94
+ });
95
+ it('has no violations on a finished turn with tools, code, a table and an artifact', async () => {
96
+ const user = userEvent.setup();
97
+ render(AgentChat, { props: { ...base, client: fakeClient(), variant: 'panel' } });
98
+ await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
99
+ await screen.findByRole('button', { name: /Open Memo|Memo/ });
100
+ // The artifact opened in a sheet; close it to check the transcript itself.
101
+ await user.keyboard('{Escape}');
102
+ expect(await violations()).toEqual([]);
103
+ });
104
+ it('has no violations in the keyboard shortcuts dialog', async () => {
105
+ const user = userEvent.setup();
106
+ render(ChatHeader, {
107
+ props: { title: 'x', shortcuts: [{ keys: ['Mod', 'J'], label: 'Open the agent panel' }] },
108
+ });
109
+ await user.click(screen.getByRole('button', { name: 'Conversation options' }));
110
+ await user.click(await screen.findByRole('menuitem', { name: /Keyboard shortcuts/ }));
111
+ await screen.findByRole('dialog', { name: 'Keyboard shortcuts' });
112
+ expect(await violations()).toEqual([]);
113
+ });
114
+ it('has no violations in the conversation rail', async () => {
115
+ const now = new Date().toISOString();
116
+ render(ConversationSidebar, {
117
+ props: {
118
+ conversations: [
119
+ {
120
+ conversationId: 'c1',
121
+ agent: 'classic-agent',
122
+ title: 'Lot 12',
123
+ createdAt: now,
124
+ updatedAt: now,
125
+ },
126
+ {
127
+ conversationId: 'c2',
128
+ agent: 'classic-agent',
129
+ title: 'Running',
130
+ createdAt: now,
131
+ updatedAt: now,
132
+ activeRequestId: '11111111-1111-4111-8111-111111111111',
133
+ },
134
+ ],
135
+ selectedId: 'c1',
136
+ onrename: vi.fn(),
137
+ ondelete: vi.fn(),
138
+ },
139
+ });
140
+ expect(await violations()).toEqual([]);
141
+ });
142
+ });
143
+ /** Answers "ok" with no artifact (an artifact opens a modal sheet, which takes focus). */
144
+ function plainAnswers(client) {
145
+ client.streamChat.mockImplementation(async (req, opts) => {
146
+ [
147
+ { type: 'start', conversationId: 'c-1', mode: 'live' },
148
+ { type: 'delta', text: 'ok' },
149
+ { type: 'done', finishReason: 'stop' },
150
+ ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
151
+ return { kind: 'done' };
152
+ });
153
+ }
154
+ describe('focus', () => {
155
+ it('starts a new chat in the composer and returns there after a suggestion', async () => {
156
+ const user = userEvent.setup();
157
+ const client = fakeClient();
158
+ plainAnswers(client);
159
+ render(AgentChat, {
160
+ props: { ...base, client: client, suggestions: [{ label: 'Open lots' }] },
161
+ });
162
+ const box = screen.getByRole('textbox', { name: 'Message' });
163
+ await waitFor(() => expect(document.activeElement).toBe(box));
164
+ await user.click(screen.getByRole('button', { name: 'Open lots' }));
165
+ await waitFor(() => expect(document.activeElement).toBe(box));
166
+ });
167
+ it('returns to the Edit button when an edit is cancelled', async () => {
168
+ const user = userEvent.setup();
169
+ const client = fakeClient();
170
+ plainAnswers(client);
171
+ render(AgentChat, { props: { ...base, client: client } });
172
+ await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
173
+ await user.click(await screen.findByRole('button', { name: 'Edit message' }));
174
+ await user.keyboard('{Escape}');
175
+ await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Edit message' })));
176
+ });
177
+ it('does not steal focus when opening an existing conversation', () => {
178
+ const client = fakeClient();
179
+ client.getConversation.mockReturnValue(new Promise(() => { }));
180
+ render(AgentChat, { props: { ...base, client: client, conversationId: 'c-9' } });
181
+ expect(document.activeElement).toBe(document.body);
182
+ });
183
+ });
@@ -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('combobox', { name: 'Message' });
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('combobox', { name: 'Message' });
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}');
@@ -94,11 +94,31 @@ describe('ConversationSidebar', () => {
94
94
  });
95
95
  });
96
96
  describe('ChatHeader', () => {
97
- it('shows the title, falls back to "New chat" and hides actions without handlers', () => {
97
+ it('shows the title, falls back to "New chat" and hides actions without handlers', async () => {
98
+ const user = userEvent.setup();
98
99
  render(ChatHeader, { props: { title: ' ' } });
99
100
  expect(screen.getByRole('heading', { name: 'New chat' })).toBeTruthy();
100
- expect(screen.queryByRole('button', { name: 'Conversation options' })).toBeNull();
101
101
  expect(screen.queryByRole('button', { name: 'Show conversations' })).toBeNull();
102
+ // Only the shortcuts entry is left in the menu.
103
+ await user.click(screen.getByRole('button', { name: 'Conversation options' }));
104
+ const items = await screen.findAllByRole('menuitem');
105
+ expect(items.map((i) => i.textContent?.trim())).toEqual(['Keyboard shortcuts']);
106
+ });
107
+ it('lists host and chat shortcuts, then returns focus to the menu button', async () => {
108
+ const user = userEvent.setup();
109
+ render(ChatHeader, {
110
+ props: { title: 'x', shortcuts: [{ keys: ['Mod', 'Shift', 'O'], label: 'New chat' }] },
111
+ });
112
+ const trigger = screen.getByRole('button', { name: 'Conversation options' });
113
+ await user.click(trigger);
114
+ await user.click(await screen.findByRole('menuitem', { name: /Keyboard shortcuts/ }));
115
+ const dialog = await screen.findByRole('dialog', { name: 'Keyboard shortcuts' });
116
+ expect(dialog.textContent).toContain('New chat');
117
+ expect(dialog.textContent).toContain('Stop the reply');
118
+ expect(dialog.textContent).toContain('Insert a saved prompt');
119
+ await user.keyboard('{Escape}');
120
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
121
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
102
122
  });
103
123
  const handlers = () => ({ title: 'Lot 12 budget', onrename: vi.fn(), ondelete: vi.fn() });
104
124
  it('offers no rename or delete before the conversation is saved', async () => {
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from './markdown.js';
5
5
  export * from './message-format.js';
6
6
  export * from './reducer.js';
7
7
  export * from './session.svelte.js';
8
+ export * from './shortcuts.js';
8
9
  export * from './ui-frame-protocol.js';
9
10
  export * from './welcome.js';
10
11
  export { default as AgentChat } from './components/AgentChat.svelte';
@@ -24,4 +25,5 @@ export { default as ArtifactChip } from './components/ArtifactChip.svelte';
24
25
  export { default as MessageFiles } from './components/MessageFiles.svelte';
25
26
  export { default as ProposalCard } from './components/ProposalCard.svelte';
26
27
  export { default as Welcome } from './components/Welcome.svelte';
28
+ export { default as ShortcutsDialog } from './components/ShortcutsDialog.svelte';
27
29
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,cAAc,CAAC;AAC7B,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AACzF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC;AACvC,cAAc,cAAc,CAAC;AAC7B,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AACzF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,qCAAqC,CAAC"}
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from './markdown.js';
5
5
  export * from './message-format.js';
6
6
  export * from './reducer.js';
7
7
  export * from './session.svelte.js';
8
+ export * from './shortcuts.js';
8
9
  export * from './ui-frame-protocol.js';
9
10
  export * from './welcome.js';
10
11
  export { default as AgentChat } from './components/AgentChat.svelte';
@@ -24,3 +25,4 @@ export { default as ArtifactChip } from './components/ArtifactChip.svelte';
24
25
  export { default as MessageFiles } from './components/MessageFiles.svelte';
25
26
  export { default as ProposalCard } from './components/ProposalCard.svelte';
26
27
  export { default as Welcome } from './components/Welcome.svelte';
28
+ export { default as ShortcutsDialog } from './components/ShortcutsDialog.svelte';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Keyboard shortcuts listed in the chat's "Keyboard shortcuts" dialog. `keys` use the theme
3
+ * `Kbd` names ("Mod" shows as ⌘ on Apple devices and Ctrl elsewhere).
4
+ */
5
+ export interface ChatShortcut {
6
+ keys: string[];
7
+ label: string;
8
+ }
9
+ /** What the chat itself handles, in the composer. Hosts add their own (e.g. a panel toggle). */
10
+ export declare const CHAT_SHORTCUTS: readonly ChatShortcut[];
11
+ //# sourceMappingURL=shortcuts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shortcuts.d.ts","sourceRoot":"","sources":["../src/lib/shortcuts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EAMjD,CAAC"}
@@ -0,0 +1,8 @@
1
+ /** What the chat itself handles, in the composer. Hosts add their own (e.g. a panel toggle). */
2
+ export const CHAT_SHORTCUTS = [
3
+ { keys: ['Enter'], label: 'Send the message' },
4
+ { keys: ['Shift', 'Enter'], label: 'New line' },
5
+ { keys: ['Esc'], label: 'Stop the reply' },
6
+ { keys: ['/'], label: 'Insert a saved prompt' },
7
+ { keys: ['Esc'], label: 'Cancel editing a message' },
8
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/agent-chat",
3
- "version": "0.8.0",
3
+ "version": "0.10.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.8.0",
21
- "@classic-homes/agent-contracts": "0.8.0",
20
+ "@classic-homes/agent-client": "0.10.0",
21
+ "@classic-homes/agent-contracts": "0.10.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",