@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,188 @@
1
+ <script lang="ts">
2
+ import { Button, Icon } from '@classic-homes/theme-svelte';
3
+ import type { ConversationSummary } from '@classic-homes/agent-contracts';
4
+
5
+ interface Props {
6
+ conversations: ConversationSummary[];
7
+ selectedId?: string | null;
8
+ loading?: boolean;
9
+ hasMore?: boolean;
10
+ query?: string;
11
+ onselect?: (conversationId: string) => void;
12
+ onnew?: () => void;
13
+ onsearch?: (query: string) => void;
14
+ onmore?: () => void;
15
+ onrename?: (conversationId: string, title: string) => void;
16
+ ondelete?: (conversationId: string) => void;
17
+ }
18
+ let {
19
+ conversations,
20
+ selectedId = null,
21
+ loading = false,
22
+ hasMore = false,
23
+ query = $bindable(''),
24
+ onselect,
25
+ onnew,
26
+ onsearch,
27
+ onmore,
28
+ onrename,
29
+ ondelete,
30
+ }: Props = $props();
31
+
32
+ function groupLabel(iso: string): string {
33
+ const d = new Date(iso);
34
+ const today = new Date();
35
+ const days = Math.floor(
36
+ (today.setHours(0, 0, 0, 0) - new Date(d).setHours(0, 0, 0, 0)) / 86_400_000,
37
+ );
38
+ if (days <= 0) return 'Today';
39
+ if (days === 1) return 'Yesterday';
40
+ if (days < 7) return 'This week';
41
+ if (days < 30) return 'This month';
42
+ return 'Older';
43
+ }
44
+ const groups = $derived.by(() => {
45
+ const map = new Map<string, ConversationSummary[]>();
46
+ for (const c of conversations) {
47
+ const key = groupLabel(c.updatedAt);
48
+ map.set(key, [...(map.get(key) ?? []), c]);
49
+ }
50
+ return [...map.entries()];
51
+ });
52
+ function rename(c: ConversationSummary): void {
53
+ const title = window.prompt('Rename conversation', c.title);
54
+ if (title && title.trim() && title !== c.title) onrename?.(c.conversationId, title.trim());
55
+ }
56
+ </script>
57
+
58
+ <nav class="agent-sidebar" aria-label="Conversations">
59
+ <div class="agent-sidebar__top">
60
+ <Button size="sm" onclick={() => onnew?.()}><Icon name="plus" size={14} /> New chat</Button>
61
+ <input
62
+ class="agent-sidebar__search"
63
+ type="search"
64
+ placeholder="Search"
65
+ aria-label="Search conversations"
66
+ bind:value={query}
67
+ oninput={() => onsearch?.(query)}
68
+ />
69
+ </div>
70
+ <div class="agent-sidebar__list">
71
+ {#each groups as [label, items] (label)}
72
+ <div class="agent-sidebar__group">{label}</div>
73
+ {#each items as c (c.conversationId)}
74
+ <div
75
+ class="agent-sidebar__row"
76
+ class:agent-sidebar__row--active={c.conversationId === selectedId}
77
+ >
78
+ <button
79
+ type="button"
80
+ class="agent-sidebar__title"
81
+ onclick={() => onselect?.(c.conversationId)}
82
+ title={c.title}
83
+ >
84
+ {c.title}{c.activeRequestId ? ' …' : ''}
85
+ </button>
86
+ <div class="agent-sidebar__actions">
87
+ {#if onrename}<button type="button" aria-label="Rename" onclick={() => rename(c)}
88
+ ><Icon name="edit" size={13} /></button
89
+ >{/if}
90
+ {#if ondelete}<button
91
+ type="button"
92
+ aria-label="Delete"
93
+ onclick={() => ondelete(c.conversationId)}><Icon name="trash" size={13} /></button
94
+ >{/if}
95
+ </div>
96
+ </div>
97
+ {/each}
98
+ {/each}
99
+ {#if !loading && conversations.length === 0}<div class="agent-sidebar__empty">
100
+ No conversations yet.
101
+ </div>{/if}
102
+ {#if hasMore}<Button variant="ghost" size="sm" {loading} onclick={() => onmore?.()}
103
+ >Load more</Button
104
+ >{/if}
105
+ </div>
106
+ </nav>
107
+
108
+ <style>
109
+ .agent-sidebar {
110
+ display: flex;
111
+ flex-direction: column;
112
+ height: 100%;
113
+ border-right: 1px solid hsl(var(--border));
114
+ background: hsl(var(--card));
115
+ width: 16rem;
116
+ }
117
+ .agent-sidebar__top {
118
+ display: grid;
119
+ gap: 0.5rem;
120
+ padding: 0.75rem;
121
+ }
122
+ .agent-sidebar__search {
123
+ font: inherit;
124
+ font-size: 0.875rem;
125
+ padding: 0.375rem 0.625rem;
126
+ border: 1px solid hsl(var(--border));
127
+ border-radius: 0.5rem;
128
+ background: hsl(var(--background));
129
+ color: inherit;
130
+ }
131
+ .agent-sidebar__list {
132
+ flex: 1;
133
+ overflow-y: auto;
134
+ padding: 0 0.5rem 0.75rem;
135
+ }
136
+ .agent-sidebar__group {
137
+ font-size: 0.6875rem;
138
+ text-transform: uppercase;
139
+ letter-spacing: 0.06em;
140
+ color: hsl(var(--muted-foreground));
141
+ padding: 0.75rem 0.5rem 0.25rem;
142
+ }
143
+ .agent-sidebar__row {
144
+ display: flex;
145
+ align-items: center;
146
+ border-radius: 0.5rem;
147
+ }
148
+ .agent-sidebar__row:hover,
149
+ .agent-sidebar__row--active {
150
+ background: hsl(var(--muted));
151
+ }
152
+ .agent-sidebar__title {
153
+ flex: 1;
154
+ min-width: 0;
155
+ text-align: left;
156
+ background: none;
157
+ border: 0;
158
+ padding: 0.375rem 0.5rem;
159
+ font: inherit;
160
+ font-size: 0.875rem;
161
+ color: inherit;
162
+ cursor: pointer;
163
+ overflow: hidden;
164
+ text-overflow: ellipsis;
165
+ white-space: nowrap;
166
+ }
167
+ .agent-sidebar__actions {
168
+ display: none;
169
+ gap: 0.125rem;
170
+ padding-right: 0.25rem;
171
+ }
172
+ .agent-sidebar__row:hover .agent-sidebar__actions,
173
+ .agent-sidebar__row--active .agent-sidebar__actions {
174
+ display: flex;
175
+ }
176
+ .agent-sidebar__actions button {
177
+ border: 0;
178
+ background: none;
179
+ cursor: pointer;
180
+ color: hsl(var(--muted-foreground));
181
+ padding: 0.25rem;
182
+ }
183
+ .agent-sidebar__empty {
184
+ font-size: 0.8125rem;
185
+ color: hsl(var(--muted-foreground));
186
+ padding: 0.5rem;
187
+ }
188
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { ConversationSummary } from '@classic-homes/agent-contracts';
2
+ interface Props {
3
+ conversations: ConversationSummary[];
4
+ selectedId?: string | null;
5
+ loading?: boolean;
6
+ hasMore?: boolean;
7
+ query?: string;
8
+ onselect?: (conversationId: string) => void;
9
+ onnew?: () => void;
10
+ onsearch?: (query: string) => void;
11
+ onmore?: () => void;
12
+ onrename?: (conversationId: string, title: string) => void;
13
+ ondelete?: (conversationId: string) => void;
14
+ }
15
+ declare const ConversationSidebar: import("svelte").Component<Props, {}, "query">;
16
+ type ConversationSidebar = ReturnType<typeof ConversationSidebar>;
17
+ export default ConversationSidebar;
18
+ //# sourceMappingURL=ConversationSidebar.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ConversationSidebar.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConversationSidebar.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAGxE,UAAU,KAAK;IACb,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3D,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7C;AA4EH,QAAA,MAAM,mBAAmB,gDAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
@@ -0,0 +1,253 @@
1
+ <script lang="ts">
2
+ import { Button, Icon } from '@classic-homes/theme-svelte';
3
+ import type { ChatMessage } from '../reducer.js';
4
+ import { errorTitle } from '../reducer.js';
5
+ import { parseMarkdown } from '../markdown.js';
6
+ import ToolTimeline from './ToolTimeline.svelte';
7
+ import UiFrame from './UiFrame.svelte';
8
+
9
+ interface Props {
10
+ message: ChatMessage;
11
+ streaming?: boolean;
12
+ sandboxOrigin: string;
13
+ fileUrl: (fileId: string) => string;
14
+ onnavigate?: (href: string) => void;
15
+ onopenartifact?: (artifactId: string) => void;
16
+ onprompt?: (text: string) => void;
17
+ ontool?: (toolName: string, params?: Record<string, unknown>) => void;
18
+ onnotify?: (message: string) => void;
19
+ onretry?: () => void;
20
+ }
21
+ let {
22
+ message,
23
+ streaming = false,
24
+ sandboxOrigin,
25
+ fileUrl,
26
+ onnavigate,
27
+ onopenartifact,
28
+ onprompt,
29
+ ontool,
30
+ onnotify,
31
+ onretry,
32
+ }: Props = $props();
33
+
34
+ // Re-parsing markdown on every token is the dominant streaming cost; throttle it.
35
+ const PARSE_THROTTLE_MS = 120;
36
+ let html = $state('');
37
+ let lastParse = 0;
38
+ let timer: ReturnType<typeof setTimeout> | null = null;
39
+ $effect(() => {
40
+ if (message.role !== 'assistant') return;
41
+ const text = message.text;
42
+ const now = Date.now();
43
+ if (!streaming || now - lastParse >= PARSE_THROTTLE_MS) {
44
+ if (timer) clearTimeout(timer);
45
+ timer = null;
46
+ lastParse = now;
47
+ html = parseMarkdown(text);
48
+ } else if (!timer) {
49
+ timer = setTimeout(
50
+ () => {
51
+ timer = null;
52
+ lastParse = Date.now();
53
+ html = parseMarkdown(message.role === 'assistant' ? message.text : '');
54
+ },
55
+ PARSE_THROTTLE_MS - (now - lastParse),
56
+ );
57
+ }
58
+ });
59
+ $effect(() => () => {
60
+ if (timer) clearTimeout(timer);
61
+ });
62
+
63
+ function handleClick(event: MouseEvent): void {
64
+ if (!onnavigate) return;
65
+ const anchor = (event.target as HTMLElement | null)?.closest?.('a.agent-internal-link');
66
+ if (!(anchor instanceof HTMLAnchorElement)) return;
67
+ event.preventDefault();
68
+ onnavigate(anchor.getAttribute('href') ?? anchor.href);
69
+ }
70
+
71
+ function formatBytes(n: number): string {
72
+ return n < 1024
73
+ ? `${n} B`
74
+ : n < 1048576
75
+ ? `${(n / 1024).toFixed(0)} KB`
76
+ : `${(n / 1048576).toFixed(1)} MB`;
77
+ }
78
+ </script>
79
+
80
+ {#if message.role === 'user'}
81
+ <div class="agent-msg agent-msg--user">
82
+ {#if message.contextLabel}<div class="agent-msg__context">
83
+ Context: {message.contextLabel}
84
+ </div>{/if}
85
+ <div class="agent-msg__bubble">{message.text}</div>
86
+ {#if message.attachments.length}
87
+ <ul class="agent-msg__attachments">
88
+ {#each message.attachments as file (file.fileId)}<li>
89
+ <Icon name="file" size={12} />
90
+ {file.name}
91
+ </li>{/each}
92
+ </ul>
93
+ {/if}
94
+ </div>
95
+ {:else}
96
+ <div class="agent-msg agent-msg--assistant" data-status={message.status}>
97
+ <ToolTimeline tools={message.tools} delegations={message.delegations} {streaming} />
98
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
99
+ <div class="agent-markdown" onclick={handleClick}>
100
+ {@html html}
101
+ {#if streaming && !message.text}<span class="agent-msg__thinking">Thinking…</span>{/if}
102
+ </div>
103
+ {#if message.artifacts.length}
104
+ <div class="agent-msg__chips">
105
+ {#each message.artifacts as a (a.artifactId)}
106
+ <Button variant="outline" size="sm" onclick={() => onopenartifact?.(a.artifactId)}>
107
+ <Icon name="file-text" size={14} />
108
+ {a.title ?? a.artifactId}{a.open ? '…' : ''}
109
+ </Button>
110
+ {/each}
111
+ </div>
112
+ {/if}
113
+ {#each message.ui as resource (resource.callId + resource.uri)}
114
+ <UiFrame {resource} {sandboxOrigin} {onprompt} {ontool} {onnotify} />
115
+ {/each}
116
+ {#if message.files.length}
117
+ <ul class="agent-msg__files">
118
+ {#each message.files as file (file.fileId)}
119
+ <li>
120
+ {#if file.mimeType.startsWith('image/')}
121
+ <img
122
+ class="agent-msg__image"
123
+ src={fileUrl(file.fileId)}
124
+ alt={file.name}
125
+ loading="lazy"
126
+ />
127
+ {/if}
128
+ <a href={fileUrl(file.fileId)} download={file.name}
129
+ ><Icon name="download" size={14} /> {file.name} ({formatBytes(file.bytes)})</a
130
+ >
131
+ </li>
132
+ {/each}
133
+ </ul>
134
+ {/if}
135
+ {#if message.proposals.length}
136
+ <div class="agent-msg__note">
137
+ Proposed {message.proposals.length} document edit{message.proposals.length === 1
138
+ ? ''
139
+ : 's'}; apply them from the Excel or Word add-in.
140
+ </div>
141
+ {/if}
142
+ {#if message.error}
143
+ <div class="agent-msg__error" role="alert">
144
+ <strong>{errorTitle(message.error.code)}.</strong>
145
+ {message.error.message}
146
+ {#if message.error.retryable && onretry}<Button variant="link" size="sm" onclick={onretry}
147
+ >Try again</Button
148
+ >{/if}
149
+ </div>
150
+ {:else if message.status === 'cancelled'}
151
+ <div class="agent-msg__note">Stopped.</div>
152
+ {:else if message.status === 'incomplete'}
153
+ <div class="agent-msg__note">The answer was cut short.</div>
154
+ {/if}
155
+ </div>
156
+ {/if}
157
+
158
+ <style>
159
+ .agent-msg {
160
+ padding: 0.5rem 1rem;
161
+ max-width: 48rem;
162
+ }
163
+ .agent-msg--user {
164
+ margin-left: auto;
165
+ display: flex;
166
+ flex-direction: column;
167
+ align-items: flex-end;
168
+ }
169
+ .agent-msg__bubble {
170
+ background: hsl(var(--primary));
171
+ color: hsl(var(--primary-foreground));
172
+ border-radius: 1rem 1rem 0.25rem 1rem;
173
+ padding: 0.5rem 0.875rem;
174
+ white-space: pre-wrap;
175
+ overflow-wrap: anywhere;
176
+ }
177
+ .agent-msg__context,
178
+ .agent-msg__attachments,
179
+ .agent-msg__note {
180
+ font-size: 0.75rem;
181
+ color: hsl(var(--muted-foreground));
182
+ }
183
+ .agent-msg__attachments,
184
+ .agent-msg__files {
185
+ list-style: none;
186
+ padding: 0;
187
+ margin: 0.25rem 0 0;
188
+ display: flex;
189
+ flex-wrap: wrap;
190
+ gap: 0.5rem;
191
+ }
192
+ .agent-msg__chips {
193
+ display: flex;
194
+ flex-wrap: wrap;
195
+ gap: 0.375rem;
196
+ margin: 0.5rem 0;
197
+ }
198
+ .agent-msg__image {
199
+ display: block;
200
+ max-width: 100%;
201
+ max-height: 24rem;
202
+ border-radius: 0.5rem;
203
+ margin-bottom: 0.25rem;
204
+ }
205
+ .agent-msg__thinking {
206
+ color: hsl(var(--muted-foreground));
207
+ font-style: italic;
208
+ }
209
+ .agent-msg__error {
210
+ margin-top: 0.5rem;
211
+ padding: 0.5rem 0.75rem;
212
+ border-radius: 0.5rem;
213
+ background: hsl(var(--destructive) / 0.1);
214
+ color: hsl(var(--destructive));
215
+ font-size: 0.875rem;
216
+ }
217
+ :global(.agent-markdown) {
218
+ overflow-wrap: anywhere;
219
+ line-height: 1.6;
220
+ }
221
+ :global(.agent-markdown pre) {
222
+ background: hsl(var(--muted));
223
+ padding: 0.75rem;
224
+ border-radius: 0.5rem;
225
+ overflow: auto;
226
+ font-size: 0.8125rem;
227
+ }
228
+ :global(.agent-markdown code) {
229
+ font-family: ui-monospace, monospace;
230
+ font-size: 0.875em;
231
+ }
232
+ :global(.agent-markdown table) {
233
+ border-collapse: collapse;
234
+ margin: 0.5rem 0;
235
+ font-size: 0.875rem;
236
+ }
237
+ :global(.agent-markdown th),
238
+ :global(.agent-markdown td) {
239
+ border: 1px solid hsl(var(--border));
240
+ padding: 0.25rem 0.5rem;
241
+ text-align: left;
242
+ }
243
+ :global(.agent-markdown blockquote) {
244
+ border-left: 3px solid hsl(var(--border));
245
+ margin: 0.5rem 0;
246
+ padding-left: 0.75rem;
247
+ color: hsl(var(--muted-foreground));
248
+ }
249
+ :global(.agent-markdown img) {
250
+ max-width: 100%;
251
+ border-radius: 0.5rem;
252
+ }
253
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { ChatMessage } from '../reducer.js';
2
+ interface Props {
3
+ message: ChatMessage;
4
+ streaming?: boolean;
5
+ sandboxOrigin: string;
6
+ fileUrl: (fileId: string) => string;
7
+ onnavigate?: (href: string) => void;
8
+ onopenartifact?: (artifactId: string) => void;
9
+ onprompt?: (text: string) => void;
10
+ ontool?: (toolName: string, params?: Record<string, unknown>) => void;
11
+ onnotify?: (message: string) => void;
12
+ onretry?: () => void;
13
+ }
14
+ declare const MessageItem: import("svelte").Component<Props, {}, "">;
15
+ type MessageItem = ReturnType<typeof MessageItem>;
16
+ export default MessageItem;
17
+ //# sourceMappingURL=MessageItem.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MessageItem.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageItem.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAO/C,UAAU,KAAK;IACb,OAAO,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,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;CACtB;AA6IH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,104 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import type { ChatMessage } from '../reducer.js';
4
+ import MessageItem from './MessageItem.svelte';
5
+
6
+ interface Props {
7
+ messages: ChatMessage[];
8
+ streaming: boolean;
9
+ sandboxOrigin: string;
10
+ fileUrl: (fileId: string) => string;
11
+ onnavigate?: (href: string) => void;
12
+ onopenartifact?: (artifactId: string) => void;
13
+ onprompt?: (text: string) => void;
14
+ ontool?: (toolName: string, params?: Record<string, unknown>) => void;
15
+ onnotify?: (message: string) => void;
16
+ onretry?: () => void;
17
+ empty?: import('svelte').Snippet;
18
+ }
19
+ let {
20
+ messages,
21
+ streaming,
22
+ sandboxOrigin,
23
+ fileUrl,
24
+ onnavigate,
25
+ onopenartifact,
26
+ onprompt,
27
+ ontool,
28
+ onnotify,
29
+ onretry,
30
+ empty,
31
+ }: Props = $props();
32
+
33
+ let container: HTMLDivElement | undefined = $state();
34
+ let follow = true;
35
+ const FOLLOW_THRESHOLD_PX = 80;
36
+ function onscroll(): void {
37
+ if (!container) return;
38
+ follow =
39
+ container.scrollTop + container.clientHeight >= container.scrollHeight - FOLLOW_THRESHOLD_PX;
40
+ }
41
+ const count = $derived(messages.length);
42
+ const lastRole = $derived(messages[messages.length - 1]?.role);
43
+ let prevCount = 0;
44
+ $effect(() => {
45
+ void messages;
46
+ if (streaming && follow && container) container.scrollTop = container.scrollHeight;
47
+ });
48
+ $effect(() => {
49
+ void count;
50
+ void lastRole;
51
+ if (count > prevCount && lastRole === 'user') follow = true;
52
+ prevCount = count;
53
+ void tick().then(() => {
54
+ if (container && follow) container.scrollTop = container.scrollHeight;
55
+ });
56
+ });
57
+ // Announce the finished answer once, not every token.
58
+ const announcement = $derived.by(() => {
59
+ if (streaming) return '';
60
+ const last = messages[messages.length - 1];
61
+ return last?.role === 'assistant' ? last.text : '';
62
+ });
63
+ </script>
64
+
65
+ <div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
66
+ <div class="agent-messages" bind:this={container} {onscroll} role="log" aria-label="Conversation">
67
+ {#if messages.length === 0}
68
+ {#if empty}{@render empty()}{/if}
69
+ {:else}
70
+ {#each messages as message, i (message.id)}
71
+ <MessageItem
72
+ {message}
73
+ streaming={streaming && i === messages.length - 1 && message.role === 'assistant'}
74
+ {sandboxOrigin}
75
+ {fileUrl}
76
+ {onnavigate}
77
+ {onopenartifact}
78
+ {onprompt}
79
+ {ontool}
80
+ {onnotify}
81
+ onretry={i === messages.length - 1 ? onretry : undefined}
82
+ />
83
+ {/each}
84
+ {/if}
85
+ </div>
86
+
87
+ <style>
88
+ .sr-only {
89
+ position: absolute;
90
+ width: 1px;
91
+ height: 1px;
92
+ overflow: hidden;
93
+ clip: rect(0, 0, 0, 0);
94
+ white-space: nowrap;
95
+ }
96
+ .agent-messages {
97
+ flex: 1;
98
+ min-height: 0;
99
+ overflow-y: auto;
100
+ padding: 0.5rem 0;
101
+ display: flex;
102
+ flex-direction: column;
103
+ }
104
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { ChatMessage } from '../reducer.js';
2
+ interface Props {
3
+ messages: ChatMessage[];
4
+ streaming: boolean;
5
+ sandboxOrigin: string;
6
+ fileUrl: (fileId: string) => string;
7
+ onnavigate?: (href: string) => void;
8
+ onopenartifact?: (artifactId: string) => void;
9
+ onprompt?: (text: string) => void;
10
+ ontool?: (toolName: string, params?: Record<string, unknown>) => void;
11
+ onnotify?: (message: string) => void;
12
+ onretry?: () => void;
13
+ empty?: import('svelte').Snippet;
14
+ }
15
+ declare const MessageList: import("svelte").Component<Props, {}, "">;
16
+ type MessageList = ReturnType<typeof MessageList>;
17
+ export default MessageList;
18
+ //# sourceMappingURL=MessageList.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MessageList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MessageList.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAI/C,UAAU,KAAK;IACb,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACtE,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;CAClC;AAmEH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import type { BackendMode } from '@classic-homes/agent-contracts';
3
+ /** Mock must always be visible (AGENTS.md). Rendered whenever the stream reports `mock`. */
4
+ let { mode }: { mode: BackendMode | undefined } = $props();
5
+ </script>
6
+
7
+ {#if mode === 'mock'}
8
+ <div class="agent-mode-banner" role="status" data-testid="mode-banner">
9
+ MOCK BACKEND — simulated responses; no AI model or company system is contacted.
10
+ </div>
11
+ {/if}
12
+
13
+ <style>
14
+ .agent-mode-banner {
15
+ padding: 0.375rem 0.75rem;
16
+ font-size: 0.75rem;
17
+ font-weight: 600;
18
+ letter-spacing: 0.02em;
19
+ color: hsl(var(--warning-foreground, 0 0% 10%));
20
+ background: hsl(var(--warning, 45 93% 58%));
21
+ border-bottom: 1px solid hsl(var(--border));
22
+ }
23
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { BackendMode } from '@classic-homes/agent-contracts';
2
+ type $$ComponentProps = {
3
+ mode: BackendMode | undefined;
4
+ };
5
+ declare const ModeBanner: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ModeBanner = ReturnType<typeof ModeBanner>;
7
+ export default ModeBanner;
8
+ //# sourceMappingURL=ModeBanner.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModeBanner.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ModeBanner.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAEjE,KAAK,gBAAgB,GAAI;IAAE,IAAI,EAAE,WAAW,GAAG,SAAS,CAAA;CAAE,CAAC;AAiB5D,QAAA,MAAM,UAAU,sDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}