@classic-homes/agent-chat 0.4.0 → 0.5.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 CHANGED
@@ -51,6 +51,10 @@ agent-api origin that serves `/frame`; artifacts and MCP-UI resources only ever
51
51
  (`ComposerPrompt.text` may be a loader, e.g. fetching the template).
52
52
  - `ToolTimeline`: tool calls with status and duration, delegations nested; `toolLabels` maps
53
53
  tool names to readable ones (`AgentChat` passes both through).
54
+ - `Welcome`: the empty conversation, with a greeting (`userName`), a capability line and up to
55
+ four `suggestions` that send when chosen. `AgentChat` shows it unless the host passes an
56
+ `empty` snippet, a loading skeleton while an existing conversation's transcript arrives, and
57
+ an error with Try again when that load fails.
54
58
  - `ArtifactPanel`, `UiFrame`, `ModeBanner`.
55
59
 
56
60
  Publishing: `docs/operations/package-publishing.md`.
@@ -0,0 +1,18 @@
1
+ /** Pinned Mermaid ESM build, loaded from the CDN the sandbox CSP allows (`script-src https:`). */
2
+ export declare const MERMAID_MODULE_URL = "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs";
3
+ /** How long the renderer may take to load before the page reports a failure. */
4
+ export declare const MERMAID_LOAD_TIMEOUT_MS = 15000;
5
+ /**
6
+ * Serialize a value as a JavaScript literal that is safe inside an inline `<script>`: no `<`
7
+ * (so no `</script>` or `<!--` breakout), `>` or `&`, and no raw U+2028/U+2029.
8
+ */
9
+ export declare function inlineScriptLiteral(value: unknown): string;
10
+ /**
11
+ * The initialization script of {@link mermaidPage}. It is emitted as `type="module"`, but is
12
+ * written to parse under either goal (dynamic `import()`, no import declarations, no top-level
13
+ * await), so a stripped `type` can never again leave the diagram unrendered without a message.
14
+ */
15
+ export declare function mermaidScript(source: string): string;
16
+ /** A sandbox page that renders Mermaid `source` and shows a visible error if it cannot. */
17
+ export declare function mermaidPage(source: string): string;
18
+ //# sourceMappingURL=artifact-pages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifact-pages.d.ts","sourceRoot":"","sources":["../src/lib/artifact-pages.ts"],"names":[],"mappings":"AAOA,kGAAkG;AAClG,eAAO,MAAM,kBAAkB,yEACyC,CAAC;AAEzE,gFAAgF;AAChF,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAO1D;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAiDpD;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAYlD"}
@@ -0,0 +1,89 @@
1
+ // Sandbox pages built for artifact types that are not HTML themselves. They render inside the
2
+ // opaque-origin sandbox frame (apps/agent-api/public/frame.html) and inherit its CSP.
3
+ //
4
+ // These builders live in a .ts module on purpose: svelte-package strips `type="…"` from every
5
+ // `<script` it finds in a .svelte file, string literals included, which silently turned the
6
+ // Mermaid loader into a classic script (audit R18). Keep page HTML out of .svelte files.
7
+ /** Pinned Mermaid ESM build, loaded from the CDN the sandbox CSP allows (`script-src https:`). */
8
+ export const MERMAID_MODULE_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs';
9
+ /** How long the renderer may take to load before the page reports a failure. */
10
+ export const MERMAID_LOAD_TIMEOUT_MS = 15_000;
11
+ /**
12
+ * Serialize a value as a JavaScript literal that is safe inside an inline `<script>`: no `<`
13
+ * (so no `</script>` or `<!--` breakout), `>` or `&`, and no raw U+2028/U+2029.
14
+ */
15
+ export function inlineScriptLiteral(value) {
16
+ return JSON.stringify(value)
17
+ .replace(/</g, '\\u003c')
18
+ .replace(/>/g, '\\u003e')
19
+ .replace(/&/g, '\\u0026')
20
+ .replace(/\u2028/g, '\\u2028')
21
+ .replace(/\u2029/g, '\\u2029');
22
+ }
23
+ /**
24
+ * The initialization script of {@link mermaidPage}. It is emitted as `type="module"`, but is
25
+ * written to parse under either goal (dynamic `import()`, no import declarations, no top-level
26
+ * await), so a stripped `type` can never again leave the diagram unrendered without a message.
27
+ */
28
+ export function mermaidScript(source) {
29
+ return `(function () {
30
+ var source = ${inlineScriptLiteral(source)};
31
+ var out = document.getElementById('diagram');
32
+ var settled = false;
33
+ function fail(message) {
34
+ if (settled) return;
35
+ settled = true;
36
+ out.removeAttribute('aria-busy');
37
+ var alert = document.createElement('p');
38
+ alert.setAttribute('role', 'alert');
39
+ alert.className = 'error';
40
+ alert.textContent = message;
41
+ var pre = document.createElement('pre');
42
+ pre.textContent = source;
43
+ out.replaceChildren(alert, pre);
44
+ }
45
+ function reason(error) {
46
+ return error && error.message ? error.message : String(error);
47
+ }
48
+ var timer = setTimeout(function () {
49
+ fail('The diagram renderer did not load in time.');
50
+ }, ${MERMAID_LOAD_TIMEOUT_MS});
51
+ import(${inlineScriptLiteral(MERMAID_MODULE_URL)}).then(
52
+ function (mod) {
53
+ clearTimeout(timer);
54
+ if (settled) return;
55
+ var mermaid = mod.default;
56
+ // Our own error replaces Mermaid's bomb graphic, which it would append to the page body.
57
+ mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true });
58
+ return mermaid.render('agent-mermaid-svg', source).then(
59
+ function (result) {
60
+ if (settled) return;
61
+ settled = true;
62
+ out.removeAttribute('aria-busy');
63
+ out.innerHTML = result.svg;
64
+ if (result.bindFunctions) result.bindFunctions(out);
65
+ },
66
+ function (error) {
67
+ fail('The diagram could not be rendered: ' + reason(error));
68
+ },
69
+ );
70
+ },
71
+ function (error) {
72
+ clearTimeout(timer);
73
+ fail('The diagram renderer could not be loaded: ' + reason(error));
74
+ },
75
+ );
76
+ })();`;
77
+ }
78
+ /** A sandbox page that renders Mermaid `source` and shows a visible error if it cannot. */
79
+ export function mermaidPage(source) {
80
+ return ('<!doctype html><html><head><meta charset="utf-8"><style>' +
81
+ 'body{margin:0;padding:12px;font:14px/1.5 system-ui,sans-serif;color:#1f2937}' +
82
+ '#diagram svg{max-width:100%;height:auto}' +
83
+ '.error{margin:0 0 8px;color:#b91c1c}' +
84
+ 'pre{margin:0;white-space:pre-wrap;font:12px/1.5 ui-monospace,monospace;color:#4b5563}' +
85
+ '</style></head><body><div id="diagram" aria-busy="true">Rendering diagram&hellip;</div>' +
86
+ '<script type="module">' +
87
+ mermaidScript(source) +
88
+ '</script></body></html>');
89
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=artifact-pages.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifact-pages.test.d.ts","sourceRoot":"","sources":["../src/lib/artifact-pages.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,102 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runInThisContext, Script } from 'node:vm';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { inlineScriptLiteral, MERMAID_LOAD_TIMEOUT_MS, MERMAID_MODULE_URL, mermaidPage, mermaidScript, } from './artifact-pages.js';
6
+ const BREAKOUT = 'graph TD\n A["</script><script>alert(1)</script>"] --> B[<!-- x & y >]\u2028';
7
+ function scripts(html) {
8
+ const doc = new DOMParser().parseFromString(html, 'text/html');
9
+ return [...doc.querySelectorAll('script')].map((s) => ({
10
+ attrs: s
11
+ .getAttributeNames()
12
+ .map((n) => `${n}=${s.getAttribute(n)}`)
13
+ .join(' '),
14
+ body: s.textContent ?? '',
15
+ }));
16
+ }
17
+ /** Run the page's init script against the current (jsdom) document with a stand-in `import()`. */
18
+ function run(source, load) {
19
+ document.body.innerHTML = '<div id="diagram" aria-busy="true"></div>';
20
+ const body = mermaidScript(source).replace('import(', '__load(');
21
+ const init = runInThisContext(`(function (__load) {\n${body}\n})`);
22
+ init(load);
23
+ }
24
+ describe('mermaidPage', () => {
25
+ it('initializes the renderer from a module script', () => {
26
+ const found = scripts(mermaidPage('graph TD; A-->B'));
27
+ expect(found).toHaveLength(1);
28
+ expect(found[0].attrs).toBe('type=module');
29
+ expect(found[0].body).toContain(`import(${JSON.stringify(MERMAID_MODULE_URL)})`);
30
+ });
31
+ it('emits a script that parses as a module and as a classic script', async () => {
32
+ const body = mermaidScript(BREAKOUT);
33
+ expect(() => new Script(body)).not.toThrow();
34
+ // Module goal: strict mode, `import`/`await` reserved. vm.SourceTextModule is behind a
35
+ // flag, so check the module-only constraints the classic parse cannot see.
36
+ expect(() => new Script(`'use strict';${body}`)).not.toThrow();
37
+ expect(body).not.toMatch(/^\s*import\s[^(]/m);
38
+ expect(body).not.toMatch(/^\s*export\s/m);
39
+ });
40
+ it('cannot be broken out of by the diagram source', () => {
41
+ const html = mermaidPage(BREAKOUT);
42
+ const found = scripts(html);
43
+ expect(found).toHaveLength(1);
44
+ expect(html.match(/<\/script/gi)).toHaveLength(1);
45
+ expect(found[0].body).not.toMatch(/<\/?script|<!--|\u2028|\u2029/i);
46
+ expect(found[0].body).toContain(inlineScriptLiteral(BREAKOUT));
47
+ // The literal round-trips to the exact source.
48
+ expect(JSON.parse(inlineScriptLiteral(BREAKOUT))).toBe(BREAKOUT);
49
+ expect(runInThisContext(`(${inlineScriptLiteral(BREAKOUT)})`)).toBe(BREAKOUT);
50
+ });
51
+ });
52
+ describe('mermaid init script', () => {
53
+ beforeEach(() => vi.useFakeTimers());
54
+ afterEach(() => vi.useRealTimers());
55
+ it('renders the SVG and passes the exact source', async () => {
56
+ const render = vi.fn(async () => ({ svg: '<svg id="ok"></svg>' }));
57
+ const initialize = vi.fn();
58
+ const load = vi.fn(async () => ({ default: { initialize, render } }));
59
+ run(BREAKOUT, load);
60
+ await vi.runAllTimersAsync();
61
+ expect(load).toHaveBeenCalledWith(MERMAID_MODULE_URL);
62
+ expect(initialize).toHaveBeenCalledWith({
63
+ startOnLoad: false,
64
+ securityLevel: 'strict',
65
+ suppressErrorRendering: true,
66
+ });
67
+ expect(render).toHaveBeenCalledWith('agent-mermaid-svg', BREAKOUT);
68
+ expect(document.querySelector('#diagram svg#ok')).not.toBeNull();
69
+ expect(document.querySelector('[role="alert"]')).toBeNull();
70
+ expect(document.getElementById('diagram').hasAttribute('aria-busy')).toBe(false);
71
+ });
72
+ it('shows an error and the source when the renderer fails to load', async () => {
73
+ run('graph TD; A-->B', () => Promise.reject(new TypeError('Failed to fetch module')));
74
+ await vi.runAllTimersAsync();
75
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram renderer could not be loaded: Failed to fetch module');
76
+ expect(document.querySelector('#diagram pre')?.textContent).toBe('graph TD; A-->B');
77
+ });
78
+ it('shows an error when the diagram does not parse', async () => {
79
+ const render = () => Promise.reject(new Error('Parse error on line 1'));
80
+ run('graph ??', async () => ({ default: { initialize() { }, render } }));
81
+ await vi.runAllTimersAsync();
82
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram could not be rendered: Parse error on line 1');
83
+ });
84
+ it('shows an error when the renderer never loads', async () => {
85
+ run('graph TD; A-->B', () => new Promise(() => { }));
86
+ await vi.advanceTimersByTimeAsync(MERMAID_LOAD_TIMEOUT_MS);
87
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram renderer did not load in time.');
88
+ });
89
+ });
90
+ describe('svelte-package script-type stripping (R18)', () => {
91
+ // svelte-package removes `type="…"` from every `<script` in a .svelte file, string literals
92
+ // included. A .svelte file may hold only its own top-level <script> tags.
93
+ it('no component builds <script> markup in a string', () => {
94
+ const dir = join(import.meta.dirname, 'components');
95
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.svelte'))) {
96
+ const text = readFileSync(join(dir, file), 'utf8');
97
+ const tags = text.match(/<script\b[^>]*>/g) ?? [];
98
+ const topLevel = text.match(/^<script\b[^>]*>/gm) ?? [];
99
+ expect(tags, file).toEqual(topLevel);
100
+ }
101
+ });
102
+ });
@@ -7,15 +7,25 @@
7
7
  FileRef,
8
8
  } from '@classic-homes/agent-contracts';
9
9
  import type { BackendMode } from '@classic-homes/agent-contracts';
10
- import { cn, ResizablePanes, Sheet } from '@classic-homes/theme-svelte';
10
+ import {
11
+ Alert,
12
+ Button,
13
+ cn,
14
+ Icon,
15
+ ResizablePanes,
16
+ Sheet,
17
+ Skeleton,
18
+ } from '@classic-homes/theme-svelte';
11
19
  import { MediaQuery } from 'svelte/reactivity';
12
20
  import { releaseStuckBodyLock } from '../body-lock.js';
13
21
  import type { ComposerPrompt } from '../composer-prompts.js';
14
22
  import { ChatSession } from '../session.svelte.js';
23
+ import type { WelcomeSuggestion } from '../welcome.js';
15
24
  import ArtifactPanel from './ArtifactPanel.svelte';
16
25
  import Composer from './Composer.svelte';
17
26
  import MessageList from './MessageList.svelte';
18
27
  import ModeBanner from './ModeBanner.svelte';
28
+ import Welcome from './Welcome.svelte';
19
29
 
20
30
  interface Props {
21
31
  client: AgentApiClient;
@@ -45,9 +55,18 @@
45
55
  prompts?: ComposerPrompt[];
46
56
  /** Readable tool names for the timeline, by tool name (`server/name` or `name`). */
47
57
  toolLabels?: Record<string, string>;
58
+ /** The signed-in user's name for the welcome greeting (first word shown). */
59
+ userName?: string | null;
60
+ /** Starters on an empty conversation (up to four); choosing one sends it. */
61
+ suggestions?: WelcomeSuggestion[];
62
+ /** Replaces the welcome's time-of-day greeting. */
63
+ welcomeTitle?: string;
64
+ /** Replaces the welcome's capability line. */
65
+ welcomeDescription?: string;
48
66
  /** Rendered above the transcript, e.g. a `ChatHeader`. */
49
67
  header?: import('svelte').Snippet;
50
68
  context?: import('svelte').Snippet;
69
+ /** Replaces the default `Welcome` on an empty conversation. */
51
70
  empty?: import('svelte').Snippet;
52
71
  class?: string;
53
72
  }
@@ -69,6 +88,10 @@
69
88
  placeholder,
70
89
  prompts,
71
90
  toolLabels,
91
+ userName,
92
+ suggestions,
93
+ welcomeTitle,
94
+ welcomeDescription,
72
95
  header,
73
96
  context,
74
97
  empty,
@@ -94,15 +117,20 @@
94
117
  }
95
118
  },
96
119
  });
120
+ let attemptedId: string | null = null;
97
121
  // The constructor already knows `conversationId`; what decides a fetch is whether the
98
122
  // transcript for it has been loaded (or started) in this session.
99
123
  $effect(() => {
100
124
  if (
101
125
  conversationId &&
102
126
  conversationId !== session.loadedConversationId &&
127
+ conversationId !== attemptedId &&
103
128
  session.status !== 'loading'
104
- )
129
+ ) {
130
+ // Once per id: a failed load waits for Try again instead of retrying in a loop.
131
+ attemptedId = conversationId;
105
132
  void session.load(conversationId);
133
+ }
106
134
  });
107
135
  $effect(() => {
108
136
  if (mode && !session.mode) session.mode = mode;
@@ -135,19 +163,82 @@
135
163
  const last = session.messages[session.messages.length - 1];
136
164
  if (last?.role === 'assistant') void session.retry(last.requestId);
137
165
  }
166
+ // An existing conversation whose transcript has not arrived yet: show a skeleton, not the
167
+ // welcome (which would flash before the messages).
168
+ const pending = $derived(
169
+ session.status === 'loading' ||
170
+ (!!conversationId &&
171
+ conversationId !== session.loadedConversationId &&
172
+ !session.loadError &&
173
+ session.messages.length === 0),
174
+ );
175
+ function reload(): void {
176
+ if (conversationId) void session.load(conversationId);
177
+ }
138
178
  function prompt(text: string): void {
139
179
  draft = text;
140
180
  composer?.focus();
141
181
  }
142
182
  </script>
143
183
 
184
+ {#snippet emptyState()}
185
+ {#if pending}
186
+ <div
187
+ class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-6"
188
+ aria-busy="true"
189
+ data-agent-loading
190
+ >
191
+ <span class="sr-only" role="status">Loading conversation…</span>
192
+ <Skeleton class="ml-auto h-10 w-2/5 rounded-2xl" />
193
+ <div class="flex flex-col gap-2">
194
+ <Skeleton variant="text" class="w-11/12" />
195
+ <Skeleton variant="text" class="w-4/5" />
196
+ <Skeleton variant="text" class="w-3/5" />
197
+ </div>
198
+ <Skeleton class="ml-auto h-10 w-1/3 rounded-2xl" />
199
+ <div class="flex flex-col gap-2">
200
+ <Skeleton variant="text" class="w-10/12" />
201
+ <Skeleton variant="text" class="w-2/3" />
202
+ </div>
203
+ </div>
204
+ {:else if session.loadError}
205
+ <!-- The alert above explains it; no welcome under a failed load. -->
206
+ {:else if empty}
207
+ {@render empty()}
208
+ {:else}
209
+ <Welcome
210
+ name={userName}
211
+ title={welcomeTitle}
212
+ description={welcomeDescription}
213
+ {suggestions}
214
+ compact={variant === 'panel'}
215
+ disabled={session.status !== 'idle'}
216
+ onselect={(text) => void send(text)}
217
+ />
218
+ {/if}
219
+ {/snippet}
220
+
144
221
  {#snippet chat()}
145
222
  <div class="flex h-full min-w-0 flex-1 flex-col">
146
223
  {#if header}{@render header()}{/if}
147
224
  <ModeBanner mode={session.mode} />
148
- {#if session.loadError}<div class="px-4 py-2 text-sm text-destructive" role="alert">
149
- {session.loadError}
150
- </div>{/if}
225
+ {#if session.loadError}
226
+ <div class="mx-auto w-full max-w-3xl px-4 pt-3">
227
+ <Alert variant="error">
228
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
229
+ <p class="min-w-0 flex-1">
230
+ <strong class="font-semibold">Could not load this conversation.</strong>
231
+ {session.loadError}
232
+ </p>
233
+ {#if conversationId}
234
+ <Button variant="outline" size="sm" onclick={reload}>
235
+ <Icon name="refresh" size={14} /> Try again
236
+ </Button>
237
+ {/if}
238
+ </div>
239
+ </Alert>
240
+ </div>
241
+ {/if}
151
242
  <MessageList
152
243
  messages={session.messages}
153
244
  streaming={session.streaming}
@@ -164,7 +255,7 @@
164
255
  {onnotify}
165
256
  onretry={retry}
166
257
  oncontinue={() => void session.send('Continue.')}
167
- {empty}
258
+ empty={emptyState}
168
259
  />
169
260
  <Composer
170
261
  bind:this={composer}
@@ -2,6 +2,7 @@ import type { AgentApiClient } from '@classic-homes/agent-client';
2
2
  import type { AgentKey, ClientKind, DocumentContext } from '@classic-homes/agent-contracts';
3
3
  import type { BackendMode } from '@classic-homes/agent-contracts';
4
4
  import type { ComposerPrompt } from '../composer-prompts.js';
5
+ import type { WelcomeSuggestion } from '../welcome.js';
5
6
  interface Props {
6
7
  client: AgentApiClient;
7
8
  /** Sandbox origin for artifacts and MCP-UI (agent-api `/frame`). */
@@ -30,9 +31,18 @@ interface Props {
30
31
  prompts?: ComposerPrompt[];
31
32
  /** Readable tool names for the timeline, by tool name (`server/name` or `name`). */
32
33
  toolLabels?: Record<string, string>;
34
+ /** The signed-in user's name for the welcome greeting (first word shown). */
35
+ userName?: string | null;
36
+ /** Starters on an empty conversation (up to four); choosing one sends it. */
37
+ suggestions?: WelcomeSuggestion[];
38
+ /** Replaces the welcome's time-of-day greeting. */
39
+ welcomeTitle?: string;
40
+ /** Replaces the welcome's capability line. */
41
+ welcomeDescription?: string;
33
42
  /** Rendered above the transcript, e.g. a `ChatHeader`. */
34
43
  header?: import('svelte').Snippet;
35
44
  context?: import('svelte').Snippet;
45
+ /** Replaces the default `Welcome` on an empty conversation. */
36
46
  empty?: import('svelte').Snippet;
37
47
  class?: string;
38
48
  }
@@ -1 +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;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAIlE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAQ3D,UAAU,KAAK;IACb,MAAM,EAAE,cAAc,CAAC;IACvB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,+EAA+E;IAC/E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,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,gFAAgF;IAChF,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,IAAI,CAAC;IACX,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,wDAAwD;IACxD,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,oFAAoF;IACpF,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAgJH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
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;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAYlE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAQrD,UAAU,KAAK;IACb,MAAM,EAAE,cAAc,CAAC;IACvB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,+EAA+E;IAC/E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,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,gFAAgF;IAChF,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,IAAI,CAAC;IACX,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,wDAAwD;IACxD,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,oFAAoF;IACpF,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAClC,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8CAA8C;IAC9C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8MH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { Button, cn, Icon } from '@classic-homes/theme-svelte';
3
3
  import type { Artifact } from '../reducer.js';
4
+ import { mermaidPage } from '../artifact-pages.js';
4
5
  import { parseMarkdown } from '../markdown.js';
5
6
  import Markdown from './Markdown.svelte';
6
7
  import SandboxFrame from './SandboxFrame.svelte';
@@ -55,11 +56,6 @@
55
56
  if (timer) clearTimeout(timer);
56
57
  });
57
58
 
58
- function mermaidPage(source: string): string {
59
- const escaped = source.replace(/</g, '&lt;');
60
- 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>`;
61
- }
62
-
63
59
  async function copy(): Promise<void> {
64
60
  if (selected) await navigator.clipboard.writeText(selected.text);
65
61
  }
@@ -119,7 +115,8 @@
119
115
  <div class="min-h-0 flex-1 overflow-auto p-3">
120
116
  {#if selected.mediaType === 'text/markdown'}
121
117
  <Markdown html={markdownHtml} />
122
- {:else if selected.mediaType === 'application/vnd.ant.code'}
118
+ {:else if selected.mediaType === 'application/vnd.ant.code' || (selected.mediaType === 'application/vnd.ant.mermaid' && selected.open)}
119
+ <!-- Mermaid renders once the artifact is complete; a partial diagram is a parse error. -->
123
120
  <pre class="overflow-auto whitespace-pre font-mono text-[13px] leading-relaxed"><code
124
121
  >{selected.text}</code
125
122
  ></pre>
@@ -1 +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;AAM5C,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;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAkHH,QAAA,MAAM,aAAa,qDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
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;AAO5C,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;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA+GH,QAAA,MAAM,aAAa,qDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,99 @@
1
+ <!--
2
+ @component
3
+ The default empty conversation: a greeting, a capability line and starter suggestions.
4
+ `AgentChat` renders it unless the host passes its own `empty` snippet.
5
+ -->
6
+ <script lang="ts">
7
+ import { cn, Icon } from '@classic-homes/theme-svelte';
8
+ import { greeting, suggestionText, type WelcomeSuggestion } from '../welcome.js';
9
+
10
+ interface Props {
11
+ /** The user's name; only the first word is shown. */
12
+ name?: string | null;
13
+ /** Replaces the time-of-day greeting. */
14
+ title?: string;
15
+ description?: string;
16
+ suggestions?: WelcomeSuggestion[];
17
+ /** Compact single-column layout for the side panel. */
18
+ compact?: boolean;
19
+ disabled?: boolean;
20
+ onselect?: (text: string, suggestion: WelcomeSuggestion) => void;
21
+ class?: string;
22
+ }
23
+ let {
24
+ name,
25
+ title,
26
+ description = 'Ask about homes, lots, documents and company data, or attach a file to work from it.',
27
+ suggestions = [],
28
+ compact = false,
29
+ disabled = false,
30
+ onselect,
31
+ class: className,
32
+ }: Props = $props();
33
+
34
+ const headingId = $props.id();
35
+ const heading = $derived(title ?? greeting(name));
36
+ const shown = $derived(suggestions.filter((s) => suggestionText(s)).slice(0, 4));
37
+ </script>
38
+
39
+ <section
40
+ class={cn(
41
+ 'm-auto flex w-full flex-col items-center px-4 text-center motion-safe:animate-fade-in',
42
+ compact ? 'max-w-sm gap-4 py-6' : 'max-w-2xl gap-6 py-10',
43
+ className,
44
+ )}
45
+ aria-labelledby={headingId}
46
+ data-agent-welcome
47
+ >
48
+ <div class="flex flex-col items-center gap-3">
49
+ <span
50
+ class={cn(
51
+ 'inline-flex items-center justify-center rounded-full bg-primary/10 text-primary',
52
+ compact ? 'size-10' : 'size-12',
53
+ )}
54
+ aria-hidden="true"
55
+ >
56
+ <Icon name="sparkles" size={compact ? 20 : 24} />
57
+ </span>
58
+ <h2
59
+ id={headingId}
60
+ class={cn('font-semibold tracking-tight text-foreground', compact ? 'text-lg' : 'text-2xl')}
61
+ >
62
+ {heading}
63
+ </h2>
64
+ {#if description}
65
+ <p class="max-w-md text-sm text-muted-foreground">{description}</p>
66
+ {/if}
67
+ </div>
68
+ {#if shown.length}
69
+ <ul
70
+ class={cn('grid w-full gap-2 text-left', !compact && 'sm:grid-cols-2')}
71
+ aria-label="Suggestions"
72
+ >
73
+ {#each shown as suggestion (suggestion.id ?? suggestion.label)}
74
+ <li class="flex">
75
+ <button
76
+ type="button"
77
+ class="group flex w-full items-start gap-3 rounded-lg border border-border bg-background p-3 text-left text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
78
+ {disabled}
79
+ onclick={() => onselect?.(suggestionText(suggestion), suggestion)}
80
+ >
81
+ <Icon
82
+ name={suggestion.icon ?? 'message-square'}
83
+ size={16}
84
+ class="mt-0.5 shrink-0 text-muted-foreground group-hover:text-foreground"
85
+ />
86
+ <span class="min-w-0">
87
+ <span class="block font-medium text-foreground">{suggestion.label}</span>
88
+ {#if suggestion.description}
89
+ <span class="mt-0.5 block text-xs text-muted-foreground"
90
+ >{suggestion.description}</span
91
+ >
92
+ {/if}
93
+ </span>
94
+ </button>
95
+ </li>
96
+ {/each}
97
+ </ul>
98
+ {/if}
99
+ </section>
@@ -0,0 +1,22 @@
1
+ import { type WelcomeSuggestion } from '../welcome.js';
2
+ interface Props {
3
+ /** The user's name; only the first word is shown. */
4
+ name?: string | null;
5
+ /** Replaces the time-of-day greeting. */
6
+ title?: string;
7
+ description?: string;
8
+ suggestions?: WelcomeSuggestion[];
9
+ /** Compact single-column layout for the side panel. */
10
+ compact?: boolean;
11
+ disabled?: boolean;
12
+ onselect?: (text: string, suggestion: WelcomeSuggestion) => void;
13
+ class?: string;
14
+ }
15
+ /**
16
+ * The default empty conversation: a greeting, a capability line and starter suggestions.
17
+ * `AgentChat` renders it unless the host passes its own `empty` snippet.
18
+ */
19
+ declare const Welcome: import("svelte").Component<Props, {}, "">;
20
+ type Welcome = ReturnType<typeof Welcome>;
21
+ export default Welcome;
22
+ //# sourceMappingURL=Welcome.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Welcome.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/Welcome.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAA4B,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAG/E,UAAU,KAAK;IACb,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAClC,uDAAuD;IACvD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8DH;;;GAGG;AACH,QAAA,MAAM,OAAO,2CAAwC,CAAC;AACtD,KAAK,OAAO,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAC;AAC1C,eAAe,OAAO,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=welcome.svelte.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"welcome.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/welcome.svelte.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { cleanup, render, screen, waitFor } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { greeting, suggestionText } from '../welcome.js';
5
+ import AgentChat from './AgentChat.svelte';
6
+ import Welcome from './Welcome.svelte';
7
+ afterEach(() => cleanup());
8
+ function fakeClient(getConversation = vi.fn()) {
9
+ return {
10
+ fileUrl: (id) => `#${id}`,
11
+ uploadFile: vi.fn(),
12
+ cancel: vi.fn(),
13
+ getConversation,
14
+ resumeTurn: vi.fn(),
15
+ streamChat: vi.fn(async (req, opts) => {
16
+ [
17
+ { type: 'start', conversationId: 'c-1', mode: 'live' },
18
+ { type: 'delta', text: 'Sure.' },
19
+ { type: 'done', finishReason: 'stop' },
20
+ ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
21
+ return { kind: 'done' };
22
+ }),
23
+ };
24
+ }
25
+ const base = { sandboxOrigin: 'https://frame.test' };
26
+ describe('welcome helpers', () => {
27
+ it('greets by the time of day and the first name only', () => {
28
+ expect(greeting('Pat Lee', new Date(2026, 10, 4, 9))).toBe('Good morning, Pat');
29
+ expect(greeting(undefined, new Date(2026, 10, 4, 14))).toBe('Good afternoon');
30
+ expect(greeting(' ', new Date(2026, 10, 4, 20))).toBe('Good evening');
31
+ expect(greeting('Sam', new Date(2026, 10, 4, 2))).toBe('Good evening, Sam');
32
+ });
33
+ it('sends the text, or the label without one', () => {
34
+ expect(suggestionText({ label: 'Open lots' })).toBe('Open lots');
35
+ expect(suggestionText({ label: 'Lots', text: ' List open lots ' })).toBe('List open lots');
36
+ });
37
+ });
38
+ describe('Welcome', () => {
39
+ it('shows at most four suggestions and reports the chosen one', async () => {
40
+ const onselect = vi.fn();
41
+ render(Welcome, {
42
+ props: {
43
+ name: 'Pat',
44
+ title: 'Hi Pat',
45
+ onselect,
46
+ suggestions: ['A', 'B', 'C', 'D', 'E'].map((label) => ({ label, text: `Do ${label}` })),
47
+ },
48
+ });
49
+ expect(screen.getByRole('heading', { name: 'Hi Pat' })).toBeTruthy();
50
+ const list = screen.getByRole('list', { name: 'Suggestions' });
51
+ expect(list.querySelectorAll('button')).toHaveLength(4);
52
+ await userEvent.click(screen.getByRole('button', { name: 'B' }));
53
+ expect(onselect).toHaveBeenCalledWith('Do B', expect.objectContaining({ label: 'B' }));
54
+ });
55
+ });
56
+ describe('AgentChat empty states', () => {
57
+ it('welcomes a new chat and sends a chosen suggestion', async () => {
58
+ const client = fakeClient();
59
+ render(AgentChat, {
60
+ props: {
61
+ ...base,
62
+ client: client,
63
+ userName: 'Pat Lee',
64
+ suggestions: [{ label: 'Open lots', text: 'List the open lots' }],
65
+ },
66
+ });
67
+ expect(screen.getByRole('heading', { name: /, Pat$/ })).toBeTruthy();
68
+ await userEvent.click(screen.getByRole('button', { name: 'Open lots' }));
69
+ await waitFor(() => expect(client.streamChat).toHaveBeenCalledOnce());
70
+ expect(client.streamChat.mock.calls[0][0]).toMatchObject({ message: 'List the open lots' });
71
+ expect((await screen.findAllByText('Sure.')).length).toBeGreaterThan(0);
72
+ expect(document.querySelector('[data-agent-welcome]')).toBeNull();
73
+ });
74
+ it('shows a skeleton, not the welcome, while a conversation loads', async () => {
75
+ let resolve;
76
+ const client = fakeClient(vi.fn(() => new Promise((r) => (resolve = r))));
77
+ render(AgentChat, { props: { ...base, client: client, conversationId: 'c-9' } });
78
+ expect(document.querySelector('[data-agent-loading]')).toBeTruthy();
79
+ expect(document.querySelector('[data-agent-welcome]')).toBeNull();
80
+ resolve({
81
+ conversation: { conversationId: 'c-9', activeRequestId: null },
82
+ messages: [
83
+ {
84
+ id: 'u-1',
85
+ role: 'user',
86
+ content: [{ type: 'text', text: 'Earlier question' }],
87
+ createdAt: Date.now(),
88
+ },
89
+ ],
90
+ });
91
+ expect(await screen.findByText('Earlier question')).toBeTruthy();
92
+ expect(document.querySelector('[data-agent-loading]')).toBeNull();
93
+ });
94
+ it('explains a failed load and retries it', async () => {
95
+ const getConversation = vi
96
+ .fn()
97
+ .mockRejectedValueOnce(new Error('Network down'))
98
+ .mockResolvedValueOnce({
99
+ conversation: { conversationId: 'c-9', activeRequestId: null },
100
+ messages: [],
101
+ });
102
+ render(AgentChat, {
103
+ props: { ...base, client: fakeClient(getConversation), conversationId: 'c-9' },
104
+ });
105
+ expect(await screen.findByText('Could not load this conversation.')).toBeTruthy();
106
+ expect(document.querySelector('[data-agent-welcome]')).toBeNull();
107
+ // No automatic retry loop.
108
+ await new Promise((r) => setTimeout(r, 50));
109
+ expect(getConversation).toHaveBeenCalledOnce();
110
+ await userEvent.click(screen.getByRole('button', { name: /Try again/ }));
111
+ await waitFor(() => expect(getConversation).toHaveBeenCalledTimes(2));
112
+ await waitFor(() => expect(screen.queryByText('Could not load this conversation.')).toBeNull());
113
+ expect(document.querySelector('[data-agent-welcome]')).toBeTruthy();
114
+ });
115
+ it('renders the welcome when the host gives no empty snippet', () => {
116
+ render(AgentChat, { props: { ...base, client: fakeClient() } });
117
+ expect(document.querySelector('[data-agent-welcome]')).toBeTruthy();
118
+ });
119
+ });
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './message-format.js';
6
6
  export * from './reducer.js';
7
7
  export * from './session.svelte.js';
8
8
  export * from './ui-frame-protocol.js';
9
+ export * from './welcome.js';
9
10
  export { default as AgentChat } from './components/AgentChat.svelte';
10
11
  export { default as ArtifactPanel } from './components/ArtifactPanel.svelte';
11
12
  export { default as Composer } from './components/Composer.svelte';
@@ -22,4 +23,5 @@ export { default as MessageActions } from './components/MessageActions.svelte';
22
23
  export { default as ArtifactChip } from './components/ArtifactChip.svelte';
23
24
  export { default as MessageFiles } from './components/MessageFiles.svelte';
24
25
  export { default as ProposalCard } from './components/ProposalCard.svelte';
26
+ export { default as Welcome } from './components/Welcome.svelte';
25
27
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AACzF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,cAAc,CAAC;AAC7B,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AACzF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC"}
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export * from './message-format.js';
6
6
  export * from './reducer.js';
7
7
  export * from './session.svelte.js';
8
8
  export * from './ui-frame-protocol.js';
9
+ export * from './welcome.js';
9
10
  export { default as AgentChat } from './components/AgentChat.svelte';
10
11
  export { default as ArtifactPanel } from './components/ArtifactPanel.svelte';
11
12
  export { default as Composer } from './components/Composer.svelte';
@@ -22,3 +23,4 @@ export { default as MessageActions } from './components/MessageActions.svelte';
22
23
  export { default as ArtifactChip } from './components/ArtifactChip.svelte';
23
24
  export { default as MessageFiles } from './components/MessageFiles.svelte';
24
25
  export { default as ProposalCard } from './components/ProposalCard.svelte';
26
+ export { default as Welcome } from './components/Welcome.svelte';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Helpers for the empty conversation: greeting and suggestion types.
3
+ */
4
+ import type { IconName } from '@classic-homes/theme-svelte';
5
+ /** A starter offered on an empty conversation. */
6
+ export interface WelcomeSuggestion {
7
+ /** Stable key; defaults to the label. */
8
+ id?: string;
9
+ /** Short card title, e.g. "Summarize this lot". */
10
+ label: string;
11
+ /** One line under the label. */
12
+ description?: string;
13
+ /** Message sent when the card is chosen (defaults to the label). */
14
+ text?: string;
15
+ icon?: IconName;
16
+ }
17
+ /** The text a suggestion sends. */
18
+ export declare function suggestionText(s: WelcomeSuggestion): string;
19
+ /** "Good morning, Pat" by the local hour; without a name, "Good morning". */
20
+ export declare function greeting(name?: string | null, now?: Date): string;
21
+ //# sourceMappingURL=welcome.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"welcome.d.ts","sourceRoot":"","sources":["../src/lib/welcome.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAE5D,kDAAkD;AAClD,MAAM,WAAW,iBAAiB;IAChC,yCAAyC;IACzC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,gCAAgC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB;AAED,mCAAmC;AACnC,wBAAgB,cAAc,CAAC,CAAC,EAAE,iBAAiB,GAAG,MAAM,CAE3D;AAED,6EAA6E;AAC7E,wBAAgB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,CAK7E"}
@@ -0,0 +1,11 @@
1
+ /** The text a suggestion sends. */
2
+ export function suggestionText(s) {
3
+ return (s.text ?? s.label).trim();
4
+ }
5
+ /** "Good morning, Pat" by the local hour; without a name, "Good morning". */
6
+ export function greeting(name, now = new Date()) {
7
+ const hour = now.getHours();
8
+ const part = hour < 5 ? 'evening' : hour < 12 ? 'morning' : hour < 18 ? 'afternoon' : 'evening';
9
+ const first = name?.trim().split(/\s+/)[0];
10
+ return first ? `Good ${part}, ${first}` : `Good ${part}`;
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/agent-chat",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Svelte 5 chat UI for Classic Agent: session state, message list, composer, tool timeline, artifacts and MCP-UI sandbox frames. Consumed by the Classic App.",
@@ -17,8 +17,8 @@
17
17
  "dist"
18
18
  ],
19
19
  "dependencies": {
20
- "@classic-homes/agent-client": "0.4.0",
21
- "@classic-homes/agent-contracts": "0.4.0",
20
+ "@classic-homes/agent-client": "0.5.0",
21
+ "@classic-homes/agent-contracts": "0.5.0",
22
22
  "dompurify": "3.4.17"
23
23
  },
24
24
  "peerDependencies": {