@classic-homes/agent-chat 0.2.1 → 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 (58) hide show
  1. package/README.md +18 -2
  2. package/dist/components/AgentChat.svelte +10 -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/ArtifactChip.svelte +45 -0
  6. package/dist/components/ArtifactChip.svelte.d.ts +14 -0
  7. package/dist/components/ArtifactChip.svelte.d.ts.map +1 -0
  8. package/dist/components/AssistantMessage.svelte +186 -0
  9. package/dist/components/AssistantMessage.svelte.d.ts +26 -0
  10. package/dist/components/AssistantMessage.svelte.d.ts.map +1 -0
  11. package/dist/components/Composer.svelte +221 -30
  12. package/dist/components/Composer.svelte.d.ts +8 -0
  13. package/dist/components/Composer.svelte.d.ts.map +1 -1
  14. package/dist/components/Markdown.svelte +87 -5
  15. package/dist/components/Markdown.svelte.d.ts +9 -1
  16. package/dist/components/Markdown.svelte.d.ts.map +1 -1
  17. package/dist/components/MessageActions.svelte +56 -0
  18. package/dist/components/MessageActions.svelte.d.ts +17 -0
  19. package/dist/components/MessageActions.svelte.d.ts.map +1 -0
  20. package/dist/components/MessageFiles.svelte +83 -0
  21. package/dist/components/MessageFiles.svelte.d.ts +14 -0
  22. package/dist/components/MessageFiles.svelte.d.ts.map +1 -0
  23. package/dist/components/MessageItem.svelte +12 -144
  24. package/dist/components/MessageItem.svelte.d.ts +4 -0
  25. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  26. package/dist/components/MessageList.svelte +102 -30
  27. package/dist/components/MessageList.svelte.d.ts +2 -0
  28. package/dist/components/MessageList.svelte.d.ts.map +1 -1
  29. package/dist/components/ProposalCard.svelte +39 -0
  30. package/dist/components/ProposalCard.svelte.d.ts +13 -0
  31. package/dist/components/ProposalCard.svelte.d.ts.map +1 -0
  32. package/dist/components/ToolTimeline.svelte +138 -54
  33. package/dist/components/ToolTimeline.svelte.d.ts +11 -3
  34. package/dist/components/ToolTimeline.svelte.d.ts.map +1 -1
  35. package/dist/components/UserMessage.svelte +46 -0
  36. package/dist/components/UserMessage.svelte.d.ts +10 -0
  37. package/dist/components/UserMessage.svelte.d.ts.map +1 -0
  38. package/dist/components/composer.svelte.test.d.ts +2 -0
  39. package/dist/components/composer.svelte.test.d.ts.map +1 -0
  40. package/dist/components/composer.svelte.test.js +162 -0
  41. package/dist/components/messages.svelte.test.d.ts +2 -0
  42. package/dist/components/messages.svelte.test.d.ts.map +1 -0
  43. package/dist/components/messages.svelte.test.js +202 -0
  44. package/dist/composer-keys.d.ts +3 -0
  45. package/dist/composer-keys.d.ts.map +1 -1
  46. package/dist/composer-keys.js +18 -0
  47. package/dist/composer-prompts.d.ts +16 -0
  48. package/dist/composer-prompts.d.ts.map +1 -0
  49. package/dist/composer-prompts.js +27 -0
  50. package/dist/index.d.ts +8 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +8 -0
  53. package/dist/markdown.d.ts.map +1 -1
  54. package/dist/markdown.js +12 -4
  55. package/dist/message-format.d.ts +58 -0
  56. package/dist/message-format.d.ts.map +1 -0
  57. package/dist/message-format.js +149 -0
  58. package/package.json +3 -3
@@ -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"}
@@ -4,7 +4,8 @@
4
4
  Tailwind arbitrary variants on the container rather than a stylesheet: the Classic App's
5
5
  cascade-layers polyfill raises the specificity of its base styles above any plain component
6
6
  CSS, while utilities still win. Values follow theme-docs `.markdown-content`, sized for chat. Tables don't break words (the
7
- column would squeeze "$1,075,000" to two lines); they scroll sideways instead.
7
+ column would squeeze "$1,075,000" to two lines); their wrapper scrolls sideways instead. Code
8
+ blocks get a language label and a copy button; external links an arrow.
8
9
  -->
9
10
  <script lang="ts" module>
10
11
  const PROSE = [
@@ -15,21 +16,78 @@
15
16
  '[&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-[1.125em] [&_h3]:font-semibold [&_h3]:leading-snug',
16
17
  '[&_h4]:mb-2 [&_h4]:mt-4 [&_h4]:text-[1em] [&_h4]:font-semibold [&_h4]:leading-snug',
17
18
  '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:text-primary/80',
19
+ "[&_a.agent-external-link]:after:ml-0.5 [&_a.agent-external-link]:after:text-[0.8em] [&_a.agent-external-link]:after:content-['↗']",
18
20
  '[&_ul]:mb-3 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:mb-3 [&_ol]:list-decimal [&_ol]:pl-6',
19
21
  '[&_li]:mb-1 [&_li>ul]:mb-0 [&_li>ul]:mt-1 [&_li>ol]:mb-0 [&_li>ol]:mt-1',
20
22
  '[&_ul.agent-tasklist]:list-none [&_ul.agent-tasklist]:pl-1',
21
23
  '[&_.agent-tasklist_input]:mr-1.5 [&_.agent-tasklist_input]:accent-primary',
22
24
  '[&_code]:rounded [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.875em]',
23
25
  '[&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted/60 [&_pre]:px-4 [&_pre]:py-3 [&_pre]:text-[13px] [&_pre]:leading-relaxed',
26
+ // Code blocks get a header (language + copy) from `enhanceCode`; the block then drops its own frame.
27
+ '[&_.agent-codeblock]:mb-3 [&_.agent-codeblock]:overflow-hidden [&_.agent-codeblock]:rounded-lg [&_.agent-codeblock]:border [&_.agent-codeblock]:border-border',
28
+ '[&_.agent-codeblock_pre]:mb-0 [&_.agent-codeblock_pre]:rounded-none [&_.agent-codeblock_pre]:border-0',
24
29
  '[&_pre_code]:rounded-none [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[length:inherit]',
25
30
  '[&_blockquote]:mb-3 [&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:bg-muted/50 [&_blockquote]:px-4 [&_blockquote]:py-2 [&_blockquote]:text-muted-foreground',
26
31
  '[&_hr]:my-5 [&_hr]:border-border',
27
- '[&_table]:mb-3 [&_table]:block [&_table]:max-w-full [&_table]:border-collapse [&_table]:overflow-x-auto [&_table]:text-[0.875em] [&_table]:[overflow-wrap:normal]',
32
+ '[&_.agent-table-wrap]:mb-3 [&_.agent-table-wrap]:max-w-full [&_.agent-table-wrap]:overflow-x-auto',
33
+ '[&_table]:border-collapse [&_table]:text-[0.875em] [&_table]:[overflow-wrap:normal]',
28
34
  '[&_th]:border [&_th]:border-border [&_th]:px-3 [&_th]:py-1.5 [&_th]:text-left [&_th]:align-top',
29
35
  '[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-1.5 [&_td]:text-left [&_td]:align-top',
30
36
  '[&_th]:bg-muted [&_th]:font-semibold',
31
37
  '[&_img]:max-w-full [&_img]:rounded-lg',
32
38
  ].join(' ');
39
+
40
+ /** Blinking caret after the last block while the answer streams. */
41
+ const STREAMING =
42
+ "[&>:last-child]:after:ml-0.5 [&>:last-child]:after:text-muted-foreground [&>:last-child]:after:content-['▍'] motion-safe:[&>:last-child]:after:animate-pulse";
43
+
44
+ const HEADER =
45
+ 'flex items-center justify-between gap-2 border-b border-border bg-muted px-3 py-1 text-xs text-muted-foreground';
46
+ const COPY =
47
+ 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-medium hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
48
+ const COPY_ICON =
49
+ '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
50
+
51
+ /**
52
+ * Wrap each rendered code block with a header that names the language and copies the code.
53
+ * Runs on the DOM after `{@html}` updates; the HTML itself stays as DOMPurify left it.
54
+ */
55
+ export function enhanceCode(root: HTMLElement): void {
56
+ for (const pre of root.querySelectorAll<HTMLPreElement>('pre.agent-code')) {
57
+ if (pre.parentElement?.classList.contains('agent-codeblock')) continue;
58
+ const lang = /agent-lang-([a-z0-9-]+)/.exec(pre.className)?.[1];
59
+ const wrap = document.createElement('div');
60
+ wrap.className = 'agent-codeblock';
61
+ const header = document.createElement('div');
62
+ header.className = HEADER;
63
+ const label = document.createElement('span');
64
+ label.className = 'font-mono';
65
+ label.textContent = lang ?? 'code';
66
+ const copy = document.createElement('button');
67
+ copy.type = 'button';
68
+ copy.className = COPY;
69
+ copy.dataset.agentCopy = '';
70
+ copy.setAttribute('aria-label', lang ? `Copy ${lang} code` : 'Copy code');
71
+ copy.innerHTML = `${COPY_ICON}<span>Copy</span>`;
72
+ header.append(label, copy);
73
+ pre.replaceWith(wrap);
74
+ wrap.append(header, pre);
75
+ }
76
+ }
77
+
78
+ async function copyCode(button: HTMLButtonElement): Promise<void> {
79
+ const code = button.closest('.agent-codeblock')?.querySelector('pre')?.textContent ?? '';
80
+ const text = button.querySelector('span');
81
+ try {
82
+ await navigator.clipboard.writeText(code);
83
+ if (text) text.textContent = 'Copied';
84
+ } catch {
85
+ if (text) text.textContent = 'Copy failed';
86
+ }
87
+ setTimeout(() => {
88
+ if (text) text.textContent = 'Copy';
89
+ }, 2000);
90
+ }
33
91
  </script>
34
92
 
35
93
  <script lang="ts">
@@ -39,11 +97,35 @@
39
97
  /** Output of `parseMarkdown` (already sanitized by DOMPurify). */
40
98
  html: string;
41
99
  class?: string;
100
+ /** Show a caret after the last block (the answer is still streaming). */
101
+ streaming?: boolean;
42
102
  onclick?: (event: MouseEvent) => void;
43
103
  }
44
- let { html, class: className, onclick }: Props = $props();
104
+ let { html, class: className, streaming = false, onclick }: Props = $props();
105
+
106
+ let root: HTMLDivElement | undefined = $state();
107
+ $effect(() => {
108
+ void html;
109
+ if (root) enhanceCode(root);
110
+ });
111
+
112
+ function handleClick(event: MouseEvent): void {
113
+ const button = (event.target as HTMLElement | null)?.closest?.('button[data-agent-copy]');
114
+ if (button instanceof HTMLButtonElement) {
115
+ void copyCode(button);
116
+ return;
117
+ }
118
+ onclick?.(event);
119
+ }
45
120
  </script>
46
121
 
47
- <!-- Clicks are delegated to internal links inside the HTML; the links themselves are focusable. -->
122
+ <!-- PROSE stays out of tailwind-merge, which could drop rules it reads as conflicting. Clicks are
123
+ delegated to internal links and code copy buttons inside the HTML; those are focusable. -->
48
124
  <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
49
- <div class={className ? `${PROSE} ${cn(className)}` : PROSE} {onclick}>{@html html}</div>
125
+ <div
126
+ bind:this={root}
127
+ class={[PROSE, streaming ? STREAMING : '', className ? cn(className) : ''].join(' ')}
128
+ onclick={handleClick}
129
+ >
130
+ {@html html}
131
+ </div>
@@ -1,7 +1,14 @@
1
+ /**
2
+ * Wrap each rendered code block with a header that names the language and copies the code.
3
+ * Runs on the DOM after `{@html}` updates; the HTML itself stays as DOMPurify left it.
4
+ */
5
+ export declare function enhanceCode(root: HTMLElement): void;
1
6
  interface Props {
2
7
  /** Output of `parseMarkdown` (already sanitized by DOMPurify). */
3
8
  html: string;
4
9
  class?: string;
10
+ /** Show a caret after the last block (the answer is still streaming). */
11
+ streaming?: boolean;
5
12
  onclick?: (event: MouseEvent) => void;
6
13
  }
7
14
  /**
@@ -9,7 +16,8 @@ interface Props {
9
16
  * Tailwind arbitrary variants on the container rather than a stylesheet: the Classic App's
10
17
  * cascade-layers polyfill raises the specificity of its base styles above any plain component
11
18
  * CSS, while utilities still win. Values follow theme-docs `.markdown-content`, sized for chat. Tables don't break words (the
12
- * column would squeeze "$1,075,000" to two lines); they scroll sideways instead.
19
+ * column would squeeze "$1,075,000" to two lines); their wrapper scrolls sideways instead. Code
20
+ * blocks get a language label and a copy button; external links an arrow.
13
21
  */
14
22
  declare const Markdown: import("svelte").Component<Props, {}, "">;
15
23
  type Markdown = ReturnType<typeof Markdown>;
@@ -1 +1 @@
1
- {"version":3,"file":"Markdown.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Markdown.svelte.ts"],"names":[],"mappings":"AA+BE,UAAU,KAAK;IACb,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACvC;AAgBH;;;;;;GAMG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Markdown.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Markdown.svelte.ts"],"names":[],"mappings":"AA2CE;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAqBnD;AAoBD,UAAU,KAAK;IACb,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACvC;AAiCH;;;;;;;GAOG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,56 @@
1
+ <!--
2
+ @component
3
+ Per-message action bar: copy the raw text and the time it was sent. Hidden until the message is
4
+ hovered or holds focus, except where `visible` (the last message) or on touch screens, which
5
+ have no hover.
6
+ -->
7
+ <script lang="ts">
8
+ import { cn, CopyButton, Tooltip } from '@classic-homes/theme-svelte';
9
+ import { formatDateTime, formatTime } from '../message-format.js';
10
+
11
+ interface Props {
12
+ text: string;
13
+ createdAt: number;
14
+ /** Keep the bar visible without hover (the last message). */
15
+ visible?: boolean;
16
+ align?: 'start' | 'end';
17
+ class?: string;
18
+ }
19
+ let { text, createdAt, visible = false, align = 'start', class: className }: Props = $props();
20
+ </script>
21
+
22
+ <div
23
+ class={cn(
24
+ 'flex items-center gap-0.5 text-xs text-muted-foreground transition-opacity',
25
+ align === 'end' && 'justify-end',
26
+ !visible &&
27
+ 'opacity-0 group-hover/message:opacity-100 group-focus-within/message:opacity-100 [@media(hover:none)]:opacity-100',
28
+ className,
29
+ )}
30
+ data-message-actions
31
+ >
32
+ {#if text}
33
+ <CopyButton
34
+ value={text}
35
+ label="Copy message"
36
+ copiedLabel="Copied"
37
+ variant="ghost"
38
+ size="icon"
39
+ class="h-7 w-7"
40
+ />
41
+ {/if}
42
+ <Tooltip content={formatDateTime(createdAt)}>
43
+ {#snippet children({ props })}
44
+ <!-- Focusable so keyboard users can read the full date in the tooltip. -->
45
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
46
+ <time
47
+ {...props}
48
+ tabindex="0"
49
+ class="rounded px-1.5 py-0.5 tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
50
+ datetime={new Date(createdAt).toISOString()}
51
+ >
52
+ {formatTime(createdAt)}
53
+ </time>
54
+ {/snippet}
55
+ </Tooltip>
56
+ </div>
@@ -0,0 +1,17 @@
1
+ interface Props {
2
+ text: string;
3
+ createdAt: number;
4
+ /** Keep the bar visible without hover (the last message). */
5
+ visible?: boolean;
6
+ align?: 'start' | 'end';
7
+ class?: string;
8
+ }
9
+ /**
10
+ * Per-message action bar: copy the raw text and the time it was sent. Hidden until the message is
11
+ * hovered or holds focus, except where `visible` (the last message) or on touch screens, which
12
+ * have no hover.
13
+ */
14
+ declare const MessageActions: import("svelte").Component<Props, {}, "">;
15
+ type MessageActions = ReturnType<typeof MessageActions>;
16
+ export default MessageActions;
17
+ //# sourceMappingURL=MessageActions.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MessageActions.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageActions.svelte.ts"],"names":[],"mappings":"AAOE,UAAU,KAAK;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAgCH;;;;GAIG;AACH,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}