@kitnai/chat 0.8.1 → 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.
@@ -1,9 +1,29 @@
1
- import { createSignal, onMount, onCleanup, For, Show, type JSX } from 'solid-js';
1
+ import { createSignal, createEffect, on, onMount, onCleanup, For, Show, type JSX } from 'solid-js';
2
2
  import { defineWebComponent } from './define';
3
3
  import { ResizableHandle, normalizeSize } from '../ui/resizable';
4
4
 
5
5
  type Orientation = 'horizontal' | 'vertical';
6
6
 
7
+ /** Bubbling, composed intent: a descendant asks the nearest enclosing
8
+ * <kc-resizable> to maximize the item containing it (filling, hiding siblings)
9
+ * or to restore. Any panel content may emit it — the protocol is zero-config. */
10
+ export interface KcMaximizeIntentDetail {
11
+ /** true = maximize the item containing me; false = restore. */
12
+ requested: boolean;
13
+ }
14
+
15
+ /** Composed, non-bubbling notification the group dispatches DOWN onto the
16
+ * affected <kc-resizable-item> (on maximize) or the group host + the formerly
17
+ * maximized item (on restore) so descendant content can sync its affordance. */
18
+ export interface KcMaximizeStateDetail {
19
+ /** Whether THIS subtree's item is the maximized one. */
20
+ maximized: boolean;
21
+ }
22
+
23
+ /** Event type names for the cross-element maximize protocol. */
24
+ export const KC_MAXIMIZE_INTENT = 'kc-maximize-intent' as const;
25
+ export const KC_MAXIMIZE_STATE = 'kc-maximize-state' as const;
26
+
7
27
  /** Parsed view of a `<kc-resizable-item>` light child. */
8
28
  interface ItemInfo {
9
29
  el: HTMLElement;
@@ -30,11 +50,15 @@ function boundAttrs(value: string | undefined): { pxAttr?: string; pctAttr?: str
30
50
  interface GroupProps extends Record<string, unknown> {
31
51
  /** Layout axis: `horizontal` (row, default) or `vertical` (column). */
32
52
  orientation?: Orientation;
53
+ /** Which item index is maximized (null = none). Declarative source of truth. */
54
+ maximizedIndex?: number | null;
33
55
  }
34
56
 
35
57
  interface GroupEvents extends Record<string, unknown> {
36
58
  /** Fired on drag-end / keyboard resize / visibility change. `detail.sizes` = panel sizes in percent. */
37
59
  change: { sizes: number[] };
60
+ /** Observe layout maximize state. */
61
+ maximizechange: { maximized: boolean; index: number | null };
38
62
  }
39
63
 
40
64
  /**
@@ -47,6 +71,7 @@ interface GroupEvents extends Record<string, unknown> {
47
71
  */
48
72
  defineWebComponent<GroupProps, GroupEvents>('kc-resizable', {
49
73
  orientation: 'horizontal',
74
+ maximizedIndex: null,
50
75
  }, (props, { element, dispatch }) => {
51
76
  const [items, setItems] = createSignal<ItemInfo[]>([]);
52
77
  const orientation = (): Orientation => (props.orientation === 'vertical' ? 'vertical' : 'horizontal');
@@ -111,11 +136,162 @@ defineWebComponent<GroupProps, GroupEvents>('kc-resizable', {
111
136
  dispatch('change', { sizes: currentSizes() });
112
137
  }
113
138
 
139
+ // --- Task 2: maximize/restore core ---
140
+
141
+ interface SavedItemState {
142
+ el: HTMLElement;
143
+ size: string | null;
144
+ hidden: boolean;
145
+ locked: boolean;
146
+ }
147
+ interface MaximizeStash {
148
+ /** The element that was maximized (identity anchor for re-target + events). */
149
+ item: HTMLElement;
150
+ /** Per-element stash keyed by element identity, not position. */
151
+ saved: SavedItemState[];
152
+ }
153
+ const [maximized, setMaximized] = createSignal<MaximizeStash | null>(null);
154
+ /** Re-entrancy guard: while we (un)apply maximize attributes, the observer must
155
+ * NOT emit a mid-flight `change`. The final relayout emits the real one. */
156
+ let applyingMaximize = false;
157
+
158
+ /** Find the capped <kc-resizable-item> ancestor of an event target, if any. */
159
+ function findContainingItem(node: Node | null): HTMLElement | null {
160
+ let el = node instanceof Element ? node : node?.parentElement ?? null;
161
+ // The intent is composed; its target may be inside the artifact's shadow.
162
+ // Resolve to a direct light child <kc-resizable-item> of THIS group.
163
+ const capped = items().map((i) => i.el);
164
+ while (el) {
165
+ if (el instanceof HTMLElement && capped.includes(el)) return el;
166
+ el = el.parentElement ?? (el.getRootNode() as ShadowRoot).host ?? null;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function readAttrState(el: HTMLElement) {
172
+ return {
173
+ size: el.getAttribute('size'),
174
+ hidden: el.hidden || (el.hasAttribute('hidden') && el.getAttribute('hidden') !== 'false'),
175
+ locked: el.hasAttribute('locked') && el.getAttribute('locked') !== 'false',
176
+ };
177
+ }
178
+
179
+ function setBoolAttr(el: HTMLElement, name: string, on: boolean) {
180
+ if (on) el.setAttribute(name, '');
181
+ else el.removeAttribute(name);
182
+ }
183
+
184
+ function maximizeItem(item: HTMLElement) {
185
+ const list = items();
186
+ const index = list.findIndex((i) => i.el === item);
187
+ if (index < 0) return;
188
+ const current = maximized();
189
+ if (current) {
190
+ if (current.item === item) return; // same item → no-op
191
+ restore(); // different item → re-target
192
+ }
193
+ applyingMaximize = true;
194
+ // Capture the EFFECTIVE current % so a post-drag layout restores faithfully.
195
+ const live = currentSizes(); // visible-order percents (Playwright-verified)
196
+ let visIdx = 0;
197
+ const saved: SavedItemState[] = list.map((info) => {
198
+ const prev = readAttrState(info.el);
199
+ if (!prev.hidden) {
200
+ // Write the live % back as the stashed size baseline.
201
+ const pct = live[visIdx++];
202
+ if (Number.isFinite(pct) && pct > 0) info.el.setAttribute('size', `${pct}%`);
203
+ }
204
+ return { el: info.el, size: info.el.getAttribute('size'), hidden: prev.hidden, locked: prev.locked };
205
+ });
206
+ // Hide every other item; free the maximized one to fill.
207
+ list.forEach((info, i) => {
208
+ if (i === index) {
209
+ info.el.removeAttribute('size');
210
+ info.el.removeAttribute('locked');
211
+ setBoolAttr(info.el, 'hidden', false);
212
+ } else {
213
+ setBoolAttr(info.el, 'hidden', true);
214
+ }
215
+ });
216
+ setMaximized({ item, saved });
217
+ element.setAttribute('data-maximized', '');
218
+ item.setAttribute('data-maximized-panel', '');
219
+ readItems();
220
+ emitChange();
221
+ // Keep applyingMaximize = true until AFTER the MutationObserver microtask fires
222
+ // so its queueMicrotask(emitChange) sees the guard and skips (storm guard).
223
+ queueMicrotask(() => { applyingMaximize = false; });
224
+ // Tell the maximized subtree (and only it) it is now maximized.
225
+ item.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: true }, bubbles: false, composed: true }));
226
+ dispatch('maximizechange', { maximized: true, index });
227
+ }
228
+
229
+ function restore() {
230
+ const stash = maximized();
231
+ if (!stash) return;
232
+ applyingMaximize = true;
233
+ // Re-apply saved state keyed by element identity; items no longer in the DOM
234
+ // are simply skipped. This is safe regardless of sibling additions/removals
235
+ // that happened while maximized (no index drift).
236
+ for (const s of stash.saved) {
237
+ if (!s.el.isConnected) continue;
238
+ if (s.size === null) s.el.removeAttribute('size');
239
+ else s.el.setAttribute('size', s.size);
240
+ setBoolAttr(s.el, 'hidden', s.hidden);
241
+ setBoolAttr(s.el, 'locked', s.locked);
242
+ s.el.removeAttribute('data-maximized-panel');
243
+ }
244
+ const prevItem = stash.item;
245
+ setMaximized(null);
246
+ element.removeAttribute('data-maximized');
247
+ readItems();
248
+ emitChange();
249
+ // Keep applyingMaximize = true until AFTER the MutationObserver microtask fires
250
+ // so its queueMicrotask(emitChange) sees the guard and skips (storm guard).
251
+ queueMicrotask(() => { applyingMaximize = false; });
252
+ // Broadcast restore on the host AND directly on the formerly-maximized item.
253
+ element.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: false }, bubbles: false, composed: true }));
254
+ prevItem?.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: false }, bubbles: false, composed: true }));
255
+ dispatch('maximizechange', { maximized: false, index: null });
256
+ }
257
+
114
258
  onMount(() => {
115
259
  readItems();
260
+ const onIntent = (e: Event) => {
261
+ const ce = e as CustomEvent<KcMaximizeIntentDetail>;
262
+ e.stopPropagation(); // nearest group wins (nesting)
263
+ const item = findContainingItem(ce.target as Node);
264
+ if (!item) return; // outside any item → ignore
265
+ if (ce.detail.requested) maximizeItem(item);
266
+ else restore();
267
+ };
268
+ element.addEventListener('kc-maximize-intent', onIntent);
269
+
270
+ const onKeydown = (e: KeyboardEvent) => {
271
+ // Only act while maximized — the capture listener on the host already
272
+ // ensures the event originates within the group.
273
+ if (e.key !== 'Escape' || !maximized()) return;
274
+ e.stopPropagation();
275
+ restore();
276
+ };
277
+ element.addEventListener('keydown', onKeydown, true);
278
+
116
279
  const mo = new MutationObserver(() => {
117
280
  readItems();
118
- // A childList / attribute change (e.g. show/hide) re-lays out → emit.
281
+ const stash = maximized();
282
+ if (stash) {
283
+ // Use element identity, not a positional index, to detect whether the
284
+ // maximized item is still present and visible.
285
+ const maximizedEl = stash.item;
286
+ const isConnected = maximizedEl.isConnected && element.contains(maximizedEl);
287
+ const isVisible = isConnected && !(maximizedEl.hidden || maximizedEl.hasAttribute('hidden'));
288
+ if (!isConnected || !isVisible) {
289
+ // The maximized item was removed or hidden out from under us → restore.
290
+ restore();
291
+ return;
292
+ }
293
+ }
294
+ if (applyingMaximize) return; // our own writes — skip auto-emit
119
295
  queueMicrotask(emitChange);
120
296
  });
121
297
  mo.observe(element, {
@@ -127,7 +303,36 @@ defineWebComponent<GroupProps, GroupEvents>('kc-resizable', {
127
303
  attributes: true,
128
304
  attributeFilter: ['size', 'locked', 'min', 'max', 'hidden'],
129
305
  });
130
- onCleanup(() => mo.disconnect());
306
+ onCleanup(() => {
307
+ mo.disconnect();
308
+ element.removeEventListener('kc-maximize-intent', onIntent);
309
+ element.removeEventListener('keydown', onKeydown, true);
310
+ });
311
+
312
+ // --- Task 3: imperative host methods + declarative maximizedIndex prop ---
313
+
314
+ // Imperative host API (assigned onto the element; typed by resizable.d.ts).
315
+ const host = element as unknown as { maximize(i: number): void; restore(): void };
316
+ host.maximize = (i: number) => {
317
+ const it = items()[i]?.el;
318
+ if (it) maximizeItem(it);
319
+ };
320
+ host.restore = () => restore();
321
+
322
+ // Declarative maximizedIndex → maximize/restore. Skip the initial null run.
323
+ createEffect(
324
+ on(
325
+ () => props.maximizedIndex,
326
+ (idx) => {
327
+ if (idx == null) restore();
328
+ else {
329
+ const it = items()[idx]?.el;
330
+ if (it) maximizeItem(it);
331
+ }
332
+ },
333
+ { defer: true },
334
+ ),
335
+ );
131
336
  });
132
337
 
133
338
  const isHoriz = () => orientation() === 'horizontal';
package/src/index.ts CHANGED
@@ -29,6 +29,16 @@ export { CARD_EVENT_NAME, emitCardEvent, routeCardEvent, listenForCardEvents } f
29
29
  export { validateAgainstSchema } from './primitives/card-validate';
30
30
  export type { JsonSchema, ValidationResult } from './primitives/card-validate';
31
31
 
32
+ // Card dispatcher (generative-UI host glue)
33
+ export { CardRenderer, renderCard } from './components/card-renderer';
34
+ export type { CardRendererProps } from './components/card-renderer';
35
+ export { CardFallback } from './components/card-fallback';
36
+ export type { CardFallbackProps } from './components/card-fallback';
37
+ export {
38
+ BUILTIN_CARD_TAGS, BUILTIN_CARD_COMPONENTS, mergeCardTags, mergeCardComponents,
39
+ } from './primitives/card-registry';
40
+ export type { CardComponent, CardComponentMap, CardTagMap } from './primitives/card-registry';
41
+
32
42
  // Card: kc-card (base shell) + kc-form (JSON-Schema form renderer)
33
43
  export { Card } from './components/card';
34
44
  export type { CardProps } from './components/card';
@@ -0,0 +1,58 @@
1
+ // src/primitives/card-registry.tsx
2
+ // One source of truth mapping a CardEnvelope.type to a renderer, for both layers:
3
+ // - CardComponentMap drives the Solid <CardRenderer>.
4
+ // - CardTagMap drives the <kc-cards> web component (child kc-* elements).
5
+ // Built-ins cover the 5 contract card types; consumers extend/override via a `types`
6
+ // prop (merged OVER the built-ins). kc-card (bare shell) is intentionally NOT a target.
7
+ import type { Component } from 'solid-js';
8
+ import type { CardEnvelope, CardHost } from './card-contract';
9
+ import { Form } from '../components/form';
10
+ import { ConfirmCard } from '../components/confirm-card';
11
+ import { TaskListCard } from '../components/task-list-card';
12
+ import { LinkCard } from '../components/link-card';
13
+ import { Embed } from '../components/embed';
14
+
15
+ /** Solid renderer for one envelope. `host` is the resolved CardHost so each wrapper
16
+ * can bridge its card's emit convention (form/confirm/task-list take `host`;
17
+ * link/embed take `onEmit`). */
18
+ export type CardComponent = Component<{ envelope: CardEnvelope; host?: CardHost }>;
19
+ export type CardComponentMap = Record<string, CardComponent>;
20
+
21
+ /** Web-component layer: envelope type → kc-* tag name. */
22
+ export type CardTagMap = Record<string, string>;
23
+
24
+ export const BUILTIN_CARD_TAGS: CardTagMap = {
25
+ form: 'kc-form',
26
+ confirm: 'kc-confirm',
27
+ 'task-list': 'kc-task-list',
28
+ link: 'kc-link-card',
29
+ embed: 'kc-embed',
30
+ };
31
+
32
+ export const BUILTIN_CARD_COMPONENTS: CardComponentMap = {
33
+ form: (p) => (
34
+ <Form data={p.envelope.data as never} cardId={p.envelope.id} heading={p.envelope.title} host={p.host} />
35
+ ),
36
+ confirm: (p) => (
37
+ <ConfirmCard data={p.envelope.data as never} cardId={p.envelope.id} heading={p.envelope.title} host={p.host} />
38
+ ),
39
+ 'task-list': (p) => (
40
+ <TaskListCard data={p.envelope.data as never} cardId={p.envelope.id} heading={p.envelope.title} host={p.host} />
41
+ ),
42
+ // link/embed have no `heading` and emit via an onEmit callback (no context).
43
+ link: (p) => (
44
+ <LinkCard data={p.envelope.data as never} cardId={p.envelope.id} onEmit={(e) => p.host?.emit(e)} />
45
+ ),
46
+ embed: (p) => (
47
+ <Embed data={p.envelope.data as never} cardId={p.envelope.id} onEmit={(e) => p.host?.emit(e)} />
48
+ ),
49
+ };
50
+
51
+ /** Built-ins with the consumer's overrides merged on top (consumer wins). */
52
+ export function mergeCardComponents(types?: CardComponentMap): CardComponentMap {
53
+ return types ? { ...BUILTIN_CARD_COMPONENTS, ...types } : { ...BUILTIN_CARD_COMPONENTS };
54
+ }
55
+
56
+ export function mergeCardTags(types?: CardTagMap): CardTagMap {
57
+ return types ? { ...BUILTIN_CARD_TAGS, ...types } : { ...BUILTIN_CARD_TAGS };
58
+ }
@@ -0,0 +1,59 @@
1
+ {/* src/stories/docs/generative-ui-overview.mdx */}
2
+ import { Meta } from '@storybook/addon-docs/blocks';
3
+
4
+ <Meta title="Generative UI/Overview" />
5
+
6
+ # Generative UI
7
+
8
+ Agents don't just reply with text — they ask the chat to **render typed, interactive
9
+ cards**: a form to fill, an action to approve, a plan to pick from, a link to preview.
10
+ `@kitnai/chat` does this with a small, typed **Card Contract** and a turnkey dispatcher.
11
+
12
+ ## The loop
13
+
14
+ ```
15
+ agent/server ──CardEnvelope(s)──▶ host sets <kc-cards>.cards
16
+ ▲ │
17
+ │ dispatcher renders the right kc-* by `type`
18
+ │ │
19
+ └── result / next envelopes ◀─ CardPolicy ◀─ kc-card event ◀─ user interacts
20
+ ```
21
+
22
+ ## The envelope
23
+
24
+ Everything an agent sends is a `CardEnvelope`:
25
+
26
+ ```ts
27
+ interface CardEnvelope { type: string; id: string; data: unknown; title?: string }
28
+ ```
29
+
30
+ `type` selects the card (`form`, `confirm`, `task-list`, `link`, `embed`); `data` follows
31
+ that type's **JSON Schema** (shipped in `dist/schemas`, so an agent/server can validate
32
+ before sending); `id` correlates every event back to this card.
33
+
34
+ ## Turnkey: drop `<kc-cards>`
35
+
36
+ ```html
37
+ <kc-cards></kc-cards>
38
+ <script type="module">
39
+ import '@kitnai/chat/elements';
40
+ const cards = document.querySelector('kc-cards');
41
+ cards.cards = [{ type: 'confirm', id: 'deploy', title: 'Deploy?',
42
+ data: { body: 'Ship it?', actions: [{ id: 'go', label: 'Deploy', default: true }] } }];
43
+ cards.policy = { onAction: (id, action) => console.log(id, action) };
44
+ </script>
45
+ ```
46
+
47
+ `<kc-cards>` renders one card per envelope and routes every interaction through `policy`
48
+ (or listen for the raw bubbling `kc-card` event). In SolidJS, use `renderCard(envelope)` /
49
+ `<CardRenderer>` inside a `<CardProvider>`.
50
+
51
+ ## Streaming & follow-ups
52
+
53
+ Swap the `cards` array to add, replace, or remove cards — e.g. replace a `confirm` with a
54
+ result card once the user acts. For **live streaming into a single card**, the SolidJS
55
+ `<CardRenderer>` re-renders reactively as its envelope's `data` changes, so an agent can
56
+ stream into it as work progresses. (Keyed-by-`id` in-place updates within a `<kc-cards>`
57
+ list are a planned refinement.)
58
+
59
+ See **Cards** for each card type, and **SDK** for the dispatcher in action.
@@ -1,4 +1,4 @@
1
- import { type JSX, splitProps, createSignal, createContext, useContext, For, Show, children as resolveChildren } from 'solid-js';
1
+ import { type JSX, splitProps, createSignal, createEffect, createContext, useContext, For, Show, children as resolveChildren } from 'solid-js';
2
2
  import { cn } from '../utils/cn';
3
3
 
4
4
  // --- Types ---
@@ -426,6 +426,10 @@ export interface ResizableProps {
426
426
  /** Show a visible grip on each interactive handle. */
427
427
  withHandle?: boolean;
428
428
  class?: string;
429
+ /** Which panel index is maximized (null = none). Hides the others. */
430
+ maximizedIndex?: number | null;
431
+ /** Fired when the maximized panel changes (index, or null on restore). */
432
+ onMaximizeChange?: (index: number | null) => void;
429
433
  /** `ResizablePanel` children. */
430
434
  children: JSX.Element;
431
435
  }
@@ -438,7 +442,9 @@ export interface ResizableProps {
438
442
  * `ResizablePanelGroup` + explicit `ResizableHandle`s.
439
443
  */
440
444
  function Resizable(props: ResizableProps) {
441
- const [local] = splitProps(props, ['orientation', 'onChange', 'withHandle', 'class', 'children']);
445
+ const [local] = splitProps(props, [
446
+ 'orientation', 'onChange', 'withHandle', 'class', 'children', 'maximizedIndex', 'onMaximizeChange',
447
+ ]);
442
448
  const orientation = () => local.orientation ?? 'horizontal';
443
449
 
444
450
  // Resolve children to the actual panel elements so we can read their props.
@@ -455,6 +461,29 @@ function Resizable(props: ResizableProps) {
455
461
 
456
462
  const visible = () => panels().filter((p) => !p.hidden);
457
463
 
464
+ const maxIdx = () => local.maximizedIndex ?? null;
465
+ // When maximized, only the maximized panel is "visible" for layout; siblings drop
466
+ // (mirrors the web-component facade hiding siblings). Indices are over all panels.
467
+ // Note: stash/restore of sizes is handled by the panels' own defaultSize/flex —
468
+ // since the convenience re-renders from the unchanged ResizablePanel children on
469
+ // restore, sizes return to their declared values. The post-drag-live-size stash
470
+ // fidelity is a web-component-only concern; the Solid story uses declarative sizes.
471
+ const renderPanels = () => {
472
+ const all = panels();
473
+ const m = maxIdx();
474
+ if (m == null) return all.filter((p) => !p.hidden);
475
+ const target = all[m];
476
+ return target && !target.hidden ? [target] : all.filter((p) => !p.hidden);
477
+ };
478
+
479
+ // Notify on change of the maximized index (defer the initial null run).
480
+ let prevMax: number | null | undefined;
481
+ createEffect(() => {
482
+ const m = maxIdx();
483
+ if (prevMax === undefined) { prevMax = m; return; }
484
+ if (m !== prevMax) { prevMax = m; local.onMaximizeChange?.(m); }
485
+ });
486
+
458
487
  function emitChange() {
459
488
  if (!local.onChange) return;
460
489
  const sizes = visible().map((p) => {
@@ -477,13 +506,13 @@ function Resizable(props: ResizableProps) {
477
506
  )}
478
507
  data-orientation={orientation()}
479
508
  >
480
- <For each={visible()}>
509
+ <For each={renderPanels()}>
481
510
  {(panel, i) => (
482
511
  <>
483
512
  <Show when={i() > 0}>
484
513
  <ResizableHandle
485
514
  withHandle={local.withHandle}
486
- static={panel.locked || visible()[i() - 1]?.locked}
515
+ static={panel.locked || renderPanels()[i() - 1]?.locked}
487
516
  onPanelResize={() => emitChange()}
488
517
  />
489
518
  </Show>