@classic-homes/agent-chat 0.0.0-stage → 0.1.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 (53) hide show
  1. package/dist/components/AgentChat.svelte +174 -0
  2. package/dist/components/AgentChat.svelte.d.ts +26 -0
  3. package/dist/components/AgentChat.svelte.d.ts.map +1 -0
  4. package/dist/components/ArtifactPanel.svelte +161 -0
  5. package/dist/components/ArtifactPanel.svelte.d.ts +11 -0
  6. package/dist/components/ArtifactPanel.svelte.d.ts.map +1 -0
  7. package/dist/components/Composer.svelte +211 -0
  8. package/dist/components/Composer.svelte.d.ts +21 -0
  9. package/dist/components/Composer.svelte.d.ts.map +1 -0
  10. package/dist/components/ConversationSidebar.svelte +188 -0
  11. package/dist/components/ConversationSidebar.svelte.d.ts +18 -0
  12. package/dist/components/ConversationSidebar.svelte.d.ts.map +1 -0
  13. package/dist/components/MessageItem.svelte +253 -0
  14. package/dist/components/MessageItem.svelte.d.ts +17 -0
  15. package/dist/components/MessageItem.svelte.d.ts.map +1 -0
  16. package/dist/components/MessageList.svelte +104 -0
  17. package/dist/components/MessageList.svelte.d.ts +18 -0
  18. package/dist/components/MessageList.svelte.d.ts.map +1 -0
  19. package/dist/components/ModeBanner.svelte +23 -0
  20. package/dist/components/ModeBanner.svelte.d.ts +8 -0
  21. package/dist/components/ModeBanner.svelte.d.ts.map +1 -0
  22. package/dist/components/ToolTimeline.svelte +113 -0
  23. package/dist/components/ToolTimeline.svelte.d.ts +10 -0
  24. package/dist/components/ToolTimeline.svelte.d.ts.map +1 -0
  25. package/dist/components/UiFrame.svelte +95 -0
  26. package/dist/components/UiFrame.svelte.d.ts +13 -0
  27. package/dist/components/UiFrame.svelte.d.ts.map +1 -0
  28. package/dist/index.d.ts +14 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +13 -0
  31. package/dist/markdown.d.ts +13 -0
  32. package/dist/markdown.d.ts.map +1 -0
  33. package/dist/markdown.js +325 -0
  34. package/dist/markdown.test.d.ts +2 -0
  35. package/dist/markdown.test.d.ts.map +1 -0
  36. package/dist/markdown.test.js +35 -0
  37. package/dist/reducer.d.ts +96 -0
  38. package/dist/reducer.d.ts.map +1 -0
  39. package/dist/reducer.js +186 -0
  40. package/dist/reducer.test.d.ts +2 -0
  41. package/dist/reducer.test.d.ts.map +1 -0
  42. package/dist/reducer.test.js +125 -0
  43. package/dist/session.svelte.d.ts +51 -0
  44. package/dist/session.svelte.d.ts.map +1 -0
  45. package/dist/session.svelte.js +152 -0
  46. package/dist/ui-frame-protocol.d.ts +42 -0
  47. package/dist/ui-frame-protocol.d.ts.map +1 -0
  48. package/dist/ui-frame-protocol.js +73 -0
  49. package/dist/ui-frame-protocol.test.d.ts +2 -0
  50. package/dist/ui-frame-protocol.test.d.ts.map +1 -0
  51. package/dist/ui-frame-protocol.test.js +34 -0
  52. package/package.json +47 -3
  53. package/README.md +0 -3
@@ -0,0 +1,174 @@
1
+ <script lang="ts">
2
+ import type { AgentApiClient } from '@classic-homes/agent-client';
3
+ import type {
4
+ AgentKey,
5
+ ClientKind,
6
+ DocumentContext,
7
+ FileRef,
8
+ } from '@classic-homes/agent-contracts';
9
+ import { ChatSession } from '../session.svelte.js';
10
+ import ArtifactPanel from './ArtifactPanel.svelte';
11
+ import Composer from './Composer.svelte';
12
+ import MessageList from './MessageList.svelte';
13
+ import ModeBanner from './ModeBanner.svelte';
14
+
15
+ interface Props {
16
+ client: AgentApiClient;
17
+ /** Sandbox origin for artifacts and MCP-UI (agent-api `/frame`). */
18
+ sandboxOrigin: string;
19
+ conversationId?: string | null;
20
+ agent?: AgentKey;
21
+ clientKind?: ClientKind;
22
+ variant?: 'page' | 'panel';
23
+ getContext?: () => DocumentContext | null | undefined;
24
+ onconversationstarted?: (conversationId: string) => void;
25
+ ontoolresult?: (name: string, status: string) => void;
26
+ onnavigate?: (href: string) => void;
27
+ onnotify?: (message: string) => void;
28
+ /** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
29
+ ontoolrequest?: (toolName: string, params?: Record<string, unknown>) => void;
30
+ placeholder?: string;
31
+ context?: import('svelte').Snippet;
32
+ empty?: import('svelte').Snippet;
33
+ class?: string;
34
+ }
35
+ let {
36
+ client,
37
+ sandboxOrigin,
38
+ conversationId = null,
39
+ agent = 'classic-agent',
40
+ clientKind,
41
+ variant = 'page',
42
+ getContext,
43
+ onconversationstarted,
44
+ ontoolresult,
45
+ onnavigate,
46
+ onnotify,
47
+ ontoolrequest,
48
+ placeholder,
49
+ context,
50
+ empty,
51
+ class: className = '',
52
+ }: Props = $props();
53
+
54
+ const session = new ChatSession({
55
+ client,
56
+ conversationId,
57
+ agent,
58
+ clientKind,
59
+ getContext,
60
+ onConversationStarted: (id) => onconversationstarted?.(id),
61
+ onToolResult: (name, status) => ontoolresult?.(name, status),
62
+ });
63
+ $effect(() => {
64
+ if (conversationId && conversationId !== session.conversationId)
65
+ void session.load(conversationId);
66
+ });
67
+
68
+ let composer: Composer | undefined = $state();
69
+ let draft = $state('');
70
+ let files = $state<FileRef[]>([]);
71
+ let selectedArtifact = $state<string | null>(null);
72
+ let artifactsOpen = $state(false);
73
+ const artifacts = $derived(
74
+ session.messages.flatMap((m) => (m.role === 'assistant' ? m.artifacts : [])),
75
+ );
76
+ $effect(() => {
77
+ // A newly opened artifact pops the panel.
78
+ const last = artifacts[artifacts.length - 1];
79
+ if (last && last.open && selectedArtifact !== last.artifactId) {
80
+ selectedArtifact = last.artifactId;
81
+ artifactsOpen = true;
82
+ }
83
+ });
84
+
85
+ async function send(text: string): Promise<void> {
86
+ const attached = files;
87
+ files = [];
88
+ await session.send(text, { files: attached });
89
+ }
90
+ function retry(): void {
91
+ const lastUser = [...session.messages].reverse().find((m) => m.role === 'user');
92
+ if (lastUser && lastUser.role === 'user')
93
+ void session.send(lastUser.text, { files: lastUser.attachments });
94
+ }
95
+ function prompt(text: string): void {
96
+ draft = text;
97
+ composer?.focus();
98
+ }
99
+ </script>
100
+
101
+ <div class={`agent-chat agent-chat--${variant} ${className}`} data-variant={variant}>
102
+ <div class="agent-chat__main">
103
+ <ModeBanner mode={session.mode} />
104
+ {#if session.loadError}<div class="agent-chat__load-error" role="alert">
105
+ {session.loadError}
106
+ </div>{/if}
107
+ <MessageList
108
+ messages={session.messages}
109
+ streaming={session.streaming}
110
+ {sandboxOrigin}
111
+ fileUrl={(id) => client.fileUrl(id)}
112
+ {onnavigate}
113
+ onopenartifact={(id) => {
114
+ selectedArtifact = id;
115
+ artifactsOpen = true;
116
+ }}
117
+ onprompt={prompt}
118
+ ontool={(name, params) => ontoolrequest?.(name, params)}
119
+ {onnotify}
120
+ onretry={retry}
121
+ {empty}
122
+ />
123
+ <Composer
124
+ bind:this={composer}
125
+ bind:value={draft}
126
+ bind:files
127
+ disabled={session.status === 'loading'}
128
+ streaming={session.streaming}
129
+ {placeholder}
130
+ upload={(file) => client.uploadFile(file, file.name)}
131
+ onsend={send}
132
+ oncancel={() => session.cancel()}
133
+ {context}
134
+ />
135
+ </div>
136
+ {#if artifactsOpen && artifacts.length}
137
+ <div class="agent-chat__side">
138
+ <ArtifactPanel
139
+ {artifacts}
140
+ {sandboxOrigin}
141
+ bind:selectedId={selectedArtifact}
142
+ onclose={() => (artifactsOpen = false)}
143
+ />
144
+ </div>
145
+ {/if}
146
+ </div>
147
+
148
+ <style>
149
+ .agent-chat {
150
+ display: flex;
151
+ height: 100%;
152
+ min-height: 0;
153
+ background: hsl(var(--background));
154
+ color: hsl(var(--foreground));
155
+ }
156
+ .agent-chat__main {
157
+ flex: 1;
158
+ min-width: 0;
159
+ display: flex;
160
+ flex-direction: column;
161
+ }
162
+ .agent-chat__side {
163
+ width: min(45%, 40rem);
164
+ min-width: 20rem;
165
+ }
166
+ .agent-chat--panel .agent-chat__side {
167
+ display: none;
168
+ }
169
+ .agent-chat__load-error {
170
+ padding: 0.5rem 1rem;
171
+ color: hsl(var(--destructive));
172
+ font-size: 0.875rem;
173
+ }
174
+ </style>
@@ -0,0 +1,26 @@
1
+ import type { AgentApiClient } from '@classic-homes/agent-client';
2
+ import type { AgentKey, ClientKind, DocumentContext } from '@classic-homes/agent-contracts';
3
+ interface Props {
4
+ client: AgentApiClient;
5
+ /** Sandbox origin for artifacts and MCP-UI (agent-api `/frame`). */
6
+ sandboxOrigin: string;
7
+ conversationId?: string | null;
8
+ agent?: AgentKey;
9
+ clientKind?: ClientKind;
10
+ variant?: 'page' | 'panel';
11
+ getContext?: () => DocumentContext | null | undefined;
12
+ onconversationstarted?: (conversationId: string) => void;
13
+ ontoolresult?: (name: string, status: string) => void;
14
+ onnavigate?: (href: string) => void;
15
+ onnotify?: (message: string) => void;
16
+ /** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
17
+ ontoolrequest?: (toolName: string, params?: Record<string, unknown>) => void;
18
+ placeholder?: string;
19
+ context?: import('svelte').Snippet;
20
+ empty?: import('svelte').Snippet;
21
+ class?: string;
22
+ }
23
+ declare const AgentChat: import("svelte").Component<Props, {}, "">;
24
+ type AgentChat = ReturnType<typeof AgentChat>;
25
+ export default AgentChat;
26
+ //# sourceMappingURL=AgentChat.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AgentChat.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AgentChat.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,KAAK,EACR,QAAQ,EACR,UAAU,EACV,eAAe,EAEhB,MAAM,gCAAgC,CAAC;AAQxC,UAAU,KAAK;IACb,MAAM,EAAE,cAAc,CAAC;IACvB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,eAAe,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,qBAAqB,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAmGH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -0,0 +1,161 @@
1
+ <script lang="ts">
2
+ import { Button, Icon } from '@classic-homes/theme-svelte';
3
+ import type { Artifact } from '../reducer.js';
4
+ import { parseMarkdown } from '../markdown.js';
5
+ import { UI_FRAME_RENDER } from '../ui-frame-protocol.js';
6
+
7
+ interface Props {
8
+ artifacts: Artifact[];
9
+ sandboxOrigin: string;
10
+ selectedId?: string | null;
11
+ onclose?: () => void;
12
+ }
13
+ let { artifacts, sandboxOrigin, selectedId = $bindable(null), onclose }: Props = $props();
14
+ const selected = $derived(
15
+ artifacts.find((a) => a.artifactId === selectedId) ?? artifacts[artifacts.length - 1],
16
+ );
17
+ let frame: HTMLIFrameElement | null = $state(null);
18
+
19
+ function sendHtml(): void {
20
+ if (!selected || !frame?.contentWindow) return;
21
+ const html =
22
+ selected.mediaType === 'application/vnd.ant.mermaid'
23
+ ? mermaidPage(selected.text)
24
+ : selected.text;
25
+ frame.contentWindow.postMessage(
26
+ { type: UI_FRAME_RENDER, html, uri: `artifact://${selected.artifactId}` },
27
+ sandboxOrigin,
28
+ );
29
+ }
30
+ $effect(() => {
31
+ void selected?.text;
32
+ if (
33
+ selected &&
34
+ (selected.mediaType === 'text/html' ||
35
+ selected.mediaType === 'application/vnd.ant.mermaid' ||
36
+ selected.mediaType === 'image/svg+xml')
37
+ )
38
+ sendHtml();
39
+ });
40
+
41
+ function mermaidPage(source: string): string {
42
+ const escaped = source.replace(/</g, '&lt;');
43
+ return `<!doctype html><html><body><pre class="mermaid">${escaped}</pre><script>import m from "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs";m.initialize({startOnLoad:true});<\/script></body></html>`;
44
+ }
45
+
46
+ async function copy(): Promise<void> {
47
+ if (selected) await navigator.clipboard.writeText(selected.text);
48
+ }
49
+ function download(): void {
50
+ if (!selected) return;
51
+ const ext =
52
+ selected.mediaType === 'text/html'
53
+ ? 'html'
54
+ : selected.mediaType === 'image/svg+xml'
55
+ ? 'svg'
56
+ : selected.mediaType === 'application/vnd.ant.mermaid'
57
+ ? 'mmd'
58
+ : selected.language
59
+ ? selected.language
60
+ : 'md';
61
+ const blob = new Blob([selected.text], { type: 'text/plain' });
62
+ const url = URL.createObjectURL(blob);
63
+ const a = document.createElement('a');
64
+ a.href = url;
65
+ a.download = `${(selected.title ?? selected.artifactId).replace(/[^\w.-]+/g, '-')}.${ext}`;
66
+ a.click();
67
+ URL.revokeObjectURL(url);
68
+ }
69
+ </script>
70
+
71
+ {#if selected}
72
+ <aside class="agent-artifacts" aria-label="Artifacts">
73
+ <header class="agent-artifacts__header">
74
+ <select class="agent-artifacts__select" bind:value={selectedId} aria-label="Artifact">
75
+ {#each artifacts as a (a.artifactId)}
76
+ <option value={a.artifactId}>{a.title ?? a.artifactId}</option>
77
+ {/each}
78
+ </select>
79
+ <div class="agent-artifacts__actions">
80
+ <Button variant="ghost" size="icon" aria-label="Copy" onclick={copy}
81
+ ><Icon name="copy" size={16} /></Button
82
+ >
83
+ <Button variant="ghost" size="icon" aria-label="Download" onclick={download}
84
+ ><Icon name="download" size={16} /></Button
85
+ >
86
+ {#if onclose}<Button variant="ghost" size="icon" aria-label="Close" onclick={onclose}
87
+ ><Icon name="close" size={16} /></Button
88
+ >{/if}
89
+ </div>
90
+ </header>
91
+ <div class="agent-artifacts__body">
92
+ {#if selected.mediaType === 'text/markdown'}
93
+ <div class="agent-markdown">{@html parseMarkdown(selected.text)}</div>
94
+ {:else if selected.mediaType === 'application/vnd.ant.code'}
95
+ <pre class="agent-code"><code>{selected.text}</code></pre>
96
+ {:else}
97
+ <!-- html, mermaid, svg, react: only ever inside the sandbox origin -->
98
+ <iframe
99
+ bind:this={frame}
100
+ title={selected.title ?? 'Artifact'}
101
+ src={`${sandboxOrigin}/frame`}
102
+ sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms"
103
+ referrerpolicy="no-referrer"
104
+ onload={sendHtml}
105
+ ></iframe>
106
+ {/if}
107
+ {#if selected.open}<div class="agent-artifacts__streaming">Writing…</div>{/if}
108
+ </div>
109
+ </aside>
110
+ {/if}
111
+
112
+ <style>
113
+ .agent-artifacts {
114
+ display: flex;
115
+ flex-direction: column;
116
+ height: 100%;
117
+ border-left: 1px solid hsl(var(--border));
118
+ background: hsl(var(--card));
119
+ }
120
+ .agent-artifacts__header {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 0.5rem;
124
+ padding: 0.5rem 0.75rem;
125
+ border-bottom: 1px solid hsl(var(--border));
126
+ }
127
+ .agent-artifacts__select {
128
+ flex: 1;
129
+ min-width: 0;
130
+ font: inherit;
131
+ background: transparent;
132
+ color: inherit;
133
+ border: 0;
134
+ }
135
+ .agent-artifacts__actions {
136
+ display: flex;
137
+ gap: 0.125rem;
138
+ }
139
+ .agent-artifacts__body {
140
+ flex: 1;
141
+ min-height: 0;
142
+ overflow: auto;
143
+ padding: 0.75rem;
144
+ }
145
+ iframe {
146
+ width: 100%;
147
+ height: 100%;
148
+ min-height: 24rem;
149
+ border: 0;
150
+ background: white;
151
+ }
152
+ .agent-code {
153
+ white-space: pre;
154
+ overflow: auto;
155
+ font-size: 0.8125rem;
156
+ }
157
+ .agent-artifacts__streaming {
158
+ font-size: 0.75rem;
159
+ color: hsl(var(--muted-foreground));
160
+ }
161
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { Artifact } from '../reducer.js';
2
+ interface Props {
3
+ artifacts: Artifact[];
4
+ sandboxOrigin: string;
5
+ selectedId?: string | null;
6
+ onclose?: () => void;
7
+ }
8
+ declare const ArtifactPanel: import("svelte").Component<Props, {}, "selectedId">;
9
+ type ArtifactPanel = ReturnType<typeof ArtifactPanel>;
10
+ export default ArtifactPanel;
11
+ //# sourceMappingURL=ArtifactPanel.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ArtifactPanel.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ArtifactPanel.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAK5C,UAAU,KAAK;IACb,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAiGH,QAAA,MAAM,aAAa,qDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,211 @@
1
+ <script lang="ts">
2
+ import { Button, Icon } from '@classic-homes/theme-svelte';
3
+ import type { FileRef } from '@classic-homes/agent-contracts';
4
+ import { LIMITS } from '@classic-homes/agent-contracts';
5
+
6
+ interface Props {
7
+ disabled?: boolean;
8
+ streaming?: boolean;
9
+ value?: string;
10
+ files?: FileRef[];
11
+ placeholder?: string;
12
+ /** Upload a file; the composer shows it as pending until the message is sent. */
13
+ upload?: (file: File) => Promise<FileRef>;
14
+ onsend?: (text: string) => void;
15
+ oncancel?: () => void;
16
+ onremovefile?: (fileId: string) => void;
17
+ /** Slot for a host-provided context chip (page context toggle). */
18
+ context?: import('svelte').Snippet;
19
+ }
20
+ let {
21
+ disabled = false,
22
+ streaming = false,
23
+ value = $bindable(''),
24
+ files = $bindable([]),
25
+ placeholder = 'Ask Classic Agent…',
26
+ upload,
27
+ onsend,
28
+ oncancel,
29
+ onremovefile,
30
+ context,
31
+ }: Props = $props();
32
+
33
+ let textarea: HTMLTextAreaElement | undefined = $state();
34
+ let fileInput: HTMLInputElement | undefined = $state();
35
+ let uploading = $state(0);
36
+ let uploadError = $state<string | undefined>();
37
+
38
+ export function focus(): void {
39
+ textarea?.focus({ preventScroll: true });
40
+ }
41
+
42
+ function submit(): void {
43
+ const text = value.trim();
44
+ if (!text || disabled || streaming || uploading > 0) return;
45
+ value = '';
46
+ if (textarea) textarea.style.height = 'auto';
47
+ onsend?.(text);
48
+ }
49
+ function onkeydown(event: KeyboardEvent): void {
50
+ if (event.key === 'Enter' && !event.shiftKey) {
51
+ event.preventDefault();
52
+ submit();
53
+ }
54
+ }
55
+ function autosize(): void {
56
+ if (!textarea) return;
57
+ textarea.style.height = 'auto';
58
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
59
+ }
60
+ async function onfiles(event: Event): Promise<void> {
61
+ const input = event.currentTarget as HTMLInputElement;
62
+ const chosen = [...(input.files ?? [])];
63
+ input.value = '';
64
+ if (!upload || chosen.length === 0) return;
65
+ uploadError = undefined;
66
+ for (const file of chosen) {
67
+ if (file.size > LIMITS.fileBytes) {
68
+ uploadError = `${file.name} is larger than ${Math.round(LIMITS.fileBytes / 1048576)} MB`;
69
+ continue;
70
+ }
71
+ uploading += 1;
72
+ try {
73
+ files = [...files, await upload(file)];
74
+ } catch (err) {
75
+ uploadError = err instanceof Error ? err.message : `Could not upload ${file.name}`;
76
+ } finally {
77
+ uploading -= 1;
78
+ }
79
+ }
80
+ }
81
+ </script>
82
+
83
+ <form
84
+ class="agent-composer"
85
+ onsubmit={(e) => {
86
+ e.preventDefault();
87
+ submit();
88
+ }}
89
+ aria-label="Send a message"
90
+ >
91
+ {#if context}<div class="agent-composer__context">{@render context()}</div>{/if}
92
+ {#if files.length || uploadError}
93
+ <div class="agent-composer__files">
94
+ {#each files as file (file.fileId)}
95
+ <span class="agent-composer__file">
96
+ <Icon name="file" size={12} />
97
+ {file.name}
98
+ <button
99
+ type="button"
100
+ aria-label={`Remove ${file.name}`}
101
+ onclick={() => {
102
+ files = files.filter((f) => f.fileId !== file.fileId);
103
+ onremovefile?.(file.fileId);
104
+ }}>×</button
105
+ >
106
+ </span>
107
+ {/each}
108
+ {#if uploadError}<span class="agent-composer__error" role="alert">{uploadError}</span>{/if}
109
+ </div>
110
+ {/if}
111
+ <div class="agent-composer__row">
112
+ {#if upload}
113
+ <input bind:this={fileInput} type="file" multiple hidden onchange={onfiles} />
114
+ <Button
115
+ type="button"
116
+ variant="ghost"
117
+ size="icon"
118
+ aria-label="Attach files"
119
+ disabled={disabled || uploading > 0}
120
+ loading={uploading > 0}
121
+ onclick={() => fileInput?.click()}
122
+ >
123
+ {#if uploading === 0}<Icon name="upload" size={18} />{/if}
124
+ </Button>
125
+ {/if}
126
+ <textarea
127
+ bind:this={textarea}
128
+ bind:value
129
+ class="agent-composer__field"
130
+ rows="1"
131
+ {placeholder}
132
+ aria-label="Message"
133
+ maxlength={LIMITS.messageChars}
134
+ {disabled}
135
+ {onkeydown}
136
+ oninput={autosize}></textarea>
137
+ {#if streaming}
138
+ <Button
139
+ type="button"
140
+ variant="outline"
141
+ size="icon"
142
+ aria-label="Stop"
143
+ onclick={() => oncancel?.()}><Icon name="square" size={16} /></Button
144
+ >
145
+ {:else}
146
+ <Button
147
+ type="submit"
148
+ size="icon"
149
+ aria-label="Send"
150
+ disabled={disabled || !value.trim() || uploading > 0}><Icon name="send" size={18} /></Button
151
+ >
152
+ {/if}
153
+ </div>
154
+ </form>
155
+
156
+ <style>
157
+ .agent-composer {
158
+ padding: 0.75rem 1rem max(0.75rem, env(safe-area-inset-bottom));
159
+ border-top: 1px solid hsl(var(--border));
160
+ background: hsl(var(--background));
161
+ display: grid;
162
+ gap: 0.5rem;
163
+ }
164
+ .agent-composer__row {
165
+ display: flex;
166
+ align-items: flex-end;
167
+ gap: 0.5rem;
168
+ }
169
+ .agent-composer__field {
170
+ flex: 1;
171
+ resize: none;
172
+ min-height: 2.75rem;
173
+ max-height: 200px;
174
+ padding: 0.625rem 0.875rem;
175
+ border: 1px solid hsl(var(--border));
176
+ border-radius: 1rem;
177
+ background: hsl(var(--background));
178
+ color: hsl(var(--foreground));
179
+ font: inherit;
180
+ font-size: 16px;
181
+ line-height: 1.5;
182
+ }
183
+ .agent-composer__field:focus {
184
+ outline: none;
185
+ border-color: hsl(var(--primary));
186
+ box-shadow: 0 0 0 3px hsl(var(--primary) / 0.15);
187
+ }
188
+ .agent-composer__files {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ gap: 0.375rem;
192
+ font-size: 0.75rem;
193
+ }
194
+ .agent-composer__file {
195
+ display: inline-flex;
196
+ align-items: center;
197
+ gap: 0.25rem;
198
+ padding: 0.125rem 0.5rem;
199
+ border-radius: 9999px;
200
+ background: hsl(var(--muted));
201
+ }
202
+ .agent-composer__file button {
203
+ border: 0;
204
+ background: none;
205
+ cursor: pointer;
206
+ font: inherit;
207
+ }
208
+ .agent-composer__error {
209
+ color: hsl(var(--destructive));
210
+ }
211
+ </style>
@@ -0,0 +1,21 @@
1
+ import type { FileRef } from '@classic-homes/agent-contracts';
2
+ interface Props {
3
+ disabled?: boolean;
4
+ streaming?: boolean;
5
+ value?: string;
6
+ files?: FileRef[];
7
+ placeholder?: string;
8
+ /** Upload a file; the composer shows it as pending until the message is sent. */
9
+ upload?: (file: File) => Promise<FileRef>;
10
+ onsend?: (text: string) => void;
11
+ oncancel?: () => void;
12
+ onremovefile?: (fileId: string) => void;
13
+ /** Slot for a host-provided context chip (page context toggle). */
14
+ context?: import('svelte').Snippet;
15
+ }
16
+ declare const Composer: import("svelte").Component<Props, {
17
+ focus: () => void;
18
+ }, "value" | "files">;
19
+ type Composer = ReturnType<typeof Composer>;
20
+ export default Composer;
21
+ //# sourceMappingURL=Composer.svelte.d.ts.map
@@ -0,0 +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;AAI5D,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;AA6GH,QAAA,MAAM,QAAQ;iBArFO,IAAI;qBAqF6B,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}