@ifc-lite/viewer 1.18.0 → 1.19.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 (61) hide show
  1. package/.turbo/turbo-build.log +19 -16
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +444 -0
  4. package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  7. package/dist/assets/{exporters-B_OBqIyD.js → exporters-xbXqEDlO.js} +2547 -1958
  8. package/dist/assets/{geometry.worker-xHHy-9DV.js → geometry.worker-DQEZB2rB.js} +1 -1
  9. package/dist/assets/ids-2WdONLlu.js +2033 -0
  10. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  11. package/dist/assets/index-BXeEKqJG.css +1 -0
  12. package/dist/assets/{index-BKq-M3Mk.js → index-D8Epw-e7.js} +51781 -32599
  13. package/dist/assets/index-XwKzDuw6.js +22 -0
  14. package/dist/assets/{native-bridge-SHXiQwFW.js → native-bridge-DKmx1z95.js} +2 -2
  15. package/dist/assets/{sandbox-jez21HtV.js → sandbox-tccwm5Bo.js} +1402 -1329
  16. package/dist/assets/{server-client-ncOQVNso.js → server-client-LoWPK1N2.js} +1 -1
  17. package/dist/assets/three-CDRZThFA.js +4057 -0
  18. package/dist/assets/{wasm-bridge-DyfBSB8z.js → wasm-bridge-BsJGgPMs.js} +1 -1
  19. package/dist/index.html +8 -7
  20. package/dist/samples/building-architecture.ifc +453 -0
  21. package/dist/samples/hello-wall.ifc +1054 -0
  22. package/dist/samples/infra-bridge.ifc +962 -0
  23. package/package.json +13 -7
  24. package/public/samples/building-architecture.ifc +453 -0
  25. package/public/samples/hello-wall.ifc +1054 -0
  26. package/public/samples/infra-bridge.ifc +962 -0
  27. package/src/App.tsx +37 -3
  28. package/src/components/mcp/HeroScene.tsx +876 -0
  29. package/src/components/mcp/McpLanding.tsx +1318 -0
  30. package/src/components/mcp/McpPlayground.tsx +524 -0
  31. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  32. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  33. package/src/components/mcp/README.md +171 -0
  34. package/src/components/mcp/data.ts +659 -0
  35. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  36. package/src/components/mcp/playground-files.ts +107 -0
  37. package/src/components/mcp/playground-uploads.ts +122 -0
  38. package/src/components/mcp/types.ts +65 -0
  39. package/src/components/mcp/use-mcp-page.ts +109 -0
  40. package/src/components/viewer/MainToolbar.tsx +23 -2
  41. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  42. package/src/components/viewer/Viewport.tsx +18 -1
  43. package/src/components/viewer/ViewportContainer.tsx +78 -9
  44. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  45. package/src/components/viewer/tools/AddElementOverlay.tsx +43 -2
  46. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  47. package/src/components/viewer/usePointCloudSync.ts +98 -0
  48. package/src/generated/mcp-catalog.json +82 -0
  49. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  50. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  51. package/src/hooks/useIfcFederation.ts +72 -3
  52. package/src/hooks/useIfcLoader.ts +67 -3
  53. package/src/services/file-dialog.ts +4 -2
  54. package/src/store/index.ts +10 -1
  55. package/src/store/slices/pointCloudSlice.ts +102 -0
  56. package/src/store/types.ts +7 -0
  57. package/vite.config.ts +7 -0
  58. package/dist/assets/basketViewActivator-Cm1QEk_R.js +0 -1
  59. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  60. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  61. package/dist/assets/index-COnQRuqY.css +0 -1
@@ -0,0 +1,1097 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * PlaygroundChat — chat panel that streams from Anthropic with the MCP
7
+ * tool catalogue exposed via Claude's native `tools` parameter.
8
+ *
9
+ * Loop:
10
+ * 1. User submits a prompt.
11
+ * 2. We POST history + tools[] to Anthropic via @anthropic-ai/sdk
12
+ * (dangerouslyAllowBrowser: true; key from BYOK localStorage).
13
+ * 3. While the response contains `tool_use` blocks AND we're under the
14
+ * 25-call hard cap: run each tool through `dispatch()`, push the
15
+ * paired `tool_result` blocks back as a new user message, ask
16
+ * Anthropic to continue.
17
+ * 4. When the response has no more tool_use blocks (or we hit the cap),
18
+ * flush the assistant message + render.
19
+ *
20
+ * Tool calls are rendered inline as collapsible cards so the agent's
21
+ * reasoning trail is the page's main signal.
22
+ */
23
+
24
+ import {
25
+ type ReactNode,
26
+ useCallback,
27
+ useEffect,
28
+ useMemo,
29
+ useRef,
30
+ useState,
31
+ } from 'react';
32
+ import Anthropic from '@anthropic-ai/sdk';
33
+ import ReactMarkdown from 'react-markdown';
34
+ import remarkGfm from 'remark-gfm';
35
+ import { ArrowUp, Check, ChevronDown, ChevronRight, Download, Key, Loader2, RefreshCcw, Wrench } from 'lucide-react';
36
+ import { cn } from '@/lib/utils';
37
+ import { getApiKeys, subscribeApiKeys, updateApiKeys, type ApiKeyConfig } from '@/services/api-keys';
38
+ import {
39
+ anthropicToolDefinitions,
40
+ dispatch,
41
+ type AnthropicToolDef,
42
+ type DispatchContext,
43
+ type LoadedPlaygroundModel,
44
+ type ToolDispatchResult,
45
+ } from './playground-dispatcher';
46
+ import { playgroundFiles, formatBytes as formatFileBytes } from './playground-files';
47
+ import { playgroundUploads, usePlaygroundUploads, type UploadedFile } from './playground-uploads';
48
+ import { Paperclip, X } from 'lucide-react';
49
+
50
+ // Default Claude model. Stays within the safe BYOK price band; users with a
51
+ // big key can swap via localStorage / a future picker.
52
+ const DEFAULT_MODEL = 'claude-sonnet-4-6';
53
+ const MAX_TOOL_CALLS = 25;
54
+ const MAX_TOKENS = 4096;
55
+ const SYSTEM_PROMPT = `You are a BIM/IFC analyst driving @ifc-lite/mcp tools against a pre-loaded model. Be terse — the user is technical and time-pressed.
56
+
57
+ Voice rules (strict):
58
+ - NO greetings, preambles, sign-offs, or apologies. Skip "Sure!", "Done!", "Here's…", "Let me know if…".
59
+ - NO emojis. Ever.
60
+ - NO unprompted follow-up menus ("Would you like to…?", "I can also…"). The user will ask if they want more.
61
+ - Aim for 1-3 short lines per reply unless the user asked for analysis.
62
+ - When a tool call already prints the result inline, your text reply can be empty or one short sentence at most.
63
+ - Markdown is fine for tables / data, but skip it for short confirmations.
64
+
65
+ Tool etiquette:
66
+ - Prefer tool calls over guessing — query_entities, get_entity, properties_unique, materials_list, geometry_volume/area, model_audit, bsdd_* are all available.
67
+ - MUTATION IS ALLOWED. The full read+write surface is wired here, not just read-only. When the user asks to set, change, add, delete, create, batch-edit, etc., go ahead — call entity_set_property, entity_set_attribute, entity_create, entity_delete, entity_delete_property, mutation_batch directly. Don't refuse, don't ask "are you sure?", don't suggest they "run this in the stdio MCP instead". Browser-local model, undo via mutation_undo, save via model_save.
68
+ - BCF authoring is also live: bcf_topic_create, bcf_topic_update, bcf_topic_close, bcf_viewpoint_create. The .bcfzip auto-stages after every BCF call — the user just clicks the pill.
69
+ - The 3D viewer is INLINE on this page. When the user asks any 3D action (open, isolate, colorize, section, fly to, etc.), call viewer_open / viewer_* DIRECTLY. Do NOT call viewer_ask first. viewer_ask is only for when YOU are proactively suggesting the viewer.
70
+ - Quote real GlobalIds + values when you cite something. Don't paraphrase.
71
+
72
+ File attachments (IDS specs etc.):
73
+ - The user can drag-drop .ids files (or any text file) onto the chat. When they do, a system note appears in their message: "[Attached file: foo.ids …]". Use ids_validate / ids_explain with ids_path: "foo.ids" — the playground resolves the upload behind the scenes. Do NOT ask the user to paste raw XML.
74
+ - If the user mentions "this IDS" / "validate against the spec" but no attachment is in this turn, ask them to drop the .ids file onto the chat (don't ask for raw XML).
75
+
76
+ Downloads (very important):
77
+ - When a tool produces a file (bcf_export, model_save, export_ifc/csv/json, ids_validate), the playground UI automatically renders an inline "Get .bcf" / "Save IFC" / etc. button under that tool call. The user clicks it explicitly — files NEVER download automatically.
78
+ - Don't tell the user "the file is in the Downloads panel" or "click the download button" — the button is right there, redundant. Just confirm what was produced in 1 line.
79
+ - BCF auto-stages: every bcf_topic_* / bcf_viewpoint_create call already produces a fresh .bcfzip pill. DO NOT call bcf_export afterwards just to "create" the download — it's already there. Only call bcf_export if the user explicitly asks to re-export with custom settings.
80
+ - After mutations, if the user is wrapping up, suggest model_save ONCE so they can grab the edited IFC. Don't keep re-suggesting.`;
81
+
82
+ // ── message model ──────────────────────────────────────────────────────────
83
+
84
+ interface ChatToolCall {
85
+ id: string;
86
+ name: string;
87
+ args: Record<string, unknown>;
88
+ result?: ToolDispatchResult;
89
+ startedAt: number;
90
+ finishedAt?: number;
91
+ }
92
+
93
+ interface ChatMessage {
94
+ id: string;
95
+ role: 'user' | 'assistant';
96
+ text: string;
97
+ toolCalls?: ChatToolCall[];
98
+ /** True while we're still streaming + looping tool calls. */
99
+ pending?: boolean;
100
+ }
101
+
102
+ // ── component ──────────────────────────────────────────────────────────────
103
+
104
+ export function PlaygroundChat({
105
+ model,
106
+ dispatchContext,
107
+ }: {
108
+ model: LoadedPlaygroundModel | null;
109
+ /** Lets the chat thread the live viewer controller (etc.) into every
110
+ * tool call so viewer_* tools can drive the inline canvas. */
111
+ dispatchContext?: () => DispatchContext;
112
+ }): ReactNode {
113
+ const [keys, setKeys] = useState<ApiKeyConfig>(() => getApiKeys());
114
+ useEffect(() => subscribeApiKeys(() => setKeys(getApiKeys())), []);
115
+
116
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
117
+ const [input, setInput] = useState('');
118
+ const [isStreaming, setStreaming] = useState(false);
119
+ const [error, setError] = useState<string | null>(null);
120
+ const scrollRef = useRef<HTMLDivElement>(null);
121
+ const fileInputRef = useRef<HTMLInputElement>(null);
122
+ const [dragOver, setDragOver] = useState(false);
123
+ // Files attached since the last send. They land in the playgroundUploads
124
+ // store (so the dispatcher can resolve them by name) AND get listed in
125
+ // a "to send" array we drain on each submit.
126
+ const uploads = usePlaygroundUploads();
127
+ const [pendingAttachments, setPendingAttachments] = useState<UploadedFile[]>([]);
128
+
129
+ // Auto-scroll on new content
130
+ useEffect(() => {
131
+ const el = scrollRef.current;
132
+ if (!el) return;
133
+ el.scrollTop = el.scrollHeight;
134
+ }, [messages]);
135
+
136
+ const tools = useMemo(() => anthropicToolDefinitions(), []);
137
+
138
+ const send = useCallback(
139
+ async (prompt: string, attached: UploadedFile[]) => {
140
+ if (!model) {
141
+ setError('Load a sample model first.');
142
+ return;
143
+ }
144
+ if (!keys.anthropicKey) {
145
+ setError('Set an Anthropic key (top right).');
146
+ return;
147
+ }
148
+ setError(null);
149
+ setStreaming(true);
150
+
151
+ // Prepend a tiny system-note prefix when files were attached so the
152
+ // agent knows the upload exists and how to reference it. Per-kind
153
+ // hints so the agent picks the right tool without guessing.
154
+ const attachNote = attached.length > 0
155
+ ? attached.map((u) => describeAttachment(u)).join('\n') + '\n\n'
156
+ : '';
157
+ const fullPrompt = attachNote + prompt;
158
+
159
+ const userMessage: ChatMessage = { id: rid(), role: 'user', text: fullPrompt };
160
+ const assistantMessage: ChatMessage = {
161
+ id: rid(),
162
+ role: 'assistant',
163
+ text: '',
164
+ toolCalls: [],
165
+ pending: true,
166
+ };
167
+ setMessages((m) => [...m, userMessage, assistantMessage]);
168
+
169
+ try {
170
+ await runConversation({
171
+ apiKey: keys.anthropicKey,
172
+ tools,
173
+ history: [...messages, userMessage],
174
+ model,
175
+ assistantId: assistantMessage.id,
176
+ getDispatchContext: dispatchContext ?? (() => ({})),
177
+ onUpdate: (patch) => {
178
+ setMessages((m) =>
179
+ m.map((msg) => (msg.id === assistantMessage.id ? { ...msg, ...patch } : msg)),
180
+ );
181
+ },
182
+ });
183
+ } catch (err) {
184
+ setMessages((m) =>
185
+ m.map((msg) =>
186
+ msg.id === assistantMessage.id
187
+ ? { ...msg, pending: false, text: msg.text || '— request failed —' }
188
+ : msg,
189
+ ),
190
+ );
191
+ setError(err instanceof Error ? err.message : String(err));
192
+ } finally {
193
+ setStreaming(false);
194
+ }
195
+ },
196
+ [keys.anthropicKey, model, tools, messages, dispatchContext],
197
+ );
198
+
199
+ const onSubmit = (e: React.FormEvent) => {
200
+ e.preventDefault();
201
+ const trimmed = input.trim();
202
+ if ((!trimmed && pendingAttachments.length === 0) || isStreaming) return;
203
+ const attachedThisTurn = pendingAttachments;
204
+ setInput('');
205
+ setPendingAttachments([]);
206
+ void send(trimmed || '(see attached file)', attachedThisTurn);
207
+ };
208
+
209
+ const attachFiles = useCallback(async (files: FileList | File[]) => {
210
+ const list: UploadedFile[] = [];
211
+ for (const f of Array.from(files)) {
212
+ // 25 MB cap so a small IFC fits, but blocks rogue gigabyte drops.
213
+ if (f.size > 25 * 1024 * 1024) {
214
+ setError(`${f.name} is over 25 MB — too large for chat attachments. Use the sample picker instead.`);
215
+ continue;
216
+ }
217
+ try {
218
+ const entry = await playgroundUploads.add(f);
219
+ list.push(entry);
220
+ } catch (err) {
221
+ setError(`Failed to read ${f.name}: ${err instanceof Error ? err.message : String(err)}`);
222
+ }
223
+ }
224
+ if (list.length > 0) setPendingAttachments((prev) => [...prev, ...list]);
225
+ }, []);
226
+
227
+ /** Per-kind system note so the agent knows what to do with the file
228
+ * without having to guess from the extension. */
229
+ function describeAttachment(u: UploadedFile): string {
230
+ const ext = u.name.toLowerCase().split('.').pop() ?? '';
231
+ const head = `[Attached: ${u.name} · ${formatFileBytes(u.size)}]`;
232
+ switch (ext) {
233
+ case 'ids':
234
+ return `${head} — IDS spec. Call ids_validate / ids_explain with ids_path: "${u.name}".`;
235
+ case 'csv':
236
+ case 'tsv': {
237
+ // Inline a small preview so the agent can summarise without a
238
+ // dedicated read_file tool. Cap at ~16 lines / 2 KB.
239
+ const preview = u.text.split('\n').slice(0, 16).join('\n').slice(0, 2048);
240
+ return `${head} — CSV data. First lines:\n\`\`\`\n${preview}\n\`\`\``;
241
+ }
242
+ case 'json': {
243
+ const preview = u.text.slice(0, 2048);
244
+ return `${head} — JSON. Preview:\n\`\`\`json\n${preview}${u.text.length > 2048 ? '\n…' : ''}\n\`\`\``;
245
+ }
246
+ case 'ifc':
247
+ return `${head} — IFC file. The playground's loaded model is the primary one; treat this as background reference. Don't try to ingest it as a second model in v1.`;
248
+ case 'bcf':
249
+ case 'bcfzip':
250
+ return `${head} — BCF bundle. The playground can only WRITE BCF in v1; tell the user this is read-only context.`;
251
+ case 'xml':
252
+ return `${head} — XML. If it looks like IDS, call ids_validate with ids_path: "${u.name}".`;
253
+ default: {
254
+ const preview = u.text.slice(0, 1024);
255
+ return `${head}\n\`\`\`\n${preview}${u.text.length > 1024 ? '\n…' : ''}\n\`\`\``;
256
+ }
257
+ }
258
+ }
259
+
260
+ // ── render ──────────────────────────────────────────────────────────────
261
+ return (
262
+ <div className="flex h-full min-h-0 flex-col bg-[#0f0f12] text-[#ede4d3]">
263
+ <KeyHeader keys={keys} onSave={(next) => updateApiKeys(next)} />
264
+
265
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-5">
266
+ {messages.length === 0 ? (
267
+ <Welcome model={model} onPickPrompt={(p) => setInput(p)} />
268
+ ) : (
269
+ <ul className="flex flex-col gap-5">
270
+ {messages.map((m) => (
271
+ <li key={m.id}>
272
+ <MessageView msg={m} />
273
+ </li>
274
+ ))}
275
+ </ul>
276
+ )}
277
+ </div>
278
+
279
+ {error && (
280
+ <div className="mx-5 mb-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[12px] text-red-200">
281
+ {error}
282
+ </div>
283
+ )}
284
+
285
+ <form
286
+ onSubmit={onSubmit}
287
+ onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
288
+ onDragLeave={() => setDragOver(false)}
289
+ onDrop={(e) => {
290
+ e.preventDefault();
291
+ setDragOver(false);
292
+ if (e.dataTransfer.files.length > 0) void attachFiles(e.dataTransfer.files);
293
+ }}
294
+ className={cn('relative border-t p-3 transition-colors', dragOver ? 'border-[#d6ff3f]/60 bg-[#d6ff3f]/5' : 'border-white/10')}
295
+ >
296
+ {/* Pending attachment chips */}
297
+ {pendingAttachments.length > 0 && (
298
+ <div className="mb-2 flex flex-wrap gap-1.5">
299
+ {pendingAttachments.map((f) => (
300
+ <span
301
+ key={f.name}
302
+ className="inline-flex items-center gap-1.5 rounded-full border border-[#d6ff3f]/30 bg-[#d6ff3f]/10 px-2 py-1 text-[11px]"
303
+ style={{ ...{ fontFamily: '"JetBrains Mono", monospace' }, color: '#d6ff3f' }}
304
+ >
305
+ <Paperclip size={11} />
306
+ <span className="max-w-[180px] truncate" title={f.name}>{f.name}</span>
307
+ <span className="text-white/40">{formatFileBytes(f.size)}</span>
308
+ <button
309
+ type="button"
310
+ onClick={() => {
311
+ playgroundUploads.remove(f.name);
312
+ setPendingAttachments((prev) => prev.filter((x) => x.name !== f.name));
313
+ }}
314
+ className="ml-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-white/50 hover:bg-white/10 hover:text-white"
315
+ aria-label={`Remove ${f.name}`}
316
+ >
317
+ <X size={10} />
318
+ </button>
319
+ </span>
320
+ ))}
321
+ </div>
322
+ )}
323
+
324
+ <div className="flex items-end gap-2 rounded-md border border-white/15 bg-white/[0.03] px-2.5 py-2">
325
+ {/* Attach */}
326
+ <button
327
+ type="button"
328
+ onClick={() => fileInputRef.current?.click()}
329
+ className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded text-white/55 hover:bg-white/5 hover:text-white"
330
+ title="Attach a file (.ids, .xml, …)"
331
+ >
332
+ <Paperclip size={14} />
333
+ </button>
334
+ <input
335
+ ref={fileInputRef}
336
+ type="file"
337
+ multiple
338
+ accept=".ids,.xml,.json,.csv,.txt,.ifc,.bcf,.bcfzip,.md"
339
+ className="hidden"
340
+ onChange={(e) => {
341
+ if (e.target.files) {
342
+ void attachFiles(e.target.files);
343
+ e.target.value = ''; // allow re-attaching the same file
344
+ }
345
+ }}
346
+ />
347
+ <textarea
348
+ value={input}
349
+ onChange={(e) => setInput(e.target.value)}
350
+ onKeyDown={(e) => {
351
+ if (e.key === 'Enter' && !e.shiftKey) {
352
+ e.preventDefault();
353
+ onSubmit(e);
354
+ }
355
+ }}
356
+ placeholder={
357
+ !model
358
+ ? 'Load a sample model first.'
359
+ : !keys.anthropicKey
360
+ ? 'Set an Anthropic key first.'
361
+ : pendingAttachments.length > 0
362
+ ? 'Add a note (or just send to validate the attached file)…'
363
+ : 'Ask the agent — drop a .ids onto the chat to validate it.'
364
+ }
365
+ disabled={!model || isStreaming}
366
+ rows={1}
367
+ className="min-h-[28px] max-h-32 flex-1 resize-none bg-transparent text-[14px] outline-none placeholder:text-white/30"
368
+ style={{ fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' }}
369
+ />
370
+ <button
371
+ type="submit"
372
+ disabled={(!input.trim() && pendingAttachments.length === 0) || isStreaming || !model || !keys.anthropicKey}
373
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-[#d6ff3f] text-[#0a0a0c] transition-opacity disabled:opacity-30"
374
+ aria-label="Send"
375
+ >
376
+ {isStreaming ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} strokeWidth={2.5} />}
377
+ </button>
378
+ </div>
379
+ <p className="mt-1.5 text-[10px] text-white/40">
380
+ BYOK · {tools.length} tools · {uploads.length > 0 ? `${uploads.length} attached file${uploads.length === 1 ? '' : 's'} · ` : ''}enter to send · ⇧+enter for newline · drop files to attach
381
+ </p>
382
+ {dragOver && (
383
+ <div
384
+ className="pointer-events-none absolute inset-2 flex items-center justify-center rounded-md border-2 border-dashed text-[12px]"
385
+ style={{ borderColor: '#d6ff3f', color: '#d6ff3f', background: 'rgba(214,255,63,0.05)', fontFamily: '"JetBrains Mono", monospace' }}
386
+ >
387
+ release to attach
388
+ </div>
389
+ )}
390
+ </form>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ // ── header / key entry ─────────────────────────────────────────────────────
396
+
397
+ function KeyHeader({
398
+ keys,
399
+ onSave,
400
+ }: {
401
+ keys: ApiKeyConfig;
402
+ onSave: (next: Partial<ApiKeyConfig>) => void;
403
+ }): ReactNode {
404
+ const [editing, setEditing] = useState(false);
405
+ const [draft, setDraft] = useState(keys.anthropicKey);
406
+ useEffect(() => setDraft(keys.anthropicKey), [keys.anthropicKey]);
407
+ const masked = keys.anthropicKey
408
+ ? `${keys.anthropicKey.slice(0, 7)}…${keys.anthropicKey.slice(-4)}`
409
+ : '';
410
+ return (
411
+ <div className="flex items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5">
412
+ <div className="flex items-center gap-2">
413
+ <span
414
+ className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[#d6ff3f]"
415
+ aria-hidden
416
+ />
417
+ <span className="text-[10px] uppercase tracking-[0.22em] text-white/60" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
418
+ ifc-lite/mcp · agent
419
+ </span>
420
+ </div>
421
+ {editing ? (
422
+ <form
423
+ onSubmit={(e) => {
424
+ e.preventDefault();
425
+ onSave({ anthropicKey: draft.trim() });
426
+ setEditing(false);
427
+ }}
428
+ className="flex items-center gap-1.5"
429
+ >
430
+ <input
431
+ type="password"
432
+ value={draft}
433
+ onChange={(e) => setDraft(e.target.value)}
434
+ placeholder="sk-ant-…"
435
+ className="w-56 rounded border border-white/20 bg-white/5 px-2 py-1 text-[11px] outline-none placeholder:text-white/30"
436
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
437
+ autoFocus
438
+ />
439
+ <button type="submit" className="rounded bg-[#d6ff3f] px-2 py-1 text-[10px] font-semibold text-[#0a0a0c]">
440
+ save
441
+ </button>
442
+ <button type="button" onClick={() => setEditing(false)} className="text-[10px] text-white/50">
443
+ cancel
444
+ </button>
445
+ </form>
446
+ ) : (
447
+ <button
448
+ onClick={() => setEditing(true)}
449
+ className={cn(
450
+ 'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10.5px]',
451
+ keys.anthropicKey
452
+ ? 'border-[#d6ff3f]/40 text-[#d6ff3f]'
453
+ : 'border-orange-400/40 text-orange-300',
454
+ )}
455
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
456
+ >
457
+ <Key size={11} />
458
+ {keys.anthropicKey ? `key set · ${masked}` : 'set Anthropic key'}
459
+ </button>
460
+ )}
461
+ </div>
462
+ );
463
+ }
464
+
465
+ // ── welcome ───────────────────────────────────────────────────────────────
466
+
467
+ const STARTER_PROMPTS = [
468
+ 'Run model_audit and tell me the score. Then list any issues.',
469
+ 'How many IfcWall vs IfcWindow vs IfcDoor are in this model?',
470
+ 'Find every IfcWall where Pset_WallCommon.IsExternal = true. Tell me their GlobalIds.',
471
+ 'Look up Pset_WallCommon in bSDD and list its canonical properties.',
472
+ 'Group the entities by storey. Which storey has the most elements?',
473
+ ];
474
+
475
+ function Welcome({
476
+ model,
477
+ onPickPrompt,
478
+ }: {
479
+ model: LoadedPlaygroundModel | null;
480
+ onPickPrompt: (p: string) => void;
481
+ }): ReactNode {
482
+ return (
483
+ <div className="flex h-full flex-col items-start justify-center gap-4">
484
+ <div>
485
+ <h2
486
+ className="text-[28px] leading-none tracking-tight"
487
+ style={{ fontFamily: '"Instrument Serif", serif', fontStyle: 'italic' }}
488
+ >
489
+ {model ? `Ask the agent about ${model.name}.` : 'Load a model. Ask the agent.'}
490
+ </h2>
491
+ <p className="mt-2 max-w-md text-[13.5px] leading-snug text-white/60">
492
+ Claude drives the same {anthropicToolDefinitions().length} tools the stdio MCP exposes — query, mutate, validate, BCF, export. Tool calls render inline.
493
+ </p>
494
+ </div>
495
+ {model && (
496
+ <div className="mt-2 flex flex-col gap-1.5">
497
+ <span className="text-[10px] uppercase tracking-[0.22em] text-white/40" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
498
+ try
499
+ </span>
500
+ <div className="flex flex-col gap-1.5">
501
+ {STARTER_PROMPTS.map((p) => (
502
+ <button
503
+ key={p}
504
+ onClick={() => onPickPrompt(p)}
505
+ className="text-left text-[12.5px] leading-snug text-white/80 underline-offset-4 hover:text-[#d6ff3f] hover:underline"
506
+ >
507
+ ↳ {p}
508
+ </button>
509
+ ))}
510
+ </div>
511
+ </div>
512
+ )}
513
+ </div>
514
+ );
515
+ }
516
+
517
+ // ── message render ────────────────────────────────────────────────────────
518
+
519
+ function MessageView({ msg }: { msg: ChatMessage }): ReactNode {
520
+ if (msg.role === 'user') {
521
+ return (
522
+ <div className="flex flex-col items-end">
523
+ <div className="max-w-[85%] rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-[13.5px] leading-snug">
524
+ {msg.text}
525
+ </div>
526
+ </div>
527
+ );
528
+ }
529
+ // The auto-staged BCF bundle reuses ONE fileId across many tool calls
530
+ // (topic_create → topic_update → viewpoint_create → bcf_export all bump
531
+ // the same blob). Render the inline `Get .bcfzip` pill only on the LAST
532
+ // call that produced each fileId, otherwise we get a wall of duplicate
533
+ // download buttons that all point at the same artifact.
534
+ const lastDownloadIdx = new Map<string, number>();
535
+ msg.toolCalls?.forEach((tc, i) => {
536
+ const fid = tc.result?.download?.fileId;
537
+ if (fid) lastDownloadIdx.set(fid, i);
538
+ });
539
+ return (
540
+ <div className="flex flex-col items-start gap-2">
541
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
542
+ <ul className="flex w-full flex-col gap-1.5">
543
+ {msg.toolCalls.map((tc, i) => {
544
+ const fid = tc.result?.download?.fileId;
545
+ const showDownload = !fid || lastDownloadIdx.get(fid) === i;
546
+ return (
547
+ <li key={tc.id}>
548
+ <ToolCallView call={tc} showDownload={showDownload} />
549
+ </li>
550
+ );
551
+ })}
552
+ </ul>
553
+ )}
554
+ {msg.text && (
555
+ <div className="prose-playground max-w-[95%] text-[13.5px] leading-relaxed text-white/95">
556
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
557
+ {msg.text}
558
+ </ReactMarkdown>
559
+ {msg.pending && <span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-[#d6ff3f]" />}
560
+ </div>
561
+ )}
562
+ {msg.pending && !msg.text && msg.toolCalls && msg.toolCalls.length > 0 && (
563
+ <div className="text-[11px] text-white/40">… composing answer …</div>
564
+ )}
565
+ {msg.pending && !msg.text && (!msg.toolCalls || msg.toolCalls.length === 0) && (
566
+ <div className="text-[11px] text-white/40">… thinking …</div>
567
+ )}
568
+ </div>
569
+ );
570
+ }
571
+
572
+ function ToolCallView({ call, showDownload = true }: { call: ChatToolCall; showDownload?: boolean }): ReactNode {
573
+ const [open, setOpen] = useState(false);
574
+ const isErr = call.result?.isError;
575
+ const ms = call.finishedAt ? call.finishedAt - call.startedAt : null;
576
+ const download = showDownload ? call.result?.download : undefined;
577
+ return (
578
+ <div
579
+ className={cn(
580
+ 'rounded-md border text-[12.5px]',
581
+ isErr ? 'border-red-500/40 bg-red-500/[0.04]' : 'border-white/10 bg-white/[0.025]',
582
+ )}
583
+ >
584
+ <button
585
+ onClick={() => setOpen((o) => !o)}
586
+ className="flex w-full items-center gap-2 px-3 py-2 text-left"
587
+ >
588
+ {open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
589
+ <Wrench size={12} className={isErr ? 'text-red-400' : 'text-[#d6ff3f]'} />
590
+ <code className="font-mono text-[12px]" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
591
+ {call.name}
592
+ </code>
593
+ <span className="ml-auto inline-flex items-center gap-2 text-[10.5px] text-white/40">
594
+ {call.result == null ? (
595
+ <Loader2 size={10} className="animate-spin" />
596
+ ) : (
597
+ <>
598
+ {ms != null && <span style={{ fontFamily: '"JetBrains Mono", monospace' }}>{ms} ms</span>}
599
+ <span
600
+ className={cn(
601
+ 'rounded px-1 py-px uppercase tracking-[0.15em]',
602
+ isErr ? 'bg-red-500/15 text-red-300' : 'bg-[#d6ff3f]/15 text-[#d6ff3f]',
603
+ )}
604
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
605
+ >
606
+ {isErr ? call.result.errorCode ?? 'error' : 'ok'}
607
+ </span>
608
+ </>
609
+ )}
610
+ </span>
611
+ </button>
612
+ {/* Inline download offer — present whenever the tool produced an
613
+ artifact. Lives between the header and the collapsible details so
614
+ it's never hidden behind a "click to expand" gesture. The
615
+ download is opt-in: it ONLY fires when this button is clicked. */}
616
+ {download && <InlineDownload download={download} />}
617
+ {open && (
618
+ <div className="border-t border-white/10 px-3 py-2.5">
619
+ {Object.keys(call.args).length > 0 && (
620
+ <>
621
+ <div className="mb-1 text-[10px] uppercase tracking-[0.22em] text-white/40" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
622
+ args
623
+ </div>
624
+ <pre
625
+ className="mb-3 overflow-x-auto rounded bg-black/40 p-2 text-[11px]"
626
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
627
+ >
628
+ {JSON.stringify(call.args, null, 2)}
629
+ </pre>
630
+ </>
631
+ )}
632
+ {call.result && (
633
+ <>
634
+ <div className="mb-1 text-[10px] uppercase tracking-[0.22em] text-white/40" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
635
+ result
636
+ </div>
637
+ <pre
638
+ className={cn(
639
+ 'overflow-x-auto whitespace-pre-wrap rounded p-2 text-[11px] leading-snug',
640
+ isErr ? 'bg-red-500/10 text-red-200' : 'bg-black/40 text-white/85',
641
+ )}
642
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
643
+ >
644
+ {call.result.text}
645
+ </pre>
646
+ {call.result.hint && (
647
+ <p className="mt-1.5 text-[10.5px] italic text-white/50">{call.result.hint}</p>
648
+ )}
649
+ </>
650
+ )}
651
+ </div>
652
+ )}
653
+ </div>
654
+ );
655
+ }
656
+
657
+ // ── conversation runner ───────────────────────────────────────────────────
658
+
659
+ interface AnthropicTextBlock {
660
+ type: 'text';
661
+ text: string;
662
+ }
663
+ interface AnthropicToolUseBlock {
664
+ type: 'tool_use';
665
+ id: string;
666
+ name: string;
667
+ input: Record<string, unknown>;
668
+ }
669
+ type AnthropicAssistantBlock = AnthropicTextBlock | AnthropicToolUseBlock;
670
+
671
+ interface AnthropicToolResultBlock {
672
+ type: 'tool_result';
673
+ tool_use_id: string;
674
+ content: string;
675
+ is_error?: boolean;
676
+ }
677
+
678
+ interface RunOpts {
679
+ apiKey: string;
680
+ tools: AnthropicToolDef[];
681
+ history: ChatMessage[];
682
+ model: LoadedPlaygroundModel;
683
+ assistantId: string;
684
+ getDispatchContext: () => DispatchContext;
685
+ onUpdate: (patch: Partial<ChatMessage>) => void;
686
+ }
687
+
688
+ type ApiMessage =
689
+ | { role: 'user'; content: string | Array<AnthropicToolResultBlock | { type: 'text'; text: string }> }
690
+ | { role: 'assistant'; content: AnthropicAssistantBlock[] };
691
+
692
+ /**
693
+ * Rebuild the Anthropic message list from React state.
694
+ *
695
+ * Anthropic's hard contract: every `tool_use` block in an assistant message
696
+ * MUST be followed by a user message whose first blocks are matching
697
+ * `tool_result`s. The naive shape (assistant turn → standalone user
698
+ * tool_result message → standalone user text message) breaks that contract
699
+ * the moment the user asks a follow-up after a tool round, because the
700
+ * NEW user text lands AFTER the tool_result, separating it from the
701
+ * tool_use by an extra turn (and yielding a double-user pair Anthropic
702
+ * also rejects).
703
+ *
704
+ * Fix: merge an assistant turn's pending tool_results into the very next
705
+ * user turn as combined blocks (`[tool_result_*…, { type:'text', text }]`).
706
+ * If no next user turn exists yet (mid-conversation, agent still wrapping
707
+ * up), fall through to a standalone tool_result user message — that is
708
+ * the in-loop intermediate shape and Anthropic accepts it.
709
+ */
710
+ function buildApiMessages(history: ChatMessage[]): ApiMessage[] {
711
+ const out: ApiMessage[] = [];
712
+ let i = 0;
713
+ while (i < history.length) {
714
+ const m = history[i];
715
+ if (m.role === 'user') {
716
+ out.push({ role: 'user', content: m.text });
717
+ i += 1;
718
+ continue;
719
+ }
720
+
721
+ // role === 'assistant'
722
+ //
723
+ // CRITICAL block ordering: Anthropic's tool-use protocol expects
724
+ // `text` (and `thinking`) blocks BEFORE `tool_use` blocks within an
725
+ // assistant turn. If a `text` block follows a `tool_use` block in the
726
+ // same turn, the API treats the tool_use as cancelled by the model's
727
+ // subsequent reasoning and rejects the next user message's
728
+ // tool_result with "tool_use ids were found without tool_result
729
+ // blocks immediately after". We were building [tool_use, text] which
730
+ // tripped exactly that check; the docs example sequences as
731
+ // [text, tool_use_1, tool_use_2, …]. Mirror that here.
732
+ const blocks: AnthropicAssistantBlock[] = [];
733
+ if (m.text) blocks.push({ type: 'text', text: m.text });
734
+ if (m.toolCalls) {
735
+ for (const tc of m.toolCalls) {
736
+ blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args });
737
+ }
738
+ }
739
+ if (blocks.length > 0) out.push({ role: 'assistant', content: blocks });
740
+
741
+ // Anthropic requires every tool_use to be paired with a tool_result in
742
+ // the next user message. Synthesise a stub for any that finished
743
+ // without a recorded result (HMR cleared state, dispatch interrupted,
744
+ // etc.) so we never emit a malformed sequence.
745
+ const results: AnthropicToolResultBlock[] = (m.toolCalls ?? []).map((tc) => ({
746
+ type: 'tool_result',
747
+ tool_use_id: tc.id,
748
+ content: tc.result?.text ?? '(tool call did not complete in this session — ignore and continue)',
749
+ is_error: tc.result?.isError ?? true,
750
+ }));
751
+
752
+ if (results.length === 0) {
753
+ i += 1;
754
+ continue;
755
+ }
756
+
757
+ // Try to fold the next user turn's text into the same user message
758
+ // so we never create a double-user pair (which Anthropic also rejects).
759
+ const next = history[i + 1];
760
+ if (next && next.role === 'user') {
761
+ out.push({
762
+ role: 'user',
763
+ content: [...results, { type: 'text', text: next.text }],
764
+ });
765
+ i += 2;
766
+ } else {
767
+ out.push({ role: 'user', content: results });
768
+ i += 1;
769
+ }
770
+ }
771
+ return out;
772
+ }
773
+
774
+ /**
775
+ * Walk the apiMessages list and confirm every `tool_use` block has a
776
+ * matching `tool_result` block in the immediately-following user message.
777
+ * Throws (caught by runConversation) if not. This exists so we fail in
778
+ * code we can fix instead of mysteriously erroring at Anthropic.
779
+ */
780
+ function assertToolUseShape(messages: ApiMessage[]): void {
781
+ for (let i = 0; i < messages.length; i++) {
782
+ const m = messages[i];
783
+ if (m.role !== 'assistant') continue;
784
+ const toolUseIds: string[] = [];
785
+ for (const block of m.content) {
786
+ if (typeof block === 'object' && block.type === 'tool_use') toolUseIds.push((block as { id: string }).id);
787
+ }
788
+ if (toolUseIds.length === 0) continue;
789
+ const next = messages[i + 1];
790
+ if (!next || next.role !== 'user' || typeof next.content === 'string') {
791
+ throw new Error(`assistant turn ${i} has tool_use but next turn isn't a user-with-blocks message`);
792
+ }
793
+ const resultIds = new Set<string>();
794
+ for (const block of next.content) {
795
+ if (typeof block === 'object' && block.type === 'tool_result') {
796
+ resultIds.add((block as { tool_use_id: string }).tool_use_id);
797
+ }
798
+ }
799
+ const missing = toolUseIds.filter((id) => !resultIds.has(id));
800
+ if (missing.length > 0) {
801
+ throw new Error(`tool_use without matching tool_result at turn ${i}: ${missing.join(', ')}`);
802
+ }
803
+ }
804
+ }
805
+
806
+ async function runConversation(opts: RunOpts): Promise<void> {
807
+ const client = new Anthropic({ apiKey: opts.apiKey, dangerouslyAllowBrowser: true });
808
+ const apiMessages = buildApiMessages(opts.history);
809
+
810
+ // Compact, opt-in diagnostic logging. The previous version dumped the
811
+ // full apiMessages payload (including raw user prompts, attachment text,
812
+ // tool args, and tool results) on every request — fine for development,
813
+ // but a privacy footgun for a BYOK feature where users own the API key
814
+ // and don't expect their conversation to land in browser devtools.
815
+ // Anything heavier than the one-line summary is now gated behind a
816
+ // localStorage flag the user has to set explicitly.
817
+ const wantsVerbose = (() => {
818
+ try {
819
+ return typeof window !== 'undefined' && window.localStorage?.getItem('ifclite-playground-debug') === '1';
820
+ } catch {
821
+ return false;
822
+ }
823
+ })();
824
+ // eslint-disable-next-line no-console
825
+ console.debug(`[playground-chat] → ${apiMessages.length} messages`);
826
+ if (wantsVerbose) {
827
+ // eslint-disable-next-line no-console
828
+ console.groupCollapsed('[playground-chat] full payload (debug)');
829
+ // eslint-disable-next-line no-console
830
+ console.log(JSON.parse(JSON.stringify(apiMessages)));
831
+ // eslint-disable-next-line no-console
832
+ console.groupEnd();
833
+ }
834
+
835
+ try {
836
+ assertToolUseShape(apiMessages);
837
+ } catch (err) {
838
+ // eslint-disable-next-line no-console
839
+ console.error('[playground-chat] FAILED tool-use shape assertion before sending:', err);
840
+ throw err;
841
+ }
842
+
843
+ const accumulated: { text: string; toolCalls: ChatToolCall[] } = { text: '', toolCalls: [] };
844
+ let toolCallCount = 0;
845
+
846
+ // Loop: each iteration is a single Anthropic round-trip. Stops when the
847
+ // assistant finishes without requesting more tools, or when we hit the cap.
848
+ while (true) {
849
+ const res = await client.messages.create({
850
+ model: DEFAULT_MODEL,
851
+ max_tokens: MAX_TOKENS,
852
+ system: SYSTEM_PROMPT,
853
+ tools: opts.tools as unknown as Parameters<typeof client.messages.create>[0]['tools'],
854
+ messages: apiMessages as Parameters<typeof client.messages.create>[0]['messages'],
855
+ });
856
+
857
+ const blocks = res.content as AnthropicAssistantBlock[];
858
+ const newToolCalls: ChatToolCall[] = [];
859
+ let newText = '';
860
+ for (const block of blocks) {
861
+ if (block.type === 'text') newText += block.text;
862
+ else if (block.type === 'tool_use') {
863
+ newToolCalls.push({
864
+ id: block.id,
865
+ name: block.name,
866
+ args: block.input ?? {},
867
+ startedAt: Date.now(),
868
+ });
869
+ }
870
+ }
871
+
872
+ if (newText) accumulated.text += (accumulated.text ? '\n' : '') + newText;
873
+ if (newToolCalls.length > 0) accumulated.toolCalls.push(...newToolCalls);
874
+ opts.onUpdate({ text: accumulated.text, toolCalls: accumulated.toolCalls });
875
+
876
+ // Push the assistant turn into the rolling history regardless of whether
877
+ // there are more tools — Anthropic requires the full assistant block list.
878
+ apiMessages.push({ role: 'assistant', content: blocks });
879
+
880
+ if (newToolCalls.length === 0 || res.stop_reason !== 'tool_use') {
881
+ // Done.
882
+ opts.onUpdate({ text: accumulated.text, toolCalls: accumulated.toolCalls, pending: false });
883
+ return;
884
+ }
885
+
886
+ if (toolCallCount + newToolCalls.length > MAX_TOOL_CALLS) {
887
+ // Cap reached — synthesise an error tool_result so Claude can wrap up.
888
+ const cap: AnthropicToolResultBlock[] = newToolCalls.map((tc) => ({
889
+ type: 'tool_result',
890
+ tool_use_id: tc.id,
891
+ content: `Stopped: this playground caps at ${MAX_TOOL_CALLS} tool calls per request. Summarise what you have so far.`,
892
+ is_error: true,
893
+ }));
894
+ apiMessages.push({ role: 'user', content: cap });
895
+ for (const tc of newToolCalls) {
896
+ tc.result = {
897
+ text: `Stopped at cap (${MAX_TOOL_CALLS}).`,
898
+ structured: null,
899
+ isError: true,
900
+ errorCode: 'CAP_REACHED',
901
+ };
902
+ tc.finishedAt = Date.now();
903
+ }
904
+ opts.onUpdate({ toolCalls: accumulated.toolCalls });
905
+ // Continue once more so Claude can produce a final text answer.
906
+ continue;
907
+ }
908
+
909
+ // Run each tool, build tool_result blocks. Re-pull the dispatch context
910
+ // every iteration so the viewer controller stays fresh if the user
911
+ // mounts/unmounts the panel mid-conversation.
912
+ const results: AnthropicToolResultBlock[] = [];
913
+ for (const tc of newToolCalls) {
914
+ const ctx = opts.getDispatchContext();
915
+ const dispatched = await dispatch(opts.model, tc.name, tc.args, ctx);
916
+ tc.result = dispatched;
917
+ tc.finishedAt = Date.now();
918
+ results.push({
919
+ type: 'tool_result',
920
+ tool_use_id: tc.id,
921
+ content: dispatched.text,
922
+ is_error: dispatched.isError,
923
+ });
924
+ }
925
+ toolCallCount += newToolCalls.length;
926
+ opts.onUpdate({ toolCalls: accumulated.toolCalls });
927
+ apiMessages.push({ role: 'user', content: results });
928
+ }
929
+ }
930
+
931
+ function rid(): string {
932
+ return Math.random().toString(36).slice(2, 11);
933
+ }
934
+
935
+ /**
936
+ * InlineDownload — chartreuse "Get .bcf" / "Save IFC" pill that surfaces
937
+ * directly under the tool-call header whenever a tool produced a file. The
938
+ * file is already in the playgroundFiles store (so the sidebar Downloads
939
+ * panel mirrors it); this is the discoverable, in-context offer right next
940
+ * to the action that produced it.
941
+ *
942
+ * UX rules baked in:
943
+ * • The artifact NEVER auto-downloads. The user has to click.
944
+ * • After a click, the button shows "Saved" for ~2s so the user has
945
+ * visible feedback without losing the affordance to re-download.
946
+ * • Re-clicking re-runs the download — useful if the user closed the
947
+ * prompt by accident.
948
+ */
949
+ function InlineDownload({
950
+ download,
951
+ }: {
952
+ download: NonNullable<ToolDispatchResult['download']>;
953
+ }): ReactNode {
954
+ const [savedAt, setSavedAt] = useState<number | null>(null);
955
+ const justSaved = savedAt != null && Date.now() - savedAt < 2000;
956
+ useEffect(() => {
957
+ if (savedAt == null) return;
958
+ const t = setTimeout(() => setSavedAt(null), 2000);
959
+ return () => clearTimeout(t);
960
+ }, [savedAt]);
961
+
962
+ return (
963
+ <div className="border-t border-white/5 px-3 py-2">
964
+ <button
965
+ onClick={() => {
966
+ playgroundFiles.download(download.fileId);
967
+ setSavedAt(Date.now());
968
+ }}
969
+ className={cn(
970
+ 'group flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left transition-colors',
971
+ justSaved ? 'bg-white/10' : 'bg-[#d6ff3f] hover:bg-[#e6ff66]',
972
+ )}
973
+ style={justSaved ? { color: '#d6ff3f' } : { color: '#0a0a0c' }}
974
+ >
975
+ <span className="flex min-w-0 items-center gap-2.5">
976
+ <span
977
+ className={cn(
978
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded',
979
+ justSaved ? 'bg-white/10' : 'bg-black/10',
980
+ )}
981
+ >
982
+ {justSaved ? <Check size={14} strokeWidth={2.6} /> : <Download size={14} strokeWidth={2.4} />}
983
+ </span>
984
+ <span className="flex min-w-0 flex-col leading-tight">
985
+ <span
986
+ className="truncate text-[13px] font-semibold"
987
+ style={{ fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' }}
988
+ >
989
+ {justSaved ? 'Saved — click again to re-download' : download.label}
990
+ </span>
991
+ <span
992
+ className={cn('truncate text-[10.5px]', justSaved ? 'text-white/50' : 'text-black/55')}
993
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
994
+ >
995
+ {download.filename} · {formatFileBytes(download.size)}
996
+ </span>
997
+ </span>
998
+ </span>
999
+ </button>
1000
+ </div>
1001
+ );
1002
+ }
1003
+
1004
+ // ── markdown rendering ────────────────────────────────────────────────────
1005
+ //
1006
+ // Tailwind utility set scoped to the chat. Headings stay quiet (the chat
1007
+ // already structures the conversation). Tables get hairline borders.
1008
+ // Inline code + code blocks pick up JetBrains Mono so they read like the
1009
+ // tool-call cards.
1010
+
1011
+ const MARKDOWN_COMPONENTS = {
1012
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1013
+ h1: (props: any) => <h3 className="mt-3 mb-1 text-[16px] font-semibold" {...props} />,
1014
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1015
+ h2: (props: any) => <h4 className="mt-3 mb-1 text-[14.5px] font-semibold" {...props} />,
1016
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1017
+ h3: (props: any) => <h5 className="mt-2 mb-1 text-[13.5px] font-semibold" {...props} />,
1018
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1019
+ p: (props: any) => <p className="my-1.5 first:mt-0 last:mb-0" {...props} />,
1020
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1021
+ ul: (props: any) => <ul className="my-1.5 ml-4 list-disc space-y-0.5" {...props} />,
1022
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1023
+ ol: (props: any) => <ol className="my-1.5 ml-4 list-decimal space-y-0.5" {...props} />,
1024
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1025
+ li: (props: any) => <li className="leading-snug" {...props} />,
1026
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1027
+ strong: (props: any) => <strong className="font-semibold text-white" {...props} />,
1028
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1029
+ em: (props: any) => <em className="italic text-white/95" {...props} />,
1030
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1031
+ a: (props: any) => (
1032
+ <a className="text-[#d6ff3f] underline-offset-2 hover:underline" target="_blank" rel="noreferrer" {...props} />
1033
+ ),
1034
+ // react-markdown v9 dropped the legacy `inline` prop, so we infer block
1035
+ // vs inline from className (fenced blocks get `language-…`) and content
1036
+ // (block code carries trailing newlines). When it's a block we render
1037
+ // a raw <code> here and let the `pre` override below own the wrapper —
1038
+ // doing the wrapping ourselves used to nest <pre> inside the <p>
1039
+ // react-markdown emits for surrounding text, tripping React's DOM
1040
+ // nesting validator.
1041
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1042
+ code: ({ className, children, ...props }: any) => {
1043
+ const cls = typeof className === 'string' ? className : '';
1044
+ const text = Array.isArray(children) ? children.join('') : String(children ?? '');
1045
+ const isBlock = cls.startsWith('language-') || /\n/.test(text);
1046
+ if (isBlock) {
1047
+ return (
1048
+ <code className={cn(cls, 'block')} style={{ fontFamily: '"JetBrains Mono", monospace' }} {...props}>
1049
+ {children}
1050
+ </code>
1051
+ );
1052
+ }
1053
+ return (
1054
+ <code
1055
+ className={cn(
1056
+ 'rounded bg-white/10 px-1 py-0.5 text-[12px] text-[#d6ff3f]',
1057
+ cls,
1058
+ )}
1059
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
1060
+ {...props}
1061
+ >
1062
+ {children}
1063
+ </code>
1064
+ );
1065
+ },
1066
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1067
+ pre: (props: any) => (
1068
+ <pre className="my-2 overflow-x-auto rounded bg-black/40 p-3 text-[12px] leading-snug" style={{ fontFamily: '"JetBrains Mono", monospace' }} {...props} />
1069
+ ),
1070
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1071
+ table: (props: any) => (
1072
+ <div className="my-2 overflow-x-auto rounded border border-white/10">
1073
+ <table className="w-full border-collapse text-[12.5px]" {...props} />
1074
+ </div>
1075
+ ),
1076
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1077
+ thead: (props: any) => <thead className="bg-white/[0.04]" {...props} />,
1078
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1079
+ th: (props: any) => (
1080
+ <th
1081
+ className="border-b border-white/10 px-2.5 py-1.5 text-left text-[10.5px] uppercase tracking-[0.18em] text-white/70"
1082
+ style={{ fontFamily: '"JetBrains Mono", monospace' }}
1083
+ {...props}
1084
+ />
1085
+ ),
1086
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1087
+ td: (props: any) => <td className="border-b border-white/5 px-2.5 py-1.5 align-top" {...props} />,
1088
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1089
+ blockquote: (props: any) => (
1090
+ <blockquote className="my-2 border-l-2 border-white/20 pl-3 italic text-white/70" {...props} />
1091
+ ),
1092
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1093
+ hr: (props: any) => <hr className="my-3 border-white/10" {...props} />,
1094
+ };
1095
+
1096
+ // Tiny re-export so the playground page can show an empty-state CTA.
1097
+ export { RefreshCcw };