@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.
- package/dist/custom-elements.json +247 -0
- package/dist/kitn-chat.es.js +29 -29
- package/dist/llms/llms-full.txt +32 -4
- package/dist/llms/llms.txt +3 -3
- package/frameworks/react/index.tsx +47 -5
- package/llms-full.txt +32 -4
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/components/artifact.tsx +241 -82
- package/src/components/card-fallback.tsx +28 -0
- package/src/components/card-renderer.tsx +52 -0
- package/src/components/checkpoint.tsx +3 -0
- package/src/components/code-block.tsx +5 -2
- package/src/components/component-meta.json +177 -12
- package/src/elements/artifact.stories.tsx +214 -0
- package/src/elements/artifact.tsx +95 -30
- package/src/elements/cards.stories.tsx +54 -0
- package/src/elements/cards.tsx +91 -0
- package/src/elements/checkpoint.tsx +4 -0
- package/src/elements/compiled.css +1 -1
- package/src/elements/element-meta.json +156 -0
- package/src/elements/element-types.d.ts +34 -0
- package/src/elements/register.ts +1 -0
- package/src/elements/resizable.d.ts +27 -0
- package/src/elements/resizable.stories.tsx +226 -1
- package/src/elements/resizable.tsx +208 -3
- package/src/index.ts +10 -0
- package/src/primitives/card-registry.tsx +58 -0
- package/src/stories/docs/generative-ui-overview.mdx +59 -0
- package/src/ui/resizable.tsx +33 -4
|
@@ -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
|
-
|
|
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(() =>
|
|
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.
|
package/src/ui/resizable.tsx
CHANGED
|
@@ -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, [
|
|
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={
|
|
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 ||
|
|
515
|
+
static={panel.locked || renderPanels()[i() - 1]?.locked}
|
|
487
516
|
onPanelResize={() => emitChange()}
|
|
488
517
|
/>
|
|
489
518
|
</Show>
|