@classic-homes/agent-chat 0.1.3 → 0.2.1

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 (70) hide show
  1. package/README.md +40 -0
  2. package/dist/body-lock.d.ts +2 -0
  3. package/dist/body-lock.d.ts.map +1 -0
  4. package/dist/body-lock.js +27 -0
  5. package/dist/components/AgentChat.svelte +85 -47
  6. package/dist/components/AgentChat.svelte.d.ts +8 -0
  7. package/dist/components/AgentChat.svelte.d.ts.map +1 -1
  8. package/dist/components/ArtifactPanel.svelte +79 -96
  9. package/dist/components/ArtifactPanel.svelte.d.ts +2 -0
  10. package/dist/components/ArtifactPanel.svelte.d.ts.map +1 -1
  11. package/dist/components/ChatHeader.svelte +122 -0
  12. package/dist/components/ChatHeader.svelte.d.ts +27 -0
  13. package/dist/components/ChatHeader.svelte.d.ts.map +1 -0
  14. package/dist/components/Composer.svelte +78 -121
  15. package/dist/components/Composer.svelte.d.ts.map +1 -1
  16. package/dist/components/ConversationDialogs.svelte +154 -0
  17. package/dist/components/ConversationDialogs.svelte.d.ts +25 -0
  18. package/dist/components/ConversationDialogs.svelte.d.ts.map +1 -0
  19. package/dist/components/ConversationSidebar.svelte +135 -114
  20. package/dist/components/ConversationSidebar.svelte.d.ts +8 -2
  21. package/dist/components/ConversationSidebar.svelte.d.ts.map +1 -1
  22. package/dist/components/Markdown.svelte +49 -0
  23. package/dist/components/Markdown.svelte.d.ts +17 -0
  24. package/dist/components/Markdown.svelte.d.ts.map +1 -0
  25. package/dist/components/MessageItem.svelte +27 -116
  26. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  27. package/dist/components/MessageList.svelte +23 -34
  28. package/dist/components/ModeBanner.svelte +7 -13
  29. package/dist/components/ModeBanner.svelte.d.ts.map +1 -1
  30. package/dist/components/SandboxFrame.svelte +43 -0
  31. package/dist/components/SandboxFrame.svelte.d.ts +13 -0
  32. package/dist/components/SandboxFrame.svelte.d.ts.map +1 -0
  33. package/dist/components/ToolTimeline.svelte +17 -68
  34. package/dist/components/ToolTimeline.svelte.d.ts.map +1 -1
  35. package/dist/components/UiFrame.svelte +9 -55
  36. package/dist/components/UiFrame.svelte.d.ts.map +1 -1
  37. package/dist/components/conversation-chrome.svelte.test.d.ts +2 -0
  38. package/dist/components/conversation-chrome.svelte.test.d.ts.map +1 -0
  39. package/dist/components/conversation-chrome.svelte.test.js +127 -0
  40. package/dist/components/live-fixes.svelte.test.d.ts +2 -0
  41. package/dist/components/live-fixes.svelte.test.d.ts.map +1 -0
  42. package/dist/components/live-fixes.svelte.test.js +127 -0
  43. package/dist/components/markdown-prose.svelte.test.d.ts +2 -0
  44. package/dist/components/markdown-prose.svelte.test.d.ts.map +1 -0
  45. package/dist/components/markdown-prose.svelte.test.js +26 -0
  46. package/dist/composer-keys.d.ts +11 -0
  47. package/dist/composer-keys.d.ts.map +1 -0
  48. package/dist/composer-keys.js +10 -0
  49. package/dist/composer-keys.test.d.ts +2 -0
  50. package/dist/composer-keys.test.d.ts.map +1 -0
  51. package/dist/composer-keys.test.js +16 -0
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +3 -0
  55. package/dist/reducer.d.ts +10 -0
  56. package/dist/reducer.d.ts.map +1 -1
  57. package/dist/reducer.js +19 -1
  58. package/dist/reducer.test.js +29 -1
  59. package/dist/session.svelte.d.ts +15 -0
  60. package/dist/session.svelte.d.ts.map +1 -1
  61. package/dist/session.svelte.js +21 -1
  62. package/dist/ui-frame-protocol.d.ts +2 -2
  63. package/dist/ui-frame-protocol.js +2 -2
  64. package/dist/ui-frame-relay.d.ts +16 -0
  65. package/dist/ui-frame-relay.d.ts.map +1 -0
  66. package/dist/ui-frame-relay.js +22 -0
  67. package/dist/ui-frame-relay.test.d.ts +2 -0
  68. package/dist/ui-frame-relay.test.d.ts.map +1 -0
  69. package/dist/ui-frame-relay.test.js +36 -0
  70. package/package.json +8 -5
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @classic-homes/agent-chat
2
+
3
+ Svelte 5 chat UI for Classic Agent: session state, message list, composer, tool timeline,
4
+ artifacts and MCP-UI sandbox frames. The Classic App's `/agent` page and agent panel use it.
5
+
6
+ ## Requirements
7
+
8
+ - `svelte` 5 and `@classic-homes/theme-svelte` 1.39 or later (peer dependencies).
9
+ - Tailwind with `@classic-homes/theme-tailwind-preset`. The components are styled with Tailwind
10
+ utility classes and theme tokens, so the consumer's Tailwind build has to scan this package:
11
+
12
+ ```js
13
+ // tailwind.config.js
14
+ export default {
15
+ content: [
16
+ './src/**/*.{html,js,svelte,ts}',
17
+ './node_modules/@classic-homes/theme-svelte/**/*.{html,js,svelte,ts}',
18
+ './node_modules/@classic-homes/agent-chat/**/*.{js,svelte}',
19
+ ],
20
+ presets: [require('@classic-homes/theme-tailwind-preset')],
21
+ };
22
+ ```
23
+
24
+ Without that entry the chat renders unstyled. Markdown prose (rendered from model text) is the
25
+ one part styled with plain CSS, shipped inside the `Markdown` component.
26
+
27
+ ## Usage
28
+
29
+ ```svelte
30
+ <script lang="ts">
31
+ import { AgentChat } from '@classic-homes/agent-chat';
32
+ </script>
33
+
34
+ <AgentChat {client} sandboxOrigin={AGENT_SANDBOX_ORIGIN} {conversationId} mode={me.mode} />
35
+ ```
36
+
37
+ `client` is an `AgentApiClient` from `@classic-homes/agent-client`. `sandboxOrigin` is the
38
+ agent-api origin that serves `/frame`; artifacts and MCP-UI resources only ever render there.
39
+
40
+ Publishing: `docs/operations/package-publishing.md`.
@@ -0,0 +1,2 @@
1
+ export declare function releaseStuckBodyLock(settleMs?: number): void;
2
+ //# sourceMappingURL=body-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body-lock.d.ts","sourceRoot":"","sources":["../src/lib/body-lock.ts"],"names":[],"mappings":"AAYA,wBAAgB,oBAAoB,CAAC,QAAQ,SAAY,GAAG,IAAI,CAY/D"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Guard for a bits-ui 1.x body-lock race (theme-svelte Sheet/Dialog): when one modal closes and
3
+ * another opens in the same tick, the closing one's deferred restore saves the opening one's
4
+ * locked style as the "original", so closing the second leaves `<body>` at
5
+ * `pointer-events: none; overflow: hidden` and the page stops taking clicks.
6
+ *
7
+ * Call after a sheet or dialog closes. Once the close animation is over, if no modal is open
8
+ * but the body is still locked, the lock is cleared. With a modal open nothing is touched.
9
+ */
10
+ const SETTLE_MS = 400;
11
+ const OPEN_MODAL = '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]';
12
+ export function releaseStuckBodyLock(settleMs = SETTLE_MS) {
13
+ if (typeof document === 'undefined')
14
+ return;
15
+ setTimeout(() => {
16
+ if (document.querySelector(OPEN_MODAL))
17
+ return;
18
+ const body = document.body;
19
+ if (body.style.pointerEvents !== 'none')
20
+ return;
21
+ body.style.removeProperty('pointer-events');
22
+ body.style.removeProperty('overflow');
23
+ body.style.removeProperty('padding-right');
24
+ body.style.removeProperty('margin-right');
25
+ body.style.removeProperty('--scrollbar-width');
26
+ }, settleMs);
27
+ }
@@ -7,6 +7,9 @@
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';
11
+ import { MediaQuery } from 'svelte/reactivity';
12
+ import { releaseStuckBodyLock } from '../body-lock.js';
10
13
  import { ChatSession } from '../session.svelte.js';
11
14
  import ArtifactPanel from './ArtifactPanel.svelte';
12
15
  import Composer from './Composer.svelte';
@@ -26,11 +29,19 @@
26
29
  getContext?: () => DocumentContext | null | undefined;
27
30
  onconversationstarted?: (conversationId: string) => void;
28
31
  ontoolresult?: (name: string, status: string) => void;
32
+ /** A turn finished streaming (any outcome); refresh conversation lists here. */
33
+ onturnend?: (info: {
34
+ requestId: string;
35
+ conversationId: string | null;
36
+ status: string;
37
+ }) => void;
29
38
  onnavigate?: (href: string) => void;
30
39
  onnotify?: (message: string) => void;
31
40
  /** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
32
41
  ontoolrequest?: (toolName: string, params?: Record<string, unknown>) => void;
33
42
  placeholder?: string;
43
+ /** Rendered above the transcript, e.g. a `ChatHeader`. */
44
+ header?: import('svelte').Snippet;
34
45
  context?: import('svelte').Snippet;
35
46
  empty?: import('svelte').Snippet;
36
47
  class?: string;
@@ -46,13 +57,15 @@
46
57
  getContext,
47
58
  onconversationstarted,
48
59
  ontoolresult,
60
+ onturnend,
49
61
  onnavigate,
50
62
  onnotify,
51
63
  ontoolrequest,
52
64
  placeholder,
65
+ header,
53
66
  context,
54
67
  empty,
55
- class: className = '',
68
+ class: className,
56
69
  }: Props = $props();
57
70
 
58
71
  const session = new ChatSession({
@@ -64,6 +77,15 @@
64
77
  getContext,
65
78
  onConversationStarted: (id) => onconversationstarted?.(id),
66
79
  onToolResult: (name, status) => ontoolresult?.(name, status),
80
+ onTurnEnd: (info) => onturnend?.(info),
81
+ // Open the pane when the runtime starts an artifact, not by watching state later: a fast
82
+ // stream can open and close it before any effect sees `open`.
83
+ onEvent: (event) => {
84
+ if (event.type === 'artifact' && event.op === 'open') {
85
+ selectedArtifact = event.artifactId;
86
+ artifactsOpen = true;
87
+ }
88
+ },
67
89
  });
68
90
  // The constructor already knows `conversationId`; what decides a fetch is whether the
69
91
  // transcript for it has been loaded (or started) in this session.
@@ -87,14 +109,15 @@
87
109
  const artifacts = $derived(
88
110
  session.messages.flatMap((m) => (m.role === 'assistant' ? m.artifacts : [])),
89
111
  );
90
- $effect(() => {
91
- // A newly opened artifact pops the panel.
92
- const last = artifacts[artifacts.length - 1];
93
- if (last && last.open && selectedArtifact !== last.artifactId) {
94
- selectedArtifact = last.artifactId;
95
- artifactsOpen = true;
96
- }
97
- });
112
+
113
+ // The page shows artifacts beside the chat on wide screens; narrow screens and the panel use a
114
+ // sheet so the transcript keeps its width.
115
+ const wide = new MediaQuery('(min-width: 1024px)', false);
116
+ const sidePane = $derived(variant === 'page' && wide.current);
117
+ const showArtifacts = $derived(artifactsOpen && artifacts.length > 0);
118
+ const selectedTitle = $derived(
119
+ artifacts.find((a) => a.artifactId === selectedArtifact)?.title ?? 'Artifact',
120
+ );
98
121
 
99
122
  async function send(text: string): Promise<void> {
100
123
  const attached = files;
@@ -102,9 +125,8 @@
102
125
  await session.send(text, { files: attached });
103
126
  }
104
127
  function retry(): void {
105
- const lastUser = [...session.messages].reverse().find((m) => m.role === 'user');
106
- if (lastUser && lastUser.role === 'user')
107
- void session.send(lastUser.text, { files: lastUser.attachments });
128
+ const last = session.messages[session.messages.length - 1];
129
+ if (last?.role === 'assistant') void session.retry(last.requestId);
108
130
  }
109
131
  function prompt(text: string): void {
110
132
  draft = text;
@@ -112,10 +134,11 @@
112
134
  }
113
135
  </script>
114
136
 
115
- <div class={`agent-chat agent-chat--${variant} ${className}`} data-variant={variant}>
116
- <div class="agent-chat__main">
137
+ {#snippet chat()}
138
+ <div class="flex h-full min-w-0 flex-1 flex-col">
139
+ {#if header}{@render header()}{/if}
117
140
  <ModeBanner mode={session.mode} />
118
- {#if session.loadError}<div class="agent-chat__load-error" role="alert">
141
+ {#if session.loadError}<div class="px-4 py-2 text-sm text-destructive" role="alert">
119
142
  {session.loadError}
120
143
  </div>{/if}
121
144
  <MessageList
@@ -147,42 +170,57 @@
147
170
  {context}
148
171
  />
149
172
  </div>
150
- {#if artifactsOpen && artifacts.length}
151
- <div class="agent-chat__side">
173
+ {/snippet}
174
+
175
+ <div
176
+ class={cn('flex h-full min-h-0 bg-background text-foreground', className)}
177
+ data-variant={variant}
178
+ >
179
+ {#if sidePane}
180
+ <!-- The chat stays mounted while the pane opens and closes (iframes, scroll position): a
181
+ closed pane hides the end side and the handle and gives the chat the full width. -->
182
+ <ResizablePanes
183
+ size={60}
184
+ min={35}
185
+ max={75}
186
+ persistKey="agent-chat:artifacts"
187
+ handleLabel="Resize artifact pane"
188
+ class={cn(!showArtifacts && '[&>[role=separator]]:hidden')}
189
+ startClass={cn('flex', !showArtifacts && '!basis-full')}
190
+ endClass={cn(!showArtifacts && 'hidden')}
191
+ >
192
+ {#snippet start()}{@render chat()}{/snippet}
193
+ {#snippet end()}
194
+ {#if showArtifacts}
195
+ <ArtifactPanel
196
+ {artifacts}
197
+ {sandboxOrigin}
198
+ bind:selectedId={selectedArtifact}
199
+ onclose={() => (artifactsOpen = false)}
200
+ />
201
+ {/if}
202
+ {/snippet}
203
+ </ResizablePanes>
204
+ {:else}
205
+ {@render chat()}
206
+ <Sheet
207
+ open={showArtifacts}
208
+ onOpenChange={(open) => {
209
+ artifactsOpen = open;
210
+ if (!open) releaseStuckBodyLock();
211
+ }}
212
+ side="right"
213
+ size="lg"
214
+ title={selectedTitle}
215
+ hideTitle
216
+ >
152
217
  <ArtifactPanel
153
218
  {artifacts}
154
219
  {sandboxOrigin}
155
220
  bind:selectedId={selectedArtifact}
156
- onclose={() => (artifactsOpen = false)}
221
+ class="border-l-0"
222
+ headerClass="pr-12"
157
223
  />
158
- </div>
224
+ </Sheet>
159
225
  {/if}
160
226
  </div>
161
-
162
- <style>
163
- .agent-chat {
164
- display: flex;
165
- height: 100%;
166
- min-height: 0;
167
- background: hsl(var(--background));
168
- color: hsl(var(--foreground));
169
- }
170
- .agent-chat__main {
171
- flex: 1;
172
- min-width: 0;
173
- display: flex;
174
- flex-direction: column;
175
- }
176
- .agent-chat__side {
177
- width: min(45%, 40rem);
178
- min-width: 20rem;
179
- }
180
- .agent-chat--panel .agent-chat__side {
181
- display: none;
182
- }
183
- .agent-chat__load-error {
184
- padding: 0.5rem 1rem;
185
- color: hsl(var(--destructive));
186
- font-size: 0.875rem;
187
- }
188
- </style>
@@ -14,11 +14,19 @@ interface Props {
14
14
  getContext?: () => DocumentContext | null | undefined;
15
15
  onconversationstarted?: (conversationId: string) => void;
16
16
  ontoolresult?: (name: string, status: string) => void;
17
+ /** A turn finished streaming (any outcome); refresh conversation lists here. */
18
+ onturnend?: (info: {
19
+ requestId: string;
20
+ conversationId: string | null;
21
+ status: string;
22
+ }) => void;
17
23
  onnavigate?: (href: string) => void;
18
24
  onnotify?: (message: string) => void;
19
25
  /** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
20
26
  ontoolrequest?: (toolName: string, params?: Record<string, unknown>) => void;
21
27
  placeholder?: string;
28
+ /** Rendered above the transcript, e.g. a `ChatHeader`. */
29
+ header?: import('svelte').Snippet;
22
30
  context?: import('svelte').Snippet;
23
31
  empty?: import('svelte').Snippet;
24
32
  class?: string;
@@ -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;AAQhE,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,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;AA+GH,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;AAWhE,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,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;AA6IH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -1,41 +1,58 @@
1
1
  <script lang="ts">
2
- import { Button, Icon } from '@classic-homes/theme-svelte';
2
+ import { Button, cn, Icon } from '@classic-homes/theme-svelte';
3
3
  import type { Artifact } from '../reducer.js';
4
4
  import { parseMarkdown } from '../markdown.js';
5
- import { FRAME_TARGET_ORIGIN, UI_FRAME_RENDER } from '../ui-frame-protocol.js';
5
+ import Markdown from './Markdown.svelte';
6
+ import SandboxFrame from './SandboxFrame.svelte';
6
7
 
7
8
  interface Props {
8
9
  artifacts: Artifact[];
9
10
  sandboxOrigin: string;
10
11
  selectedId?: string | null;
11
12
  onclose?: () => void;
13
+ class?: string;
14
+ headerClass?: string;
12
15
  }
13
- let { artifacts, sandboxOrigin, selectedId = $bindable(null), onclose }: Props = $props();
16
+ let {
17
+ artifacts,
18
+ sandboxOrigin,
19
+ selectedId = $bindable(null),
20
+ onclose,
21
+ class: className,
22
+ headerClass,
23
+ }: Props = $props();
14
24
  const selected = $derived(
15
25
  artifacts.find((a) => a.artifactId === selectedId) ?? artifacts[artifacts.length - 1],
16
26
  );
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
- FRAME_TARGET_ORIGIN,
28
- );
29
- }
27
+ // Markdown artifacts stream like messages; re-parsing on every token is the dominant cost, so
28
+ // throttle while the artifact is still open (same budget as MessageItem).
29
+ const PARSE_THROTTLE_MS = 120;
30
+ let markdownHtml = $state('');
31
+ let lastParse = 0;
32
+ let timer: ReturnType<typeof setTimeout> | null = null;
30
33
  $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();
34
+ if (selected?.mediaType !== 'text/markdown') return;
35
+ const text = selected.text;
36
+ const streaming = selected.open;
37
+ const now = Date.now();
38
+ if (!streaming || now - lastParse >= PARSE_THROTTLE_MS) {
39
+ if (timer) clearTimeout(timer);
40
+ timer = null;
41
+ lastParse = now;
42
+ markdownHtml = parseMarkdown(text);
43
+ } else if (!timer) {
44
+ timer = setTimeout(
45
+ () => {
46
+ timer = null;
47
+ lastParse = Date.now();
48
+ markdownHtml = parseMarkdown(selected?.text ?? '');
49
+ },
50
+ PARSE_THROTTLE_MS - (now - lastParse),
51
+ );
52
+ }
53
+ });
54
+ $effect(() => () => {
55
+ if (timer) clearTimeout(timer);
39
56
  });
40
57
 
41
58
  function mermaidPage(source: string): string {
@@ -69,93 +86,59 @@
69
86
  </script>
70
87
 
71
88
  {#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">
89
+ <aside
90
+ class={cn('flex h-full flex-col border-l border-border bg-card', className)}
91
+ aria-label="Artifacts"
92
+ >
93
+ <header class={cn('flex items-center gap-2 border-b border-border px-3 py-2', headerClass)}>
94
+ <select
95
+ class="min-w-0 flex-1 truncate rounded-md bg-transparent py-1 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
96
+ bind:value={selectedId}
97
+ aria-label="Artifact"
98
+ >
75
99
  {#each artifacts as a (a.artifactId)}
76
100
  <option value={a.artifactId}>{a.title ?? a.artifactId}</option>
77
101
  {/each}
78
102
  </select>
79
- <div class="agent-artifacts__actions">
80
- <Button variant="ghost" size="icon" aria-label="Copy" onclick={copy}
103
+ <div class="flex gap-0.5">
104
+ <Button variant="ghost" size="icon" class="size-8" aria-label="Copy" onclick={copy}
81
105
  ><Icon name="copy" size={16} /></Button
82
106
  >
83
- <Button variant="ghost" size="icon" aria-label="Download" onclick={download}
107
+ <Button variant="ghost" size="icon" class="size-8" aria-label="Download" onclick={download}
84
108
  ><Icon name="download" size={16} /></Button
85
109
  >
86
- {#if onclose}<Button variant="ghost" size="icon" aria-label="Close" onclick={onclose}
87
- ><Icon name="close" size={16} /></Button
110
+ {#if onclose}<Button
111
+ variant="ghost"
112
+ size="icon"
113
+ class="size-8"
114
+ aria-label="Close"
115
+ onclick={onclose}><Icon name="close" size={16} /></Button
88
116
  >{/if}
89
117
  </div>
90
118
  </header>
91
- <div class="agent-artifacts__body">
119
+ <div class="min-h-0 flex-1 overflow-auto p-3">
92
120
  {#if selected.mediaType === 'text/markdown'}
93
- <div class="agent-markdown">{@html parseMarkdown(selected.text)}</div>
121
+ <Markdown html={markdownHtml} />
94
122
  {:else if selected.mediaType === 'application/vnd.ant.code'}
95
- <pre class="agent-code"><code>{selected.text}</code></pre>
123
+ <pre class="overflow-auto whitespace-pre font-mono text-[13px] leading-relaxed"><code
124
+ >{selected.text}</code
125
+ ></pre>
96
126
  {: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>
127
+ <!-- html, mermaid, svg, react: only ever inside the sandbox origin. The sandbox page is
128
+ transparent and model HTML assumes a white page, so it gets a white surface. -->
129
+ <div class="h-full min-h-96 overflow-hidden rounded-md border border-border bg-white">
130
+ <SandboxFrame
131
+ {sandboxOrigin}
132
+ html={selected.mediaType === 'application/vnd.ant.mermaid'
133
+ ? mermaidPage(selected.text)
134
+ : selected.text}
135
+ uri={`artifact://${selected.artifactId}`}
136
+ title={selected.title ?? 'Artifact'}
137
+ style="height:100%"
138
+ />
139
+ </div>
106
140
  {/if}
107
- {#if selected.open}<div class="agent-artifacts__streaming">Writing…</div>{/if}
141
+ {#if selected.open}<div class="mt-2 text-xs text-muted-foreground">Writing…</div>{/if}
108
142
  </div>
109
143
  </aside>
110
144
  {/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>
@@ -4,6 +4,8 @@ interface Props {
4
4
  sandboxOrigin: string;
5
5
  selectedId?: string | null;
6
6
  onclose?: () => void;
7
+ class?: string;
8
+ headerClass?: string;
7
9
  }
8
10
  declare const ArtifactPanel: import("svelte").Component<Props, {}, "selectedId">;
9
11
  type ArtifactPanel = ReturnType<typeof ArtifactPanel>;
@@ -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;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"}
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"}
@@ -0,0 +1,122 @@
1
+ <!--
2
+ @component
3
+ Header for a chat: conversation title, agent badge and the conversation menu (rename,
4
+ open full page, delete). Pass it to `AgentChat` through the `header` snippet. Actions only
5
+ show when their handler is set; rename and delete also need a `conversationId`.
6
+ -->
7
+ <script lang="ts">
8
+ import {
9
+ Badge,
10
+ Button,
11
+ cn,
12
+ DropdownMenu,
13
+ Icon,
14
+ type DropdownMenuItem,
15
+ } from '@classic-homes/theme-svelte';
16
+ import type { Snippet } from 'svelte';
17
+ import ConversationDialogs, { type ConversationRef } from './ConversationDialogs.svelte';
18
+
19
+ interface Props {
20
+ title?: string;
21
+ conversationId?: string | null;
22
+ /** Agent name shown as a badge, e.g. "Classic Agent". */
23
+ agentLabel?: string;
24
+ /** Opens the conversation list; the button only shows below `lg`. */
25
+ onopenrail?: () => void;
26
+ onnew?: () => void;
27
+ /** Opens this conversation as a full page (panel variant). */
28
+ onexpand?: () => void;
29
+ onrename?: (conversationId: string, title: string) => void | Promise<void>;
30
+ ondelete?: (conversationId: string) => void | Promise<void>;
31
+ onerror?: (message: string) => void;
32
+ /** Extra controls before the menu. */
33
+ actions?: Snippet;
34
+ class?: string;
35
+ }
36
+ let {
37
+ title,
38
+ conversationId = null,
39
+ agentLabel,
40
+ onopenrail,
41
+ onnew,
42
+ onexpand,
43
+ onrename,
44
+ ondelete,
45
+ onerror,
46
+ actions,
47
+ class: className,
48
+ }: Props = $props();
49
+
50
+ let renaming = $state<ConversationRef | null>(null);
51
+ let deleting = $state<ConversationRef | null>(null);
52
+ let headerEl = $state<HTMLElement | null>(null);
53
+ let returnFocus = $state<HTMLElement | null>(null);
54
+ /** Remember the menu trigger before a dialog takes focus from the closing menu. */
55
+ function open(set: () => void): void {
56
+ returnFocus = headerEl?.querySelector<HTMLElement>('[aria-haspopup="menu"]') ?? null;
57
+ set();
58
+ }
59
+ const displayTitle = $derived(title?.trim() || 'New chat');
60
+ const current = $derived(conversationId ? { conversationId, title: displayTitle } : null);
61
+
62
+ const items = $derived.by<DropdownMenuItem[]>(() => {
63
+ const list: DropdownMenuItem[] = [];
64
+ if (current && onrename)
65
+ list.push({
66
+ label: 'Rename',
67
+ icon: 'edit',
68
+ onSelect: () => open(() => (renaming = current)),
69
+ });
70
+ if (onexpand) list.push({ label: 'Open full page', icon: 'expand', onSelect: onexpand });
71
+ if (current && ondelete) {
72
+ if (list.length) list.push({ type: 'separator' });
73
+ list.push({
74
+ label: 'Delete',
75
+ icon: 'trash',
76
+ destructive: true,
77
+ onSelect: () => open(() => (deleting = current)),
78
+ });
79
+ }
80
+ return list;
81
+ });
82
+ </script>
83
+
84
+ <header
85
+ bind:this={headerEl}
86
+ class={cn('flex min-h-12 items-center gap-2 border-b border-border px-3 py-2', className)}
87
+ >
88
+ {#if onopenrail}
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+ class="size-8 lg:hidden"
93
+ aria-label="Show conversations"
94
+ onclick={onopenrail}><Icon name="sidebar" size={18} /></Button
95
+ >
96
+ {/if}
97
+ <div class="flex min-w-0 flex-1 items-center gap-2">
98
+ <h2 class="truncate text-sm font-semibold" title={displayTitle}>{displayTitle}</h2>
99
+ {#if agentLabel}<Badge variant="outline" size="sm" class="shrink-0">{agentLabel}</Badge>{/if}
100
+ </div>
101
+ {#if actions}{@render actions()}{/if}
102
+ {#if onnew}
103
+ <Button variant="ghost" size="icon" class="size-8" aria-label="New chat" onclick={onnew}
104
+ ><Icon name="plus" size={18} /></Button
105
+ >
106
+ {/if}
107
+ {#if items.length}
108
+ <DropdownMenu
109
+ {items}
110
+ align="end"
111
+ triggerVariant="ghost"
112
+ triggerSize="icon"
113
+ triggerClass="size-8"
114
+ triggerLabel="Conversation options"
115
+ showChevron={false}
116
+ >
117
+ {#snippet trigger()}<Icon name="more-horizontal" size={18} />{/snippet}
118
+ </DropdownMenu>
119
+ {/if}
120
+ </header>
121
+
122
+ <ConversationDialogs bind:renaming bind:deleting {onrename} {ondelete} {onerror} {returnFocus} />