@kitn.ai/ui 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (376) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-tooling/construct/public.d.ts +15 -0
  3. package/dist/agent-tooling/construct/schema.d.ts +111 -0
  4. package/dist/construct-cli.es.js +3 -3
  5. package/dist/construct.d.ts +5 -0
  6. package/dist/construct.js +327 -0
  7. package/dist/core-C8fzo39E.js +1 -12
  8. package/dist/engine-javascript-C1x7zo1_.js +1 -141
  9. package/dist/mcp.es.js +1 -1
  10. package/package.json +16 -16
  11. package/bin/route.test.js +0 -58
  12. package/src/agent-tooling/README.md +0 -192
  13. package/src/agent-tooling/archetypes.ts +0 -205
  14. package/src/agent-tooling/catalog/README.md +0 -676
  15. package/src/agent-tooling/catalog/catalog-types.ts +0 -231
  16. package/src/agent-tooling/catalog/fabrications.ts +0 -96
  17. package/src/agent-tooling/catalog/invariants.ts +0 -284
  18. package/src/agent-tooling/catalog/labs-titles.ts +0 -114
  19. package/src/agent-tooling/catalog/scenarios.ts +0 -87
  20. package/src/agent-tooling/catalog/surfaces.ts +0 -347
  21. package/src/agent-tooling/construct/cli-entry.ts +0 -8
  22. package/src/agent-tooling/construct/cli.ts +0 -147
  23. package/src/agent-tooling/construct/codegen.ts +0 -1635
  24. package/src/agent-tooling/construct/construct.v1.schema.json +0 -319
  25. package/src/agent-tooling/construct/dev.ts +0 -148
  26. package/src/agent-tooling/construct/fixtures/demo-widget.construct.json +0 -7
  27. package/src/agent-tooling/construct/fixtures/ops-console.construct.json +0 -44
  28. package/src/agent-tooling/construct/fixtures/owner-widget.construct.json +0 -32
  29. package/src/agent-tooling/construct/schema.ts +0 -452
  30. package/src/agent-tooling/integrations/anthropic.ts +0 -442
  31. package/src/agent-tooling/integrations/cloudflare.ts +0 -218
  32. package/src/agent-tooling/integrations/langgraph.ts +0 -133
  33. package/src/agent-tooling/integrations/mastra.ts +0 -182
  34. package/src/agent-tooling/integrations/mock.ts +0 -148
  35. package/src/agent-tooling/integrations/ollama.ts +0 -97
  36. package/src/agent-tooling/integrations/openai.ts +0 -95
  37. package/src/agent-tooling/integrations/openrouter.ts +0 -82
  38. package/src/agent-tooling/integrations/pi.ts +0 -176
  39. package/src/agent-tooling/integrations/pydantic-ai.ts +0 -108
  40. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +0 -398
  41. package/src/agent-tooling/mcp/css-raw.d.ts +0 -12
  42. package/src/agent-tooling/mcp/manifest.ts +0 -422
  43. package/src/agent-tooling/mcp/server.ts +0 -116
  44. package/src/agent-tooling/mcp/stdio.ts +0 -18
  45. package/src/agent-tooling/mcp/tools/construct.ts +0 -130
  46. package/src/agent-tooling/mcp/tools/debug.ts +0 -621
  47. package/src/agent-tooling/mcp/tools/reference.ts +0 -980
  48. package/src/agent-tooling/mcp/tools/scaffold.ts +0 -6708
  49. package/src/agent-tooling/mcp/tools/theme.ts +0 -456
  50. package/src/agent-tooling/mcp/tools/types.ts +0 -14
  51. package/src/agent-tooling/mcp/validate-args.ts +0 -141
  52. package/src/agent-tooling/recipes/composed-thread.ts +0 -714
  53. package/src/agent-tooling/recipes/index.ts +0 -22
  54. package/src/agent-tooling/recipes/types.ts +0 -32
  55. package/src/agent-tooling/registry.ts +0 -132
  56. package/src/agent-tooling/route-emit.ts +0 -305
  57. package/src/agent-tooling/types.ts +0 -390
  58. package/src/components/artifact-card.tsx +0 -120
  59. package/src/components/artifact.tsx +0 -914
  60. package/src/components/attachment-types.ts +0 -66
  61. package/src/components/attachments.tsx +0 -536
  62. package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +0 -257
  63. package/src/components/audio-visualizer/aurora.glsl.ts +0 -330
  64. package/src/components/audio-visualizer/fit-scale.ts +0 -112
  65. package/src/components/audio-visualizer/index.tsx +0 -528
  66. package/src/components/audio-visualizer/labs/lab-choreography.ts +0 -187
  67. package/src/components/audio-visualizer/labs/lab-shaders.ts +0 -363
  68. package/src/components/audio-visualizer/labs/lab-visualizer.tsx +0 -102
  69. package/src/components/audio-visualizer/shader-canvas.tsx +0 -876
  70. package/src/components/audio-visualizer/sizes.ts +0 -54
  71. package/src/components/audio-visualizer/variant-aurora.tsx +0 -305
  72. package/src/components/audio-visualizer/variant-bar.tsx +0 -175
  73. package/src/components/audio-visualizer/variant-custom.tsx +0 -344
  74. package/src/components/audio-visualizer/variant-grid.tsx +0 -175
  75. package/src/components/audio-visualizer/variant-radial.tsx +0 -163
  76. package/src/components/audio-visualizer/variant-wave.tsx +0 -130
  77. package/src/components/audio-visualizer/wave.glsl.ts +0 -130
  78. package/src/components/card-fallback.tsx +0 -60
  79. package/src/components/card-renderer.tsx +0 -220
  80. package/src/components/card.tsx +0 -212
  81. package/src/components/chain-of-thought.tsx +0 -348
  82. package/src/components/chat-container.tsx +0 -89
  83. package/src/components/chat-scope-picker.tsx +0 -57
  84. package/src/components/chat-thread.tsx +0 -1014
  85. package/src/components/checkpoint.tsx +0 -88
  86. package/src/components/choice-card.tsx +0 -743
  87. package/src/components/coachmark.tsx +0 -249
  88. package/src/components/code-block.tsx +0 -181
  89. package/src/components/composer-dom.ts +0 -159
  90. package/src/components/composer-highlight.ts +0 -242
  91. package/src/components/composer-history.ts +0 -82
  92. package/src/components/composer.tsx +0 -1075
  93. package/src/components/confirm-card.tsx +0 -402
  94. package/src/components/context.tsx +0 -377
  95. package/src/components/conversation-item.tsx +0 -241
  96. package/src/components/conversation-list.tsx +0 -429
  97. package/src/components/conversation-panel.tsx +0 -118
  98. package/src/components/dismissed-stub.tsx +0 -80
  99. package/src/components/embed.tsx +0 -200
  100. package/src/components/empty.tsx +0 -177
  101. package/src/components/feedback-bar.tsx +0 -166
  102. package/src/components/file-tree.tsx +0 -530
  103. package/src/components/file-upload.tsx +0 -167
  104. package/src/components/form-widgets.tsx +0 -525
  105. package/src/components/form.tsx +0 -1296
  106. package/src/components/home-panel.tsx +0 -159
  107. package/src/components/image.tsx +0 -65
  108. package/src/components/link-preview.tsx +0 -195
  109. package/src/components/loader.tsx +0 -367
  110. package/src/components/markdown.tsx +0 -162
  111. package/src/components/message-skills.tsx +0 -40
  112. package/src/components/message.tsx +0 -765
  113. package/src/components/model-switcher.tsx +0 -132
  114. package/src/components/prompt-input.tsx +0 -188
  115. package/src/components/prompt-suggestion.tsx +0 -150
  116. package/src/components/reasoning.tsx +0 -293
  117. package/src/components/response-compare-types.ts +0 -152
  118. package/src/components/response-compare.tsx +0 -455
  119. package/src/components/response-stream.tsx +0 -109
  120. package/src/components/screen.tsx +0 -181
  121. package/src/components/scroll-button.tsx +0 -93
  122. package/src/components/source.tsx +0 -192
  123. package/src/components/tasks-card.tsx +0 -801
  124. package/src/components/text-shimmer.tsx +0 -37
  125. package/src/components/thinking-bar.tsx +0 -50
  126. package/src/components/thread.tsx +0 -228
  127. package/src/components/toast.tsx +0 -503
  128. package/src/components/tool-classify.ts +0 -26
  129. package/src/components/tool-types.ts +0 -49
  130. package/src/components/tool.tsx +0 -190
  131. package/src/components/use-card-resolution.ts +0 -44
  132. package/src/components/voice-input.tsx +0 -222
  133. package/src/components/voice-output.tsx +0 -242
  134. package/src/components/widget-tab-bar.tsx +0 -73
  135. package/src/components/workspace-shell.tsx +0 -285
  136. package/src/diagnostics/hook.ts +0 -352
  137. package/src/diagnostics/index.ts +0 -82
  138. package/src/diagnostics/report-request.ts +0 -296
  139. package/src/elements/agent-card.tsx +0 -67
  140. package/src/elements/artifact.tsx +0 -194
  141. package/src/elements/attachments.tsx +0 -140
  142. package/src/elements/audio-visualizer.tsx +0 -227
  143. package/src/elements/autoloader.ts +0 -89
  144. package/src/elements/avatar.tsx +0 -33
  145. package/src/elements/badge.tsx +0 -32
  146. package/src/elements/button.tsx +0 -149
  147. package/src/elements/card.tsx +0 -133
  148. package/src/elements/cards.tsx +0 -266
  149. package/src/elements/chain-of-thought.tsx +0 -141
  150. package/src/elements/chat-scope-picker.tsx +0 -65
  151. package/src/elements/chat-types.ts +0 -102
  152. package/src/elements/chat-workspace.tsx +0 -188
  153. package/src/elements/chat.tsx +0 -304
  154. package/src/elements/checkbox-group.tsx +0 -164
  155. package/src/elements/checkbox.tsx +0 -134
  156. package/src/elements/checkpoint.tsx +0 -47
  157. package/src/elements/choice.tsx +0 -100
  158. package/src/elements/coachmark.tsx +0 -90
  159. package/src/elements/code-block.tsx +0 -60
  160. package/src/elements/command.tsx +0 -197
  161. package/src/elements/compare.tsx +0 -111
  162. package/src/elements/compiled.css +0 -2
  163. package/src/elements/composer.tsx +0 -162
  164. package/src/elements/confirm-card.tsx +0 -76
  165. package/src/elements/context-meter.tsx +0 -107
  166. package/src/elements/conversation-item.tsx +0 -120
  167. package/src/elements/conversation-list.tsx +0 -203
  168. package/src/elements/css.ts +0 -5
  169. package/src/elements/default-input.tsx +0 -392
  170. package/src/elements/define-entry.ts +0 -12
  171. package/src/elements/define.tsx +0 -563
  172. package/src/elements/diagnostic-events.ts +0 -114
  173. package/src/elements/dialog.tsx +0 -145
  174. package/src/elements/disclosure.ts +0 -95
  175. package/src/elements/dock.tsx +0 -215
  176. package/src/elements/dropdown.tsx +0 -123
  177. package/src/elements/editable-label.tsx +0 -140
  178. package/src/elements/element-data-types.ts +0 -152
  179. package/src/elements/element-diagnostics.ts +0 -392
  180. package/src/elements/element-manifest.json +0 -354
  181. package/src/elements/element-nonscalar.json +0 -187
  182. package/src/elements/element-types.d.ts +0 -4353
  183. package/src/elements/embed.tsx +0 -35
  184. package/src/elements/empty.tsx +0 -29
  185. package/src/elements/feedback-bar.tsx +0 -66
  186. package/src/elements/file-tree.tsx +0 -62
  187. package/src/elements/file-upload.tsx +0 -44
  188. package/src/elements/form.tsx +0 -94
  189. package/src/elements/hover-card.tsx +0 -80
  190. package/src/elements/icon.tsx +0 -41
  191. package/src/elements/image.tsx +0 -32
  192. package/src/elements/input.tsx +0 -492
  193. package/src/elements/kbd.tsx +0 -45
  194. package/src/elements/link-preview.tsx +0 -34
  195. package/src/elements/loader.tsx +0 -25
  196. package/src/elements/markdown.tsx +0 -38
  197. package/src/elements/menu.tsx +0 -241
  198. package/src/elements/message-skills.tsx +0 -83
  199. package/src/elements/message.tsx +0 -361
  200. package/src/elements/model-switcher.tsx +0 -121
  201. package/src/elements/nav.tsx +0 -87
  202. package/src/elements/notice.tsx +0 -53
  203. package/src/elements/pane-grid.tsx +0 -111
  204. package/src/elements/pane-group.tsx +0 -119
  205. package/src/elements/pane.tsx +0 -129
  206. package/src/elements/popover.tsx +0 -81
  207. package/src/elements/progress-bar.tsx +0 -39
  208. package/src/elements/prompt-dock.tsx +0 -82
  209. package/src/elements/prompt-input.tsx +0 -240
  210. package/src/elements/prompt-suggestions.tsx +0 -113
  211. package/src/elements/radio-group.tsx +0 -129
  212. package/src/elements/reasoning.tsx +0 -71
  213. package/src/elements/register-impl.ts +0 -148
  214. package/src/elements/register.ts +0 -76
  215. package/src/elements/remote.tsx +0 -225
  216. package/src/elements/resizable.globals.d.ts +0 -36
  217. package/src/elements/resizable.tsx +0 -704
  218. package/src/elements/response-stream.tsx +0 -40
  219. package/src/elements/screen.tsx +0 -90
  220. package/src/elements/scroll-area.tsx +0 -33
  221. package/src/elements/scroll-button.tsx +0 -179
  222. package/src/elements/search.tsx +0 -185
  223. package/src/elements/segmented.tsx +0 -129
  224. package/src/elements/select.tsx +0 -165
  225. package/src/elements/separator.tsx +0 -36
  226. package/src/elements/setting-item.tsx +0 -50
  227. package/src/elements/settings-group.tsx +0 -37
  228. package/src/elements/skeleton.tsx +0 -45
  229. package/src/elements/slider.tsx +0 -171
  230. package/src/elements/slot-text.ts +0 -73
  231. package/src/elements/slots.ts +0 -787
  232. package/src/elements/source.tsx +0 -126
  233. package/src/elements/status.tsx +0 -42
  234. package/src/elements/styles.css +0 -328
  235. package/src/elements/switch.tsx +0 -118
  236. package/src/elements/tabs.tsx +0 -95
  237. package/src/elements/tasks.tsx +0 -103
  238. package/src/elements/text-shimmer.tsx +0 -28
  239. package/src/elements/thinking-bar.tsx +0 -43
  240. package/src/elements/thread.tsx +0 -139
  241. package/src/elements/toast.tsx +0 -106
  242. package/src/elements/tool.tsx +0 -78
  243. package/src/elements/tooltip.tsx +0 -85
  244. package/src/elements/validate-messages.ts +0 -76
  245. package/src/elements/voice-input.tsx +0 -100
  246. package/src/elements/voice-output.tsx +0 -82
  247. package/src/index.ts +0 -430
  248. package/src/primitives/audio-bands.ts +0 -174
  249. package/src/primitives/card-component-types.ts +0 -63
  250. package/src/primitives/card-contract.ts +0 -80
  251. package/src/primitives/card-data-types.ts +0 -361
  252. package/src/primitives/card-host.tsx +0 -37
  253. package/src/primitives/card-recovery.ts +0 -160
  254. package/src/primitives/card-registry.tsx +0 -78
  255. package/src/primitives/card-resolution.ts +0 -36
  256. package/src/primitives/card-routing.ts +0 -85
  257. package/src/primitives/card-schemas/artifact.schema.json +0 -92
  258. package/src/primitives/card-schemas/card-envelope.schema.json +0 -14
  259. package/src/primitives/card-schemas/card-event.schema.json +0 -12
  260. package/src/primitives/card-schemas/choice.schema.json +0 -75
  261. package/src/primitives/card-schemas/confirm.schema.json +0 -65
  262. package/src/primitives/card-schemas/embed.schema.json +0 -63
  263. package/src/primitives/card-schemas/form.result.schema.json +0 -7
  264. package/src/primitives/card-schemas/form.schema.json +0 -53
  265. package/src/primitives/card-schemas/link.schema.json +0 -54
  266. package/src/primitives/card-schemas/tasks.result.schema.json +0 -16
  267. package/src/primitives/card-schemas/tasks.schema.json +0 -83
  268. package/src/primitives/card-tags.ts +0 -61
  269. package/src/primitives/card-validate-cards.ts +0 -255
  270. package/src/primitives/card-validate-schemas.ts +0 -58
  271. package/src/primitives/card-validate.ts +0 -131
  272. package/src/primitives/chat-config.tsx +0 -103
  273. package/src/primitives/composer-model.ts +0 -49
  274. package/src/primitives/composer-triggers.ts +0 -35
  275. package/src/primitives/controllable.ts +0 -21
  276. package/src/primitives/conversation-store.ts +0 -248
  277. package/src/primitives/create-kai-chat.ts +0 -66
  278. package/src/primitives/create-tween.ts +0 -266
  279. package/src/primitives/embed-providers.ts +0 -254
  280. package/src/primitives/field-mask.ts +0 -256
  281. package/src/primitives/field-semantics.ts +0 -115
  282. package/src/primitives/highlighter.ts +0 -157
  283. package/src/primitives/input-mask.ts +0 -853
  284. package/src/primitives/link-preview.ts +0 -87
  285. package/src/primitives/message-feedback.ts +0 -124
  286. package/src/primitives/pdf-preview.ts +0 -121
  287. package/src/primitives/toast-store.ts +0 -338
  288. package/src/primitives/url-scheme-policy.ts +0 -70
  289. package/src/primitives/use-audio-analysis.ts +0 -340
  290. package/src/primitives/use-auto-resize.ts +0 -31
  291. package/src/primitives/use-resize-observer.ts +0 -45
  292. package/src/primitives/use-sequencer.ts +0 -72
  293. package/src/primitives/use-speech-recognition.ts +0 -146
  294. package/src/primitives/use-stick-to-bottom.ts +0 -75
  295. package/src/primitives/use-text-stream.ts +0 -143
  296. package/src/primitives/use-voice-recorder.ts +0 -62
  297. package/src/primitives/visualizer-sequences.ts +0 -350
  298. package/src/remote/host-embed.ts +0 -345
  299. package/src/remote/index.ts +0 -2
  300. package/src/remote/origin.ts +0 -30
  301. package/src/remote/provider-runtime.ts +0 -262
  302. package/src/remote/provider.ts +0 -2
  303. package/src/remote/validate.ts +0 -22
  304. package/src/remote/version.ts +0 -12
  305. package/src/remote/wire.ts +0 -48
  306. package/src/schemas/from-tool-call.ts +0 -151
  307. package/src/schemas/index.ts +0 -341
  308. package/src/schemas/provider-subsets.ts +0 -538
  309. package/src/schemas/registry.ts +0 -355
  310. package/src/schemas/tool-defs.ts +0 -793
  311. package/src/solid.ts +0 -173
  312. package/src/state/index.ts +0 -44
  313. package/src/state/messages.ts +0 -53
  314. package/src/state/mock.ts +0 -297
  315. package/src/state/parts.ts +0 -295
  316. package/src/state/persistence.ts +0 -180
  317. package/src/state/stream.ts +0 -246
  318. package/src/state/suggestions.ts +0 -9
  319. package/src/state/threads.ts +0 -142
  320. package/src/test-utils/fake-clock.ts +0 -88
  321. package/src/types.ts +0 -95
  322. package/src/ui/action-icons.ts +0 -53
  323. package/src/ui/agent-card.tsx +0 -218
  324. package/src/ui/avatar.tsx +0 -23
  325. package/src/ui/badge.tsx +0 -21
  326. package/src/ui/button.tsx +0 -49
  327. package/src/ui/card.tsx +0 -245
  328. package/src/ui/checkbox-group.tsx +0 -153
  329. package/src/ui/checkbox.tsx +0 -63
  330. package/src/ui/collapsible.tsx +0 -163
  331. package/src/ui/command.tsx +0 -103
  332. package/src/ui/dialog.tsx +0 -223
  333. package/src/ui/dock.tsx +0 -630
  334. package/src/ui/dropdown.tsx +0 -636
  335. package/src/ui/editable-label.tsx +0 -112
  336. package/src/ui/hover-card.tsx +0 -345
  337. package/src/ui/icon.tsx +0 -167
  338. package/src/ui/input.tsx +0 -391
  339. package/src/ui/kbd.tsx +0 -118
  340. package/src/ui/nav.tsx +0 -282
  341. package/src/ui/notice.tsx +0 -84
  342. package/src/ui/overlay.tsx +0 -277
  343. package/src/ui/pane-grid.tsx +0 -117
  344. package/src/ui/pane-group.tsx +0 -291
  345. package/src/ui/pane.tsx +0 -193
  346. package/src/ui/popover.tsx +0 -106
  347. package/src/ui/progress-bar.tsx +0 -83
  348. package/src/ui/prompt-dock.tsx +0 -145
  349. package/src/ui/radio.tsx +0 -150
  350. package/src/ui/resizable.tsx +0 -693
  351. package/src/ui/scroll-area.tsx +0 -26
  352. package/src/ui/segmented.tsx +0 -109
  353. package/src/ui/select.tsx +0 -168
  354. package/src/ui/separator.tsx +0 -10
  355. package/src/ui/settings-group.tsx +0 -67
  356. package/src/ui/skeleton.tsx +0 -73
  357. package/src/ui/slider.tsx +0 -178
  358. package/src/ui/status.tsx +0 -48
  359. package/src/ui/switch.tsx +0 -119
  360. package/src/ui/tabs.tsx +0 -168
  361. package/src/ui/textarea.tsx +0 -21
  362. package/src/ui/tooltip.tsx +0 -118
  363. package/src/utils/cn.ts +0 -30
  364. package/src/wire/chunk.ts +0 -248
  365. package/src/wire/consume.ts +0 -599
  366. package/src/wire/diagnostics.ts +0 -727
  367. package/src/wire/encode-probe.ts +0 -214
  368. package/src/wire/encode.ts +0 -947
  369. package/src/wire/files.ts +0 -342
  370. package/src/wire/formats/anthropic.ts +0 -274
  371. package/src/wire/formats/openai.ts +0 -228
  372. package/src/wire/index.ts +0 -95
  373. package/src/wire/media-types.ts +0 -344
  374. package/src/wire/read.ts +0 -396
  375. package/src/wire/sink-helpers.ts +0 -50
  376. package/src/wire/sse.ts +0 -153
@@ -1,914 +0,0 @@
1
- import {
2
- type JSX,
3
- splitProps,
4
- mergeProps,
5
- createSignal,
6
- createEffect,
7
- createMemo,
8
- on,
9
- onMount,
10
- onCleanup,
11
- Show,
12
- } from 'solid-js';
13
- import { cn } from '../utils/cn';
14
- import { Button } from '../ui/button';
15
- import { CodeBlock, CodeBlockCode } from './code-block';
16
- import { FileTree, type FileTreeFile } from './file-tree';
17
- import { Loader } from './loader';
18
- import { isPdfPreviewEnabled, renderPdfInto } from '../primitives/pdf-preview';
19
- import { isSafeUrl, isScriptUrl } from '../primitives/card-routing';
20
- import {
21
- ArrowLeft,
22
- ArrowRight,
23
- RotateCw,
24
- House,
25
- Eye,
26
- Code as CodeIcon,
27
- FileText,
28
- ExternalLink,
29
- Download,
30
- Maximize2,
31
- Minimize2,
32
- } from 'lucide-solid';
33
-
34
- export type ArtifactTab = 'preview' | 'code';
35
-
36
- /** A file the artifact can preview + show source for. */
37
- export type ArtifactFile = FileTreeFile;
38
-
39
- /** Imperative handle exposed via `controllerRef` — surfaces the artifact's latent
40
- * toolbar capabilities (history back/forward/reload/home, programmatic navigate,
41
- * file selection, open-in-new-tab, maximize/restore) so the `<kai-artifact>`
42
- * facade can forward them as instance methods. Each delegates to the SAME internal
43
- * handler the toolbar buttons use, so every existing event still fires. */
44
- export interface ArtifactController {
45
- /** Go back in the artifact's own history stack (no-op when there's no prior entry). */
46
- back(): void;
47
- /** Go forward in the history stack (no-op when there's no forward entry). */
48
- forward(): void;
49
- /** Force-reload the current preview url (also re-renders an inline PDF). */
50
- reload(): void;
51
- /** Navigate to the `src` home url (no-op when there's no `src`). */
52
- home(): void;
53
- /** Push + load a url in the preview (the path-field submit path). */
54
- navigate(url: string): void;
55
- /** Select a file by path: highlights the tree, shows its source, navigates the preview. */
56
- selectFile(path: string): void;
57
- /** Open the current url in a new browser tab (no-op when there's no concrete url). */
58
- openExternal(): void;
59
- /** Enter the maximized view-state (fires onMaximizeChange/kai-maximize-change). */
60
- maximize(): void;
61
- /** Exit the maximized view-state. */
62
- restore(): void;
63
- }
64
-
65
- export interface ArtifactProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
66
- /** URL the preview iframe frames. */
67
- src?: string;
68
- /** Files for the Code tab's tree (+ each file's preview `url`). */
69
- files?: ArtifactFile[];
70
- /** Controlled active tab — when set, the artifact follows it (re-asserted on
71
- * every change). When undefined the tab is uncontrolled (see `defaultTab`). */
72
- tab?: ArtifactTab;
73
- /** Uncontrolled INITIAL tab (used only when `tab` is undefined). The user can
74
- * then freely switch tabs; defaults to `preview`. */
75
- defaultTab?: ArtifactTab;
76
- /** Selected file path (syncs tree highlight + Code source + preview). */
77
- activeFile?: string;
78
- /** iframe `sandbox` override. Default `allow-scripts allow-forms`. */
79
- sandbox?: string;
80
- /** Accessible iframe title. */
81
- iframeTitle?: string;
82
- /** Fired when the preview navigates (back/forward/reload/path-edit/file-click). */
83
- onNavigate?: (url: string) => void;
84
- /** Fired when the Preview|Code tab changes. */
85
- onTabChange?: (tab: ArtifactTab) => void;
86
- /** Fired when a file is selected in the tree. */
87
- onFileSelect?: (path: string) => void;
88
- // view-state
89
- /** Controlled maximize view-state (drives the expand/restore button). */
90
- maximized?: boolean;
91
- /** Fired when the expand/restore button toggles the maximize view-state. */
92
- onMaximizeChange?: (maximized: boolean) => void;
93
- // toolbar composition — existing five default SHOWN (no-* flags invert in the facade)
94
- /** Show the back/forward nav buttons. Default `true`. */
95
- showNav?: boolean;
96
- /** Show the reload button. Default `true`. */
97
- showReload?: boolean;
98
- /** Show the home button. Default `true`. */
99
- showHome?: boolean;
100
- /** Show the editable path/address field. Default `true`. */
101
- showPathField?: boolean;
102
- /** Show the Preview|Code tab toggle. Default `true`. */
103
- showTabs?: boolean;
104
- // new affordances — OPT-IN (default hidden; see resolved decision #2)
105
- /** Show the expand-to-fill button. Default `false` (opt-in). */
106
- expandable?: boolean;
107
- /** Show the open-in-new-tab button. Default `false` (opt-in). */
108
- openInTab?: boolean;
109
- // chrome
110
- /** Standalone chrome: rounded + bordered (default in-panel = square, borderless). */
111
- standalone?: boolean;
112
- /** Make the path field read-only (visible, nav-tracking, non-editable). */
113
- readonlyPath?: boolean;
114
- /** Friendly address shown in the path field INSTEAD of the real current url
115
- * (read-only, non-navigable). Use when the framed url is not consumer-facing
116
- * (e.g. a `data:` blob) so a clean address is shown instead of leaking it.
117
- * Unset = show the real url (editable per `readonlyPath`). */
118
- displayUrl?: string;
119
- /** Receive the imperative controller once mounted. The `<kai-artifact>` facade
120
- * forwards these as element methods (back/forward/reload/home/navigate/
121
- * selectFile/openExternal/maximize/restore). */
122
- controllerRef?: (controller: ArtifactController) => void;
123
- }
124
-
125
- const DEFAULT_SANDBOX = 'allow-scripts allow-forms';
126
-
127
- /** Refuse loudly. `src` and `files[].url` are MODEL-REACHABLE -- they arrive on
128
- * an `artifact` card envelope produced by a tool call, and the card schema
129
- * validates them only as `format: "uri"`, which constrains no scheme -- so
130
- * every url this component puts in an href, hands to `window.open`, or frames
131
- * is filtered. A refusal without a trace would be a decision made while hiding
132
- * that it happened; the component has no error channel of its own, so it warns
133
- * the way `card-routing` does. */
134
- function warnBlockedUrl(sink: string, url: string): void {
135
- // eslint-disable-next-line no-console
136
- console.warn(`[kai-artifact] blocked unsafe url in ${sink}: ${url}`);
137
- }
138
-
139
- /** Resolve a file's preview URL: explicit `url`, else `<src-origin> + /path`. */
140
- function resolveFileUrl(file: ArtifactFile, src: string | undefined): string {
141
- if (file.url) return file.url;
142
- if (!src) return file.path;
143
- try {
144
- return new URL(file.path, src).href;
145
- } catch {
146
- return file.path;
147
- }
148
- }
149
-
150
- /** True when `url` should render as a PDF: a matching `files` entry is typed
151
- * `'pdf'`, or the URL path (query/hash stripped) ends in `.pdf`. */
152
- export function isPdfUrl(url: string, files: ArtifactFile[]): boolean {
153
- if (!url) return false;
154
- const match = files.find((f) => f.url === url || f.path === url);
155
- if (match?.type === 'pdf') return true;
156
- const path = url.split(/[?#]/)[0];
157
- return /\.pdf$/i.test(path);
158
- }
159
-
160
- /**
161
- * `Artifact` — a framed, switchable generated-artifact viewer. A functional nav
162
- * toolbar (back · forward · reload · home + editable path field + Preview|Code
163
- * toggle) over a sandboxed `<iframe>` (Preview) or a file-tree + `<kai-code-block>`
164
- * (Code). The component self-navigates the iframe and emits `kai-navigate` /
165
- * `kai-tab-change` / `kai-file-select` so a consumer can observe/sync.
166
- */
167
- export function Artifact(props: ArtifactProps): JSX.Element {
168
- const merged = mergeProps(
169
- {
170
- files: [] as ArtifactFile[],
171
- // No `tab` default: a present `tab` means controlled. The uncontrolled
172
- // initial tab comes from `defaultTab` (seeded into the internal signal below).
173
- defaultTab: 'preview' as ArtifactTab,
174
- sandbox: DEFAULT_SANDBOX,
175
- showNav: true,
176
- showReload: true,
177
- showHome: true,
178
- showPathField: true,
179
- showTabs: true,
180
- expandable: false,
181
- openInTab: false,
182
- standalone: false,
183
- readonlyPath: false,
184
- maximized: false,
185
- },
186
- props,
187
- );
188
- const [local, rest] = splitProps(merged, [
189
- 'src',
190
- 'files',
191
- 'tab',
192
- 'defaultTab',
193
- 'activeFile',
194
- 'sandbox',
195
- 'iframeTitle',
196
- 'onNavigate',
197
- 'onTabChange',
198
- 'onFileSelect',
199
- 'maximized',
200
- 'onMaximizeChange',
201
- 'showNav',
202
- 'showReload',
203
- 'showHome',
204
- 'showPathField',
205
- 'showTabs',
206
- 'expandable',
207
- 'openInTab',
208
- 'standalone',
209
- 'readonlyPath',
210
- 'displayUrl',
211
- 'controllerRef',
212
- 'class',
213
- ]);
214
-
215
- // A sandboxed iframe WITHOUT `allow-same-origin` makes the framed document's
216
- // `contentWindow.history`/`location` opaque (a deliberate security property —
217
- // see the spec). So the component keeps its OWN history stack for the
218
- // navigations it initiates (path-edit, file-click, home, src changes) and
219
- // back/forward/reload operate on that. (In-frame relative-link clicks navigate
220
- // the iframe itself but can't be observed cross-origin — a known, accepted
221
- // sandbox limitation.)
222
- //
223
- // DO NOT add `allow-same-origin` to get those clicks tracked. This comment
224
- // used to suggest exactly that "if the consumer trusts the artifact", and it
225
- // was the worst advice in the file: `allow-same-origin` gives the framed
226
- // document the HOST's origin, so a hostile `src` runs with full access to the
227
- // embedding page, zero clicks -- confirmed executing in Chromium. And "the
228
- // artifact is trusted" is not a premise you get to hold when the url can come
229
- // from tool output: on the card path `src` and `files[].url` are
230
- // model-supplied. The scheme filter below is a backstop, not a licence --
231
- // `allow-same-origin` still hands the framed document the host's cookies,
232
- // storage and DOM for everything else it does. Losing in-frame click tracking
233
- // is the correct trade.
234
- const [history, setHistory] = createSignal<string[]>(local.src ? [local.src] : []);
235
- const [cursor, setCursor] = createSignal(local.src ? 0 : -1);
236
- const currentUrl = () => history()[cursor()] ?? '';
237
- const canBack = () => cursor() > 0;
238
- const canForward = () => cursor() < history().length - 1;
239
-
240
- /** What the iframe is actually pointed at.
241
- *
242
- * Separate from `currentUrl()` on purpose: the history stack and the address
243
- * field keep reporting what ARRIVED (a reader looking at a refused artifact
244
- * should see the address that was refused, not a blank), while this is the
245
- * only value that reaches the `src` sink.
246
- *
247
- * `isScriptUrl` and not `isSafeUrl`: a `data:`/`blob:` src is a legitimate
248
- * artifact -- see the note on `displayUrl` -- and gets an opaque origin, so
249
- * it cannot touch the host page. `javascript:`/`vbscript:` run in the
250
- * EMBEDDER's origin, which the default sandbox already refuses but a
251
- * consumer-set `allow-same-origin` re-enables (see the note on the history
252
- * stack above for why nothing here recommends that setting). */
253
- const framedUrl = createMemo(() => {
254
- const u = currentUrl();
255
- if (u && isScriptUrl(u)) {
256
- warnBlockedUrl('preview', u);
257
- return '';
258
- }
259
- return u;
260
- });
261
-
262
- // Seed the internal tab: a present `tab` (controlled) wins; otherwise the
263
- // uncontrolled `defaultTab` (which merges to 'preview'). The user can then
264
- // switch freely unless `tab` is set (the effect below re-asserts the controlled
265
- // value on each change).
266
- const [tab, setTab] = createSignal<ArtifactTab>(local.tab ?? local.defaultTab);
267
- const [activeFile, setActiveFile] = createSignal<string | undefined>(local.activeFile);
268
- const [reloadKey, setReloadKey] = createSignal(0);
269
-
270
- // Maximize view-state — controlled by `local.maximized`, toggled by the button.
271
- const [maximized, setMaximized] = createSignal<boolean>(local.maximized ?? false);
272
- createEffect(() => setMaximized(local.maximized ?? false));
273
- const toggleMaximize = () => {
274
- const next = !maximized();
275
- setMaximized(next);
276
- local.onMaximizeChange?.(next);
277
- };
278
-
279
- // Open-in-tab — only enabled when there's a concrete SAFE url to open. The
280
- // scheme filter is part of `canOpenInTab` rather than only of the handler so
281
- // the button renders DISABLED for a url that would be refused: a control that
282
- // looks live and does nothing is the worse failure.
283
- const canOpenInTab = createMemo(() => {
284
- const u = currentUrl();
285
- return !!u && u !== 'about:blank' && isSafeUrl(u);
286
- });
287
- const openInNewTab = () => {
288
- const u = currentUrl();
289
- if (!canOpenInTab()) {
290
- // `openExternal()` on the controller reaches this without any button, so
291
- // the refusal has to be stated here and not only in the disabled state.
292
- if (u && u !== 'about:blank') warnBlockedUrl('openExternal', u);
293
- return;
294
- }
295
- window.open(u, '_blank', 'noopener,noreferrer');
296
- };
297
-
298
- // The expand button is suppressed in standalone (no enclosing resizable).
299
- const showExpand = createMemo(() => local.expandable && !local.standalone);
300
- // Omit the whole toolbar when nothing is shown.
301
- const showAnyToolbar = createMemo(
302
- () =>
303
- local.showNav ||
304
- local.showReload ||
305
- local.showHome ||
306
- local.showPathField ||
307
- local.showTabs ||
308
- showExpand() ||
309
- local.openInTab,
310
- );
311
-
312
- let iframeEl: HTMLIFrameElement | undefined;
313
-
314
- // Controlled syncing: when the consumer changes the props, follow them. `tab` is
315
- // followed ONLY when it's actually set — an undefined `tab` means uncontrolled,
316
- // so the internal signal (seeded from defaultTab) is left for the user to drive.
317
- createEffect(() => {
318
- if (local.tab !== undefined) setTab(local.tab);
319
- });
320
- createEffect(() => setActiveFile(local.activeFile));
321
- // `src` change → navigate. Use `on(local.src, …)` so the effect tracks ONLY
322
- // the `src` prop — NOT `currentUrl()` — otherwise navigating away (file click,
323
- // path edit) would re-trigger it and snap the iframe back to `src`. Skip the
324
- // initial run: history is already seeded with `src` and the iframe binds it.
325
- createEffect(
326
- on(
327
- () => local.src,
328
- (next) => {
329
- if (next && next !== currentUrl()) navigate(next);
330
- },
331
- { defer: true },
332
- ),
333
- );
334
-
335
- const fileFor = (path: string | undefined): ArtifactFile | undefined =>
336
- path === undefined ? undefined : local.files.find((f) => f.path === path);
337
-
338
- const activeFileObj = createMemo(() => fileFor(activeFile()));
339
- // Code applies only to text-ish files that carry source.
340
- const hasSource = createMemo(() => {
341
- const f = activeFileObj();
342
- return !!f && f.type !== 'image' && f.type !== 'pdf' && typeof f.code === 'string';
343
- });
344
-
345
- /** Push a new entry (truncating any forward history) and load it. */
346
- function navigate(url: string) {
347
- if (url === currentUrl()) {
348
- reload();
349
- return;
350
- }
351
- setHistory((h) => [...h.slice(0, cursor() + 1), url]);
352
- setCursor((c) => c + 1);
353
- loadCurrent();
354
- }
355
-
356
- /** Point the iframe at the current cursor entry + emit `navigate`. */
357
- function loadCurrent() {
358
- if (iframeEl) iframeEl.src = framedUrl() || 'about:blank';
359
- // Reports the REAL url, not the framed one: a consumer observing navigation
360
- // (or a card patching `src` back into its envelope) must not be told the
361
- // model sent something it did not.
362
- local.onNavigate?.(currentUrl());
363
- }
364
-
365
- function selectTab(next: ArtifactTab) {
366
- if (next === tab()) return;
367
- setTab(next);
368
- local.onTabChange?.(next);
369
- }
370
-
371
- function selectFile(path: string, file: ArtifactFile) {
372
- setActiveFile(path);
373
- local.onFileSelect?.(path);
374
- navigate(resolveFileUrl(file, local.src));
375
- }
376
-
377
- // Toolbar actions — operate on the component's own history stack (see above).
378
- const goBack = () => {
379
- if (!canBack()) return;
380
- setCursor((c) => c - 1);
381
- loadCurrent();
382
- };
383
- const goForward = () => {
384
- if (!canForward()) return;
385
- setCursor((c) => c + 1);
386
- loadCurrent();
387
- };
388
- function reload() {
389
- if (iframeEl) {
390
- // Force a real reload even when the src is unchanged.
391
- iframeEl.src = 'about:blank';
392
- iframeEl.src = framedUrl() || 'about:blank';
393
- }
394
- setReloadKey((k) => k + 1); // re-render the inline PDF viewer too
395
- local.onNavigate?.(currentUrl());
396
- }
397
- const goHome = () => {
398
- if (local.src) navigate(local.src);
399
- };
400
-
401
- // Explicitly drive the maximize view-state to a target (the imperative twin of
402
- // the toggle button). Fires onMaximizeChange only when the value actually flips,
403
- // matching toggleMaximize's "report on change" contract.
404
- const setMaximizeState = (next: boolean) => {
405
- if (maximized() === next) return;
406
- setMaximized(next);
407
- local.onMaximizeChange?.(next);
408
- };
409
-
410
- // --- Imperative controller (Pattern C): hand the facade a handle over the
411
- // artifact's latent toolbar capabilities. Every method delegates to the
412
- // SAME internal handler the toolbar buttons use, so navigate/tab-change/
413
- // file-select/maximize-change all still fire. ---
414
- onMount(() => {
415
- local.controllerRef?.({
416
- back: () => goBack(),
417
- forward: () => goForward(),
418
- reload: () => reload(),
419
- home: () => goHome(),
420
- navigate: (url) => navigate(url),
421
- // Resolve the file for the path so highlight + source + preview all sync;
422
- // a path with no matching file entry is a no-op (nothing to show).
423
- selectFile: (path) => {
424
- const file = fileFor(path);
425
- if (file) selectFile(path, file);
426
- },
427
- openExternal: () => openInNewTab(),
428
- maximize: () => setMaximizeState(true),
429
- restore: () => setMaximizeState(false),
430
- });
431
- });
432
-
433
- // Best-effort: if the consumer opted into `allow-same-origin`, keep the path
434
- // field truthful as the framed doc navigates itself. Cross-origin (the secure
435
- // default) throws and we keep our own stack.
436
- const onIframeLoad = () => {
437
- try {
438
- const href = iframeEl?.contentWindow?.location.href;
439
- if (href && href !== 'about:blank' && href !== currentUrl()) {
440
- setHistory((h) => [...h.slice(0, cursor() + 1), href]);
441
- setCursor((c) => c + 1);
442
- local.onNavigate?.(href);
443
- }
444
- } catch {
445
- /* cross-origin (sandboxed without allow-same-origin): keep our own url */
446
- }
447
- };
448
-
449
- const submitPath = (e: Event) => {
450
- e.preventDefault();
451
- const input = (e.currentTarget as HTMLFormElement).elements.namedItem(
452
- 'kai-artifact-path',
453
- ) as HTMLInputElement | null;
454
- if (local.readonlyPath || local.displayUrl != null) {
455
- // Submit is a no-op while read-only or showing a friendly displayUrl; keep
456
- // the field reflecting the shown value (never push the real, possibly data:, url).
457
- if (input) input.value = local.displayUrl ?? currentUrl();
458
- return;
459
- }
460
- if (input && input.value) navigate(input.value);
461
- };
462
-
463
- return (
464
- <div
465
- class={cn(
466
- 'flex h-full w-full flex-col overflow-hidden bg-card text-card-foreground',
467
- local.standalone && 'rounded-xl border border-border',
468
- local.class,
469
- )}
470
- {...rest}
471
- >
472
- <Show when={showAnyToolbar()}>
473
- <ArtifactToolbar
474
- url={() => local.displayUrl ?? currentUrl()}
475
- tab={tab}
476
- canBack={canBack}
477
- canForward={canForward}
478
- canHome={() => !!local.src}
479
- onBack={goBack}
480
- onForward={goForward}
481
- onReload={reload}
482
- onHome={goHome}
483
- onSubmitPath={submitPath}
484
- onTab={selectTab}
485
- showNav={() => local.showNav}
486
- showReload={() => local.showReload}
487
- showHome={() => local.showHome}
488
- showPathField={() => local.showPathField}
489
- showTabs={() => local.showTabs}
490
- showExpand={showExpand}
491
- showOpenInTab={() => local.openInTab}
492
- maximized={maximized}
493
- onToggleMaximize={toggleMaximize}
494
- canOpenInTab={canOpenInTab}
495
- onOpenInTab={openInNewTab}
496
- readonlyPath={() => local.readonlyPath || local.displayUrl != null}
497
- />
498
- </Show>
499
- <div class="relative min-h-0 flex-1">
500
- <Show
501
- when={tab() === 'preview'}
502
- fallback={
503
- <ArtifactCode
504
- files={local.files}
505
- activeFile={activeFile}
506
- activeFileObj={activeFileObj}
507
- hasSource={hasSource}
508
- onSelect={selectFile}
509
- />
510
- }
511
- >
512
- <Show
513
- when={isPdfUrl(currentUrl(), local.files)}
514
- fallback={
515
- <ArtifactPreview
516
- ref={(el) => (iframeEl = el)}
517
- src={framedUrl}
518
- sandbox={local.sandbox}
519
- title={local.iframeTitle ?? 'Artifact preview'}
520
- onLoad={onIframeLoad}
521
- />
522
- }
523
- >
524
- <ArtifactPdfPreview url={currentUrl()} reloadKey={reloadKey()} />
525
- </Show>
526
- </Show>
527
- </div>
528
- </div>
529
- );
530
- }
531
-
532
- // --- ArtifactToolbar (internal) -------------------------------------------
533
-
534
- interface ToolbarProps {
535
- url: () => string;
536
- tab: () => ArtifactTab;
537
- canBack: () => boolean;
538
- canForward: () => boolean;
539
- canHome: () => boolean;
540
- onBack: () => void;
541
- onForward: () => void;
542
- onReload: () => void;
543
- onHome: () => void;
544
- onSubmitPath: (e: Event) => void;
545
- onTab: (tab: ArtifactTab) => void;
546
- showNav: () => boolean;
547
- showReload: () => boolean;
548
- showHome: () => boolean;
549
- showPathField: () => boolean;
550
- showTabs: () => boolean;
551
- showExpand: () => boolean;
552
- showOpenInTab: () => boolean;
553
- maximized: () => boolean;
554
- onToggleMaximize: () => void;
555
- canOpenInTab: () => boolean;
556
- onOpenInTab: () => void;
557
- readonlyPath: () => boolean;
558
- }
559
-
560
- function ArtifactToolbar(props: ToolbarProps): JSX.Element {
561
- return (
562
- <div
563
- data-artifact-toolbar
564
- class="flex shrink-0 items-center gap-1.5 border-b border-border bg-surface px-2 py-1.5"
565
- >
566
- <Show when={props.showNav()}>
567
- <Button
568
- variant="ghost"
569
- size="icon-sm"
570
- aria-label="Back"
571
- disabled={!props.canBack()}
572
- onClick={() => props.onBack()}
573
- >
574
- <ArrowLeft size={16} aria-hidden="true" />
575
- </Button>
576
- <Button
577
- variant="ghost"
578
- size="icon-sm"
579
- aria-label="Forward"
580
- disabled={!props.canForward()}
581
- onClick={() => props.onForward()}
582
- >
583
- <ArrowRight size={16} aria-hidden="true" />
584
- </Button>
585
- </Show>
586
- <Show when={props.showReload()}>
587
- <Button variant="ghost" size="icon-sm" aria-label="Reload" onClick={() => props.onReload()}>
588
- <RotateCw size={15} aria-hidden="true" />
589
- </Button>
590
- </Show>
591
- <Show when={props.showHome()}>
592
- <Button
593
- variant="ghost"
594
- size="icon-sm"
595
- aria-label="Home"
596
- disabled={!props.canHome()}
597
- onClick={() => props.onHome()}
598
- >
599
- <House size={15} aria-hidden="true" />
600
- </Button>
601
- </Show>
602
- <Show when={props.showPathField()}>
603
- <form class="min-w-0 flex-1" onSubmit={(e) => props.onSubmitPath(e)}>
604
- <label class="sr-only" for="kai-artifact-path">
605
- Address
606
- </label>
607
- <input
608
- id="kai-artifact-path"
609
- name="kai-artifact-path"
610
- type="text"
611
- spellcheck={false}
612
- autocomplete="off"
613
- readonly={props.readonlyPath() || undefined}
614
- aria-readonly={props.readonlyPath() ? 'true' : undefined}
615
- value={props.url()}
616
- class={cn(
617
- 'h-7 w-full rounded-md border border-border px-2.5 text-xs text-foreground font-mono outline-none',
618
- props.readonlyPath()
619
- ? 'bg-muted/40 cursor-default'
620
- : 'bg-background focus-visible:ring-2 focus-visible:ring-ring',
621
- )}
622
- placeholder="Enter a path or URL…"
623
- />
624
- </form>
625
- </Show>
626
- <Show when={!props.showPathField()}>
627
- <div class="flex-1" aria-hidden="true" />
628
- </Show>
629
- <Show when={props.showExpand()}>
630
- <Button
631
- variant="ghost"
632
- size="icon-sm"
633
- aria-label={props.maximized() ? 'Collapse' : 'Expand'}
634
- aria-expanded={props.maximized()}
635
- onClick={() => props.onToggleMaximize()}
636
- >
637
- <Show when={props.maximized()} fallback={<Maximize2 size={15} aria-hidden="true" />}>
638
- <Minimize2 size={15} aria-hidden="true" />
639
- </Show>
640
- </Button>
641
- </Show>
642
- <Show when={props.showOpenInTab()}>
643
- <Button
644
- variant="ghost"
645
- size="icon-sm"
646
- aria-label="Open in new tab"
647
- disabled={!props.canOpenInTab()}
648
- onClick={() => props.onOpenInTab()}
649
- >
650
- <ExternalLink size={15} aria-hidden="true" />
651
- </Button>
652
- </Show>
653
- <Show when={props.showTabs()}>
654
- <div
655
- role="tablist"
656
- aria-label="View"
657
- class="flex shrink-0 items-center gap-0.5 rounded-md bg-muted p-0.5"
658
- >
659
- <SegmentButton
660
- label="Preview"
661
- icon={<Eye size={14} aria-hidden="true" />}
662
- selected={props.tab() === 'preview'}
663
- onClick={() => props.onTab('preview')}
664
- />
665
- <SegmentButton
666
- label="Code"
667
- icon={<CodeIcon size={14} aria-hidden="true" />}
668
- selected={props.tab() === 'code'}
669
- onClick={() => props.onTab('code')}
670
- />
671
- </div>
672
- </Show>
673
- </div>
674
- );
675
- }
676
-
677
- function SegmentButton(props: {
678
- label: string;
679
- icon: JSX.Element;
680
- selected: boolean;
681
- onClick: () => void;
682
- }): JSX.Element {
683
- return (
684
- <button
685
- type="button"
686
- role="tab"
687
- aria-selected={props.selected}
688
- class={cn(
689
- 'inline-flex h-6 items-center gap-1.5 rounded px-2 text-xs font-medium transition-colors outline-none',
690
- 'focus-visible:ring-2 focus-visible:ring-ring',
691
- props.selected
692
- ? 'bg-background text-foreground shadow-sm'
693
- : 'text-muted-foreground hover:text-foreground',
694
- )}
695
- onClick={() => props.onClick()}
696
- >
697
- {props.icon}
698
- {props.label}
699
- </button>
700
- );
701
- }
702
-
703
- // --- ArtifactPreview (internal) -------------------------------------------
704
-
705
- interface PreviewProps {
706
- ref: (el: HTMLIFrameElement) => void;
707
- src: () => string;
708
- sandbox: string;
709
- title: string;
710
- onLoad: () => void;
711
- }
712
-
713
- function ArtifactPreview(props: PreviewProps): JSX.Element {
714
- return (
715
- <iframe
716
- ref={props.ref}
717
- src={props.src() || 'about:blank'}
718
- sandbox={props.sandbox}
719
- title={props.title}
720
- class="absolute inset-0 h-full w-full border-0 bg-white"
721
- onLoad={() => props.onLoad()}
722
- />
723
- );
724
- }
725
-
726
- // --- ArtifactCode (internal) ----------------------------------------------
727
-
728
- interface CodeProps {
729
- files: ArtifactFile[];
730
- activeFile: () => string | undefined;
731
- activeFileObj: () => ArtifactFile | undefined;
732
- hasSource: () => boolean;
733
- onSelect: (path: string, file: ArtifactFile) => void;
734
- }
735
-
736
- function ArtifactCode(props: CodeProps): JSX.Element {
737
- return (
738
- <div class="absolute inset-0 flex">
739
- <div class="w-56 shrink-0 overflow-auto border-r border-border bg-surface-sunken py-1.5 scrollbar-thin">
740
- <FileTree
741
- files={props.files}
742
- activeFile={props.activeFile()}
743
- onSelect={(path, file) => props.onSelect(path, file)}
744
- />
745
- </div>
746
- <div class="min-w-0 flex-1 overflow-auto scrollbar-thin">
747
- <Show
748
- when={props.activeFileObj()}
749
- fallback={
750
- <div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
751
- Select a file to view its source.
752
- </div>
753
- }
754
- >
755
- <Show
756
- when={props.hasSource()}
757
- fallback={
758
- <div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
759
- No source — this file ({props.activeFileObj()!.type ?? 'binary'}) has no code view.
760
- </div>
761
- }
762
- >
763
- <CodeBlock class="h-full rounded-none border-0">
764
- <CodeBlockCode
765
- code={props.activeFileObj()!.code ?? ''}
766
- language={props.activeFileObj()!.language}
767
- />
768
- </CodeBlock>
769
- </Show>
770
- </Show>
771
- </div>
772
- </div>
773
- );
774
- }
775
-
776
- // --- ArtifactPdfFallback (internal) ---------------------------------------
777
-
778
- /** Shown when inline PDF rendering is disabled or fails (CORS / load / parse).
779
- *
780
- * THIS IS THE DEFAULT VIEW FOR A HOSTILE URL, not an edge case: pdf.js has to
781
- * FETCH the document to render it inline, and it cannot fetch a `javascript:`
782
- * or `data:` one, so every such url fails straight through to here -- where two
783
- * anchors used to put it in an `href`, one click from executing in the host
784
- * page's origin. The url is model-reachable (`artifact` card `src` /
785
- * `files[].url`, schema-validated only as `format: "uri"`), so it is filtered
786
- * with the same `isSafeUrl` the markdown renderer and the card `open` verb use.
787
- * Relative paths pass, which matters here: `resolveFileUrl` returns a bare
788
- * `path` when the artifact has no `src`. */
789
- function ArtifactPdfFallback(props: { url: string }): JSX.Element {
790
- const name = () => {
791
- const path = props.url.split(/[?#]/)[0];
792
- return path.slice(path.lastIndexOf('/') + 1) || 'document.pdf';
793
- };
794
- /** The url actually put in `href`, or `undefined` -- which OMITS the attribute.
795
- * Never `''`: an empty href navigates to the current page. */
796
- const href = createMemo(() => {
797
- if (!props.url) return undefined;
798
- if (isSafeUrl(props.url)) return props.url;
799
- warnBlockedUrl('pdf fallback', props.url);
800
- return undefined;
801
- });
802
- const linkClass =
803
- 'inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
804
- return (
805
- <div
806
- role="region"
807
- aria-label="PDF preview unavailable"
808
- class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-card p-6 text-center"
809
- >
810
- <FileText size={40} class="text-muted-foreground" aria-hidden="true" />
811
- <div class="text-sm font-medium text-foreground">{name()}</div>
812
- <div class="text-xs text-muted-foreground">Can't preview this PDF inline.</div>
813
- {/* No safe address, no controls. Two buttons that silently do nothing
814
- would be worse than saying what happened -- and a bare `<a>` with the
815
- href dropped is exactly that. */}
816
- <Show
817
- when={href()}
818
- fallback={
819
- <div class="text-xs text-muted-foreground">
820
- Blocked: that address uses a scheme this viewer won't open.
821
- </div>
822
- }
823
- >
824
- <div class="flex flex-wrap items-center justify-center gap-2">
825
- <a class={linkClass} href={href()} target="_blank" rel="noopener noreferrer">
826
- Open in new tab
827
- <ExternalLink size={13} aria-hidden="true" />
828
- </a>
829
- <a class={linkClass} href={href()} download="">
830
- <Download size={13} aria-hidden="true" />
831
- Download
832
- </a>
833
- </div>
834
- </Show>
835
- </div>
836
- );
837
- }
838
-
839
- // --- ArtifactPdfPreview (internal) ----------------------------------------
840
-
841
- /**
842
- * Renders a PDF inline via pdf.js (loaded on demand). Four states:
843
- * disabled → fallback (no network); loading → spinner; success → stacked
844
- * fit-width canvases; error (load/CORS/parse) → fallback card. Re-renders when
845
- * the url or `reloadKey` changes, and (debounced) when the panel resizes.
846
- */
847
- function ArtifactPdfPreview(props: { url: string; reloadKey: number }): JSX.Element {
848
- const [state, setState] = createSignal<'loading' | 'success' | 'error'>('loading');
849
- let container: HTMLDivElement | undefined;
850
- let token = 0;
851
- let resizeTimer: ReturnType<typeof setTimeout> | undefined;
852
-
853
- const renderNow = async () => {
854
- if (!isPdfPreviewEnabled() || !container) {
855
- setState('error');
856
- return;
857
- }
858
- const mine = ++token;
859
- setState('loading');
860
- try {
861
- const width = container.clientWidth || 600;
862
- await renderPdfInto(props.url, container, width);
863
- if (mine === token) setState('success');
864
- } catch {
865
- if (mine === token) setState('error');
866
- }
867
- };
868
-
869
- createEffect(
870
- on(
871
- () => [props.url, props.reloadKey] as const,
872
- () => {
873
- void renderNow();
874
- },
875
- ),
876
- );
877
-
878
- onMount(() => {
879
- if (!container || typeof ResizeObserver === 'undefined') return;
880
- const ro = new ResizeObserver(() => {
881
- if (state() !== 'success') return;
882
- clearTimeout(resizeTimer);
883
- resizeTimer = setTimeout(() => void renderNow(), 200);
884
- });
885
- ro.observe(container);
886
- onCleanup(() => {
887
- ro.disconnect();
888
- clearTimeout(resizeTimer);
889
- token++; // cancel any in-flight render
890
- });
891
- });
892
-
893
- return (
894
- <div class="absolute inset-0">
895
- {/* Always present so clientWidth is the real panel width. */}
896
- <div
897
- ref={(el) => (container = el)}
898
- role="region"
899
- aria-label="PDF preview"
900
- aria-busy={state() === 'loading'}
901
- tabindex="0"
902
- class="absolute inset-0 flex flex-col items-center gap-3 overflow-auto bg-surface-sunken p-3 scrollbar-thin focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
903
- />
904
- <Show when={isPdfPreviewEnabled() && state() === 'loading'}>
905
- <div class="pointer-events-none absolute inset-0 flex items-center justify-center">
906
- <Loader variant="circular" size="md" />
907
- </div>
908
- </Show>
909
- <Show when={!isPdfPreviewEnabled() || state() === 'error'}>
910
- <ArtifactPdfFallback url={props.url} />
911
- </Show>
912
- </div>
913
- );
914
- }