@kitnai/chat 0.8.0 → 0.9.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.
@@ -26,6 +26,8 @@ import {
26
26
  FileText,
27
27
  ExternalLink,
28
28
  Download,
29
+ Maximize2,
30
+ Minimize2,
29
31
  } from 'lucide-solid';
30
32
 
31
33
  export type ArtifactTab = 'preview' | 'code';
@@ -52,6 +54,32 @@ export interface ArtifactProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>,
52
54
  onTabChange?: (tab: ArtifactTab) => void;
53
55
  /** Fired when a file is selected in the tree. */
54
56
  onFileSelect?: (path: string) => void;
57
+ // view-state
58
+ /** Controlled maximize view-state (drives the expand/restore button). */
59
+ maximized?: boolean;
60
+ /** Fired when the expand/restore button toggles the maximize view-state. */
61
+ onMaximizeChange?: (maximized: boolean) => void;
62
+ // toolbar composition — existing five default SHOWN (no-* flags invert in the facade)
63
+ /** Show the back/forward nav buttons. Default `true`. */
64
+ showNav?: boolean;
65
+ /** Show the reload button. Default `true`. */
66
+ showReload?: boolean;
67
+ /** Show the home button. Default `true`. */
68
+ showHome?: boolean;
69
+ /** Show the editable path/address field. Default `true`. */
70
+ showPathField?: boolean;
71
+ /** Show the Preview|Code tab toggle. Default `true`. */
72
+ showTabs?: boolean;
73
+ // new affordances — OPT-IN (default hidden; see resolved decision #2)
74
+ /** Show the expand-to-fill button. Default `false` (opt-in). */
75
+ expandable?: boolean;
76
+ /** Show the open-in-new-tab button. Default `false` (opt-in). */
77
+ openInTab?: boolean;
78
+ // chrome
79
+ /** Standalone chrome: rounded + bordered (default in-panel = square, borderless). */
80
+ standalone?: boolean;
81
+ /** Make the path field read-only (visible, nav-tracking, non-editable). */
82
+ readonlyPath?: boolean;
55
83
  }
56
84
 
57
85
  const DEFAULT_SANDBOX = 'allow-scripts allow-forms';
@@ -86,7 +114,21 @@ export function isPdfUrl(url: string, files: ArtifactFile[]): boolean {
86
114
  */
87
115
  export function Artifact(props: ArtifactProps): JSX.Element {
88
116
  const merged = mergeProps(
89
- { files: [] as ArtifactFile[], tab: 'preview' as ArtifactTab, sandbox: DEFAULT_SANDBOX },
117
+ {
118
+ files: [] as ArtifactFile[],
119
+ tab: 'preview' as ArtifactTab,
120
+ sandbox: DEFAULT_SANDBOX,
121
+ showNav: true,
122
+ showReload: true,
123
+ showHome: true,
124
+ showPathField: true,
125
+ showTabs: true,
126
+ expandable: false,
127
+ openInTab: false,
128
+ standalone: false,
129
+ readonlyPath: false,
130
+ maximized: false,
131
+ },
90
132
  props,
91
133
  );
92
134
  const [local, rest] = splitProps(merged, [
@@ -99,6 +141,17 @@ export function Artifact(props: ArtifactProps): JSX.Element {
99
141
  'onNavigate',
100
142
  'onTabChange',
101
143
  'onFileSelect',
144
+ 'maximized',
145
+ 'onMaximizeChange',
146
+ 'showNav',
147
+ 'showReload',
148
+ 'showHome',
149
+ 'showPathField',
150
+ 'showTabs',
151
+ 'expandable',
152
+ 'openInTab',
153
+ 'standalone',
154
+ 'readonlyPath',
102
155
  'class',
103
156
  ]);
104
157
 
@@ -120,6 +173,39 @@ export function Artifact(props: ArtifactProps): JSX.Element {
120
173
  const [activeFile, setActiveFile] = createSignal<string | undefined>(local.activeFile);
121
174
  const [reloadKey, setReloadKey] = createSignal(0);
122
175
 
176
+ // Maximize view-state — controlled by `local.maximized`, toggled by the button.
177
+ const [maximized, setMaximized] = createSignal<boolean>(local.maximized ?? false);
178
+ createEffect(() => setMaximized(local.maximized ?? false));
179
+ const toggleMaximize = () => {
180
+ const next = !maximized();
181
+ setMaximized(next);
182
+ local.onMaximizeChange?.(next);
183
+ };
184
+
185
+ // Open-in-tab — only enabled when there's a concrete url to open.
186
+ const canOpenInTab = createMemo(() => {
187
+ const u = currentUrl();
188
+ return !!u && u !== 'about:blank';
189
+ });
190
+ const openInNewTab = () => {
191
+ if (!canOpenInTab()) return;
192
+ window.open(currentUrl(), '_blank', 'noopener,noreferrer');
193
+ };
194
+
195
+ // The expand button is suppressed in standalone (no enclosing resizable).
196
+ const showExpand = createMemo(() => local.expandable && !local.standalone);
197
+ // Omit the whole toolbar when nothing is shown.
198
+ const showAnyToolbar = createMemo(
199
+ () =>
200
+ local.showNav ||
201
+ local.showReload ||
202
+ local.showHome ||
203
+ local.showPathField ||
204
+ local.showTabs ||
205
+ showExpand() ||
206
+ local.openInTab,
207
+ );
208
+
123
209
  let iframeEl: HTMLIFrameElement | undefined;
124
210
 
125
211
  // Controlled syncing: when the consumer changes the props, follow them.
@@ -225,30 +311,50 @@ export function Artifact(props: ArtifactProps): JSX.Element {
225
311
  const input = (e.currentTarget as HTMLFormElement).elements.namedItem(
226
312
  'kc-artifact-path',
227
313
  ) as HTMLInputElement | null;
314
+ if (local.readonlyPath) {
315
+ // Submit is a no-op while read-only; keep the field reflecting currentUrl.
316
+ if (input) input.value = currentUrl();
317
+ return;
318
+ }
228
319
  if (input && input.value) navigate(input.value);
229
320
  };
230
321
 
231
322
  return (
232
323
  <div
233
324
  class={cn(
234
- 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground',
325
+ 'flex h-full w-full flex-col overflow-hidden bg-card text-card-foreground',
326
+ local.standalone && 'rounded-xl border border-border',
235
327
  local.class,
236
328
  )}
237
329
  {...rest}
238
330
  >
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
- />
331
+ <Show when={showAnyToolbar()}>
332
+ <ArtifactToolbar
333
+ url={currentUrl}
334
+ tab={tab}
335
+ canBack={canBack}
336
+ canForward={canForward}
337
+ canHome={() => !!local.src}
338
+ onBack={goBack}
339
+ onForward={goForward}
340
+ onReload={reload}
341
+ onHome={goHome}
342
+ onSubmitPath={submitPath}
343
+ onTab={selectTab}
344
+ showNav={() => local.showNav}
345
+ showReload={() => local.showReload}
346
+ showHome={() => local.showHome}
347
+ showPathField={() => local.showPathField}
348
+ showTabs={() => local.showTabs}
349
+ showExpand={showExpand}
350
+ showOpenInTab={() => local.openInTab}
351
+ maximized={maximized}
352
+ onToggleMaximize={toggleMaximize}
353
+ canOpenInTab={canOpenInTab}
354
+ onOpenInTab={openInNewTab}
355
+ readonlyPath={() => local.readonlyPath}
356
+ />
357
+ </Show>
252
358
  <div class="relative min-h-0 flex-1">
253
359
  <Show
254
360
  when={tab() === 'preview'}
@@ -296,77 +402,130 @@ interface ToolbarProps {
296
402
  onHome: () => void;
297
403
  onSubmitPath: (e: Event) => void;
298
404
  onTab: (tab: ArtifactTab) => void;
405
+ showNav: () => boolean;
406
+ showReload: () => boolean;
407
+ showHome: () => boolean;
408
+ showPathField: () => boolean;
409
+ showTabs: () => boolean;
410
+ showExpand: () => boolean;
411
+ showOpenInTab: () => boolean;
412
+ maximized: () => boolean;
413
+ onToggleMaximize: () => void;
414
+ canOpenInTab: () => boolean;
415
+ onOpenInTab: () => void;
416
+ readonlyPath: () => boolean;
299
417
  }
300
418
 
301
419
  function ArtifactToolbar(props: ToolbarProps): JSX.Element {
302
420
  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>
421
+ <div
422
+ data-artifact-toolbar
423
+ class="flex shrink-0 items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1.5"
424
+ >
425
+ <Show when={props.showNav()}>
426
+ <Button
427
+ variant="ghost"
428
+ size="icon-sm"
429
+ aria-label="Back"
430
+ disabled={!props.canBack()}
431
+ onClick={() => props.onBack()}
432
+ >
433
+ <ArrowLeft size={16} aria-hidden="true" />
434
+ </Button>
435
+ <Button
436
+ variant="ghost"
437
+ size="icon-sm"
438
+ aria-label="Forward"
439
+ disabled={!props.canForward()}
440
+ onClick={() => props.onForward()}
441
+ >
442
+ <ArrowRight size={16} aria-hidden="true" />
443
+ </Button>
444
+ </Show>
445
+ <Show when={props.showReload()}>
446
+ <Button variant="ghost" size="icon-sm" aria-label="Reload" onClick={() => props.onReload()}>
447
+ <RotateCw size={15} aria-hidden="true" />
448
+ </Button>
449
+ </Show>
450
+ <Show when={props.showHome()}>
451
+ <Button
452
+ variant="ghost"
453
+ size="icon-sm"
454
+ aria-label="Home"
455
+ disabled={!props.canHome()}
456
+ onClick={() => props.onHome()}
457
+ >
458
+ <House size={15} aria-hidden="true" />
459
+ </Button>
460
+ </Show>
461
+ <Show when={props.showPathField()}>
462
+ <form class="min-w-0 flex-1" onSubmit={(e) => props.onSubmitPath(e)}>
463
+ <label class="sr-only" for="kc-artifact-path">
464
+ Address
465
+ </label>
466
+ <input
467
+ id="kc-artifact-path"
468
+ name="kc-artifact-path"
469
+ type="text"
470
+ spellcheck={false}
471
+ autocomplete="off"
472
+ readonly={props.readonlyPath() || undefined}
473
+ aria-readonly={props.readonlyPath() ? 'true' : undefined}
474
+ value={props.url()}
475
+ class={cn(
476
+ 'h-7 w-full rounded-md border border-border px-2.5 text-xs text-foreground font-mono outline-none',
477
+ props.readonlyPath()
478
+ ? 'bg-muted/40 cursor-default'
479
+ : 'bg-background focus-visible:ring-2 focus-visible:ring-ring',
480
+ )}
481
+ placeholder="Enter a path or URL…"
482
+ />
483
+ </form>
484
+ </Show>
485
+ <Show when={props.showExpand()}>
486
+ <Button
487
+ variant="ghost"
488
+ size="icon-sm"
489
+ aria-label={props.maximized() ? 'Collapse' : 'Expand'}
490
+ aria-expanded={props.maximized()}
491
+ onClick={() => props.onToggleMaximize()}
492
+ >
493
+ <Show when={props.maximized()} fallback={<Maximize2 size={15} aria-hidden="true" />}>
494
+ <Minimize2 size={15} aria-hidden="true" />
495
+ </Show>
496
+ </Button>
497
+ </Show>
498
+ <Show when={props.showOpenInTab()}>
499
+ <Button
500
+ variant="ghost"
501
+ size="icon-sm"
502
+ aria-label="Open in new tab"
503
+ disabled={!props.canOpenInTab()}
504
+ onClick={() => props.onOpenInTab()}
505
+ >
506
+ <ExternalLink size={15} aria-hidden="true" />
507
+ </Button>
508
+ </Show>
509
+ <Show when={props.showTabs()}>
510
+ <div
511
+ role="tablist"
512
+ aria-label="View"
513
+ class="flex shrink-0 items-center gap-0.5 rounded-md bg-muted p-0.5"
514
+ >
515
+ <SegmentButton
516
+ label="Preview"
517
+ icon={<Eye size={14} aria-hidden="true" />}
518
+ selected={props.tab() === 'preview'}
519
+ onClick={() => props.onTab('preview')}
520
+ />
521
+ <SegmentButton
522
+ label="Code"
523
+ icon={<CodeIcon size={14} aria-hidden="true" />}
524
+ selected={props.tab() === 'code'}
525
+ onClick={() => props.onTab('code')}
526
+ />
527
+ </div>
528
+ </Show>
370
529
  </div>
371
530
  );
372
531
  }
@@ -0,0 +1,28 @@
1
+ // src/components/card-fallback.tsx
2
+ // Rendered by the dispatcher when an envelope's `type` has no registered card.
3
+ // Visual: reuses the Card chrome so it sits naturally in a card stream.
4
+ import type { JSX } from 'solid-js';
5
+ import { Card } from './card';
6
+ import { AlertTriangle } from 'lucide-solid';
7
+
8
+ export interface CardFallbackProps {
9
+ /** The unrecognized envelope type, shown to aid debugging. */
10
+ type: string;
11
+ /** The envelope id (for parity with cards; not displayed). */
12
+ cardId?: string;
13
+ }
14
+
15
+ /** Inert, themed fallback for an unsupported card type. Emits nothing itself —
16
+ * the dispatcher emits the contract `error` event alongside rendering this. */
17
+ export function CardFallback(props: CardFallbackProps): JSX.Element {
18
+ return (
19
+ <Card>
20
+ <div role="alert" class="flex items-center gap-2 p-4 text-sm text-muted-foreground">
21
+ <AlertTriangle class="size-4 shrink-0 text-destructive dark:text-red-400" aria-hidden="true" />
22
+ <span>
23
+ Unsupported card type: <code class="font-mono">{props.type}</code>
24
+ </span>
25
+ </div>
26
+ </Card>
27
+ );
28
+ }
@@ -0,0 +1,52 @@
1
+ // src/components/card-renderer.tsx
2
+ // The Solid single-envelope dispatcher: pick the card for envelope.type and render it
3
+ // with the envelope spread onto its props. Routing uses the ambient CardProvider
4
+ // (useCardHost). Unknown type → CardFallback + a one-shot contract `error` emit.
5
+ import { createMemo, untrack, Show, type JSX } from 'solid-js';
6
+ import { Dynamic } from 'solid-js/web';
7
+ import type { CardEnvelope } from '../primitives/card-contract';
8
+ import { useCardHost } from '../primitives/card-host';
9
+ import { mergeCardComponents, type CardComponentMap } from '../primitives/card-registry';
10
+ import { CardFallback } from './card-fallback';
11
+
12
+ export interface CardRendererProps {
13
+ envelope: CardEnvelope;
14
+ /** Add/override type→component entries (merged over the built-ins). */
15
+ types?: CardComponentMap;
16
+ }
17
+
18
+ export function CardRenderer(props: CardRendererProps): JSX.Element {
19
+ const host = useCardHost();
20
+ const map = createMemo(() => mergeCardComponents(props.types));
21
+ const entry = createMemo(() => map()[props.envelope.type]);
22
+
23
+ return (
24
+ <Show
25
+ when={entry()}
26
+ fallback={<UnknownCard envelope={props.envelope} />}
27
+ >
28
+ {(comp) => <Dynamic component={comp()} envelope={props.envelope} host={host} />}
29
+ </Show>
30
+ );
31
+ }
32
+
33
+ /** Renders the fallback and emits exactly one `error` (untracked, on first render). */
34
+ function UnknownCard(props: { envelope: CardEnvelope }): JSX.Element {
35
+ const host = useCardHost();
36
+ untrack(() =>
37
+ host?.emit({
38
+ kind: 'error',
39
+ cardId: props.envelope.id,
40
+ message: `Unsupported card type: ${props.envelope.type}`,
41
+ }),
42
+ );
43
+ return <CardFallback type={props.envelope.type} cardId={props.envelope.id} />;
44
+ }
45
+
46
+ /** Function sugar: renderCard(env) ≡ <CardRenderer envelope={env} />. */
47
+ export function renderCard(
48
+ envelope: CardEnvelope,
49
+ opts?: { types?: CardComponentMap },
50
+ ): JSX.Element {
51
+ return <CardRenderer envelope={envelope} types={opts?.types} />;
52
+ }
@@ -50,6 +50,8 @@ export function CheckpointIcon(props: CheckpointIconProps) {
50
50
 
51
51
  export interface CheckpointTriggerProps {
52
52
  tooltip?: string;
53
+ /** Accessible name for the button — required when it has no visible text (icon-only). */
54
+ 'aria-label'?: string;
53
55
  onClick?: () => void;
54
56
  children?: JSX.Element;
55
57
  class?: string;
@@ -70,6 +72,7 @@ export function CheckpointTrigger(props: CheckpointTriggerProps) {
70
72
  variant={variant()}
71
73
  size={size()}
72
74
  type="button"
75
+ aria-label={props['aria-label']}
73
76
  onClick={props.onClick}
74
77
  class={props.class}
75
78
  >
@@ -65,15 +65,18 @@ function CodeBlockCode(props: CodeBlockCodeProps) {
65
65
  );
66
66
 
67
67
  return (
68
+ // `tabindex={0}` makes the horizontally-scrollable region reachable by
69
+ // keyboard (axe `scrollable-region-focusable`); `{...rest}` lets a consumer
70
+ // override it. No `role="region"` — that would demand an accessible name.
68
71
  <Show
69
72
  when={highlighted()}
70
73
  fallback={
71
- <div class={classNames()} {...rest}>
74
+ <div class={classNames()} tabindex={0} {...rest}>
72
75
  <pre><code>{local.code}</code></pre>
73
76
  </div>
74
77
  }
75
78
  >
76
- <div class={classNames()} innerHTML={highlighted()} {...rest} />
79
+ <div class={classNames()} tabindex={0} innerHTML={highlighted()} {...rest} />
77
80
  </Show>
78
81
  );
79
82
  }