claude-artifact-framework 0.7.0 → 0.7.2
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/README.md +70 -8
- package/dist/index.esm.js +417 -260
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +40 -9
- package/dist/index.global.js.map +4 -4
- package/package.json +1 -1
- package/src/app.js +192 -23
- package/src/appState.js +1 -1
- package/src/blocks.js +2 -0
- package/src/chat.js +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Utilities for building apps inside Claude Artifacts: platform storage, chat tool-calling loops, and other primitives verified against the real runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.esm.js",
|
package/src/app.js
CHANGED
|
@@ -17,13 +17,13 @@ import { RecordsLayout } from "./records.js";
|
|
|
17
17
|
import { StepsLayout } from "./steps.js";
|
|
18
18
|
import { ChatLayout, CHAT_KEYS } from "./chat.js";
|
|
19
19
|
|
|
20
|
-
const LAYOUTS = ["panel", "records", "steps", "chat"];
|
|
21
|
-
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
|
|
20
|
+
const LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
|
|
21
|
+
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible"];
|
|
22
22
|
|
|
23
23
|
// Frontier models still invent identifiers a few percent of the time, so an
|
|
24
24
|
// unknown name has to fail loudly and name the alternatives rather than
|
|
25
25
|
// silently rendering nothing — a blank pane is indistinguishable from a bug.
|
|
26
|
-
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared"];
|
|
26
|
+
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar"];
|
|
27
27
|
|
|
28
28
|
// An unknown field type used to fall through silently to a text input — the
|
|
29
29
|
// exact quiet degradation the framework exists to prevent (a blind-tested
|
|
@@ -44,6 +44,27 @@ function checkFields(fields, where) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function checkChatConfig(c, where) {
|
|
48
|
+
if (!c || (typeof c.system !== "string" && typeof c.system !== "function")) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`claude-artifact-framework: ${where} needs \`{ system: "..." }\` (a string or a function of data).`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
|
|
54
|
+
if (unknown.length) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`claude-artifact-framework: ${where} has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
for (const [name, tool] of Object.entries(c.tools || {})) {
|
|
60
|
+
if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
const ACTION_KEYS = ["label", "run", "when", "kind"];
|
|
48
69
|
|
|
49
70
|
function checkActions(actions, where) {
|
|
@@ -72,7 +93,7 @@ function checkBlocks(blocks) {
|
|
|
72
93
|
throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
|
|
73
94
|
}
|
|
74
95
|
const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
|
|
75
|
-
const known = keys.filter((k) => BLOCK_NAMES.includes(k));
|
|
96
|
+
const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs");
|
|
76
97
|
if (known.length === 0) {
|
|
77
98
|
throw new Error(
|
|
78
99
|
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
|
|
@@ -89,6 +110,25 @@ function checkBlocks(blocks) {
|
|
|
89
110
|
}
|
|
90
111
|
checkFields(block.fields, `blocks[${i}]`);
|
|
91
112
|
if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
|
|
113
|
+
if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
|
|
114
|
+
if (known[0] === "tabs") {
|
|
115
|
+
const tabs = block.tabs;
|
|
116
|
+
if (!Array.isArray(tabs) || tabs.length === 0) {
|
|
117
|
+
throw new Error(`claude-artifact-framework: blocks[${i}].tabs needs an array of { label, blocks: [...] }.`);
|
|
118
|
+
}
|
|
119
|
+
tabs.forEach((t, j) => {
|
|
120
|
+
if (!t || typeof t.label !== "string" || !Array.isArray(t.blocks) || t.blocks.length === 0) {
|
|
121
|
+
throw new Error(`claude-artifact-framework: tabs[${j}] needs a \`label\` string and a non-empty \`blocks\` array.`);
|
|
122
|
+
}
|
|
123
|
+
if (t.blocks.some((b) => b && b.tabs)) {
|
|
124
|
+
throw new Error("claude-artifact-framework: tabs cannot nest inside tabs.");
|
|
125
|
+
}
|
|
126
|
+
checkBlocks(t.blocks);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (block.when !== undefined && typeof block.when !== "function") {
|
|
130
|
+
throw new Error(`claude-artifact-framework: blocks[${i}].when must be a function of data returning true/false.`);
|
|
131
|
+
}
|
|
92
132
|
});
|
|
93
133
|
}
|
|
94
134
|
|
|
@@ -138,25 +178,28 @@ function validate(spec) {
|
|
|
138
178
|
return layout;
|
|
139
179
|
}
|
|
140
180
|
if (layout === "chat") {
|
|
141
|
-
|
|
142
|
-
|
|
181
|
+
checkChatConfig(spec.chat, "chat");
|
|
182
|
+
return layout;
|
|
183
|
+
}
|
|
184
|
+
if (layout === "workspace") {
|
|
185
|
+
if (!Array.isArray(spec.main) || spec.main.length === 0) {
|
|
143
186
|
throw new Error(
|
|
144
|
-
'claude-artifact-framework: layout "
|
|
187
|
+
'claude-artifact-framework: layout "workspace" needs `main: [...]` (an array of blocks). `sidebar: [...]` is optional.'
|
|
145
188
|
);
|
|
146
189
|
}
|
|
147
|
-
|
|
148
|
-
if (
|
|
190
|
+
checkBlocks(spec.main);
|
|
191
|
+
if (spec.sidebar !== undefined) {
|
|
192
|
+
if (!Array.isArray(spec.sidebar)) {
|
|
193
|
+
throw new Error("claude-artifact-framework: workspace `sidebar` must be an array of blocks.");
|
|
194
|
+
}
|
|
195
|
+
checkBlocks(spec.sidebar);
|
|
196
|
+
}
|
|
197
|
+
const chats = flattenBlocks([...spec.main, ...(spec.sidebar || [])]).filter((b) => b && b.chat);
|
|
198
|
+
if (chats.length > 1) {
|
|
149
199
|
throw new Error(
|
|
150
|
-
|
|
200
|
+
"claude-artifact-framework: only one chat block per app — the conversation persists under data.chat and there is exactly one."
|
|
151
201
|
);
|
|
152
202
|
}
|
|
153
|
-
for (const [name, tool] of Object.entries(c.tools || {})) {
|
|
154
|
-
if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
|
|
155
|
-
throw new Error(
|
|
156
|
-
`claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
203
|
return layout;
|
|
161
204
|
}
|
|
162
205
|
if (layout === "records") {
|
|
@@ -222,15 +265,28 @@ function validate(spec) {
|
|
|
222
265
|
// Fields declare their own initial value, so the data shape is derived from
|
|
223
266
|
// the declaration instead of being restated in a separate object. That removes
|
|
224
267
|
// the key as an invention point: it is written exactly once.
|
|
268
|
+
// Tabs hold blocks of their own, so every scan over "all blocks" (field
|
|
269
|
+
// defaults, chat detection) has to see through them.
|
|
270
|
+
function flattenBlocks(blocks) {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const b of blocks || []) {
|
|
273
|
+
out.push(b);
|
|
274
|
+
if (b && Array.isArray(b.tabs)) for (const t of b.tabs) out.push(...flattenBlocks(t.blocks));
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
225
279
|
function defaultsFrom(spec) {
|
|
226
280
|
const data = { ...(spec.data || {}) };
|
|
227
281
|
if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
|
|
228
282
|
data.records = [];
|
|
229
283
|
}
|
|
230
|
-
|
|
284
|
+
const allBlocks = flattenBlocks([...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || [])]);
|
|
285
|
+
const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
|
|
286
|
+
if (hasChat && !data.chat) {
|
|
231
287
|
data.chat = { api: [], log: [] };
|
|
232
288
|
}
|
|
233
|
-
for (const block of [...
|
|
289
|
+
for (const block of [...allBlocks, ...(spec.steps || [])]) {
|
|
234
290
|
for (const field of block.fields || []) {
|
|
235
291
|
if (field && field.key !== undefined && !(field.key in data)) {
|
|
236
292
|
data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
|
|
@@ -270,10 +326,77 @@ function boundary(React) {
|
|
|
270
326
|
return BlockBoundary;
|
|
271
327
|
}
|
|
272
328
|
|
|
329
|
+
// Tabs are defined here (not blocks.js) because they render other blocks —
|
|
330
|
+
// keeping the recursion in one file avoids an import cycle.
|
|
331
|
+
function TabsBlock({ spec, ctx }) {
|
|
332
|
+
const tabs = spec.tabs;
|
|
333
|
+
const key = tabs.map((t) => t.label).join("|");
|
|
334
|
+
const stored = ctx.view.tabs ? ctx.view.tabs[key] : undefined;
|
|
335
|
+
const active = Math.min(stored === undefined ? 0 : stored, tabs.length - 1);
|
|
336
|
+
return h(
|
|
337
|
+
"div",
|
|
338
|
+
{ className: "caf-tabs" },
|
|
339
|
+
h(
|
|
340
|
+
"div",
|
|
341
|
+
{ className: "caf-tabbar", role: "tablist" },
|
|
342
|
+
tabs.map((t, i) =>
|
|
343
|
+
h(
|
|
344
|
+
"button",
|
|
345
|
+
{
|
|
346
|
+
key: t.label,
|
|
347
|
+
type: "button",
|
|
348
|
+
role: "tab",
|
|
349
|
+
"aria-selected": i === active,
|
|
350
|
+
className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
|
|
351
|
+
onClick: () => ctx.setTab(key, i),
|
|
352
|
+
},
|
|
353
|
+
t.label
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
),
|
|
357
|
+
h(
|
|
358
|
+
"div",
|
|
359
|
+
{ className: "caf-tabs-body" },
|
|
360
|
+
tabs[active].blocks.map((b, i) => h(Block, { key: active + ":" + i, block: b, ctx }))
|
|
361
|
+
)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
|
|
366
|
+
const ALL_NAMES = [...BLOCK_NAMES, "tabs"];
|
|
367
|
+
|
|
273
368
|
function Block({ block, ctx }) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
369
|
+
// `when` hides a block entirely — the primitive under conditional UIs.
|
|
370
|
+
if (typeof block.when === "function" && !block.when(ctx.data)) return null;
|
|
371
|
+
const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
372
|
+
const Component = ALL_BLOCKS[name];
|
|
373
|
+
const inner = h(
|
|
374
|
+
boundary(ctx.React),
|
|
375
|
+
null,
|
|
376
|
+
h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx })
|
|
377
|
+
);
|
|
378
|
+
if (!block.collapsible) return inner;
|
|
379
|
+
// Collapsed state is framework-owned view state, keyed by the block's title
|
|
380
|
+
// (its stable, human-visible identity), so it survives reload.
|
|
381
|
+
const key = typeof block.title === "string" && block.title ? block.title : name;
|
|
382
|
+
const collapsed =
|
|
383
|
+
ctx.view.collapsed && key in ctx.view.collapsed ? ctx.view.collapsed[key] : block.collapsible === "collapsed";
|
|
384
|
+
return h(
|
|
385
|
+
"div",
|
|
386
|
+
{ className: "caf-collapse" },
|
|
387
|
+
h(
|
|
388
|
+
"button",
|
|
389
|
+
{
|
|
390
|
+
type: "button",
|
|
391
|
+
className: "caf-collapse-head",
|
|
392
|
+
"aria-expanded": !collapsed,
|
|
393
|
+
onClick: () => ctx.toggleCollapsed(key, !collapsed),
|
|
394
|
+
},
|
|
395
|
+
h("span", null, block.title || name),
|
|
396
|
+
h("span", { className: collapsed ? "caf-chevron caf-chevron-closed" : "caf-chevron" }, "▾")
|
|
397
|
+
),
|
|
398
|
+
collapsed ? null : h("div", { className: "caf-collapse-body" }, inner)
|
|
399
|
+
);
|
|
277
400
|
}
|
|
278
401
|
|
|
279
402
|
function Panel({ spec, ctx }) {
|
|
@@ -317,7 +440,20 @@ function RecordsWithBlocks({ spec, ctx }) {
|
|
|
317
440
|
);
|
|
318
441
|
}
|
|
319
442
|
|
|
320
|
-
|
|
443
|
+
// The "content + copilot" shape: a full-width main column and a sidebar.
|
|
444
|
+
// Stacks vertically on narrow screens (sidebar after main).
|
|
445
|
+
function WorkspaceLayout({ spec, ctx }) {
|
|
446
|
+
const render = (blocks) =>
|
|
447
|
+
(blocks || []).map((block, i) => h("section", { key: i, className: "caf-ws-slot" }, h(Block, { block, ctx })));
|
|
448
|
+
return h(
|
|
449
|
+
"div",
|
|
450
|
+
{ className: "caf-workspace" },
|
|
451
|
+
h("div", { className: "caf-ws-main" }, render(spec.main)),
|
|
452
|
+
spec.sidebar && spec.sidebar.length ? h("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout };
|
|
321
457
|
|
|
322
458
|
export function createApp(spec = {}) {
|
|
323
459
|
const layout = validate(spec);
|
|
@@ -350,6 +486,8 @@ export function createApp(spec = {}) {
|
|
|
350
486
|
go: state.go,
|
|
351
487
|
back: state.back,
|
|
352
488
|
setStep: (n) => state.setView({ step: n }),
|
|
489
|
+
setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
|
|
490
|
+
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
353
491
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
|
|
354
492
|
};
|
|
355
493
|
|
|
@@ -510,6 +648,37 @@ const BASE_CSS = `
|
|
|
510
648
|
.caf-md-h { display: block; margin-top: 0.3rem; }
|
|
511
649
|
.caf-md-li { display: block; padding-left: 1rem; position: relative; }
|
|
512
650
|
.caf-md-li::before { content: "•"; position: absolute; left: 0.2rem; opacity: 0.6; }
|
|
651
|
+
.caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
|
|
652
|
+
.caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
|
|
653
|
+
.caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: 1rem; position: sticky; top: 1rem; }
|
|
654
|
+
.caf-ws-slot { min-width: 0; }
|
|
655
|
+
.caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
|
|
656
|
+
.caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
|
|
657
|
+
@media (max-width: 900px) {
|
|
658
|
+
.caf-workspace { flex-direction: column; align-items: stretch; }
|
|
659
|
+
.caf-ws-side { width: 100%; position: static; }
|
|
660
|
+
}
|
|
661
|
+
.caf-tabs { display: flex; flex-direction: column; gap: 0.8rem; min-width: 0; }
|
|
662
|
+
.caf-tabbar { display: flex; gap: 0.2rem; border-bottom: 1px solid var(--caf-border); overflow-x: auto; }
|
|
663
|
+
.caf-tab {
|
|
664
|
+
font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
|
665
|
+
padding: 0.45rem 0.8rem; border: none; background: none; color: var(--caf-muted);
|
|
666
|
+
border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap;
|
|
667
|
+
}
|
|
668
|
+
.caf-tab:hover { color: var(--caf-text); }
|
|
669
|
+
.caf-tab:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: -2px; }
|
|
670
|
+
.caf-tab-active { color: var(--caf-accent); border-bottom-color: var(--caf-accent); }
|
|
671
|
+
.caf-tabs-body { display: flex; flex-direction: column; gap: 0.8rem; }
|
|
672
|
+
.caf-collapse { display: flex; flex-direction: column; gap: 0.5rem; min-width: 0; }
|
|
673
|
+
.caf-collapse-head {
|
|
674
|
+
font: inherit; font-size: 0.95rem; font-weight: 650; cursor: pointer;
|
|
675
|
+
display: flex; align-items: center; justify-content: space-between; gap: 0.6rem;
|
|
676
|
+
padding: 0.7rem 1rem; border: 1px solid var(--caf-border); border-radius: var(--caf-radius);
|
|
677
|
+
background: var(--caf-surface); color: var(--caf-text); width: 100%; text-align: left;
|
|
678
|
+
}
|
|
679
|
+
.caf-collapse-head:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
680
|
+
.caf-chevron { transition: transform 0.15s ease; color: var(--caf-muted); }
|
|
681
|
+
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
513
682
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
|
514
683
|
.caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
|
|
515
684
|
.caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
|
package/src/appState.js
CHANGED
|
@@ -16,7 +16,7 @@ import { storage } from "./storage.js";
|
|
|
16
16
|
const DATA_KEY = "caf:data";
|
|
17
17
|
const VIEW_KEY = "caf:view";
|
|
18
18
|
|
|
19
|
-
const EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null };
|
|
19
|
+
const EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null, tabs: {}, collapsed: {} };
|
|
20
20
|
|
|
21
21
|
function isPlainObject(v) {
|
|
22
22
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
package/src/blocks.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// Bound lazily rather than snapshotted as `React.createElement`, because this
|
|
12
12
|
// runs at module load — before the host has had a chance to hand React over.
|
|
13
13
|
import { createElement as h, useState, useEffect } from "react";
|
|
14
|
+
import { ChatPanel } from "./chat.js";
|
|
14
15
|
|
|
15
16
|
const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
|
|
16
17
|
|
|
@@ -425,6 +426,7 @@ export const BLOCKS = {
|
|
|
425
426
|
chart: ChartBlock,
|
|
426
427
|
timer: TimerBlock,
|
|
427
428
|
actions: ActionsBlock,
|
|
429
|
+
chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx }),
|
|
428
430
|
custom: CustomBlock,
|
|
429
431
|
};
|
|
430
432
|
|
package/src/chat.js
CHANGED
|
@@ -168,8 +168,11 @@ function Bubble({ m }) {
|
|
|
168
168
|
});
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
// The embeddable core: everything the chat layout does, as a single column
|
|
172
|
+
// that can live in a workspace sidebar (or any block slot) as well as fill
|
|
173
|
+
// the whole app. One conversation per app — it persists under data.chat.
|
|
174
|
+
export function ChatPanel({ cfg, ctx }) {
|
|
175
|
+
const c = cfg;
|
|
173
176
|
const [draft, setDraft] = useState("");
|
|
174
177
|
const [busy, setBusy] = useState(false);
|
|
175
178
|
const [live, setLive] = useState(null);
|
|
@@ -243,7 +246,7 @@ export function ChatLayout({ spec, ctx }) {
|
|
|
243
246
|
|
|
244
247
|
return h(
|
|
245
248
|
"div",
|
|
246
|
-
{ className: "caf-
|
|
249
|
+
{ className: "caf-chat-panel" },
|
|
247
250
|
h(
|
|
248
251
|
"div",
|
|
249
252
|
{ className: "caf-block caf-chat-log" },
|
|
@@ -287,3 +290,7 @@ export function ChatLayout({ spec, ctx }) {
|
|
|
287
290
|
)
|
|
288
291
|
);
|
|
289
292
|
}
|
|
293
|
+
|
|
294
|
+
export function ChatLayout({ spec, ctx }) {
|
|
295
|
+
return h("div", { className: "caf-panel caf-panel-single" }, h(ChatPanel, { cfg: spec.chat, ctx }));
|
|
296
|
+
}
|