@classic-homes/agent-chat 0.3.0 → 0.4.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.
Files changed (35) hide show
  1. package/README.md +5 -1
  2. package/dist/components/AgentChat.svelte +9 -0
  3. package/dist/components/AgentChat.svelte.d.ts +5 -0
  4. package/dist/components/AgentChat.svelte.d.ts.map +1 -1
  5. package/dist/components/AssistantMessage.svelte +8 -1
  6. package/dist/components/AssistantMessage.svelte.d.ts +1 -0
  7. package/dist/components/AssistantMessage.svelte.d.ts.map +1 -1
  8. package/dist/components/Composer.svelte +221 -30
  9. package/dist/components/Composer.svelte.d.ts +8 -0
  10. package/dist/components/Composer.svelte.d.ts.map +1 -1
  11. package/dist/components/MessageItem.svelte +1 -0
  12. package/dist/components/MessageItem.svelte.d.ts +1 -0
  13. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  14. package/dist/components/MessageList.svelte +3 -0
  15. package/dist/components/MessageList.svelte.d.ts +1 -0
  16. package/dist/components/MessageList.svelte.d.ts.map +1 -1
  17. package/dist/components/ToolTimeline.svelte +138 -54
  18. package/dist/components/ToolTimeline.svelte.d.ts +11 -3
  19. package/dist/components/ToolTimeline.svelte.d.ts.map +1 -1
  20. package/dist/components/composer.svelte.test.d.ts +2 -0
  21. package/dist/components/composer.svelte.test.d.ts.map +1 -0
  22. package/dist/components/composer.svelte.test.js +162 -0
  23. package/dist/composer-keys.d.ts +3 -0
  24. package/dist/composer-keys.d.ts.map +1 -1
  25. package/dist/composer-keys.js +18 -0
  26. package/dist/composer-prompts.d.ts +16 -0
  27. package/dist/composer-prompts.d.ts.map +1 -0
  28. package/dist/composer-prompts.js +27 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/message-format.d.ts +28 -0
  33. package/dist/message-format.d.ts.map +1 -1
  34. package/dist/message-format.js +31 -0
  35. package/package.json +3 -3
package/README.md CHANGED
@@ -47,6 +47,10 @@ agent-api origin that serves `/frame`; artifacts and MCP-UI resources only ever
47
47
  - `MessageItem` → `UserMessage` / `AssistantMessage`, with `MessageActions` (copy, time),
48
48
  `ArtifactChip`, `MessageFiles` (image lightbox, downloads) and `ProposalCard` (read-only
49
49
  document edits; applying stays in the Office add-in).
50
- - `Composer`, `ToolTimeline`, `ArtifactPanel`, `UiFrame`, `ModeBanner`.
50
+ - `Composer`: attach by button, drop or paste; `prompts` turns on the `/` saved-prompt menu
51
+ (`ComposerPrompt.text` may be a loader, e.g. fetching the template).
52
+ - `ToolTimeline`: tool calls with status and duration, delegations nested; `toolLabels` maps
53
+ tool names to readable ones (`AgentChat` passes both through).
54
+ - `ArtifactPanel`, `UiFrame`, `ModeBanner`.
51
55
 
52
56
  Publishing: `docs/operations/package-publishing.md`.
@@ -10,6 +10,7 @@
10
10
  import { cn, ResizablePanes, Sheet } from '@classic-homes/theme-svelte';
11
11
  import { MediaQuery } from 'svelte/reactivity';
12
12
  import { releaseStuckBodyLock } from '../body-lock.js';
13
+ import type { ComposerPrompt } from '../composer-prompts.js';
13
14
  import { ChatSession } from '../session.svelte.js';
14
15
  import ArtifactPanel from './ArtifactPanel.svelte';
15
16
  import Composer from './Composer.svelte';
@@ -40,6 +41,10 @@
40
41
  /** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
41
42
  ontoolrequest?: (toolName: string, params?: Record<string, unknown>) => void;
42
43
  placeholder?: string;
44
+ /** Saved prompts offered by the composer's `/` menu. */
45
+ prompts?: ComposerPrompt[];
46
+ /** Readable tool names for the timeline, by tool name (`server/name` or `name`). */
47
+ toolLabels?: Record<string, string>;
43
48
  /** Rendered above the transcript, e.g. a `ChatHeader`. */
44
49
  header?: import('svelte').Snippet;
45
50
  context?: import('svelte').Snippet;
@@ -62,6 +67,8 @@
62
67
  onnotify,
63
68
  ontoolrequest,
64
69
  placeholder,
70
+ prompts,
71
+ toolLabels,
65
72
  header,
66
73
  context,
67
74
  empty,
@@ -146,6 +153,7 @@
146
153
  streaming={session.streaming}
147
154
  {sandboxOrigin}
148
155
  fileUrl={(id) => client.fileUrl(id)}
156
+ {toolLabels}
149
157
  {onnavigate}
150
158
  onopenartifact={(id) => {
151
159
  selectedArtifact = id;
@@ -165,6 +173,7 @@
165
173
  disabled={session.status === 'loading'}
166
174
  streaming={session.streaming}
167
175
  {placeholder}
176
+ {prompts}
168
177
  upload={(file) => client.uploadFile(file, file.name)}
169
178
  onsend={send}
170
179
  oncancel={() => session.cancel()}
@@ -1,6 +1,7 @@
1
1
  import type { AgentApiClient } from '@classic-homes/agent-client';
2
2
  import type { AgentKey, ClientKind, DocumentContext } from '@classic-homes/agent-contracts';
3
3
  import type { BackendMode } from '@classic-homes/agent-contracts';
4
+ import type { ComposerPrompt } from '../composer-prompts.js';
4
5
  interface Props {
5
6
  client: AgentApiClient;
6
7
  /** Sandbox origin for artifacts and MCP-UI (agent-api `/frame`). */
@@ -25,6 +26,10 @@ interface Props {
25
26
  /** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
26
27
  ontoolrequest?: (toolName: string, params?: Record<string, unknown>) => void;
27
28
  placeholder?: string;
29
+ /** Saved prompts offered by the composer's `/` menu. */
30
+ prompts?: ComposerPrompt[];
31
+ /** Readable tool names for the timeline, by tool name (`server/name` or `name`). */
32
+ toolLabels?: Record<string, string>;
28
33
  /** Rendered above the transcript, e.g. a `ChatHeader`. */
29
34
  header?: import('svelte').Snippet;
30
35
  context?: 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;AAWhE,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,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,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,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA6IH,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,UAAU,EACV,eAAe,EAEhB,MAAM,gCAAgC,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAIlE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAQ3D,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,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,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,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAgJH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -23,6 +23,7 @@
23
23
  last?: boolean;
24
24
  sandboxOrigin: string;
25
25
  fileUrl: (fileId: string) => string;
26
+ toolLabels?: Record<string, string>;
26
27
  onnavigate?: (href: string) => void;
27
28
  onopenartifact?: (artifactId: string) => void;
28
29
  onprompt?: (text: string) => void;
@@ -38,6 +39,7 @@
38
39
  last = false,
39
40
  sandboxOrigin,
40
41
  fileUrl,
42
+ toolLabels,
41
43
  onnavigate,
42
44
  onopenartifact,
43
45
  onprompt,
@@ -101,7 +103,12 @@
101
103
  </span>
102
104
  <div class="flex min-w-0 flex-1 flex-col gap-2">
103
105
  {#if message.tools.length || message.delegations.length}
104
- <ToolTimeline tools={message.tools} delegations={message.delegations} {streaming} />
106
+ <ToolTimeline
107
+ tools={message.tools}
108
+ delegations={message.delegations}
109
+ {streaming}
110
+ {toolLabels}
111
+ />
105
112
  {/if}
106
113
  {#if html}<Markdown {html} streaming={streaming && !!message.text} onclick={handleClick} />{/if}
107
114
  {#if thinking}
@@ -6,6 +6,7 @@ interface Props {
6
6
  last?: boolean;
7
7
  sandboxOrigin: string;
8
8
  fileUrl: (fileId: string) => string;
9
+ toolLabels?: Record<string, string>;
9
10
  onnavigate?: (href: string) => void;
10
11
  onopenartifact?: (artifactId: string) => void;
11
12
  onprompt?: (text: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantMessage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AssistantMessage.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAYpD,UAAU,KAAK;IACb,OAAO,EAAE,gBAAgB,CAAC;IAC1B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uFAAuF;IACvF,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,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,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAuJH;;;GAGG;AACH,QAAA,MAAM,gBAAgB,2CAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
1
+ {"version":3,"file":"AssistantMessage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AssistantMessage.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAYpD,UAAU,KAAK;IACb,OAAO,EAAE,gBAAgB,CAAC;IAC1B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uFAAuF;IACvF,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,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,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAwJH;;;GAGG;AACH,QAAA,MAAM,gBAAgB,2CAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
@@ -1,8 +1,16 @@
1
+ <!--
2
+ @component
3
+ The message box: an autosizing textarea in a card with attach, the host's context chip and
4
+ Send/Stop. Files can also be dropped on it or pasted. Typing `/` at the start opens the saved
5
+ prompt menu when the host passes `prompts`.
6
+ -->
1
7
  <script lang="ts">
2
- import { Button, Icon, Textarea } from '@classic-homes/theme-svelte';
8
+ import { cn, Button, Icon, Kbd, Popover, Spinner, Textarea } from '@classic-homes/theme-svelte';
3
9
  import type { FileRef } from '@classic-homes/agent-contracts';
4
10
  import { LIMITS } from '@classic-homes/agent-contracts';
5
- import { composerKeyAction } from '../composer-keys.js';
11
+ import { composerKeyAction, promptMenuKeyAction } from '../composer-keys.js';
12
+ import { filterPrompts, promptQuery, type ComposerPrompt } from '../composer-prompts.js';
13
+ import { fileIcon, formatBytes } from '../message-format.js';
6
14
 
7
15
  interface Props {
8
16
  disabled?: boolean;
@@ -12,6 +20,8 @@
12
20
  placeholder?: string;
13
21
  /** Upload a file; the composer shows it as pending until the message is sent. */
14
22
  upload?: (file: File) => Promise<FileRef>;
23
+ /** Saved prompts for the `/` menu; the menu is off without them. */
24
+ prompts?: ComposerPrompt[];
15
25
  onsend?: (text: string) => void;
16
26
  oncancel?: () => void;
17
27
  onremovefile?: (fileId: string) => void;
@@ -25,28 +35,76 @@
25
35
  files = $bindable([]),
26
36
  placeholder = 'Ask Classic Agent…',
27
37
  upload,
38
+ prompts,
28
39
  onsend,
29
40
  oncancel,
30
41
  onremovefile,
31
42
  context,
32
43
  }: Props = $props();
33
44
 
45
+ const uid = $props.id();
34
46
  let textarea = $state<HTMLTextAreaElement | null>(null);
47
+ let card: HTMLDivElement | undefined = $state();
35
48
  let fileInput: HTMLInputElement | undefined = $state();
36
- let uploading = $state(0);
49
+ /** Files being uploaded right now, shown as chips with a spinner. */
50
+ let pending = $state<{ key: string; name: string; bytes: number; mimeType: string }[]>([]);
37
51
  let uploadError = $state<string | undefined>();
52
+ let dragging = $state(false);
53
+ let dragDepth = 0;
38
54
 
39
55
  export function focus(): void {
40
56
  textarea?.focus({ preventScroll: true });
41
57
  }
42
58
 
59
+ // `/` prompt menu
60
+ const query = $derived(prompts?.length ? promptQuery(value) : null);
61
+ const matches = $derived(query === null ? [] : filterPrompts(prompts ?? [], query));
62
+ let dismissedFor = $state<string | null>(null);
63
+ const menuOpen = $derived(query !== null && matches.length > 0 && dismissedFor !== value);
64
+ let active = $state(0);
65
+ let loadingPrompt = $state(false);
66
+ $effect(() => {
67
+ void query;
68
+ active = 0;
69
+ });
70
+ async function choose(prompt: ComposerPrompt | undefined): Promise<void> {
71
+ if (!prompt) return;
72
+ if (typeof prompt.text === 'string') value = prompt.text;
73
+ else {
74
+ loadingPrompt = true;
75
+ try {
76
+ value = await prompt.text();
77
+ } catch {
78
+ uploadError = `Could not load “${prompt.label}”`;
79
+ } finally {
80
+ loadingPrompt = false;
81
+ }
82
+ }
83
+ focus();
84
+ }
85
+
86
+ const chars = $derived(value.length);
87
+ const showCount = $derived(chars > LIMITS.messageChars * 0.8);
88
+ const busy = $derived(pending.length > 0 || loadingPrompt);
89
+
43
90
  function submit(): void {
44
91
  const text = value.trim();
45
- if (!text || disabled || streaming || uploading > 0) return;
92
+ if (!text || disabled || streaming || busy) return;
46
93
  value = '';
47
94
  onsend?.(text);
48
95
  }
49
96
  function onkeydown(event: KeyboardEvent): void {
97
+ if (menuOpen) {
98
+ const menu = promptMenuKeyAction(event);
99
+ if (menu) {
100
+ event.preventDefault();
101
+ if (menu === 'next') active = (active + 1) % matches.length;
102
+ else if (menu === 'previous') active = (active - 1 + matches.length) % matches.length;
103
+ else if (menu === 'choose') void choose(matches[active]);
104
+ else dismissedFor = value;
105
+ return;
106
+ }
107
+ }
50
108
  const action = composerKeyAction(event, streaming);
51
109
  if (action === 'send') {
52
110
  event.preventDefault();
@@ -56,31 +114,70 @@
56
114
  oncancel?.();
57
115
  }
58
116
  }
59
- async function onfiles(event: Event): Promise<void> {
60
- const input = event.currentTarget as HTMLInputElement;
61
- const chosen = [...(input.files ?? [])];
62
- input.value = '';
117
+
118
+ async function addFiles(chosen: File[]): Promise<void> {
63
119
  if (!upload || chosen.length === 0) return;
64
120
  uploadError = undefined;
65
121
  for (const file of chosen) {
122
+ if (files.length + pending.length >= LIMITS.requestFiles) {
123
+ uploadError = `Attach up to ${LIMITS.requestFiles} files per message`;
124
+ break;
125
+ }
66
126
  if (file.size > LIMITS.fileBytes) {
67
127
  uploadError = `${file.name} is larger than ${Math.round(LIMITS.fileBytes / 1048576)} MB`;
68
128
  continue;
69
129
  }
70
- uploading += 1;
130
+ const key = `${file.name}-${file.size}-${Math.random().toString(36).slice(2)}`;
131
+ pending = [...pending, { key, name: file.name, bytes: file.size, mimeType: file.type }];
71
132
  try {
72
133
  files = [...files, await upload(file)];
73
134
  } catch (err) {
74
135
  uploadError = err instanceof Error ? err.message : `Could not upload ${file.name}`;
75
136
  } finally {
76
- uploading -= 1;
137
+ pending = pending.filter((p) => p.key !== key);
77
138
  }
78
139
  }
79
140
  }
141
+ function onfiles(event: Event): void {
142
+ const input = event.currentTarget as HTMLInputElement;
143
+ const chosen = [...(input.files ?? [])];
144
+ input.value = '';
145
+ void addFiles(chosen);
146
+ }
147
+ function onpaste(event: ClipboardEvent): void {
148
+ const pasted = [...(event.clipboardData?.files ?? [])];
149
+ if (!upload || pasted.length === 0) return;
150
+ // Pasting a screenshot or a copied file attaches it; plain text pastes as usual.
151
+ event.preventDefault();
152
+ void addFiles(pasted);
153
+ }
154
+ const hasFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false;
155
+ function ondragenter(event: DragEvent): void {
156
+ if (!upload || disabled || !hasFiles(event)) return;
157
+ event.preventDefault();
158
+ dragDepth += 1;
159
+ dragging = true;
160
+ }
161
+ function ondragover(event: DragEvent): void {
162
+ if (!upload || disabled || !hasFiles(event)) return;
163
+ event.preventDefault();
164
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
165
+ }
166
+ function ondragleave(): void {
167
+ dragDepth = Math.max(0, dragDepth - 1);
168
+ if (dragDepth === 0) dragging = false;
169
+ }
170
+ function ondrop(event: DragEvent): void {
171
+ if (!upload || disabled || !hasFiles(event)) return;
172
+ event.preventDefault();
173
+ dragDepth = 0;
174
+ dragging = false;
175
+ void addFiles([...(event.dataTransfer?.files ?? [])]);
176
+ }
80
177
  </script>
81
178
 
82
179
  <form
83
- class="bg-background px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2"
180
+ class="group/composer bg-background px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2"
84
181
  onsubmit={(e) => {
85
182
  e.preventDefault();
86
183
  submit();
@@ -88,29 +185,58 @@
88
185
  aria-label="Send a message"
89
186
  >
90
187
  <div
91
- class="mx-auto w-full max-w-3xl rounded-xl border border-input bg-background shadow-sm transition-shadow focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20"
188
+ bind:this={card}
189
+ class={cn(
190
+ 'relative mx-auto w-full max-w-3xl rounded-xl border border-input bg-background shadow-sm transition-shadow focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20',
191
+ dragging && 'border-primary ring-2 ring-primary/30',
192
+ )}
193
+ role="group"
194
+ aria-label="Message composer"
195
+ {ondragenter}
196
+ {ondragover}
197
+ {ondragleave}
198
+ {ondrop}
92
199
  >
93
- {#if files.length || uploadError}
94
- <div class="flex flex-wrap items-center gap-1.5 px-3 pt-3 text-xs">
200
+ {#if dragging}
201
+ <div
202
+ class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center gap-2 rounded-xl bg-background/90 text-sm font-medium text-primary"
203
+ >
204
+ <Icon name="paperclip" size={16} /> Drop files to attach
205
+ </div>
206
+ {/if}
207
+ {#if files.length || pending.length || uploadError}
208
+ <ul class="flex flex-wrap items-center gap-1.5 px-3 pt-3 text-xs" aria-label="Attachments">
95
209
  {#each files as file (file.fileId)}
96
- <span
97
- class="inline-flex max-w-full items-center gap-1 rounded-full bg-muted py-0.5 pl-2 pr-0.5"
210
+ <li
211
+ class="inline-flex max-w-60 items-center gap-1.5 rounded-md border border-border bg-background py-1 pl-2 pr-0.5"
98
212
  >
99
- <Icon name="file" size={12} class="shrink-0 text-muted-foreground" />
213
+ <Icon name={fileIcon(file.mimeType)} size={14} class="shrink-0 text-muted-foreground" />
100
214
  <span class="truncate">{file.name}</span>
101
- <button
215
+ <span class="shrink-0 text-muted-foreground">{formatBytes(file.bytes)}</span>
216
+ <Button
102
217
  type="button"
103
- class="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
218
+ variant="ghost"
219
+ size="icon"
220
+ class="size-6 shrink-0 text-muted-foreground"
104
221
  aria-label={`Remove ${file.name}`}
105
222
  onclick={() => {
106
223
  files = files.filter((f) => f.fileId !== file.fileId);
107
224
  onremovefile?.(file.fileId);
108
- }}><Icon name="close" size={12} /></button
225
+ }}><Icon name="close" size={12} /></Button
109
226
  >
110
- </span>
227
+ </li>
111
228
  {/each}
112
- {#if uploadError}<span class="text-destructive" role="alert">{uploadError}</span>{/if}
113
- </div>
229
+ {#each pending as file (file.key)}
230
+ <li
231
+ class="inline-flex max-w-60 items-center gap-1.5 rounded-md border border-dashed border-border py-1 pl-2 pr-2 text-muted-foreground"
232
+ >
233
+ <Spinner size="sm" label={`Uploading ${file.name}`} class="shrink-0" />
234
+ <span class="truncate">{file.name}</span>
235
+ <span class="shrink-0">{formatBytes(file.bytes)}</span>
236
+ </li>
237
+ {/each}
238
+ {#if uploadError}<li class="text-destructive" role="alert">{uploadError}</li>{/if}
239
+ </ul>
114
240
  {/if}
115
241
  <Textarea
116
242
  bind:ref={textarea}
@@ -122,8 +248,14 @@
122
248
  {placeholder}
123
249
  aria-label="Message"
124
250
  maxlength={LIMITS.messageChars}
251
+ role={prompts?.length ? 'combobox' : undefined}
252
+ aria-autocomplete={prompts?.length ? 'list' : undefined}
253
+ aria-expanded={prompts?.length ? menuOpen : undefined}
254
+ aria-controls={menuOpen ? `${uid}-prompts` : undefined}
255
+ aria-activedescendant={menuOpen ? `${uid}-prompt-${active}` : undefined}
125
256
  {disabled}
126
257
  {onkeydown}
258
+ {onpaste}
127
259
  />
128
260
  <div class="flex items-center gap-1 px-2 pb-2">
129
261
  {#if upload}
@@ -134,22 +266,31 @@
134
266
  size="icon"
135
267
  class="size-8 text-muted-foreground"
136
268
  aria-label="Attach files"
137
- disabled={disabled || uploading > 0}
138
- loading={uploading > 0}
269
+ disabled={disabled || files.length >= LIMITS.requestFiles}
139
270
  onclick={() => fileInput?.click()}
140
271
  >
141
- {#if uploading === 0}<Icon name="paperclip" size={18} />{/if}
272
+ <Icon name="paperclip" size={18} />
142
273
  </Button>
143
274
  {/if}
144
275
  {#if context}<div class="min-w-0">{@render context()}</div>{/if}
145
- <div class="ml-auto">
276
+ <div class="ml-auto flex items-center gap-2">
277
+ {#if showCount}
278
+ <span
279
+ class={cn(
280
+ 'text-xs tabular-nums text-muted-foreground',
281
+ chars >= LIMITS.messageChars && 'text-destructive',
282
+ )}
283
+ aria-live="polite"
284
+ >{chars.toLocaleString()} / {LIMITS.messageChars.toLocaleString()}</span
285
+ >
286
+ {/if}
146
287
  {#if streaming}
147
288
  <Button
148
289
  type="button"
149
290
  variant="outline"
150
291
  size="icon"
151
292
  class="size-8"
152
- aria-label="Stop"
293
+ aria-label="Stop (Esc)"
153
294
  onclick={() => oncancel?.()}><Icon name="stop" size={14} /></Button
154
295
  >
155
296
  {:else}
@@ -158,11 +299,61 @@
158
299
  size="icon"
159
300
  class="size-8"
160
301
  aria-label="Send"
161
- disabled={disabled || !value.trim() || uploading > 0}
162
- ><Icon name="send" size={16} /></Button
302
+ disabled={disabled || !value.trim() || busy}><Icon name="send" size={16} /></Button
163
303
  >
164
304
  {/if}
165
305
  </div>
166
306
  </div>
167
307
  </div>
308
+ <p
309
+ class="mx-auto mt-1.5 hidden max-w-3xl items-center gap-1.5 px-1 text-[11px] text-muted-foreground opacity-0 transition-opacity group-focus-within/composer:opacity-100 sm:flex"
310
+ aria-hidden="true"
311
+ >
312
+ {#if streaming}
313
+ <Kbd size="sm">Esc</Kbd> to stop
314
+ {:else}
315
+ <Kbd size="sm">Enter</Kbd> to send · <Kbd keys={['Shift', 'Enter']} size="sm" /> for a new line
316
+ {#if prompts?.length}· <Kbd size="sm">/</Kbd> for saved prompts{/if}
317
+ {/if}
318
+ </p>
168
319
  </form>
320
+
321
+ {#if prompts?.length}
322
+ <Popover
323
+ open={menuOpen}
324
+ onOpenChange={(next) => {
325
+ if (!next) dismissedFor = value;
326
+ }}
327
+ anchor={card ?? null}
328
+ side="top"
329
+ align="start"
330
+ sideOffset={6}
331
+ autoFocus={false}
332
+ aria-label="Saved prompts"
333
+ class="w-[min(28rem,calc(100vw-2rem))] p-1"
334
+ >
335
+ <ul id={`${uid}-prompts`} role="listbox" aria-label="Saved prompts" class="grid gap-0.5">
336
+ {#each matches as prompt, i (prompt.id)}
337
+ <!-- Keyboard choice happens in the textarea (combobox + aria-activedescendant). -->
338
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
339
+ <li
340
+ id={`${uid}-prompt-${i}`}
341
+ role="option"
342
+ aria-selected={i === active}
343
+ class={cn(
344
+ 'cursor-pointer rounded-md px-2.5 py-1.5 text-sm',
345
+ i === active ? 'bg-muted text-foreground' : 'text-foreground hover:bg-muted/60',
346
+ )}
347
+ onpointerdown={(e) => e.preventDefault()}
348
+ onclick={() => void choose(prompt)}
349
+ onpointermove={() => (active = i)}
350
+ >
351
+ <span class="block truncate font-medium">{prompt.label}</span>
352
+ {#if prompt.description}
353
+ <span class="block truncate text-xs text-muted-foreground">{prompt.description}</span>
354
+ {/if}
355
+ </li>
356
+ {/each}
357
+ </ul>
358
+ </Popover>
359
+ {/if}
@@ -1,4 +1,5 @@
1
1
  import type { FileRef } from '@classic-homes/agent-contracts';
2
+ import { type ComposerPrompt } from '../composer-prompts.js';
2
3
  interface Props {
3
4
  disabled?: boolean;
4
5
  streaming?: boolean;
@@ -7,12 +8,19 @@ interface Props {
7
8
  placeholder?: string;
8
9
  /** Upload a file; the composer shows it as pending until the message is sent. */
9
10
  upload?: (file: File) => Promise<FileRef>;
11
+ /** Saved prompts for the `/` menu; the menu is off without them. */
12
+ prompts?: ComposerPrompt[];
10
13
  onsend?: (text: string) => void;
11
14
  oncancel?: () => void;
12
15
  onremovefile?: (fileId: string) => void;
13
16
  /** Slot for a host-provided context chip (page context toggle). */
14
17
  context?: import('svelte').Snippet;
15
18
  }
19
+ /**
20
+ * The message box: an autosizing textarea in a card with attach, the host's context chip and
21
+ * Send/Stop. Files can also be dropped on it or pasted. Typing `/` at the start opens the saved
22
+ * prompt menu when the host passes `prompts`.
23
+ */
16
24
  declare const Composer: import("svelte").Component<Props, {
17
25
  focus: () => void;
18
26
  }, "value" | "files">;
@@ -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;AAK5D,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,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;CACpC;AA8GH,QAAA,MAAM,QAAQ;iBArFO,IAAI;qBAqF6B,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,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;CACpC;AA2PH;;;;GAIG;AACH,QAAA,MAAM,QAAQ;iBA/NO,IAAI;qBA+N6B,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -13,6 +13,7 @@
13
13
  last?: boolean;
14
14
  sandboxOrigin: string;
15
15
  fileUrl: (fileId: string) => string;
16
+ toolLabels?: Record<string, string>;
16
17
  onnavigate?: (href: string) => void;
17
18
  onopenartifact?: (artifactId: string) => void;
18
19
  onprompt?: (text: string) => void;
@@ -5,6 +5,7 @@ interface Props {
5
5
  last?: boolean;
6
6
  sandboxOrigin: string;
7
7
  fileUrl: (fileId: string) => string;
8
+ toolLabels?: Record<string, string>;
8
9
  onnavigate?: (href: string) => void;
9
10
  onopenartifact?: (artifactId: string) => void;
10
11
  onprompt?: (text: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"MessageItem.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageItem.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK/C,UAAU,KAAK;IACb,OAAO,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,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,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAkBH,sEAAsE;AACtE,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"MessageItem.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageItem.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK/C,UAAU,KAAK;IACb,OAAO,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,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,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAkBH,sEAAsE;AACtE,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -10,6 +10,7 @@
10
10
  streaming: boolean;
11
11
  sandboxOrigin: string;
12
12
  fileUrl: (fileId: string) => string;
13
+ toolLabels?: Record<string, string>;
13
14
  onnavigate?: (href: string) => void;
14
15
  onopenartifact?: (artifactId: string) => void;
15
16
  onprompt?: (text: string) => void;
@@ -24,6 +25,7 @@
24
25
  streaming,
25
26
  sandboxOrigin,
26
27
  fileUrl,
28
+ toolLabels,
27
29
  onnavigate,
28
30
  onopenartifact,
29
31
  onprompt,
@@ -133,6 +135,7 @@
133
135
  streaming={streaming && i === messages.length - 1 && message.role === 'assistant'}
134
136
  {sandboxOrigin}
135
137
  {fileUrl}
138
+ {toolLabels}
136
139
  {onnavigate}
137
140
  {onopenartifact}
138
141
  {onprompt}
@@ -4,6 +4,7 @@ interface Props {
4
4
  streaming: boolean;
5
5
  sandboxOrigin: string;
6
6
  fileUrl: (fileId: string) => string;
7
+ toolLabels?: Record<string, string>;
7
8
  onnavigate?: (href: string) => void;
8
9
  onopenartifact?: (artifactId: string) => void;
9
10
  onprompt?: (text: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"MessageList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageList.svelte.ts"],"names":[],"mappings":"AAKA,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,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,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;AA2HH,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,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,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;AA4HH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -1,62 +1,146 @@
1
+ <!--
2
+ @component
3
+ What the agent did in a turn: tool calls (status icon, readable name, duration) and the
4
+ specialists it delegated to, with their own calls nested. Open while the turn runs and folded
5
+ away when it finishes, unless the reader toggled it.
6
+ -->
1
7
  <script lang="ts">
2
- import { cn, Icon } from '@classic-homes/theme-svelte';
8
+ import { cn, Collapsible, Icon, Spinner, type IconName } from '@classic-homes/theme-svelte';
3
9
  import type { DelegationState, ToolCallState } from '../reducer.js';
10
+ import { formatDuration, groupToolRuns, humanizeToolName } from '../message-format.js';
4
11
 
5
- let {
6
- tools,
7
- delegations,
8
- streaming = false,
9
- }: { tools: ToolCallState[]; delegations: DelegationState[]; streaming?: boolean } = $props();
10
- let expanded = $state(false);
11
- const running = $derived(tools.filter((t) => !t.status).length);
12
- const label = $derived.by(() => {
13
- if (streaming && running > 0)
14
- return `Working: ${tools
15
- .filter((t) => !t.status)
16
- .map((t) => t.name)
17
- .join(', ')}`;
18
- const children = delegations.map((d) => d.child);
19
- const parts = [tools.length ? `${tools.length} tool call${tools.length === 1 ? '' : 's'}` : ''];
20
- if (children.length) parts.push(`via ${[...new Set(children)].join(', ')}`);
21
- return parts.filter(Boolean).join(' ');
12
+ interface Props {
13
+ tools: ToolCallState[];
14
+ delegations: DelegationState[];
15
+ streaming?: boolean;
16
+ /** Readable names by tool name (with or without the `server/` prefix). */
17
+ toolLabels?: Record<string, string>;
18
+ class?: string;
19
+ }
20
+ let { tools, delegations, streaming = false, toolLabels, class: className }: Props = $props();
21
+
22
+ /** The reader's choice; until they make one the timeline follows the turn. */
23
+ let toggled = $state<boolean | null>(null);
24
+ const open = $derived(toggled ?? streaming);
25
+ let details = $state<Record<string, boolean>>({});
26
+
27
+ const running = $derived(tools.filter((t) => !t.status));
28
+ const failed = $derived(
29
+ tools.filter((t) => t.status === 'error' || t.status === 'unauthenticated').length,
30
+ );
31
+ const refused = $derived(tools.filter((t) => t.status === 'refused').length);
32
+ const groups = $derived(groupToolRuns(tools, delegations));
33
+
34
+ function labelFor(tool: ToolCallState): string {
35
+ const full = tool.server ? `${tool.server}/${tool.name}` : tool.name;
36
+ return toolLabels?.[full] ?? toolLabels?.[tool.name] ?? humanizeToolName(tool.name);
37
+ }
38
+ function statusIcon(tool: ToolCallState): { icon: IconName; tone: string; label: string } {
39
+ switch (tool.status) {
40
+ case 'ok':
41
+ return { icon: 'check-circle', tone: 'text-success', label: 'Done' };
42
+ case 'error':
43
+ return { icon: 'x-circle', tone: 'text-destructive', label: 'Failed' };
44
+ case 'refused':
45
+ return { icon: 'alert-triangle', tone: 'text-warning', label: 'Refused' };
46
+ case 'unauthenticated':
47
+ return { icon: 'lock', tone: 'text-warning', label: 'Needs sign-in' };
48
+ default:
49
+ return { icon: 'circle', tone: 'text-muted-foreground', label: 'Interrupted' };
50
+ }
51
+ }
52
+
53
+ const summary = $derived.by(() => {
54
+ if (streaming && running.length) return `${labelFor(running[running.length - 1]!)}…`;
55
+ const parts: string[] = [];
56
+ if (tools.length) parts.push(`${tools.length} tool call${tools.length === 1 ? '' : 's'}`);
57
+ const children = [...new Set(delegations.map((d) => d.child))];
58
+ if (children.length) parts.push(`via ${children.join(', ')}`);
59
+ if (failed) parts.push(`${failed} failed`);
60
+ if (refused) parts.push(`${refused} refused`);
61
+ return parts.join(' · ') || 'Working…';
22
62
  });
23
63
  </script>
24
64
 
25
- {#if tools.length > 0 || delegations.length > 0}
26
- <div class="mb-2 mt-1 text-xs text-muted-foreground">
27
- <button
28
- type="button"
29
- class="inline-flex items-center gap-1.5 rounded-sm py-0.5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
30
- onclick={() => (expanded = !expanded)}
31
- aria-expanded={expanded}
32
- >
33
- <Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={14} />
34
- <span>{label}</span>
35
- {#if streaming && running > 0}<span
36
- class="size-2 rounded-full bg-primary motion-safe:animate-pulse"
37
- aria-hidden="true"
38
- ></span>{/if}
39
- </button>
40
- {#if expanded}
41
- <ol class="mt-1.5 grid gap-1 pl-4">
42
- {#each tools as tool (tool.callId)}
43
- <li class="grid grid-cols-[auto_1fr_auto] gap-x-2">
44
- <span class="font-mono">{tool.server ? `${tool.server}/` : ''}{tool.name}</span>
45
- <span>{tool.agent}</span>
46
- <span
47
- class={cn(
48
- (tool.status === 'error' || tool.status === 'refused') && 'text-destructive',
49
- )}
50
- >{tool.status ?? 'running'}{tool.durationMs !== undefined
51
- ? ` · ${tool.durationMs} ms`
52
- : ''}</span
53
- >
54
- {#if tool.summary}<div class="col-span-full whitespace-pre-wrap opacity-80">
55
- {tool.summary}
56
- </div>{/if}
57
- </li>
58
- {/each}
59
- </ol>
65
+ {#snippet toolRow(tool: ToolCallState)}
66
+ {@const s = statusIcon(tool)}
67
+ {@const preview = [tool.argsPreview, tool.summary].filter(Boolean).join('\n→ ')}
68
+ <li class="min-w-0">
69
+ <div class="flex items-center gap-2">
70
+ {#if !tool.status && streaming}
71
+ <Spinner size="sm" label="Running" class="shrink-0" />
72
+ {:else}
73
+ <span class={cn('shrink-0', s.tone)} title={s.label}>
74
+ <Icon name={s.icon} size={14} />
75
+ <span class="sr-only">{s.label}</span>
76
+ </span>
77
+ {/if}
78
+ <span class="min-w-0 truncate text-foreground">{labelFor(tool)}</span>
79
+ {#if tool.server}<span class="hidden shrink-0 font-mono sm:inline">{tool.server}</span>{/if}
80
+ {#if tool.durationMs !== undefined}
81
+ <span class="ml-auto shrink-0 tabular-nums">{formatDuration(tool.durationMs)}</span>
82
+ {/if}
83
+ </div>
84
+ {#if preview}
85
+ <button
86
+ type="button"
87
+ class={cn(
88
+ 'ml-[22px] mt-0.5 block max-w-full whitespace-pre-wrap rounded text-left font-mono text-[11px] leading-snug [overflow-wrap:anywhere] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
89
+ !details[tool.callId] && 'line-clamp-2',
90
+ )}
91
+ aria-expanded={!!details[tool.callId]}
92
+ aria-label={details[tool.callId] ? 'Show less' : 'Show the full call'}
93
+ onclick={() => (details[tool.callId] = !details[tool.callId])}>{preview}</button
94
+ >
60
95
  {/if}
61
- </div>
96
+ </li>
97
+ {/snippet}
98
+
99
+ {#if tools.length > 0 || delegations.length > 0}
100
+ <Collapsible
101
+ {open}
102
+ onOpenChange={(next) => (toggled = next)}
103
+ class={cn('text-xs text-muted-foreground', className)}
104
+ triggerClass="gap-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
105
+ contentClass="pt-1.5"
106
+ >
107
+ {#snippet trigger()}
108
+ <span class="inline-flex items-center gap-1.5">
109
+ {#if streaming && running.length}
110
+ <Spinner size="sm" label="Working" />
111
+ {:else}
112
+ <Icon name="tool" size={12} />
113
+ {/if}
114
+ <span>{summary}</span>
115
+ </span>
116
+ {/snippet}
117
+ <div class="grid gap-2 border-l border-border pl-3">
118
+ {#each groups as group, gi (group.delegation?.childRunId ?? `top-${gi}`)}
119
+ {#if group.delegation}
120
+ <div class="grid gap-1">
121
+ <div class="flex items-center gap-2 text-foreground">
122
+ {#if group.delegation.status === 'start' && streaming}
123
+ <Spinner size="sm" label="Working" class="shrink-0" />
124
+ {:else}
125
+ <Icon name="corner-down-right" size={14} class="shrink-0 text-muted-foreground" />
126
+ {/if}
127
+ <span>Delegated to <span class="font-medium">{group.delegation.child}</span></span>
128
+ </div>
129
+ {#if group.delegation.summary}
130
+ <p class="ml-[22px] [overflow-wrap:anywhere]">{group.delegation.summary}</p>
131
+ {/if}
132
+ {#if group.tools.length}
133
+ <ol class="ml-[22px] grid gap-1">
134
+ {#each group.tools as tool (tool.callId)}{@render toolRow(tool)}{/each}
135
+ </ol>
136
+ {/if}
137
+ </div>
138
+ {:else}
139
+ <ol class="grid gap-1">
140
+ {#each group.tools as tool (tool.callId)}{@render toolRow(tool)}{/each}
141
+ </ol>
142
+ {/if}
143
+ {/each}
144
+ </div>
145
+ </Collapsible>
62
146
  {/if}
@@ -1,10 +1,18 @@
1
1
  import type { DelegationState, ToolCallState } from '../reducer.js';
2
- type $$ComponentProps = {
2
+ interface Props {
3
3
  tools: ToolCallState[];
4
4
  delegations: DelegationState[];
5
5
  streaming?: boolean;
6
- };
7
- declare const ToolTimeline: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ /** Readable names by tool name (with or without the `server/` prefix). */
7
+ toolLabels?: Record<string, string>;
8
+ class?: string;
9
+ }
10
+ /**
11
+ * What the agent did in a turn: tool calls (status icon, readable name, duration) and the
12
+ * specialists it delegated to, with their own calls nested. Open while the turn runs and folded
13
+ * away when it finishes, unless the reader toggled it.
14
+ */
15
+ declare const ToolTimeline: import("svelte").Component<Props, {}, "">;
8
16
  type ToolTimeline = ReturnType<typeof ToolTimeline>;
9
17
  export default ToolTimeline;
10
18
  //# sourceMappingURL=ToolTimeline.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ToolTimeline.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ToolTimeline.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnE,KAAK,gBAAgB,GAAI;IAAE,KAAK,EAAE,aAAa,EAAE,CAAC;IAAC,WAAW,EAAE,eAAe,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAuD1G,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"ToolTimeline.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ToolTimeline.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAIlE,UAAU,KAAK;IACb,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA6HH;;;;GAIG;AACH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=composer.svelte.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composer.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/composer.svelte.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,162 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { LIMITS } from '@classic-homes/agent-contracts';
5
+ import { promptMenuKeyAction } from '../composer-keys.js';
6
+ import { filterPrompts, promptQuery } from '../composer-prompts.js';
7
+ import { formatDuration, groupToolRuns, humanizeToolName } from '../message-format.js';
8
+ import Composer from './Composer.svelte';
9
+ import ToolTimeline from './ToolTimeline.svelte';
10
+ afterEach(() => cleanup());
11
+ const prompts = [
12
+ {
13
+ id: '1',
14
+ label: 'Weekly recap',
15
+ description: 'Summary for a community',
16
+ text: 'Recap {{community}}',
17
+ },
18
+ { id: '2', label: 'Lot report', text: async () => 'Report on lot {{lot}}' },
19
+ { id: '3', label: 'Draft email', description: 'Weekly buyer update', text: 'Email' },
20
+ ];
21
+ describe('prompt menu helpers', () => {
22
+ it('reads the query only from a bare /command', () => {
23
+ expect(promptQuery('/')).toBe('');
24
+ expect(promptQuery('/wee')).toBe('wee');
25
+ expect(promptQuery('/weekly recap')).toBeNull();
26
+ expect(promptQuery('hello /x')).toBeNull();
27
+ });
28
+ it('ranks label matches over description matches', () => {
29
+ expect(filterPrompts(prompts, 'weekly').map((p) => p.id)).toEqual(['1', '3']);
30
+ expect(filterPrompts(prompts, 'lot').map((p) => p.id)).toEqual(['2']);
31
+ expect(filterPrompts(prompts, '')).toHaveLength(3);
32
+ });
33
+ it('maps keys while the menu is open', () => {
34
+ expect(promptMenuKeyAction({ key: 'ArrowDown' })).toBe('next');
35
+ expect(promptMenuKeyAction({ key: 'Enter' })).toBe('choose');
36
+ expect(promptMenuKeyAction({ key: 'Enter', shiftKey: true })).toBeNull();
37
+ expect(promptMenuKeyAction({ key: 'Enter', isComposing: true })).toBeNull();
38
+ expect(promptMenuKeyAction({ key: 'Escape' })).toBe('close');
39
+ });
40
+ });
41
+ describe('tool helpers', () => {
42
+ it('humanizes names and durations', () => {
43
+ expect(humanizeToolName('hotsheet/list_plans')).toBe('List plans');
44
+ expect(humanizeToolName('getLotDetails')).toBe('Get lot details');
45
+ expect(formatDuration(640)).toBe('640 ms');
46
+ expect(formatDuration(1234)).toBe('1.2 s');
47
+ expect(formatDuration(75_000)).toBe('1 min 15 s');
48
+ });
49
+ it('groups calls under their delegation', () => {
50
+ const t = (callId, runId) => ({ callId, runId });
51
+ const groups = groupToolRuns([t('a', 'root'), t('b', 'child-1'), t('c', 'root')], [{ child: 'research', childRunId: 'child-1', status: 'end' }]);
52
+ expect(groups.map((g) => [g.delegation?.child, g.tools.map((x) => x.callId)])).toEqual([
53
+ [undefined, ['a', 'c']],
54
+ ['research', ['b']],
55
+ ]);
56
+ });
57
+ });
58
+ describe('Composer', () => {
59
+ it('inserts a saved prompt from the / menu with the keyboard', async () => {
60
+ const user = userEvent.setup();
61
+ render(Composer, { props: { prompts } });
62
+ const box = screen.getByRole('combobox', { name: 'Message' });
63
+ await user.type(box, '/lot');
64
+ const listbox = await screen.findByRole('listbox', { name: 'Saved prompts' });
65
+ expect(listbox.textContent).toContain('Lot report');
66
+ await user.keyboard('{Enter}');
67
+ await waitFor(() => expect(box.value).toBe('Report on lot {{lot}}'));
68
+ expect(screen.queryByRole('listbox')).toBeNull();
69
+ });
70
+ it('closes the menu on Escape and keeps the draft', async () => {
71
+ const user = userEvent.setup();
72
+ render(Composer, { props: { prompts } });
73
+ const box = screen.getByRole('combobox', { name: 'Message' });
74
+ await user.type(box, '/');
75
+ await screen.findByRole('listbox');
76
+ await user.keyboard('{ArrowDown}{Escape}');
77
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
78
+ expect(box.value).toBe('/');
79
+ });
80
+ it('uploads pasted and dropped files and shows them while uploading', async () => {
81
+ let finish = () => { };
82
+ const upload = vi.fn((file) => new Promise((resolve) => {
83
+ finish = resolve;
84
+ void file;
85
+ }));
86
+ render(Composer, { props: { upload } });
87
+ const box = screen.getByRole('textbox', { name: 'Message' });
88
+ const file = new File(['x'], 'shot.png', { type: 'image/png' });
89
+ await fireEvent.paste(box, { clipboardData: { files: [file], types: ['Files'] } });
90
+ expect(upload).toHaveBeenCalledWith(file);
91
+ expect(await screen.findByText('shot.png')).toBeTruthy();
92
+ expect(screen.getByRole('button', { name: 'Send' }).hasAttribute('disabled')).toBe(true);
93
+ finish({ fileId: 'f1', name: 'shot.png', mimeType: 'image/png', bytes: 1, status: 'uploaded' });
94
+ expect(await screen.findByRole('button', { name: 'Remove shot.png' })).toBeTruthy();
95
+ const card = screen.getByRole('group', { name: 'Message composer' });
96
+ const dropped = new File(['y'], 'plan.pdf', { type: 'application/pdf' });
97
+ const dataTransfer = { types: ['Files'], files: [dropped], dropEffect: 'none' };
98
+ await fireEvent.dragEnter(card, { dataTransfer });
99
+ expect(screen.getByText('Drop files to attach')).toBeTruthy();
100
+ await fireEvent.drop(card, { dataTransfer });
101
+ expect(upload).toHaveBeenLastCalledWith(dropped);
102
+ expect(screen.queryByText('Drop files to attach')).toBeNull();
103
+ });
104
+ it('counts characters near the limit', async () => {
105
+ const { rerender } = render(Composer, { props: { value: 'short' } });
106
+ expect(screen.queryByText(/\/ 8,000/)).toBeNull();
107
+ await rerender({ value: 'x'.repeat(Math.ceil(LIMITS.messageChars * 0.85)) });
108
+ expect(screen.getByText(/\/ 8,000/)).toBeTruthy();
109
+ });
110
+ });
111
+ describe('ToolTimeline', () => {
112
+ const tool = (patch) => ({
113
+ callId: 'c1',
114
+ name: 'list_plans',
115
+ server: 'hotsheet',
116
+ argsPreview: '{"community":"Wolf Ranch"}',
117
+ agent: 'classic-agent',
118
+ runId: 'root',
119
+ ...patch,
120
+ });
121
+ it('is open with a spinner while running and folds when the turn ends', async () => {
122
+ const { rerender } = render(ToolTimeline, {
123
+ props: { tools: [tool({})], delegations: [], streaming: true },
124
+ });
125
+ expect(screen.getAllByText('List plans…').length).toBeGreaterThan(0);
126
+ expect(screen.getByText('{"community":"Wolf Ranch"}')).toBeTruthy();
127
+ await rerender({
128
+ tools: [tool({ status: 'ok', durationMs: 1234 })],
129
+ delegations: [],
130
+ streaming: false,
131
+ });
132
+ const trigger = screen.getByRole('button', { name: /1 tool call/ });
133
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
134
+ await userEvent.click(trigger);
135
+ expect(screen.getByText('1.2 s')).toBeTruthy();
136
+ });
137
+ it('uses host labels, nests delegations and counts failures', async () => {
138
+ render(ToolTimeline, {
139
+ props: {
140
+ streaming: false,
141
+ toolLabels: { 'hotsheet/list_plans': 'Look up plans' },
142
+ tools: [
143
+ tool({ status: 'ok' }),
144
+ tool({
145
+ callId: 'c2',
146
+ name: 'web_search',
147
+ server: undefined,
148
+ runId: 'child',
149
+ status: 'error',
150
+ }),
151
+ ],
152
+ delegations: [{ child: 'research', childRunId: 'child', status: 'end' }],
153
+ },
154
+ });
155
+ const trigger = screen.getByRole('button', { name: /2 tool calls · via research · 1 failed/ });
156
+ await userEvent.click(trigger);
157
+ expect(screen.getByText('Look up plans')).toBeTruthy();
158
+ expect(screen.getByText('research')).toBeTruthy();
159
+ expect(screen.getByText('Web search')).toBeTruthy();
160
+ expect(screen.getByText('Failed')).toBeTruthy();
161
+ });
162
+ });
@@ -1,5 +1,7 @@
1
1
  /** What a keydown in the composer textarea should do. Pure so it can be tested without a DOM. */
2
2
  export type ComposerKeyAction = 'send' | 'cancel' | null;
3
+ /** What a keydown does while the `/` prompt menu is open. */
4
+ export type PromptMenuKeyAction = 'next' | 'previous' | 'choose' | 'close' | null;
3
5
  export interface ComposerKey {
4
6
  key: string;
5
7
  shiftKey?: boolean;
@@ -8,4 +10,5 @@ export interface ComposerKey {
8
10
  keyCode?: number;
9
11
  }
10
12
  export declare function composerKeyAction(event: ComposerKey, streaming: boolean): ComposerKeyAction;
13
+ export declare function promptMenuKeyAction(event: ComposerKey): PromptMenuKeyAction;
11
14
  //# sourceMappingURL=composer-keys.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"composer-keys.d.ts","sourceRoot":"","sources":["../src/lib/composer-keys.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;AAEzD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAM3F"}
1
+ {"version":3,"file":"composer-keys.d.ts","sourceRoot":"","sources":["../src/lib/composer-keys.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;AAEzD,6DAA6D;AAC7D,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,IAAI,CAAC;AAElF,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAM3F;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,WAAW,GAAG,mBAAmB,CAgB3E"}
@@ -8,3 +8,21 @@ export function composerKeyAction(event, streaming) {
8
8
  return 'send';
9
9
  return null;
10
10
  }
11
+ export function promptMenuKeyAction(event) {
12
+ if (event.isComposing || event.keyCode === 229)
13
+ return null;
14
+ switch (event.key) {
15
+ case 'ArrowDown':
16
+ return 'next';
17
+ case 'ArrowUp':
18
+ return 'previous';
19
+ case 'Enter':
20
+ return event.shiftKey ? null : 'choose';
21
+ case 'Tab':
22
+ return 'choose';
23
+ case 'Escape':
24
+ return 'close';
25
+ default:
26
+ return null;
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The composer's `/` prompt menu: which query the draft asks for and which prompts match. Pure,
3
+ * so the matching can be tested without a DOM.
4
+ */
5
+ export interface ComposerPrompt {
6
+ id: string;
7
+ label: string;
8
+ description?: string;
9
+ /** The text to insert, or a loader for it (e.g. fetch the saved template). */
10
+ text: string | (() => Promise<string>);
11
+ }
12
+ /** The filter text when the draft is a bare `/command` (no spaces or newlines yet), else null. */
13
+ export declare function promptQuery(draft: string): string | null;
14
+ /** Prompts whose label or description contains every word of the query, labels first. */
15
+ export declare function filterPrompts<T extends Pick<ComposerPrompt, 'label' | 'description'>>(prompts: T[], query: string, limit?: number): T[];
16
+ //# sourceMappingURL=composer-prompts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composer-prompts.d.ts","sourceRoot":"","sources":["../src/lib/composer-prompts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,IAAI,EAAE,MAAM,GAAG,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;CACxC;AAED,kGAAkG;AAClG,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGxD;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,CAAC,SAAS,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,aAAa,CAAC,EACnF,OAAO,EAAE,CAAC,EAAE,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,SAAI,GACR,CAAC,EAAE,CAkBL"}
@@ -0,0 +1,27 @@
1
+ /** The filter text when the draft is a bare `/command` (no spaces or newlines yet), else null. */
2
+ export function promptQuery(draft) {
3
+ const match = /^\/(\S*)$/.exec(draft);
4
+ return match ? match[1] : null;
5
+ }
6
+ /** Prompts whose label or description contains every word of the query, labels first. */
7
+ export function filterPrompts(prompts, query, limit = 8) {
8
+ const words = query
9
+ .toLowerCase()
10
+ .split(/[-_\s]+/)
11
+ .filter(Boolean);
12
+ const score = (p) => {
13
+ const label = p.label.toLowerCase();
14
+ const haystack = `${label} ${p.description?.toLowerCase() ?? ''}`;
15
+ if (!words.every((w) => haystack.includes(w)))
16
+ return -1;
17
+ if (!words.length)
18
+ return 1;
19
+ return words.every((w) => label.includes(w)) ? (label.startsWith(words[0]) ? 3 : 2) : 1;
20
+ };
21
+ return prompts
22
+ .map((p, i) => ({ p, i, s: score(p) }))
23
+ .filter((x) => x.s >= 0)
24
+ .sort((a, b) => b.s - a.s || a.i - b.i)
25
+ .slice(0, limit)
26
+ .map((x) => x.p);
27
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './body-lock.js';
2
2
  export * from './composer-keys.js';
3
+ export * from './composer-prompts.js';
3
4
  export * from './markdown.js';
4
5
  export * from './message-format.js';
5
6
  export * from './reducer.js';
@@ -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,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,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"}
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,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"}
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './body-lock.js';
2
2
  export * from './composer-keys.js';
3
+ export * from './composer-prompts.js';
3
4
  export * from './markdown.js';
4
5
  export * from './message-format.js';
5
6
  export * from './reducer.js';
@@ -27,4 +27,32 @@ export interface ProposalDescription {
27
27
  }
28
28
  /** Read-only description of a proposed document edit; applying happens in the Office add-in. */
29
29
  export declare function describeProposal(op: ProposedOperation): ProposalDescription;
30
+ /** "list_plans" / "listPlans" / "hotsheet/list-plans" → "List plans". */
31
+ export declare function humanizeToolName(name: string): string;
32
+ /** 640 → "640 ms", 1234 → "1.2 s", 75000 → "1 min 15 s". */
33
+ export declare function formatDuration(ms: number): string;
34
+ export interface ToolRunGroup<T extends {
35
+ runId: string;
36
+ }> {
37
+ /** The delegation this group ran under; undefined for the top-level agent. */
38
+ delegation?: {
39
+ child: string;
40
+ childRunId: string;
41
+ status: 'start' | 'end';
42
+ summary?: string;
43
+ };
44
+ tools: T[];
45
+ }
46
+ /**
47
+ * Group tool calls by the run that made them: the top-level agent's own calls first, then one
48
+ * group per delegation (in the order the delegations started) with the child's calls.
49
+ */
50
+ export declare function groupToolRuns<T extends {
51
+ runId: string;
52
+ }>(tools: T[], delegations: {
53
+ child: string;
54
+ childRunId: string;
55
+ status: 'start' | 'end';
56
+ summary?: string;
57
+ }[]): ToolRunGroup<T>[];
30
58
  //# sourceMappingURL=message-format.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"message-format.d.ts","sourceRoot":"","sources":["../src/lib/message-format.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC3F,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAI7C;AAED,wCAAwC;AACxC,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,wCAAwC;AACxC,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CASlE;AAQD,2FAA2F;AAC3F,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,SAAa,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CASrF;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,QAAQ,CAcnE;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,MAAM,CAejE;AAED,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAQnD;AAED,MAAM,WAAW,mBAAmB;IAClC,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,gGAAgG;AAChG,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,iBAAiB,GAAG,mBAAmB,CAgB3E"}
1
+ {"version":3,"file":"message-format.d.ts","sourceRoot":"","sources":["../src/lib/message-format.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC3F,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAI7C;AAED,wCAAwC;AACxC,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,wCAAwC;AACxC,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CASlE;AAQD,2FAA2F;AAC3F,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,SAAa,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CASrF;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,QAAQ,CAcnE;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,MAAM,CAejE;AAED,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAQnD;AAED,MAAM,WAAW,mBAAmB;IAClC,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,gGAAgG;AAChG,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,iBAAiB,GAAG,mBAAmB,CAgB3E;AAED,yEAAyE;AACzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQrD;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAMjD;AAED,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE;IACvD,8EAA8E;IAC9E,UAAU,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,GAAG,KAAK,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9F,KAAK,EAAE,CAAC,EAAE,CAAC;CACZ;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,EACvD,KAAK,EAAE,CAAC,EAAE,EACV,WAAW,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,GAAG,KAAK,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GAC9F,YAAY,CAAC,CAAC,CAAC,EAAE,CAMnB"}
@@ -116,3 +116,34 @@ export function describeProposal(op) {
116
116
  return { action: 'Replace selection', host: 'Word' };
117
117
  }
118
118
  }
119
+ /** "list_plans" / "listPlans" / "hotsheet/list-plans" → "List plans". */
120
+ export function humanizeToolName(name) {
121
+ const bare = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
122
+ const words = bare
123
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
124
+ .replace(/[_\-.]+/g, ' ')
125
+ .trim()
126
+ .toLowerCase();
127
+ return words ? words[0].toUpperCase() + words.slice(1) : name;
128
+ }
129
+ /** 640 → "640 ms", 1234 → "1.2 s", 75000 → "1 min 15 s". */
130
+ export function formatDuration(ms) {
131
+ if (ms < 1000)
132
+ return `${Math.round(ms)} ms`;
133
+ if (ms < 60_000)
134
+ return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
135
+ const min = Math.floor(ms / 60_000);
136
+ const s = Math.round((ms % 60_000) / 1000);
137
+ return s ? `${min} min ${s} s` : `${min} min`;
138
+ }
139
+ /**
140
+ * Group tool calls by the run that made them: the top-level agent's own calls first, then one
141
+ * group per delegation (in the order the delegations started) with the child's calls.
142
+ */
143
+ export function groupToolRuns(tools, delegations) {
144
+ const childRuns = new Set(delegations.map((d) => d.childRunId));
145
+ const groups = [{ tools: tools.filter((t) => !childRuns.has(t.runId)) }];
146
+ for (const delegation of delegations)
147
+ groups.push({ delegation, tools: tools.filter((t) => t.runId === delegation.childRunId) });
148
+ return groups.filter((g) => g.delegation || g.tools.length);
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/agent-chat",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.3.0",
21
- "@classic-homes/agent-contracts": "0.3.0",
20
+ "@classic-homes/agent-client": "0.4.0",
21
+ "@classic-homes/agent-contracts": "0.4.0",
22
22
  "dompurify": "3.4.17"
23
23
  },
24
24
  "peerDependencies": {