@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.
- package/README.md +18 -2
- package/dist/components/AgentChat.svelte +10 -0
- package/dist/components/AgentChat.svelte.d.ts +5 -0
- package/dist/components/AgentChat.svelte.d.ts.map +1 -1
- package/dist/components/ArtifactChip.svelte +45 -0
- package/dist/components/ArtifactChip.svelte.d.ts +14 -0
- package/dist/components/ArtifactChip.svelte.d.ts.map +1 -0
- package/dist/components/AssistantMessage.svelte +186 -0
- package/dist/components/AssistantMessage.svelte.d.ts +26 -0
- package/dist/components/AssistantMessage.svelte.d.ts.map +1 -0
- package/dist/components/Composer.svelte +221 -30
- package/dist/components/Composer.svelte.d.ts +8 -0
- package/dist/components/Composer.svelte.d.ts.map +1 -1
- package/dist/components/Markdown.svelte +87 -5
- package/dist/components/Markdown.svelte.d.ts +9 -1
- package/dist/components/Markdown.svelte.d.ts.map +1 -1
- package/dist/components/MessageActions.svelte +56 -0
- package/dist/components/MessageActions.svelte.d.ts +17 -0
- package/dist/components/MessageActions.svelte.d.ts.map +1 -0
- package/dist/components/MessageFiles.svelte +83 -0
- package/dist/components/MessageFiles.svelte.d.ts +14 -0
- package/dist/components/MessageFiles.svelte.d.ts.map +1 -0
- package/dist/components/MessageItem.svelte +12 -144
- package/dist/components/MessageItem.svelte.d.ts +4 -0
- package/dist/components/MessageItem.svelte.d.ts.map +1 -1
- package/dist/components/MessageList.svelte +102 -30
- package/dist/components/MessageList.svelte.d.ts +2 -0
- package/dist/components/MessageList.svelte.d.ts.map +1 -1
- package/dist/components/ProposalCard.svelte +39 -0
- package/dist/components/ProposalCard.svelte.d.ts +13 -0
- package/dist/components/ProposalCard.svelte.d.ts.map +1 -0
- package/dist/components/ToolTimeline.svelte +138 -54
- package/dist/components/ToolTimeline.svelte.d.ts +11 -3
- package/dist/components/ToolTimeline.svelte.d.ts.map +1 -1
- package/dist/components/UserMessage.svelte +46 -0
- package/dist/components/UserMessage.svelte.d.ts +10 -0
- package/dist/components/UserMessage.svelte.d.ts.map +1 -0
- package/dist/components/composer.svelte.test.d.ts +2 -0
- package/dist/components/composer.svelte.test.d.ts.map +1 -0
- package/dist/components/composer.svelte.test.js +162 -0
- package/dist/components/messages.svelte.test.d.ts +2 -0
- package/dist/components/messages.svelte.test.d.ts.map +1 -0
- package/dist/components/messages.svelte.test.js +202 -0
- package/dist/composer-keys.d.ts +3 -0
- package/dist/composer-keys.d.ts.map +1 -1
- package/dist/composer-keys.js +18 -0
- package/dist/composer-prompts.d.ts +16 -0
- package/dist/composer-prompts.d.ts.map +1 -0
- package/dist/composer-prompts.js +27 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +12 -4
- package/dist/message-format.d.ts +58 -0
- package/dist/message-format.d.ts.map +1 -0
- package/dist/message-format.js +149 -0
- 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
|
-
|
|
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 ||
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
<div
|
|
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
|
-
<
|
|
97
|
-
class="inline-flex max-w-
|
|
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=
|
|
213
|
+
<Icon name={fileIcon(file.mimeType)} size={14} class="shrink-0 text-muted-foreground" />
|
|
100
214
|
<span class="truncate">{file.name}</span>
|
|
101
|
-
<
|
|
215
|
+
<span class="shrink-0 text-muted-foreground">{formatBytes(file.bytes)}</span>
|
|
216
|
+
<Button
|
|
102
217
|
type="button"
|
|
103
|
-
|
|
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} /></
|
|
225
|
+
}}><Icon name="close" size={12} /></Button
|
|
109
226
|
>
|
|
110
|
-
</
|
|
227
|
+
</li>
|
|
111
228
|
{/each}
|
|
112
|
-
{#
|
|
113
|
-
|
|
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 ||
|
|
138
|
-
loading={uploading > 0}
|
|
269
|
+
disabled={disabled || files.length >= LIMITS.requestFiles}
|
|
139
270
|
onclick={() => fileInput?.click()}
|
|
140
271
|
>
|
|
141
|
-
|
|
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() ||
|
|
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;
|
|
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);
|
|
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
|
-
'[&
|
|
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
|
-
<!--
|
|
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
|
|
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);
|
|
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":"
|
|
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"}
|