@kitnai/chat 0.7.0 → 0.8.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 (212) hide show
  1. package/README.md +9 -9
  2. package/dist/custom-elements.json +1626 -883
  3. package/dist/kitn-chat.es.js +36 -36
  4. package/dist/llms/llms-full.txt +303 -142
  5. package/dist/llms/llms.txt +18 -18
  6. package/dist/schemas/card-envelope.schema.json +14 -0
  7. package/dist/schemas/card-event.schema.json +12 -0
  8. package/dist/schemas/confirm.schema.json +65 -0
  9. package/dist/schemas/embed.schema.json +65 -0
  10. package/dist/schemas/form.result.schema.json +7 -0
  11. package/dist/schemas/form.schema.json +33 -0
  12. package/dist/schemas/link.schema.json +56 -0
  13. package/dist/schemas/task-list.result.schema.json +16 -0
  14. package/dist/schemas/task-list.schema.json +78 -0
  15. package/dist/theme.tokens.css +65 -65
  16. package/dist/tsx-B8rCNbgL.js +1 -0
  17. package/dist/typescript-RycA9KXf.js +1 -0
  18. package/frameworks/react/index.tsx +356 -189
  19. package/frameworks/react/runtime.tsx +2 -2
  20. package/llms-full.txt +303 -142
  21. package/llms.txt +18 -18
  22. package/package.json +5 -2
  23. package/src/components/artifact.stories.tsx +138 -0
  24. package/src/components/artifact.tsx +581 -0
  25. package/src/components/attachments.stories.tsx +7 -8
  26. package/src/components/attachments.tsx +2 -2
  27. package/src/components/card.tsx +110 -0
  28. package/src/components/chain-of-thought.stories.tsx +7 -8
  29. package/src/components/chat-container.stories.tsx +7 -8
  30. package/src/components/chat-container.tsx +4 -0
  31. package/src/components/checkpoint.stories.tsx +7 -8
  32. package/src/components/code-block.stories.tsx +8 -9
  33. package/src/components/component-meta.json +3411 -0
  34. package/src/components/confirm-card.stories.tsx +74 -0
  35. package/src/components/confirm-card.tsx +299 -0
  36. package/src/components/context.stories.tsx +7 -8
  37. package/src/components/conversation-item.stories.tsx +7 -8
  38. package/src/components/conversation-item.tsx +2 -2
  39. package/src/components/conversation-list.stories.tsx +7 -8
  40. package/src/components/conversation-list.tsx +1 -1
  41. package/src/components/embed.tsx +196 -0
  42. package/src/components/empty.stories.tsx +8 -9
  43. package/src/components/feedback-bar.stories.tsx +7 -8
  44. package/src/components/file-tree.stories.tsx +73 -0
  45. package/src/components/file-tree.tsx +383 -0
  46. package/src/components/file-upload.stories.tsx +7 -8
  47. package/src/components/form-widgets.tsx +461 -0
  48. package/src/components/form.tsx +796 -0
  49. package/src/components/image.stories.tsx +7 -8
  50. package/src/components/link-card.tsx +194 -0
  51. package/src/components/loader.stories.tsx +7 -8
  52. package/src/components/markdown.stories.tsx +7 -8
  53. package/src/components/message-narrow.stories.tsx +12 -13
  54. package/src/components/message-skills.stories.tsx +16 -17
  55. package/src/components/message.stories.tsx +17 -18
  56. package/src/components/model-switcher.stories.tsx +7 -8
  57. package/src/components/prompt-input.stories.tsx +8 -9
  58. package/src/components/prompt-suggestion.stories.tsx +7 -8
  59. package/src/components/prompt-suggestion.tsx +3 -3
  60. package/src/components/reasoning.stories.tsx +7 -8
  61. package/src/components/scroll-button.stories.tsx +7 -8
  62. package/src/components/slash-command.stories.tsx +8 -9
  63. package/src/components/slash-command.tsx +2 -2
  64. package/src/components/source.stories.tsx +7 -8
  65. package/src/components/source.tsx +1 -1
  66. package/src/components/task-list-card.stories.tsx +78 -0
  67. package/src/components/task-list-card.tsx +388 -0
  68. package/src/components/text-shimmer.stories.tsx +7 -8
  69. package/src/components/thinking-bar.stories.tsx +7 -8
  70. package/src/components/tool.stories.tsx +7 -8
  71. package/src/components/tool.tsx +2 -2
  72. package/src/components/voice-input.stories.tsx +7 -8
  73. package/src/elements/artifact.stories.tsx +291 -0
  74. package/src/elements/artifact.tsx +72 -0
  75. package/src/elements/{kitn-attachments.stories.tsx → attachments.stories.tsx} +11 -20
  76. package/src/elements/attachments.tsx +4 -4
  77. package/src/elements/card.stories.tsx +118 -0
  78. package/src/elements/card.tsx +40 -0
  79. package/src/elements/catalog.stories.tsx +491 -0
  80. package/src/elements/{kitn-chain-of-thought.stories.tsx → chain-of-thought.stories.tsx} +13 -22
  81. package/src/elements/chain-of-thought.tsx +3 -3
  82. package/src/elements/{kitn-chat-scope-picker.stories.tsx → chat-scope-picker.stories.tsx} +10 -19
  83. package/src/elements/chat-scope-picker.tsx +4 -4
  84. package/src/elements/{kitn-chat-workspace.stories.tsx → chat-workspace.stories.tsx} +15 -23
  85. package/src/elements/chat-workspace.tsx +2 -2
  86. package/src/elements/{kitn-chat.stories.tsx → chat.stories.tsx} +12 -20
  87. package/src/elements/chat.tsx +2 -2
  88. package/src/elements/{kitn-checkpoint.stories.tsx → checkpoint.stories.tsx} +11 -20
  89. package/src/elements/checkpoint.tsx +4 -4
  90. package/src/elements/{kitn-code-block.stories.tsx → code-block.stories.tsx} +10 -19
  91. package/src/elements/code-block.tsx +3 -3
  92. package/src/elements/compiled.css +1 -1
  93. package/src/elements/composed-shell.stories.tsx +316 -0
  94. package/src/elements/confirm-card.stories.tsx +186 -0
  95. package/src/elements/confirm-card.tsx +45 -0
  96. package/src/elements/{kitn-context-meter.stories.tsx → context-meter.stories.tsx} +10 -19
  97. package/src/elements/context-meter.tsx +3 -3
  98. package/src/elements/{kitn-conversation-list.stories.tsx → conversation-list.stories.tsx} +12 -20
  99. package/src/elements/conversation-list.tsx +2 -2
  100. package/src/elements/css.ts +1 -1
  101. package/src/elements/define.tsx +10 -10
  102. package/src/elements/element-meta.json +1379 -733
  103. package/src/elements/element-types.d.ts +251 -125
  104. package/src/elements/embed.stories.tsx +197 -0
  105. package/src/elements/embed.tsx +35 -0
  106. package/src/elements/{kitn-empty.stories.tsx → empty.stories.tsx} +12 -21
  107. package/src/elements/empty.tsx +3 -3
  108. package/src/elements/{kitn-feedback-bar.stories.tsx → feedback-bar.stories.tsx} +11 -20
  109. package/src/elements/feedback-bar.tsx +4 -4
  110. package/src/elements/file-tree.stories.tsx +133 -0
  111. package/src/elements/file-tree.tsx +52 -0
  112. package/src/elements/{kitn-file-upload.stories.tsx → file-upload.stories.tsx} +12 -21
  113. package/src/elements/file-upload.tsx +4 -4
  114. package/src/elements/form.stories.tsx +204 -0
  115. package/src/elements/form.tsx +37 -0
  116. package/src/elements/{kitn-image.stories.tsx → image.stories.tsx} +10 -19
  117. package/src/elements/image.tsx +3 -3
  118. package/src/elements/link-card.stories.tsx +193 -0
  119. package/src/elements/link-card.tsx +34 -0
  120. package/src/elements/{kitn-loader.stories.tsx → loader.stories.tsx} +11 -20
  121. package/src/elements/loader.tsx +3 -3
  122. package/src/elements/{kitn-markdown.stories.tsx → markdown.stories.tsx} +10 -19
  123. package/src/elements/markdown.tsx +3 -3
  124. package/src/elements/{kitn-message-skills.stories.tsx → message-skills.stories.tsx} +10 -19
  125. package/src/elements/message-skills.tsx +3 -3
  126. package/src/elements/{kitn-message.stories.tsx → message.stories.tsx} +12 -21
  127. package/src/elements/message.tsx +5 -5
  128. package/src/elements/{kitn-model-switcher.stories.tsx → model-switcher.stories.tsx} +10 -19
  129. package/src/elements/model-switcher.tsx +5 -5
  130. package/src/elements/{kitn-prompt-input.stories.tsx → prompt-input.stories.tsx} +14 -22
  131. package/src/elements/prompt-input.tsx +3 -3
  132. package/src/elements/{kitn-prompt-suggestions.stories.tsx → prompt-suggestions.stories.tsx} +13 -22
  133. package/src/elements/prompt-suggestions.tsx +4 -4
  134. package/src/elements/{kitn-reasoning.stories.tsx → reasoning.stories.tsx} +10 -19
  135. package/src/elements/reasoning.tsx +4 -4
  136. package/src/elements/register.ts +11 -1
  137. package/src/elements/resizable.stories.tsx +200 -0
  138. package/src/elements/resizable.tsx +264 -0
  139. package/src/elements/{kitn-response-stream.stories.tsx → response-stream.stories.tsx} +10 -19
  140. package/src/elements/response-stream.tsx +4 -4
  141. package/src/elements/{kitn-source-list.stories.tsx → source-list.stories.tsx} +11 -20
  142. package/src/elements/{kitn-source.stories.tsx → source.stories.tsx} +12 -21
  143. package/src/elements/source.tsx +5 -5
  144. package/src/elements/styles.css +140 -1
  145. package/src/elements/task-list-card.stories.tsx +194 -0
  146. package/src/elements/task-list-card.tsx +40 -0
  147. package/src/elements/{kitn-text-shimmer.stories.tsx → text-shimmer.stories.tsx} +10 -19
  148. package/src/elements/text-shimmer.tsx +3 -3
  149. package/src/elements/{kitn-thinking-bar.stories.tsx → thinking-bar.stories.tsx} +11 -20
  150. package/src/elements/thinking-bar.tsx +5 -5
  151. package/src/elements/{kitn-tool.stories.tsx → tool.stories.tsx} +10 -19
  152. package/src/elements/tool.tsx +3 -3
  153. package/src/elements/{kitn-voice-input.stories.tsx → voice-input.stories.tsx} +10 -19
  154. package/src/elements/voice-input.tsx +4 -4
  155. package/src/index.ts +94 -2
  156. package/src/primitives/card-contract.ts +60 -0
  157. package/src/primitives/card-host.tsx +35 -0
  158. package/src/primitives/card-routing.ts +79 -0
  159. package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
  160. package/src/primitives/card-schemas/card-event.schema.json +12 -0
  161. package/src/primitives/card-schemas/confirm.schema.json +65 -0
  162. package/src/primitives/card-schemas/embed.schema.json +65 -0
  163. package/src/primitives/card-schemas/form.result.schema.json +7 -0
  164. package/src/primitives/card-schemas/form.schema.json +33 -0
  165. package/src/primitives/card-schemas/link.schema.json +56 -0
  166. package/src/primitives/card-schemas/task-list.result.schema.json +16 -0
  167. package/src/primitives/card-schemas/task-list.schema.json +78 -0
  168. package/src/primitives/card-validate.ts +95 -0
  169. package/src/primitives/embed-providers.ts +254 -0
  170. package/src/primitives/highlighter.ts +4 -0
  171. package/src/primitives/link-preview.ts +87 -0
  172. package/src/primitives/pdf-preview.ts +121 -0
  173. package/src/stories/chat-panel-layout.stories.tsx +2 -1
  174. package/src/stories/chat-scene.tsx +22 -21
  175. package/src/stories/checkpoint-restore.stories.tsx +10 -10
  176. package/src/stories/conversation-with-reasoning.stories.tsx +4 -4
  177. package/src/stories/conversation-with-sources.stories.tsx +7 -7
  178. package/src/stories/docs/Accessibility.mdx +2 -2
  179. package/src/stories/docs/ForAIAgents.mdx +3 -3
  180. package/src/stories/docs/GettingStarted.mdx +2 -2
  181. package/src/stories/docs/Installation.mdx +2 -2
  182. package/src/stories/docs/Integrations.mdx +29 -29
  183. package/src/stories/docs/Introduction.mdx +3 -3
  184. package/src/stories/docs/Theming.mdx +2 -2
  185. package/src/stories/docs/element-controls.ts +32 -0
  186. package/src/stories/docs/theme-editor/theme-editor.tsx +1 -0
  187. package/src/stories/examples/ChoosingComponents.mdx +94 -0
  188. package/src/stories/examples/sample-data.ts +79 -0
  189. package/src/stories/message-actions.stories.tsx +13 -13
  190. package/src/stories/pattern-centered-conversation.stories.tsx +3 -3
  191. package/src/stories/pattern-docked-widget.stories.tsx +1 -1
  192. package/src/stories/pattern-empty-state.stories.tsx +3 -3
  193. package/src/stories/prompt-input-variants.stories.tsx +13 -13
  194. package/src/stories/streaming-response.stories.tsx +3 -3
  195. package/src/stories/typography.stories.tsx +4 -4
  196. package/src/ui/avatar.stories.tsx +7 -8
  197. package/src/ui/badge.stories.tsx +7 -8
  198. package/src/ui/button.stories.tsx +8 -9
  199. package/src/ui/button.tsx +1 -0
  200. package/src/ui/collapsible.stories.tsx +6 -7
  201. package/src/ui/dropdown.stories.tsx +6 -7
  202. package/src/ui/hover-card.stories.tsx +6 -7
  203. package/src/ui/resizable.stories.tsx +74 -9
  204. package/src/ui/resizable.tsx +351 -71
  205. package/src/ui/scroll-area.stories.tsx +6 -7
  206. package/src/ui/scroll-area.tsx +3 -1
  207. package/src/ui/separator.stories.tsx +7 -8
  208. package/src/ui/skeleton.stories.tsx +7 -8
  209. package/src/ui/textarea.stories.tsx +6 -7
  210. package/src/ui/tooltip.stories.tsx +8 -9
  211. package/theme.css +65 -65
  212. package/src/stories/docs/element-spec.tsx +0 -86
@@ -0,0 +1,581 @@
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 {
20
+ ArrowLeft,
21
+ ArrowRight,
22
+ RotateCw,
23
+ House,
24
+ Eye,
25
+ Code as CodeIcon,
26
+ FileText,
27
+ ExternalLink,
28
+ Download,
29
+ } from 'lucide-solid';
30
+
31
+ export type ArtifactTab = 'preview' | 'code';
32
+
33
+ /** A file the artifact can preview + show source for. */
34
+ export type ArtifactFile = FileTreeFile;
35
+
36
+ export interface ArtifactProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
37
+ /** URL the preview iframe frames. */
38
+ src?: string;
39
+ /** Files for the Code tab's tree (+ each file's preview `url`). */
40
+ files?: ArtifactFile[];
41
+ /** Active tab. Default `preview`. */
42
+ tab?: ArtifactTab;
43
+ /** Selected file path (syncs tree highlight + Code source + preview). */
44
+ activeFile?: string;
45
+ /** iframe `sandbox` override. Default `allow-scripts allow-forms`. */
46
+ sandbox?: string;
47
+ /** Accessible iframe title. */
48
+ iframeTitle?: string;
49
+ /** Fired when the preview navigates (back/forward/reload/path-edit/file-click). */
50
+ onNavigate?: (url: string) => void;
51
+ /** Fired when the Preview|Code tab changes. */
52
+ onTabChange?: (tab: ArtifactTab) => void;
53
+ /** Fired when a file is selected in the tree. */
54
+ onFileSelect?: (path: string) => void;
55
+ }
56
+
57
+ const DEFAULT_SANDBOX = 'allow-scripts allow-forms';
58
+
59
+ /** Resolve a file's preview URL: explicit `url`, else `<src-origin> + /path`. */
60
+ function resolveFileUrl(file: ArtifactFile, src: string | undefined): string {
61
+ if (file.url) return file.url;
62
+ if (!src) return file.path;
63
+ try {
64
+ return new URL(file.path, src).href;
65
+ } catch {
66
+ return file.path;
67
+ }
68
+ }
69
+
70
+ /** True when `url` should render as a PDF: a matching `files` entry is typed
71
+ * `'pdf'`, or the URL path (query/hash stripped) ends in `.pdf`. */
72
+ export function isPdfUrl(url: string, files: ArtifactFile[]): boolean {
73
+ if (!url) return false;
74
+ const match = files.find((f) => f.url === url || f.path === url);
75
+ if (match?.type === 'pdf') return true;
76
+ const path = url.split(/[?#]/)[0];
77
+ return /\.pdf$/i.test(path);
78
+ }
79
+
80
+ /**
81
+ * `Artifact` — a framed, switchable generated-artifact viewer. A functional nav
82
+ * toolbar (back · forward · reload · home + editable path field + Preview|Code
83
+ * toggle) over a sandboxed `<iframe>` (Preview) or a file-tree + `<kc-code-block>`
84
+ * (Code). The component self-navigates the iframe and emits `navigate` /
85
+ * `tabchange` / `fileselect` so a consumer can observe/sync.
86
+ */
87
+ export function Artifact(props: ArtifactProps): JSX.Element {
88
+ const merged = mergeProps(
89
+ { files: [] as ArtifactFile[], tab: 'preview' as ArtifactTab, sandbox: DEFAULT_SANDBOX },
90
+ props,
91
+ );
92
+ const [local, rest] = splitProps(merged, [
93
+ 'src',
94
+ 'files',
95
+ 'tab',
96
+ 'activeFile',
97
+ 'sandbox',
98
+ 'iframeTitle',
99
+ 'onNavigate',
100
+ 'onTabChange',
101
+ 'onFileSelect',
102
+ 'class',
103
+ ]);
104
+
105
+ // A sandboxed iframe WITHOUT `allow-same-origin` makes the framed document's
106
+ // `contentWindow.history`/`location` opaque (a deliberate security property —
107
+ // see the spec). So the component keeps its OWN history stack for the
108
+ // navigations it initiates (path-edit, file-click, home, src changes) and
109
+ // back/forward/reload operate on that. (In-frame relative-link clicks navigate
110
+ // the iframe itself but can't be observed cross-origin — a known, accepted
111
+ // sandbox limitation; opt into `allow-same-origin` if the consumer trusts the
112
+ // artifact and wants those tracked.)
113
+ const [history, setHistory] = createSignal<string[]>(local.src ? [local.src] : []);
114
+ const [cursor, setCursor] = createSignal(local.src ? 0 : -1);
115
+ const currentUrl = () => history()[cursor()] ?? '';
116
+ const canBack = () => cursor() > 0;
117
+ const canForward = () => cursor() < history().length - 1;
118
+
119
+ const [tab, setTab] = createSignal<ArtifactTab>(local.tab);
120
+ const [activeFile, setActiveFile] = createSignal<string | undefined>(local.activeFile);
121
+ const [reloadKey, setReloadKey] = createSignal(0);
122
+
123
+ let iframeEl: HTMLIFrameElement | undefined;
124
+
125
+ // Controlled syncing: when the consumer changes the props, follow them.
126
+ createEffect(() => setTab(local.tab));
127
+ createEffect(() => setActiveFile(local.activeFile));
128
+ // `src` change → navigate. Use `on(local.src, …)` so the effect tracks ONLY
129
+ // the `src` prop — NOT `currentUrl()` — otherwise navigating away (file click,
130
+ // path edit) would re-trigger it and snap the iframe back to `src`. Skip the
131
+ // initial run: history is already seeded with `src` and the iframe binds it.
132
+ createEffect(
133
+ on(
134
+ () => local.src,
135
+ (next) => {
136
+ if (next && next !== currentUrl()) navigate(next);
137
+ },
138
+ { defer: true },
139
+ ),
140
+ );
141
+
142
+ const fileFor = (path: string | undefined): ArtifactFile | undefined =>
143
+ path === undefined ? undefined : local.files.find((f) => f.path === path);
144
+
145
+ const activeFileObj = createMemo(() => fileFor(activeFile()));
146
+ // Code applies only to text-ish files that carry source.
147
+ const hasSource = createMemo(() => {
148
+ const f = activeFileObj();
149
+ return !!f && f.type !== 'image' && f.type !== 'pdf' && typeof f.code === 'string';
150
+ });
151
+
152
+ /** Push a new entry (truncating any forward history) and load it. */
153
+ function navigate(url: string) {
154
+ if (url === currentUrl()) {
155
+ reload();
156
+ return;
157
+ }
158
+ setHistory((h) => [...h.slice(0, cursor() + 1), url]);
159
+ setCursor((c) => c + 1);
160
+ loadCurrent();
161
+ }
162
+
163
+ /** Point the iframe at the current cursor entry + emit `navigate`. */
164
+ function loadCurrent() {
165
+ const url = currentUrl();
166
+ if (iframeEl) iframeEl.src = url || 'about:blank';
167
+ local.onNavigate?.(url);
168
+ }
169
+
170
+ function selectTab(next: ArtifactTab) {
171
+ if (next === tab()) return;
172
+ setTab(next);
173
+ local.onTabChange?.(next);
174
+ }
175
+
176
+ function selectFile(path: string, file: ArtifactFile) {
177
+ setActiveFile(path);
178
+ local.onFileSelect?.(path);
179
+ navigate(resolveFileUrl(file, local.src));
180
+ }
181
+
182
+ // Toolbar actions — operate on the component's own history stack (see above).
183
+ const goBack = () => {
184
+ if (!canBack()) return;
185
+ setCursor((c) => c - 1);
186
+ loadCurrent();
187
+ };
188
+ const goForward = () => {
189
+ if (!canForward()) return;
190
+ setCursor((c) => c + 1);
191
+ loadCurrent();
192
+ };
193
+ function reload() {
194
+ const url = currentUrl();
195
+ if (iframeEl) {
196
+ // Force a real reload even when the src is unchanged.
197
+ iframeEl.src = 'about:blank';
198
+ iframeEl.src = url || 'about:blank';
199
+ }
200
+ setReloadKey((k) => k + 1); // re-render the inline PDF viewer too
201
+ local.onNavigate?.(url);
202
+ }
203
+ const goHome = () => {
204
+ if (local.src) navigate(local.src);
205
+ };
206
+
207
+ // Best-effort: if the consumer opted into `allow-same-origin`, keep the path
208
+ // field truthful as the framed doc navigates itself. Cross-origin (the secure
209
+ // default) throws and we keep our own stack.
210
+ const onIframeLoad = () => {
211
+ try {
212
+ const href = iframeEl?.contentWindow?.location.href;
213
+ if (href && href !== 'about:blank' && href !== currentUrl()) {
214
+ setHistory((h) => [...h.slice(0, cursor() + 1), href]);
215
+ setCursor((c) => c + 1);
216
+ local.onNavigate?.(href);
217
+ }
218
+ } catch {
219
+ /* cross-origin (sandboxed without allow-same-origin): keep our own url */
220
+ }
221
+ };
222
+
223
+ const submitPath = (e: Event) => {
224
+ e.preventDefault();
225
+ const input = (e.currentTarget as HTMLFormElement).elements.namedItem(
226
+ 'kc-artifact-path',
227
+ ) as HTMLInputElement | null;
228
+ if (input && input.value) navigate(input.value);
229
+ };
230
+
231
+ return (
232
+ <div
233
+ class={cn(
234
+ 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground',
235
+ local.class,
236
+ )}
237
+ {...rest}
238
+ >
239
+ <ArtifactToolbar
240
+ url={currentUrl}
241
+ tab={tab}
242
+ canBack={canBack}
243
+ canForward={canForward}
244
+ canHome={() => !!local.src}
245
+ onBack={goBack}
246
+ onForward={goForward}
247
+ onReload={reload}
248
+ onHome={goHome}
249
+ onSubmitPath={submitPath}
250
+ onTab={selectTab}
251
+ />
252
+ <div class="relative min-h-0 flex-1">
253
+ <Show
254
+ when={tab() === 'preview'}
255
+ fallback={
256
+ <ArtifactCode
257
+ files={local.files}
258
+ activeFile={activeFile}
259
+ activeFileObj={activeFileObj}
260
+ hasSource={hasSource}
261
+ onSelect={selectFile}
262
+ />
263
+ }
264
+ >
265
+ <Show
266
+ when={isPdfUrl(currentUrl(), local.files)}
267
+ fallback={
268
+ <ArtifactPreview
269
+ ref={(el) => (iframeEl = el)}
270
+ src={currentUrl}
271
+ sandbox={local.sandbox}
272
+ title={local.iframeTitle ?? 'Artifact preview'}
273
+ onLoad={onIframeLoad}
274
+ />
275
+ }
276
+ >
277
+ <ArtifactPdfPreview url={currentUrl()} reloadKey={reloadKey()} />
278
+ </Show>
279
+ </Show>
280
+ </div>
281
+ </div>
282
+ );
283
+ }
284
+
285
+ // --- ArtifactToolbar (internal) -------------------------------------------
286
+
287
+ interface ToolbarProps {
288
+ url: () => string;
289
+ tab: () => ArtifactTab;
290
+ canBack: () => boolean;
291
+ canForward: () => boolean;
292
+ canHome: () => boolean;
293
+ onBack: () => void;
294
+ onForward: () => void;
295
+ onReload: () => void;
296
+ onHome: () => void;
297
+ onSubmitPath: (e: Event) => void;
298
+ onTab: (tab: ArtifactTab) => void;
299
+ }
300
+
301
+ function ArtifactToolbar(props: ToolbarProps): JSX.Element {
302
+ return (
303
+ <div class="flex shrink-0 items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1.5">
304
+ <Button
305
+ variant="ghost"
306
+ size="icon-sm"
307
+ aria-label="Back"
308
+ disabled={!props.canBack()}
309
+ onClick={() => props.onBack()}
310
+ >
311
+ <ArrowLeft size={16} aria-hidden="true" />
312
+ </Button>
313
+ <Button
314
+ variant="ghost"
315
+ size="icon-sm"
316
+ aria-label="Forward"
317
+ disabled={!props.canForward()}
318
+ onClick={() => props.onForward()}
319
+ >
320
+ <ArrowRight size={16} aria-hidden="true" />
321
+ </Button>
322
+ <Button variant="ghost" size="icon-sm" aria-label="Reload" onClick={() => props.onReload()}>
323
+ <RotateCw size={15} aria-hidden="true" />
324
+ </Button>
325
+ <Button
326
+ variant="ghost"
327
+ size="icon-sm"
328
+ aria-label="Home"
329
+ disabled={!props.canHome()}
330
+ onClick={() => props.onHome()}
331
+ >
332
+ <House size={15} aria-hidden="true" />
333
+ </Button>
334
+ <form class="min-w-0 flex-1" onSubmit={(e) => props.onSubmitPath(e)}>
335
+ <label class="sr-only" for="kc-artifact-path">
336
+ Address
337
+ </label>
338
+ <input
339
+ id="kc-artifact-path"
340
+ name="kc-artifact-path"
341
+ type="text"
342
+ spellcheck={false}
343
+ autocomplete="off"
344
+ value={props.url()}
345
+ class={cn(
346
+ 'h-7 w-full rounded-md border border-border bg-background px-2.5 text-xs text-foreground',
347
+ 'font-mono outline-none focus-visible:ring-2 focus-visible:ring-ring',
348
+ )}
349
+ placeholder="Enter a path or URL…"
350
+ />
351
+ </form>
352
+ <div
353
+ role="tablist"
354
+ aria-label="View"
355
+ class="flex shrink-0 items-center gap-0.5 rounded-md bg-muted p-0.5"
356
+ >
357
+ <SegmentButton
358
+ label="Preview"
359
+ icon={<Eye size={14} aria-hidden="true" />}
360
+ selected={props.tab() === 'preview'}
361
+ onClick={() => props.onTab('preview')}
362
+ />
363
+ <SegmentButton
364
+ label="Code"
365
+ icon={<CodeIcon size={14} aria-hidden="true" />}
366
+ selected={props.tab() === 'code'}
367
+ onClick={() => props.onTab('code')}
368
+ />
369
+ </div>
370
+ </div>
371
+ );
372
+ }
373
+
374
+ function SegmentButton(props: {
375
+ label: string;
376
+ icon: JSX.Element;
377
+ selected: boolean;
378
+ onClick: () => void;
379
+ }): JSX.Element {
380
+ return (
381
+ <button
382
+ type="button"
383
+ role="tab"
384
+ aria-selected={props.selected}
385
+ class={cn(
386
+ 'inline-flex h-6 items-center gap-1.5 rounded px-2 text-xs font-medium transition-colors outline-none',
387
+ 'focus-visible:ring-2 focus-visible:ring-ring',
388
+ props.selected
389
+ ? 'bg-background text-foreground shadow-sm'
390
+ : 'text-muted-foreground hover:text-foreground',
391
+ )}
392
+ onClick={() => props.onClick()}
393
+ >
394
+ {props.icon}
395
+ {props.label}
396
+ </button>
397
+ );
398
+ }
399
+
400
+ // --- ArtifactPreview (internal) -------------------------------------------
401
+
402
+ interface PreviewProps {
403
+ ref: (el: HTMLIFrameElement) => void;
404
+ src: () => string;
405
+ sandbox: string;
406
+ title: string;
407
+ onLoad: () => void;
408
+ }
409
+
410
+ function ArtifactPreview(props: PreviewProps): JSX.Element {
411
+ return (
412
+ <iframe
413
+ ref={props.ref}
414
+ src={props.src() || 'about:blank'}
415
+ sandbox={props.sandbox}
416
+ title={props.title}
417
+ class="absolute inset-0 h-full w-full border-0 bg-white"
418
+ onLoad={() => props.onLoad()}
419
+ />
420
+ );
421
+ }
422
+
423
+ // --- ArtifactCode (internal) ----------------------------------------------
424
+
425
+ interface CodeProps {
426
+ files: ArtifactFile[];
427
+ activeFile: () => string | undefined;
428
+ activeFileObj: () => ArtifactFile | undefined;
429
+ hasSource: () => boolean;
430
+ onSelect: (path: string, file: ArtifactFile) => void;
431
+ }
432
+
433
+ function ArtifactCode(props: CodeProps): JSX.Element {
434
+ return (
435
+ <div class="absolute inset-0 flex">
436
+ <div class="w-56 shrink-0 overflow-auto border-r border-border bg-muted/20 py-1.5 scrollbar-thin">
437
+ <FileTree
438
+ files={props.files}
439
+ activeFile={props.activeFile()}
440
+ onSelect={(path, file) => props.onSelect(path, file)}
441
+ />
442
+ </div>
443
+ <div class="min-w-0 flex-1 overflow-auto scrollbar-thin">
444
+ <Show
445
+ when={props.activeFileObj()}
446
+ fallback={
447
+ <div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
448
+ Select a file to view its source.
449
+ </div>
450
+ }
451
+ >
452
+ <Show
453
+ when={props.hasSource()}
454
+ fallback={
455
+ <div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
456
+ No source — this file ({props.activeFileObj()!.type ?? 'binary'}) has no code view.
457
+ </div>
458
+ }
459
+ >
460
+ <CodeBlock class="h-full rounded-none border-0">
461
+ <CodeBlockCode
462
+ code={props.activeFileObj()!.code ?? ''}
463
+ language={props.activeFileObj()!.language}
464
+ />
465
+ </CodeBlock>
466
+ </Show>
467
+ </Show>
468
+ </div>
469
+ </div>
470
+ );
471
+ }
472
+
473
+ // --- ArtifactPdfFallback (internal) ---------------------------------------
474
+
475
+ /** Shown when inline PDF rendering is disabled or fails (CORS / load / parse). */
476
+ function ArtifactPdfFallback(props: { url: string }): JSX.Element {
477
+ const name = () => {
478
+ const path = props.url.split(/[?#]/)[0];
479
+ return path.slice(path.lastIndexOf('/') + 1) || 'document.pdf';
480
+ };
481
+ const linkClass =
482
+ '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';
483
+ return (
484
+ <div
485
+ role="region"
486
+ aria-label="PDF preview unavailable"
487
+ class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-card p-6 text-center"
488
+ >
489
+ <FileText size={40} class="text-muted-foreground" aria-hidden="true" />
490
+ <div class="text-sm font-medium text-foreground">{name()}</div>
491
+ <div class="text-xs text-muted-foreground">Can't preview this PDF inline.</div>
492
+ <div class="flex flex-wrap items-center justify-center gap-2">
493
+ <a class={linkClass} href={props.url} target="_blank" rel="noopener noreferrer">
494
+ Open in new tab
495
+ <ExternalLink size={13} aria-hidden="true" />
496
+ </a>
497
+ <a class={linkClass} href={props.url} download="">
498
+ <Download size={13} aria-hidden="true" />
499
+ Download
500
+ </a>
501
+ </div>
502
+ </div>
503
+ );
504
+ }
505
+
506
+ // --- ArtifactPdfPreview (internal) ----------------------------------------
507
+
508
+ /**
509
+ * Renders a PDF inline via pdf.js (loaded on demand). Four states:
510
+ * disabled → fallback (no network); loading → spinner; success → stacked
511
+ * fit-width canvases; error (load/CORS/parse) → fallback card. Re-renders when
512
+ * the url or `reloadKey` changes, and (debounced) when the panel resizes.
513
+ */
514
+ function ArtifactPdfPreview(props: { url: string; reloadKey: number }): JSX.Element {
515
+ const [state, setState] = createSignal<'loading' | 'success' | 'error'>('loading');
516
+ let container: HTMLDivElement | undefined;
517
+ let token = 0;
518
+ let resizeTimer: ReturnType<typeof setTimeout> | undefined;
519
+
520
+ const renderNow = async () => {
521
+ if (!isPdfPreviewEnabled() || !container) {
522
+ setState('error');
523
+ return;
524
+ }
525
+ const mine = ++token;
526
+ setState('loading');
527
+ try {
528
+ const width = container.clientWidth || 600;
529
+ await renderPdfInto(props.url, container, width);
530
+ if (mine === token) setState('success');
531
+ } catch {
532
+ if (mine === token) setState('error');
533
+ }
534
+ };
535
+
536
+ createEffect(
537
+ on(
538
+ () => [props.url, props.reloadKey] as const,
539
+ () => {
540
+ void renderNow();
541
+ },
542
+ ),
543
+ );
544
+
545
+ onMount(() => {
546
+ if (!container || typeof ResizeObserver === 'undefined') return;
547
+ const ro = new ResizeObserver(() => {
548
+ if (state() !== 'success') return;
549
+ clearTimeout(resizeTimer);
550
+ resizeTimer = setTimeout(() => void renderNow(), 200);
551
+ });
552
+ ro.observe(container);
553
+ onCleanup(() => {
554
+ ro.disconnect();
555
+ clearTimeout(resizeTimer);
556
+ token++; // cancel any in-flight render
557
+ });
558
+ });
559
+
560
+ return (
561
+ <div class="absolute inset-0">
562
+ {/* Always present so clientWidth is the real panel width. */}
563
+ <div
564
+ ref={(el) => (container = el)}
565
+ role="region"
566
+ aria-label="PDF preview"
567
+ aria-busy={state() === 'loading'}
568
+ tabindex="0"
569
+ class="absolute inset-0 flex flex-col items-center gap-3 overflow-auto bg-muted/20 p-3 scrollbar-thin focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
570
+ />
571
+ <Show when={isPdfPreviewEnabled() && state() === 'loading'}>
572
+ <div class="pointer-events-none absolute inset-0 flex items-center justify-center">
573
+ <Loader variant="circular" size="md" />
574
+ </div>
575
+ </Show>
576
+ <Show when={!isPdfPreviewEnabled() || state() === 'error'}>
577
+ <ArtifactPdfFallback url={props.url} />
578
+ </Show>
579
+ </div>
580
+ );
581
+ }
@@ -12,6 +12,7 @@ import {
12
12
  AttachmentEmpty,
13
13
  } from './attachments';
14
14
  import type { AttachmentData } from './attachments';
15
+ import { componentDescription } from '../stories/docs/element-controls';
15
16
 
16
17
  const sampleAttachments: AttachmentData[] = [
17
18
  {
@@ -62,14 +63,12 @@ const meta = {
62
63
  parameters: {
63
64
  layout: 'padded',
64
65
  docs: {
65
- description: {
66
- component: [
67
- 'A composable container for displaying file and source-document attachments as thumbnails, inline chips, or a vertical list. Built from `Attachments` + per-item `Attachment` with `AttachmentPreview`, `AttachmentInfo`, and `AttachmentRemove` parts.',
68
- '**When to use:** to show files a user attached to a prompt, or documents/sources referenced by a message. Choose `grid` for thumbnails, `inline` for compact chips, `list` for a detailed rows view.',
69
- '**How to use:** set `variant` on `Attachments`, then map your data to `Attachment` (passing each item via `data` and an `onRemove` handler) and compose the preview/info/remove parts inside.',
70
- '**Placement:** prompt input area (pending uploads), message bodies (attached or cited files), and document panels.',
71
- ].join('\n\n'),
72
- },
66
+ description: componentDescription([
67
+ 'A composable container for displaying file and source-document attachments as thumbnails, inline chips, or a vertical list. Built from `Attachments` + per-item `Attachment` with `AttachmentPreview`, `AttachmentInfo`, and `AttachmentRemove` parts.',
68
+ '**When to use:** to show files a user attached to a prompt, or documents/sources referenced by a message. Choose `grid` for thumbnails, `inline` for compact chips, `list` for a detailed rows view.',
69
+ '**How to use:** set `variant` on `Attachments`, then map your data to `Attachment` (passing each item via `data` and an `onRemove` handler) and compose the preview/info/remove parts inside.',
70
+ '**Placement:** prompt input area (pending uploads), message bodies (attached or cited files), and document panels.',
71
+ ]),
73
72
  controls: { exclude: ['use:eventListener'] },
74
73
  },
75
74
  },
@@ -157,12 +157,12 @@ function Attachment(props: AttachmentProps) {
157
157
  variant === 'grid' && 'size-24 overflow-hidden rounded-lg',
158
158
  variant === 'inline' && [
159
159
  'flex h-8 cursor-pointer select-none items-center gap-1.5',
160
- 'rounded-md bg-muted/50 px-1.5',
160
+ 'rounded-md bg-muted/50 px-1.5 text-foreground',
161
161
  'font-medium text-sm transition-all',
162
162
  'hover:bg-muted',
163
163
  ],
164
164
  variant === 'list' && [
165
- 'flex w-full items-center gap-3 rounded-lg bg-muted/30 p-3',
165
+ 'flex w-full items-center gap-3 rounded-lg bg-muted/30 p-3 text-foreground',
166
166
  'hover:bg-muted/50',
167
167
  ],
168
168
  local.class,