@classic-homes/agent-chat 0.2.1 → 0.3.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 (41) hide show
  1. package/README.md +14 -2
  2. package/dist/components/AgentChat.svelte +1 -0
  3. package/dist/components/ArtifactChip.svelte +45 -0
  4. package/dist/components/ArtifactChip.svelte.d.ts +14 -0
  5. package/dist/components/ArtifactChip.svelte.d.ts.map +1 -0
  6. package/dist/components/AssistantMessage.svelte +179 -0
  7. package/dist/components/AssistantMessage.svelte.d.ts +25 -0
  8. package/dist/components/AssistantMessage.svelte.d.ts.map +1 -0
  9. package/dist/components/Markdown.svelte +87 -5
  10. package/dist/components/Markdown.svelte.d.ts +9 -1
  11. package/dist/components/Markdown.svelte.d.ts.map +1 -1
  12. package/dist/components/MessageActions.svelte +56 -0
  13. package/dist/components/MessageActions.svelte.d.ts +17 -0
  14. package/dist/components/MessageActions.svelte.d.ts.map +1 -0
  15. package/dist/components/MessageFiles.svelte +83 -0
  16. package/dist/components/MessageFiles.svelte.d.ts +14 -0
  17. package/dist/components/MessageFiles.svelte.d.ts.map +1 -0
  18. package/dist/components/MessageItem.svelte +11 -144
  19. package/dist/components/MessageItem.svelte.d.ts +3 -0
  20. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  21. package/dist/components/MessageList.svelte +99 -30
  22. package/dist/components/MessageList.svelte.d.ts +1 -0
  23. package/dist/components/MessageList.svelte.d.ts.map +1 -1
  24. package/dist/components/ProposalCard.svelte +39 -0
  25. package/dist/components/ProposalCard.svelte.d.ts +13 -0
  26. package/dist/components/ProposalCard.svelte.d.ts.map +1 -0
  27. package/dist/components/UserMessage.svelte +46 -0
  28. package/dist/components/UserMessage.svelte.d.ts +10 -0
  29. package/dist/components/UserMessage.svelte.d.ts.map +1 -0
  30. package/dist/components/messages.svelte.test.d.ts +2 -0
  31. package/dist/components/messages.svelte.test.d.ts.map +1 -0
  32. package/dist/components/messages.svelte.test.js +202 -0
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -0
  36. package/dist/markdown.d.ts.map +1 -1
  37. package/dist/markdown.js +12 -4
  38. package/dist/message-format.d.ts +30 -0
  39. package/dist/message-format.d.ts.map +1 -0
  40. package/dist/message-format.js +118 -0
  41. package/package.json +3 -3
package/README.md CHANGED
@@ -21,8 +21,9 @@ artifacts and MCP-UI sandbox frames. The Classic App's `/agent` page and agent p
21
21
  };
22
22
  ```
23
23
 
24
- Without that entry the chat renders unstyled. Markdown prose (rendered from model text) is the
25
- one part styled with plain CSS, shipped inside the `Markdown` component.
24
+ Without that entry the chat renders unstyled. Markdown prose (rendered from model text) uses
25
+ arbitrary-variant utilities on its container (`[&_ul]:list-disc`), not a stylesheet: the
26
+ Classic App's cascade-layers polyfill outranks plain component CSS.
26
27
 
27
28
  ## Usage
28
29
 
@@ -37,4 +38,15 @@ artifacts and MCP-UI sandbox frames. The Classic App's `/agent` page and agent p
37
38
  `client` is an `AgentApiClient` from `@classic-homes/agent-client`. `sandboxOrigin` is the
38
39
  agent-api origin that serves `/frame`; artifacts and MCP-UI resources only ever render there.
39
40
 
41
+ ## Components
42
+
43
+ `AgentChat` composes the rest; each is also exported for other layouts:
44
+
45
+ - `ChatHeader`, `ConversationSidebar`: title and conversation actions; the history rail.
46
+ - `MessageList`: transcript with day separators and a jump-to-latest button.
47
+ - `MessageItem` → `UserMessage` / `AssistantMessage`, with `MessageActions` (copy, time),
48
+ `ArtifactChip`, `MessageFiles` (image lightbox, downloads) and `ProposalCard` (read-only
49
+ document edits; applying stays in the Office add-in).
50
+ - `Composer`, `ToolTimeline`, `ArtifactPanel`, `UiFrame`, `ModeBanner`.
51
+
40
52
  Publishing: `docs/operations/package-publishing.md`.
@@ -155,6 +155,7 @@
155
155
  ontool={(name, params) => ontoolrequest?.(name, params)}
156
156
  {onnotify}
157
157
  onretry={retry}
158
+ oncontinue={() => void session.send('Continue.')}
158
159
  {empty}
159
160
  />
160
161
  <Composer
@@ -0,0 +1,45 @@
1
+ <!--
2
+ @component
3
+ A card-like button for one artifact in a message: type icon, title and kind, and a spinner while
4
+ the artifact is still being written. Opens it in the artifact pane.
5
+ -->
6
+ <script lang="ts">
7
+ import { cn, Icon, Spinner } from '@classic-homes/theme-svelte';
8
+ import type { Artifact } from '../reducer.js';
9
+ import { artifactIcon, artifactKind } from '../message-format.js';
10
+
11
+ interface Props {
12
+ artifact: Artifact;
13
+ onopen?: (artifactId: string) => void;
14
+ class?: string;
15
+ }
16
+ let { artifact, onopen, class: className }: Props = $props();
17
+ const title = $derived(artifact.title ?? 'Untitled artifact');
18
+ </script>
19
+
20
+ <button
21
+ type="button"
22
+ class={cn(
23
+ 'flex w-full max-w-sm items-center gap-3 rounded-lg border border-border bg-background px-3 py-2 text-left shadow-sm transition hover:border-primary/40 hover:shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
24
+ className,
25
+ )}
26
+ aria-label={artifact.open ? `${title} (writing)` : `Open ${title}`}
27
+ onclick={() => onopen?.(artifact.artifactId)}
28
+ >
29
+ <span
30
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
31
+ >
32
+ <Icon name={artifactIcon(artifact.mediaType)} size={18} />
33
+ </span>
34
+ <span class="min-w-0 flex-1">
35
+ <span class="block truncate text-sm font-medium text-foreground">{title}</span>
36
+ <span class="block text-xs text-muted-foreground">
37
+ {artifact.open ? 'Writing…' : artifactKind(artifact.mediaType)}
38
+ </span>
39
+ </span>
40
+ {#if artifact.open}
41
+ <Spinner size="sm" label="Writing" />
42
+ {:else}
43
+ <Icon name="chevron-right" size={16} class="text-muted-foreground" />
44
+ {/if}
45
+ </button>
@@ -0,0 +1,14 @@
1
+ import type { Artifact } from '../reducer.js';
2
+ interface Props {
3
+ artifact: Artifact;
4
+ onopen?: (artifactId: string) => void;
5
+ class?: string;
6
+ }
7
+ /**
8
+ * A card-like button for one artifact in a message: type icon, title and kind, and a spinner while
9
+ * the artifact is still being written. Opens it in the artifact pane.
10
+ */
11
+ declare const ArtifactChip: import("svelte").Component<Props, {}, "">;
12
+ type ArtifactChip = ReturnType<typeof ArtifactChip>;
13
+ export default ArtifactChip;
14
+ //# sourceMappingURL=ArtifactChip.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ArtifactChip.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ArtifactChip.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAI5C,UAAU,KAAK;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAiCH;;;GAGG;AACH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,179 @@
1
+ <!--
2
+ @component
3
+ The agent's turn: identity mark, tool timeline, the answer (throttled markdown with a caret
4
+ while streaming), artifacts, UI frames, files, proposed edits, the turn's outcome and actions.
5
+ -->
6
+ <script lang="ts">
7
+ import { Alert, Button, Icon } from '@classic-homes/theme-svelte';
8
+ import type { AssistantMessage } from '../reducer.js';
9
+ import { errorTitle } from '../reducer.js';
10
+ import { parseMarkdown } from '../markdown.js';
11
+ import ArtifactChip from './ArtifactChip.svelte';
12
+ import Markdown from './Markdown.svelte';
13
+ import MessageActions from './MessageActions.svelte';
14
+ import MessageFiles from './MessageFiles.svelte';
15
+ import ProposalCard from './ProposalCard.svelte';
16
+ import ToolTimeline from './ToolTimeline.svelte';
17
+ import UiFrame from './UiFrame.svelte';
18
+
19
+ interface Props {
20
+ message: AssistantMessage;
21
+ streaming?: boolean;
22
+ /** The last message: actions stay visible and the turn can be retried or continued. */
23
+ last?: boolean;
24
+ sandboxOrigin: string;
25
+ fileUrl: (fileId: string) => string;
26
+ onnavigate?: (href: string) => void;
27
+ onopenartifact?: (artifactId: string) => void;
28
+ onprompt?: (text: string) => void;
29
+ ontool?: (toolName: string, params?: Record<string, unknown>) => void;
30
+ onnotify?: (message: string) => void;
31
+ onretry?: () => void;
32
+ /** Ask the agent to pick up a stopped or cut-short answer. */
33
+ oncontinue?: () => void;
34
+ }
35
+ let {
36
+ message,
37
+ streaming = false,
38
+ last = false,
39
+ sandboxOrigin,
40
+ fileUrl,
41
+ onnavigate,
42
+ onopenartifact,
43
+ onprompt,
44
+ ontool,
45
+ onnotify,
46
+ onretry,
47
+ oncontinue,
48
+ }: Props = $props();
49
+
50
+ // Re-parsing markdown on every token is the dominant streaming cost; throttle it.
51
+ const PARSE_THROTTLE_MS = 120;
52
+ let html = $state('');
53
+ let lastParse = 0;
54
+ let timer: ReturnType<typeof setTimeout> | null = null;
55
+ $effect(() => {
56
+ const text = message.text;
57
+ const now = Date.now();
58
+ if (!streaming || now - lastParse >= PARSE_THROTTLE_MS) {
59
+ if (timer) clearTimeout(timer);
60
+ timer = null;
61
+ lastParse = now;
62
+ html = parseMarkdown(text);
63
+ } else if (!timer) {
64
+ timer = setTimeout(
65
+ () => {
66
+ timer = null;
67
+ lastParse = Date.now();
68
+ html = parseMarkdown(message.text);
69
+ },
70
+ PARSE_THROTTLE_MS - (now - lastParse),
71
+ );
72
+ }
73
+ });
74
+ $effect(() => () => {
75
+ if (timer) clearTimeout(timer);
76
+ });
77
+
78
+ function handleClick(event: MouseEvent): void {
79
+ if (!onnavigate) return;
80
+ const anchor = (event.target as HTMLElement | null)?.closest?.('a.agent-internal-link');
81
+ if (!(anchor instanceof HTMLAnchorElement)) return;
82
+ event.preventDefault();
83
+ onnavigate(anchor.getAttribute('href') ?? anchor.href);
84
+ }
85
+
86
+ const thinking = $derived(
87
+ streaming &&
88
+ !message.text &&
89
+ !message.tools.some((t) => !t.status) &&
90
+ !message.artifacts.some((a) => a.open),
91
+ );
92
+ const canContinue = $derived(last && !!oncontinue);
93
+ </script>
94
+
95
+ <div class="group/message flex gap-3 px-4 py-2" data-role="assistant" data-status={message.status}>
96
+ <span
97
+ class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
98
+ aria-hidden="true"
99
+ >
100
+ <Icon name="sparkles" size={14} />
101
+ </span>
102
+ <div class="flex min-w-0 flex-1 flex-col gap-2">
103
+ {#if message.tools.length || message.delegations.length}
104
+ <ToolTimeline tools={message.tools} delegations={message.delegations} {streaming} />
105
+ {/if}
106
+ {#if html}<Markdown {html} streaming={streaming && !!message.text} onclick={handleClick} />{/if}
107
+ {#if thinking}
108
+ <div class="flex items-center gap-2 text-sm text-muted-foreground" role="status">
109
+ <span class="flex gap-1" aria-hidden="true">
110
+ <span class="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce"></span>
111
+ <span
112
+ class="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce [animation-delay:150ms]"
113
+ ></span>
114
+ <span
115
+ class="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce [animation-delay:300ms]"
116
+ ></span>
117
+ </span>
118
+ Thinking
119
+ </div>
120
+ {/if}
121
+ {#if message.artifacts.length}
122
+ <div class="flex flex-col gap-1.5">
123
+ {#each message.artifacts as artifact (artifact.artifactId)}
124
+ <ArtifactChip {artifact} onopen={onopenartifact} />
125
+ {/each}
126
+ </div>
127
+ {/if}
128
+ {#each message.ui as resource (resource.callId + resource.uri)}
129
+ <UiFrame {resource} {sandboxOrigin} {onprompt} {ontool} {onnotify} />
130
+ {/each}
131
+ {#if message.files.length}<MessageFiles files={message.files} {fileUrl} />{/if}
132
+ {#if message.proposals.length}
133
+ <div class="flex flex-col gap-1.5">
134
+ {#each message.proposals as operation (operation.operationId)}
135
+ <ProposalCard {operation} />
136
+ {/each}
137
+ </div>
138
+ {/if}
139
+ {#if message.error}
140
+ <Alert variant="error">
141
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
142
+ <p class="min-w-0 flex-1">
143
+ <strong class="font-semibold">{errorTitle(message.error.code)}.</strong>
144
+ {message.error.message}
145
+ </p>
146
+ {#if message.error.retryable && onretry}
147
+ <Button variant="outline" size="sm" onclick={onretry}>
148
+ <Icon name="refresh" size={14} /> Try again
149
+ </Button>
150
+ {/if}
151
+ </div>
152
+ </Alert>
153
+ {:else if message.status === 'cancelled'}
154
+ <div class="flex items-center gap-2 text-sm text-muted-foreground">
155
+ <Icon name="stop" size={14} /> Stopped
156
+ {#if canContinue}
157
+ <Button variant="link" size="sm" class="h-auto p-0" onclick={oncontinue}>Continue</Button>
158
+ {/if}
159
+ </div>
160
+ {:else if message.status === 'incomplete'}
161
+ <Alert variant="warning">
162
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
163
+ <p class="min-w-0 flex-1">The answer was cut short.</p>
164
+ {#if canContinue}
165
+ <Button variant="outline" size="sm" onclick={oncontinue}>Continue</Button>
166
+ {/if}
167
+ </div>
168
+ </Alert>
169
+ {/if}
170
+ {#if !streaming && message.status !== 'streaming'}
171
+ <MessageActions
172
+ text={message.text}
173
+ createdAt={message.createdAt}
174
+ visible={last}
175
+ class="-ml-1.5"
176
+ />
177
+ {/if}
178
+ </div>
179
+ </div>
@@ -0,0 +1,25 @@
1
+ import type { AssistantMessage } from '../reducer.js';
2
+ interface Props {
3
+ message: AssistantMessage;
4
+ streaming?: boolean;
5
+ /** The last message: actions stay visible and the turn can be retried or continued. */
6
+ last?: boolean;
7
+ sandboxOrigin: string;
8
+ fileUrl: (fileId: string) => string;
9
+ onnavigate?: (href: string) => void;
10
+ onopenartifact?: (artifactId: string) => void;
11
+ onprompt?: (text: string) => void;
12
+ ontool?: (toolName: string, params?: Record<string, unknown>) => void;
13
+ onnotify?: (message: string) => void;
14
+ onretry?: () => void;
15
+ /** Ask the agent to pick up a stopped or cut-short answer. */
16
+ oncontinue?: () => void;
17
+ }
18
+ /**
19
+ * The agent's turn: identity mark, tool timeline, the answer (throttled markdown with a caret
20
+ * while streaming), artifacts, UI frames, files, proposed edits, the turn's outcome and actions.
21
+ */
22
+ declare const AssistantMessage: import("svelte").Component<Props, {}, "">;
23
+ type AssistantMessage = ReturnType<typeof AssistantMessage>;
24
+ export default AssistantMessage;
25
+ //# sourceMappingURL=AssistantMessage.svelte.d.ts.map
@@ -0,0 +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"}
@@ -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"}
@@ -0,0 +1,83 @@
1
+ <!--
2
+ @component
3
+ Files the agent produced in a turn: images as thumbnails that open in a lightbox, everything
4
+ else as a download card with a type icon and size.
5
+ -->
6
+ <script lang="ts">
7
+ import { cn, Icon, Lightbox } from '@classic-homes/theme-svelte';
8
+ import type { FileState } from '../reducer.js';
9
+ import { fileIcon, formatBytes } from '../message-format.js';
10
+
11
+ interface Props {
12
+ files: FileState[];
13
+ fileUrl: (fileId: string) => string;
14
+ class?: string;
15
+ }
16
+ let { files, fileUrl, class: className }: Props = $props();
17
+
18
+ const images = $derived(files.filter((f) => f.mimeType.startsWith('image/')));
19
+ const others = $derived(files.filter((f) => !f.mimeType.startsWith('image/')));
20
+ const items = $derived(
21
+ images.map((f) => ({
22
+ id: f.fileId,
23
+ kind: 'image' as const,
24
+ src: fileUrl(f.fileId),
25
+ alt: f.name,
26
+ title: f.name,
27
+ })),
28
+ );
29
+ let open = $state(false);
30
+ let index = $state(0);
31
+ </script>
32
+
33
+ <div class={cn('flex flex-col gap-2', className)}>
34
+ {#if images.length}
35
+ <ul class="flex flex-wrap gap-2" aria-label="Images">
36
+ {#each images as file, i (file.fileId)}
37
+ <li>
38
+ <button
39
+ type="button"
40
+ class="block overflow-hidden rounded-lg border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
41
+ aria-label={`View ${file.name}`}
42
+ onclick={() => {
43
+ index = i;
44
+ open = true;
45
+ }}
46
+ >
47
+ <img
48
+ class="block h-40 max-w-xs object-cover"
49
+ src={fileUrl(file.fileId)}
50
+ alt={file.name}
51
+ loading="lazy"
52
+ />
53
+ </button>
54
+ </li>
55
+ {/each}
56
+ </ul>
57
+ <Lightbox {items} bind:open bind:index downloadable showCaption />
58
+ {/if}
59
+ {#if others.length}
60
+ <ul class="flex flex-wrap gap-2" aria-label="Files">
61
+ {#each others as file (file.fileId)}
62
+ <li>
63
+ <a
64
+ class="flex max-w-xs items-center gap-3 rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm transition hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
65
+ href={fileUrl(file.fileId)}
66
+ download={file.name}
67
+ >
68
+ <span
69
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
70
+ >
71
+ <Icon name={fileIcon(file.mimeType)} size={18} />
72
+ </span>
73
+ <span class="min-w-0 flex-1">
74
+ <span class="block truncate font-medium text-foreground">{file.name}</span>
75
+ <span class="block text-xs text-muted-foreground">{formatBytes(file.bytes)}</span>
76
+ </span>
77
+ <Icon name="download" size={16} class="text-muted-foreground" />
78
+ </a>
79
+ </li>
80
+ {/each}
81
+ </ul>
82
+ {/if}
83
+ </div>
@@ -0,0 +1,14 @@
1
+ import type { FileState } from '../reducer.js';
2
+ interface Props {
3
+ files: FileState[];
4
+ fileUrl: (fileId: string) => string;
5
+ class?: string;
6
+ }
7
+ /**
8
+ * Files the agent produced in a turn: images as thumbnails that open in a lightbox, everything
9
+ * else as a download card with a type icon and size.
10
+ */
11
+ declare const MessageFiles: import("svelte").Component<Props, {}, "">;
12
+ type MessageFiles = ReturnType<typeof MessageFiles>;
13
+ export default MessageFiles;
14
+ //# sourceMappingURL=MessageFiles.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MessageFiles.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageFiles.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAI7C,UAAU,KAAK;IACb,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8DH;;;GAGG;AACH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}