claude-artifact-framework 0.7.0 → 0.7.1

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/index.esm.js CHANGED
@@ -372,6 +372,241 @@ function seriesColors(seed, n) {
372
372
  return Array.from({ length: n }, (_, i) => hsl((h0 + i * 137.5) % 360, s, 52));
373
373
  }
374
374
 
375
+ // src/chat.js
376
+ var CHAT_KEYS = ["system", "greeting", "placeholder", "tools", "model", "maxTokens"];
377
+ var DEFAULT_MODEL = "claude-sonnet-4-6";
378
+ var MAX_ROUNDS = 8;
379
+ async function streamCall({ model, maxTokens, system, messages, onText }) {
380
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
381
+ method: "POST",
382
+ headers: { "Content-Type": "application/json" },
383
+ body: JSON.stringify({ model, max_tokens: maxTokens, system, messages, stream: true })
384
+ });
385
+ if (!res.ok || !res.body) {
386
+ let detail = "";
387
+ try {
388
+ const b = await res.json();
389
+ detail = b && b.error && b.error.message || JSON.stringify(b);
390
+ } catch {
391
+ detail = await res.text().catch(() => "");
392
+ }
393
+ throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`);
394
+ }
395
+ const reader = res.body.getReader();
396
+ const decoder = new TextDecoder();
397
+ let full = "";
398
+ let buf = "";
399
+ for (; ; ) {
400
+ const { done, value } = await reader.read();
401
+ if (done) break;
402
+ buf += decoder.decode(value, { stream: true });
403
+ const lines = buf.split("\n");
404
+ buf = lines.pop();
405
+ for (const line of lines) {
406
+ if (!line.startsWith("data: ")) continue;
407
+ try {
408
+ const j = JSON.parse(line.slice(6));
409
+ if (j.type === "content_block_delta" && j.delta && j.delta.text) {
410
+ full += j.delta.text;
411
+ if (onText) onText(full);
412
+ }
413
+ } catch {
414
+ }
415
+ }
416
+ }
417
+ return full;
418
+ }
419
+ function parseToolCalls(text) {
420
+ const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
421
+ if (!match) return null;
422
+ try {
423
+ const calls = JSON.parse(match[1]);
424
+ return Array.isArray(calls) && calls.length ? calls : null;
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
429
+ function stripToolCallBlock(text) {
430
+ return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
431
+ }
432
+ function toolProtocol(tools) {
433
+ const list = Object.entries(tools).map(([name, t]) => `- ${name}: ${t.description}
434
+ Input: ${JSON.stringify(t.input || {})}`).join("\n");
435
+ return `
436
+
437
+ You have tools available:
438
+ ${list}
439
+
440
+ This environment does not support native function calling. To use a tool, reply ONLY with a \`\`\`tool_call code block containing a JSON array, with no other text in that turn. Example:
441
+
442
+ \`\`\`tool_call
443
+ [{ "name": "tool_name", "input": { "param": "value" } }]
444
+ \`\`\`
445
+
446
+ You may include several calls in one array. NEVER invent a tool's result \u2014 emit the pure tool_call block and wait for the real result, which arrives in the next message tagged [TOOL_RESULT]. When you have everything you need, answer in natural language with no tool_call block.`;
447
+ }
448
+ function runTools(calls, tools, ctx) {
449
+ return calls.map((call) => {
450
+ const tool = tools[call.name];
451
+ if (!tool) {
452
+ return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
453
+ }
454
+ try {
455
+ const result = tool.run(call.input || {}, ctx);
456
+ return { name: call.name, result: result === void 0 ? { ok: true } : result };
457
+ } catch (e) {
458
+ return { name: call.name, error: String(e && e.message || e) };
459
+ }
460
+ });
461
+ }
462
+ var escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
463
+ function renderMarkdown(text) {
464
+ const codeBlocks = [];
465
+ let t = String(text).replace(/```[a-z]*\n?([\s\S]*?)```/g, (m, code) => {
466
+ codeBlocks.push(code.replace(/\n$/, ""));
467
+ return "\0" + (codeBlocks.length - 1) + "\0";
468
+ });
469
+ t = escHtml(t);
470
+ t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
471
+ t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
472
+ t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
473
+ t = t.replace(/^#{1,3} (.+)$/gm, '<strong class="caf-md-h">$1</strong>');
474
+ t = t.replace(/^[-•*] (.+)$/gm, '<span class="caf-md-li">$1</span>');
475
+ t = t.replace(/^\d+\. (.+)$/gm, '<span class="caf-md-li caf-md-num">$1</span>');
476
+ t = t.replace(/\n/g, "<br>");
477
+ t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => '<pre class="caf-code">' + escHtml(codeBlocks[Number(i)]) + "</pre>");
478
+ return t;
479
+ }
480
+ function Bubble({ m }) {
481
+ if (m.role === "tools") {
482
+ return createElement("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
483
+ }
484
+ if (m.role === "error") {
485
+ return createElement(
486
+ "div",
487
+ { className: "caf-msg caf-msg-error" },
488
+ createElement("strong", null, "The call failed"),
489
+ createElement("span", null, m.content),
490
+ createElement("span", { className: "caf-hint" }, "Your message is kept \u2014 send again to retry.")
491
+ );
492
+ }
493
+ if (m.role === "user") return createElement("div", { className: "caf-msg caf-msg-user" }, m.content);
494
+ return createElement("div", {
495
+ className: "caf-msg caf-msg-assistant",
496
+ dangerouslySetInnerHTML: { __html: renderMarkdown(m.content) }
497
+ });
498
+ }
499
+ function ChatPanel({ cfg, ctx }) {
500
+ const c = cfg;
501
+ const [draft, setDraft] = useState("");
502
+ const [busy, setBusy] = useState(false);
503
+ const [live, setLive] = useState(null);
504
+ const endRef = useRef(null);
505
+ const chat = ctx.data.chat || { api: [], log: [] };
506
+ const log = chat.log || [];
507
+ useEffect(() => {
508
+ if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
509
+ }, [log.length, live]);
510
+ async function send() {
511
+ const text = draft.trim();
512
+ if (!text || busy) return;
513
+ setDraft("");
514
+ setBusy(true);
515
+ let api = [...chat.api || [], { role: "user", content: text }];
516
+ let logNow = [...log, { role: "user", content: text }];
517
+ const commit = () => ctx.update((d) => {
518
+ d.chat = { api, log: logNow };
519
+ });
520
+ commit();
521
+ try {
522
+ const system = (typeof c.system === "function" ? c.system(ctx.data) : c.system || "") + (c.tools ? toolProtocol(c.tools) : "");
523
+ for (let round = 0; round < MAX_ROUNDS; round++) {
524
+ const raw = await streamCall({
525
+ model: c.model || DEFAULT_MODEL,
526
+ maxTokens: c.maxTokens || 2e3,
527
+ system,
528
+ messages: api,
529
+ onText: (t) => setLive(stripToolCallBlock(t))
530
+ });
531
+ api = [...api, { role: "assistant", content: raw }];
532
+ const shown = stripToolCallBlock(raw);
533
+ const calls = c.tools ? parseToolCalls(raw) : null;
534
+ if (!calls) {
535
+ logNow = [...logNow, { role: "assistant", content: shown || raw }];
536
+ commit();
537
+ break;
538
+ }
539
+ const results = runTools(calls, c.tools, ctx);
540
+ if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
541
+ logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
542
+ api = [
543
+ ...api,
544
+ {
545
+ role: "user",
546
+ content: `[TOOL_RESULT]
547
+ ${JSON.stringify(results)}
548
+ [/TOOL_RESULT]
549
+
550
+ Continue with this information. If you have everything you need, answer in natural language without further tool_call.`
551
+ }
552
+ ];
553
+ commit();
554
+ setLive(null);
555
+ }
556
+ } catch (e) {
557
+ logNow = [...logNow, { role: "error", content: String(e && e.message || e) }];
558
+ commit();
559
+ } finally {
560
+ setLive(null);
561
+ setBusy(false);
562
+ }
563
+ }
564
+ return createElement(
565
+ "div",
566
+ { className: "caf-chat-panel" },
567
+ createElement(
568
+ "div",
569
+ { className: "caf-block caf-chat-log" },
570
+ c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } }) : null,
571
+ log.map((m, i) => createElement(Bubble, { key: i, m })),
572
+ live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "\u2026") } }) : null,
573
+ busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
574
+ createElement("div", { ref: endRef })
575
+ ),
576
+ // Deliberately NOT a <form>: the artifact iframe's sandbox lacks
577
+ // allow-forms, so native form submission is blocked there — and on mobile
578
+ // the keyboard's send key triggers exactly that implicit submission. A
579
+ // plain div with onClick + Enter via onKeyDown never touches the native
580
+ // form machinery, so it can't be sandboxed away.
581
+ createElement(
582
+ "div",
583
+ { className: "caf-chat-input" },
584
+ createElement("input", {
585
+ value: draft,
586
+ placeholder: c.placeholder || "Message\u2026",
587
+ disabled: busy,
588
+ enterKeyHint: "send",
589
+ onChange: (e) => setDraft(e.target.value),
590
+ onKeyDown: (e) => {
591
+ if (e.key === "Enter") {
592
+ e.preventDefault();
593
+ send();
594
+ }
595
+ },
596
+ "aria-label": "Message"
597
+ }),
598
+ createElement(
599
+ "button",
600
+ { type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
601
+ "Send"
602
+ )
603
+ )
604
+ );
605
+ }
606
+ function ChatLayout({ spec, ctx }) {
607
+ return createElement("div", { className: "caf-panel caf-panel-single" }, createElement(ChatPanel, { cfg: spec.chat, ctx }));
608
+ }
609
+
375
610
  // src/blocks.js
376
611
  var FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
377
612
  function formatValue(value, format) {
@@ -738,6 +973,7 @@ var BLOCKS = {
738
973
  chart: ChartBlock,
739
974
  timer: TimerBlock,
740
975
  actions: ActionsBlock,
976
+ chat: ({ spec, ctx }) => createElement(ChatPanel, { cfg: spec.chat, ctx }),
741
977
  custom: CustomBlock
742
978
  };
743
979
  var BLOCK_NAMES = Object.keys(BLOCKS);
@@ -1079,242 +1315,10 @@ function StepsLayout({ spec, ctx }) {
1079
1315
  );
1080
1316
  }
1081
1317
 
1082
- // src/chat.js
1083
- var CHAT_KEYS = ["system", "greeting", "placeholder", "tools", "model", "maxTokens"];
1084
- var DEFAULT_MODEL = "claude-sonnet-4-6";
1085
- var MAX_ROUNDS = 8;
1086
- async function streamCall({ model, maxTokens, system, messages, onText }) {
1087
- const res = await fetch("https://api.anthropic.com/v1/messages", {
1088
- method: "POST",
1089
- headers: { "Content-Type": "application/json" },
1090
- body: JSON.stringify({ model, max_tokens: maxTokens, system, messages, stream: true })
1091
- });
1092
- if (!res.ok || !res.body) {
1093
- let detail = "";
1094
- try {
1095
- const b = await res.json();
1096
- detail = b && b.error && b.error.message || JSON.stringify(b);
1097
- } catch {
1098
- detail = await res.text().catch(() => "");
1099
- }
1100
- throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`);
1101
- }
1102
- const reader = res.body.getReader();
1103
- const decoder = new TextDecoder();
1104
- let full = "";
1105
- let buf = "";
1106
- for (; ; ) {
1107
- const { done, value } = await reader.read();
1108
- if (done) break;
1109
- buf += decoder.decode(value, { stream: true });
1110
- const lines = buf.split("\n");
1111
- buf = lines.pop();
1112
- for (const line of lines) {
1113
- if (!line.startsWith("data: ")) continue;
1114
- try {
1115
- const j = JSON.parse(line.slice(6));
1116
- if (j.type === "content_block_delta" && j.delta && j.delta.text) {
1117
- full += j.delta.text;
1118
- if (onText) onText(full);
1119
- }
1120
- } catch {
1121
- }
1122
- }
1123
- }
1124
- return full;
1125
- }
1126
- function parseToolCalls(text) {
1127
- const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
1128
- if (!match) return null;
1129
- try {
1130
- const calls = JSON.parse(match[1]);
1131
- return Array.isArray(calls) && calls.length ? calls : null;
1132
- } catch {
1133
- return null;
1134
- }
1135
- }
1136
- function stripToolCallBlock(text) {
1137
- return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
1138
- }
1139
- function toolProtocol(tools) {
1140
- const list = Object.entries(tools).map(([name, t]) => `- ${name}: ${t.description}
1141
- Input: ${JSON.stringify(t.input || {})}`).join("\n");
1142
- return `
1143
-
1144
- You have tools available:
1145
- ${list}
1146
-
1147
- This environment does not support native function calling. To use a tool, reply ONLY with a \`\`\`tool_call code block containing a JSON array, with no other text in that turn. Example:
1148
-
1149
- \`\`\`tool_call
1150
- [{ "name": "tool_name", "input": { "param": "value" } }]
1151
- \`\`\`
1152
-
1153
- You may include several calls in one array. NEVER invent a tool's result \u2014 emit the pure tool_call block and wait for the real result, which arrives in the next message tagged [TOOL_RESULT]. When you have everything you need, answer in natural language with no tool_call block.`;
1154
- }
1155
- function runTools(calls, tools, ctx) {
1156
- return calls.map((call) => {
1157
- const tool = tools[call.name];
1158
- if (!tool) {
1159
- return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
1160
- }
1161
- try {
1162
- const result = tool.run(call.input || {}, ctx);
1163
- return { name: call.name, result: result === void 0 ? { ok: true } : result };
1164
- } catch (e) {
1165
- return { name: call.name, error: String(e && e.message || e) };
1166
- }
1167
- });
1168
- }
1169
- var escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
1170
- function renderMarkdown(text) {
1171
- const codeBlocks = [];
1172
- let t = String(text).replace(/```[a-z]*\n?([\s\S]*?)```/g, (m, code) => {
1173
- codeBlocks.push(code.replace(/\n$/, ""));
1174
- return "\0" + (codeBlocks.length - 1) + "\0";
1175
- });
1176
- t = escHtml(t);
1177
- t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
1178
- t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1179
- t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
1180
- t = t.replace(/^#{1,3} (.+)$/gm, '<strong class="caf-md-h">$1</strong>');
1181
- t = t.replace(/^[-•*] (.+)$/gm, '<span class="caf-md-li">$1</span>');
1182
- t = t.replace(/^\d+\. (.+)$/gm, '<span class="caf-md-li caf-md-num">$1</span>');
1183
- t = t.replace(/\n/g, "<br>");
1184
- t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => '<pre class="caf-code">' + escHtml(codeBlocks[Number(i)]) + "</pre>");
1185
- return t;
1186
- }
1187
- function Bubble({ m }) {
1188
- if (m.role === "tools") {
1189
- return createElement("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
1190
- }
1191
- if (m.role === "error") {
1192
- return createElement(
1193
- "div",
1194
- { className: "caf-msg caf-msg-error" },
1195
- createElement("strong", null, "The call failed"),
1196
- createElement("span", null, m.content),
1197
- createElement("span", { className: "caf-hint" }, "Your message is kept \u2014 send again to retry.")
1198
- );
1199
- }
1200
- if (m.role === "user") return createElement("div", { className: "caf-msg caf-msg-user" }, m.content);
1201
- return createElement("div", {
1202
- className: "caf-msg caf-msg-assistant",
1203
- dangerouslySetInnerHTML: { __html: renderMarkdown(m.content) }
1204
- });
1205
- }
1206
- function ChatLayout({ spec, ctx }) {
1207
- const c = spec.chat;
1208
- const [draft, setDraft] = useState("");
1209
- const [busy, setBusy] = useState(false);
1210
- const [live, setLive] = useState(null);
1211
- const endRef = useRef(null);
1212
- const chat = ctx.data.chat || { api: [], log: [] };
1213
- const log = chat.log || [];
1214
- useEffect(() => {
1215
- if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
1216
- }, [log.length, live]);
1217
- async function send() {
1218
- const text = draft.trim();
1219
- if (!text || busy) return;
1220
- setDraft("");
1221
- setBusy(true);
1222
- let api = [...chat.api || [], { role: "user", content: text }];
1223
- let logNow = [...log, { role: "user", content: text }];
1224
- const commit = () => ctx.update((d) => {
1225
- d.chat = { api, log: logNow };
1226
- });
1227
- commit();
1228
- try {
1229
- const system = (typeof c.system === "function" ? c.system(ctx.data) : c.system || "") + (c.tools ? toolProtocol(c.tools) : "");
1230
- for (let round = 0; round < MAX_ROUNDS; round++) {
1231
- const raw = await streamCall({
1232
- model: c.model || DEFAULT_MODEL,
1233
- maxTokens: c.maxTokens || 2e3,
1234
- system,
1235
- messages: api,
1236
- onText: (t) => setLive(stripToolCallBlock(t))
1237
- });
1238
- api = [...api, { role: "assistant", content: raw }];
1239
- const shown = stripToolCallBlock(raw);
1240
- const calls = c.tools ? parseToolCalls(raw) : null;
1241
- if (!calls) {
1242
- logNow = [...logNow, { role: "assistant", content: shown || raw }];
1243
- commit();
1244
- break;
1245
- }
1246
- const results = runTools(calls, c.tools, ctx);
1247
- if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
1248
- logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
1249
- api = [
1250
- ...api,
1251
- {
1252
- role: "user",
1253
- content: `[TOOL_RESULT]
1254
- ${JSON.stringify(results)}
1255
- [/TOOL_RESULT]
1256
-
1257
- Continue with this information. If you have everything you need, answer in natural language without further tool_call.`
1258
- }
1259
- ];
1260
- commit();
1261
- setLive(null);
1262
- }
1263
- } catch (e) {
1264
- logNow = [...logNow, { role: "error", content: String(e && e.message || e) }];
1265
- commit();
1266
- } finally {
1267
- setLive(null);
1268
- setBusy(false);
1269
- }
1270
- }
1271
- return createElement(
1272
- "div",
1273
- { className: "caf-panel caf-panel-single" },
1274
- createElement(
1275
- "div",
1276
- { className: "caf-block caf-chat-log" },
1277
- c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } }) : null,
1278
- log.map((m, i) => createElement(Bubble, { key: i, m })),
1279
- live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "\u2026") } }) : null,
1280
- busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
1281
- createElement("div", { ref: endRef })
1282
- ),
1283
- // Deliberately NOT a <form>: the artifact iframe's sandbox lacks
1284
- // allow-forms, so native form submission is blocked there — and on mobile
1285
- // the keyboard's send key triggers exactly that implicit submission. A
1286
- // plain div with onClick + Enter via onKeyDown never touches the native
1287
- // form machinery, so it can't be sandboxed away.
1288
- createElement(
1289
- "div",
1290
- { className: "caf-chat-input" },
1291
- createElement("input", {
1292
- value: draft,
1293
- placeholder: c.placeholder || "Message\u2026",
1294
- disabled: busy,
1295
- enterKeyHint: "send",
1296
- onChange: (e) => setDraft(e.target.value),
1297
- onKeyDown: (e) => {
1298
- if (e.key === "Enter") {
1299
- e.preventDefault();
1300
- send();
1301
- }
1302
- },
1303
- "aria-label": "Message"
1304
- }),
1305
- createElement(
1306
- "button",
1307
- { type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
1308
- "Send"
1309
- )
1310
- )
1311
- );
1312
- }
1313
-
1314
1318
  // src/app.js
1315
- var LAYOUTS = ["panel", "records", "steps", "chat"];
1319
+ var LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
1316
1320
  var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
1317
- var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared"];
1321
+ var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar"];
1318
1322
  function checkFields(fields, where) {
1319
1323
  for (const f of fields || []) {
1320
1324
  if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in ${where} needs a \`key\`.`);
@@ -1330,6 +1334,26 @@ function checkFields(fields, where) {
1330
1334
  }
1331
1335
  }
1332
1336
  }
1337
+ function checkChatConfig(c, where) {
1338
+ if (!c || typeof c.system !== "string" && typeof c.system !== "function") {
1339
+ throw new Error(
1340
+ `claude-artifact-framework: ${where} needs \`{ system: "..." }\` (a string or a function of data).`
1341
+ );
1342
+ }
1343
+ const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
1344
+ if (unknown.length) {
1345
+ throw new Error(
1346
+ `claude-artifact-framework: ${where} has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
1347
+ );
1348
+ }
1349
+ for (const [name, tool] of Object.entries(c.tools || {})) {
1350
+ if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
1351
+ throw new Error(
1352
+ `claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
1353
+ );
1354
+ }
1355
+ }
1356
+ }
1333
1357
  var ACTION_KEYS = ["label", "run", "when", "kind"];
1334
1358
  function checkActions(actions, where) {
1335
1359
  if (!Array.isArray(actions)) {
@@ -1371,6 +1395,7 @@ function checkBlocks(blocks) {
1371
1395
  }
1372
1396
  checkFields(block.fields, `blocks[${i}]`);
1373
1397
  if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
1398
+ if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
1374
1399
  });
1375
1400
  }
1376
1401
  function validate(spec) {
@@ -1419,25 +1444,28 @@ function validate(spec) {
1419
1444
  return layout;
1420
1445
  }
1421
1446
  if (layout === "chat") {
1422
- const c = spec.chat;
1423
- if (!c || typeof c.system !== "string" && typeof c.system !== "function") {
1447
+ checkChatConfig(spec.chat, "chat");
1448
+ return layout;
1449
+ }
1450
+ if (layout === "workspace") {
1451
+ if (!Array.isArray(spec.main) || spec.main.length === 0) {
1424
1452
  throw new Error(
1425
- 'claude-artifact-framework: layout "chat" needs `chat: { system: "..." }` (a string or a function of data).'
1453
+ 'claude-artifact-framework: layout "workspace" needs `main: [...]` (an array of blocks). `sidebar: [...]` is optional.'
1426
1454
  );
1427
1455
  }
1428
- const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
1429
- if (unknown.length) {
1456
+ checkBlocks(spec.main);
1457
+ if (spec.sidebar !== void 0) {
1458
+ if (!Array.isArray(spec.sidebar)) {
1459
+ throw new Error("claude-artifact-framework: workspace `sidebar` must be an array of blocks.");
1460
+ }
1461
+ checkBlocks(spec.sidebar);
1462
+ }
1463
+ const chats = [...spec.main, ...spec.sidebar || []].filter((b) => b && b.chat);
1464
+ if (chats.length > 1) {
1430
1465
  throw new Error(
1431
- `claude-artifact-framework: chat has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
1466
+ "claude-artifact-framework: only one chat block per app \u2014 the conversation persists under data.chat and there is exactly one."
1432
1467
  );
1433
1468
  }
1434
- for (const [name, tool] of Object.entries(c.tools || {})) {
1435
- if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
1436
- throw new Error(
1437
- `claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
1438
- );
1439
- }
1440
- }
1441
1469
  return layout;
1442
1470
  }
1443
1471
  if (layout === "records") {
@@ -1504,10 +1532,12 @@ function defaultsFrom(spec) {
1504
1532
  if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
1505
1533
  data.records = [];
1506
1534
  }
1507
- if ((spec.layout || "panel") === "chat" && !data.chat) {
1535
+ const allBlocks = [...spec.blocks || [], ...spec.main || [], ...spec.sidebar || []];
1536
+ const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
1537
+ if (hasChat && !data.chat) {
1508
1538
  data.chat = { api: [], log: [] };
1509
1539
  }
1510
- for (const block of [...spec.blocks || [], ...spec.steps || []]) {
1540
+ for (const block of [...allBlocks, ...spec.steps || []]) {
1511
1541
  for (const field of block.fields || []) {
1512
1542
  if (field && field.key !== void 0 && !(field.key in data)) {
1513
1543
  data[field.key] = field.value !== void 0 ? field.value : field.type === "check" ? false : "";
@@ -1579,7 +1609,16 @@ function RecordsWithBlocks({ spec, ctx }) {
1579
1609
  ) : null
1580
1610
  );
1581
1611
  }
1582
- var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout };
1612
+ function WorkspaceLayout({ spec, ctx }) {
1613
+ const render = (blocks) => (blocks || []).map((block, i) => createElement("section", { key: i, className: "caf-ws-slot" }, createElement(Block, { block, ctx })));
1614
+ return createElement(
1615
+ "div",
1616
+ { className: "caf-workspace" },
1617
+ createElement("div", { className: "caf-ws-main" }, render(spec.main)),
1618
+ spec.sidebar && spec.sidebar.length ? createElement("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
1619
+ );
1620
+ }
1621
+ var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout };
1583
1622
  function createApp(spec = {}) {
1584
1623
  const layout = validate(spec);
1585
1624
  const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
@@ -1759,6 +1798,16 @@ var BASE_CSS = `
1759
1798
  .caf-md-h { display: block; margin-top: 0.3rem; }
1760
1799
  .caf-md-li { display: block; padding-left: 1rem; position: relative; }
1761
1800
  .caf-md-li::before { content: "\u2022"; position: absolute; left: 0.2rem; opacity: 0.6; }
1801
+ .caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
1802
+ .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
1803
+ .caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: 1rem; position: sticky; top: 1rem; }
1804
+ .caf-ws-slot { min-width: 0; }
1805
+ .caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
1806
+ .caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
1807
+ @media (max-width: 900px) {
1808
+ .caf-workspace { flex-direction: column; align-items: stretch; }
1809
+ .caf-ws-side { width: 100%; position: static; }
1810
+ }
1762
1811
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
1763
1812
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
1764
1813
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }