@kitn.ai/ui 0.25.2 → 0.26.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/README.md +2 -2
- package/dist/components/attachment-types.d.ts +46 -1
- package/dist/components/attachments.d.ts +31 -0
- package/dist/components/audio-visualizer/fit-scale.d.ts +50 -0
- package/dist/components/audio-visualizer/index.d.ts +15 -1
- package/dist/components/audio-visualizer/variant-bar.d.ts +24 -1
- package/dist/components/chat-thread.d.ts +3 -41
- package/dist/components/code-block.d.ts +22 -0
- package/dist/components/conversation-item.d.ts +54 -1
- package/dist/components/conversation-list.d.ts +65 -0
- package/dist/components/form.d.ts +42 -0
- package/dist/components/message.d.ts +7 -0
- package/dist/components/toast.d.ts +2 -1
- package/dist/components/voice-input.d.ts +8 -0
- package/dist/components/voice-output.d.ts +12 -1
- package/dist/components/workspace-shell.d.ts +87 -0
- package/dist/{create-tween-B5Y4Im8G.js → create-tween-BFT6c-Y8.js} +1 -1
- package/dist/{create-tween-4z1XYLkR.js → create-tween-CJNlzoaO.js} +1 -1
- package/dist/{create-tween-CUzOlaMS.js → create-tween-CQED4T0g.js} +1 -1
- package/dist/custom-elements.json +2931 -2572
- package/dist/diagnostics/hook.d.ts +81 -0
- package/dist/diagnostics/index.d.ts +7 -0
- package/dist/diagnostics/report-request.d.ts +61 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +209 -0
- package/dist/elements/agent-card.js +1 -1
- package/dist/elements/artifact.js +1 -1
- package/dist/elements/attachments.js +1 -1
- package/dist/elements/audio-visualizer.js +1 -1
- package/dist/elements/autoloader.js +1 -1
- package/dist/elements/avatar.js +1 -1
- package/dist/elements/badge.js +1 -1
- package/dist/elements/button.js +1 -1
- package/dist/elements/card.js +1 -1
- package/dist/elements/cards.js +1 -1
- package/dist/elements/chain-of-thought.js +1 -1
- package/dist/elements/chat-scope-picker.js +1 -1
- package/dist/elements/chat-workspace.js +1 -1
- package/dist/elements/chat.js +1 -1
- package/dist/elements/checkpoint.js +1 -1
- package/dist/elements/choice.js +1 -1
- package/dist/elements/chunks/{Icon-b5A8hlHs.js → Icon--Y8vsFal.js} +1 -1
- package/dist/elements/chunks/action-icons-D9u3OzYW.js +1 -0
- package/dist/elements/chunks/arrow-left-CVh1TeN7.js +1 -0
- package/dist/elements/chunks/{artifact-BSuPYFHY.js → artifact-BkYcfIlZ.js} +1 -1
- package/dist/elements/chunks/attachments-DwfNlubq.js +1 -0
- package/dist/elements/chunks/audio-visualizer-D7lB6mvR.js +1 -0
- package/dist/elements/chunks/{badge-TIMiJFKc.js → badge-Q0-Cmwzz.js} +1 -1
- package/dist/elements/chunks/{button-B9okqG73.js → button-flUbeufF.js} +1 -1
- package/dist/elements/chunks/{card-renderer-CSoTL_e1.js → card-renderer-3Dyl-FpN.js} +1 -1
- package/dist/elements/chunks/check-B5kBPKeo.js +1 -0
- package/dist/elements/chunks/chevron-down-DZ1GS39h.js +1 -0
- package/dist/elements/chunks/chevron-right-CvP7OUd2.js +1 -0
- package/dist/elements/chunks/{choice-card-CGMc3kek.js → choice-card-D4QUIOWZ.js} +1 -1
- package/dist/elements/chunks/circle-CDf9g6MQ.js +1 -0
- package/dist/elements/chunks/circle-check-CA47Nqbz.js +1 -0
- package/dist/elements/chunks/{circle-x-mhH7pOK2.js → circle-x-CJ_HVbC8.js} +1 -1
- package/dist/elements/chunks/code-block-BdLiTitd.js +1 -0
- package/dist/elements/chunks/collapsible-BzbXpPzr.js +1 -0
- package/dist/elements/chunks/{composer-CZZmtYOT.js → composer-Byve-n8K.js} +2 -2
- package/dist/elements/chunks/{confirm-card-BWjf2-xo.js → confirm-card-B0flE3MN.js} +1 -1
- package/dist/elements/chunks/context-VLIL3sJ-.js +1 -0
- package/dist/elements/chunks/controllable-DTwTIlwp.js +1 -0
- package/dist/elements/chunks/conversation-item-BDztezOG.js +1 -0
- package/dist/elements/chunks/copy-Nf-kqxXH.js +1 -0
- package/dist/elements/chunks/{create-tween-CUrM6D_o.js → create-tween-c4Z8TDYz.js} +2 -2
- package/dist/elements/chunks/default-input-C2iLFq0b.js +1 -0
- package/dist/elements/chunks/define-wjHR1MkU.js +1 -0
- package/dist/elements/chunks/disclosure-CcNbyLy0.js +1 -0
- package/dist/elements/chunks/{download-DnCyWFJE.js → download-C4V7tBdb.js} +1 -1
- package/dist/elements/chunks/dropdown-BgWTthUx.js +1 -0
- package/dist/elements/chunks/{ellipsis-CVxqfsDZ.js → ellipsis-7gU33mL2.js} +1 -1
- package/dist/elements/chunks/embed-D3KewsIJ.js +1 -0
- package/dist/elements/chunks/{external-link-DG2sRdQL.js → external-link-CSOEsNlx.js} +1 -1
- package/dist/elements/chunks/{file-text-D0J9F6M7.js → file-text-D1G8khle.js} +1 -1
- package/dist/elements/chunks/{file-tree-yGWNK0Js.js → file-tree-zvB2i7P9.js} +1 -1
- package/dist/elements/chunks/{folder-NV1pi_ud.js → folder-WW2LGLid.js} +1 -1
- package/dist/elements/chunks/form-CQirdC4W.js +1 -0
- package/dist/elements/chunks/hover-card-CJJkfHTK.js +1 -0
- package/dist/elements/chunks/icon-Cfgmf4fa.js +1 -0
- package/dist/elements/chunks/{info-C8r5Q6cc.js → info-DX3YMOt6.js} +1 -1
- package/dist/elements/chunks/input-BtW9U6Sq.js +1 -0
- package/dist/elements/chunks/{kbd-OGJDUT-O.js → kbd-LGXwuT_V.js} +1 -1
- package/dist/elements/chunks/{link-BdsfLB7b.js → link-2My69BqT.js} +1 -1
- package/dist/elements/chunks/{link-preview-Cf5KOP-i.js → link-preview-OQ9IEq0f.js} +1 -1
- package/dist/elements/chunks/{loader-B5QzK_8_.js → loader-iOxc_Tu-.js} +1 -1
- package/dist/elements/chunks/{markdown-DbozyYqa.js → markdown-DgBFaGAz.js} +1 -1
- package/dist/elements/chunks/{message-DtXKPqiR.js → message-VMZohrXx.js} +1 -1
- package/dist/elements/chunks/message-circle-CpXMM-_n.js +1 -0
- package/dist/elements/chunks/message-square-DxcWtETQ.js +1 -0
- package/dist/elements/chunks/message-tZmV2A3p.js +1 -0
- package/dist/elements/chunks/{minimize-2-SegJ_oku.js → minimize-2-DPIUKTrT.js} +1 -1
- package/dist/elements/chunks/{model-switcher-D7JrFFHp.js → model-switcher-DsT8tFB0.js} +1 -1
- package/dist/elements/chunks/{overlay-CV7z2YuX.js → overlay-DuiwdXjm.js} +1 -1
- package/dist/elements/chunks/{paperclip-Bg0ITU3b.js → paperclip-CRZVp4z_.js} +1 -1
- package/dist/elements/chunks/{progress-bar-D1_rzt6N.js → progress-bar-DNy66e_E.js} +1 -1
- package/dist/elements/chunks/{prompt-suggestion-D5Sm8xF1.js → prompt-suggestion-Bn64Nl7L.js} +1 -1
- package/dist/elements/chunks/reasoning-Emn9N4-T.js +1 -0
- package/dist/elements/chunks/{resizable-Dpzjlvp4.js → resizable-D181b4fM.js} +1 -1
- package/dist/elements/chunks/{rotate-cw-DzVDJOA1.js → rotate-cw-CPWe37D9.js} +1 -1
- package/dist/elements/chunks/{scroll-area-93A1bbeG.js → scroll-area-ClZkZxIk.js} +1 -1
- package/dist/elements/chunks/scroll-button-DPE-t3x_.js +1 -0
- package/dist/elements/chunks/{separator-DH2r_ZSZ.js → separator-CeJHQqLW.js} +1 -1
- package/dist/elements/chunks/{settings-BFMOd6yB.js → settings-CqaHgJF9.js} +1 -1
- package/dist/elements/chunks/{settings-group-B3VZKIcz.js → settings-group-CjqjLCOn.js} +1 -1
- package/dist/elements/chunks/share-CyfnNEqp.js +1 -0
- package/dist/elements/chunks/{skeleton-N8BuWYmf.js → skeleton-D8akBnKS.js} +1 -1
- package/dist/elements/chunks/slots-DaNjX0rc.js +1 -0
- package/dist/elements/chunks/{source-w9V0NnBn.js → source-UWCvmVak.js} +1 -1
- package/dist/elements/chunks/{star-DHvACxtm.js → star-DoHFAikQ.js} +1 -1
- package/dist/elements/chunks/store-Cjq0qwYN.js +1 -0
- package/dist/elements/chunks/{tasks-card-lPxoodlA.js → tasks-card-CmZ5xh6v.js} +1 -1
- package/dist/elements/chunks/{text-shimmer-BY_rI3s5.js → text-shimmer-DYn09zrz.js} +1 -1
- package/dist/elements/chunks/textarea-skiGHURM.js +1 -0
- package/dist/elements/chunks/{thumbs-up-DQe0hvkZ.js → thumbs-up-DX2Ijk3H.js} +1 -1
- package/dist/elements/chunks/{toast-store-VUZZBhzH.js → toast-store-CFnQpThX.js} +1 -1
- package/dist/elements/chunks/{tool-C1YtxwPu.js → tool-DKQ2D1DP.js} +1 -1
- package/dist/elements/chunks/{tooltip-BqwnFWhn.js → tooltip-B0G99y8w.js} +1 -1
- package/dist/elements/chunks/{triangle-alert-B6or7F5u.js → triangle-alert-F9cyUEyy.js} +1 -1
- package/dist/elements/chunks/{use-card-resolution-DJg32jGH.js → use-card-resolution-DoA2f_EU.js} +1 -1
- package/dist/elements/chunks/{variant-aurora-QXTZPyST.js → variant-aurora-qt6Zct8M.js} +2 -2
- package/dist/elements/chunks/variant-custom-tJp22_BQ.js +1 -0
- package/dist/{variant-wave-BwzrHD1s.js → elements/chunks/variant-wave-CaQ5ti39.js} +2 -2
- package/dist/elements/chunks/x-B6UnOiLw.js +1 -0
- package/dist/elements/coachmark.js +1 -1
- package/dist/elements/code-block.js +1 -1
- package/dist/elements/command.js +1 -1
- package/dist/elements/compare.js +1 -1
- package/dist/elements/composer.js +1 -1
- package/dist/elements/confirm-card.js +1 -1
- package/dist/elements/context-meter.js +1 -1
- package/dist/elements/conversation-item.d.ts +1 -0
- package/dist/elements/conversation-item.js +1 -0
- package/dist/elements/conversation-list.js +1 -1
- package/dist/elements/default-input.d.ts +3 -3
- package/dist/elements/define.d.ts +27 -0
- package/dist/elements/diagnostic-events.d.ts +74 -0
- package/dist/elements/dialog.js +1 -1
- package/dist/elements/dock.d.ts +1 -0
- package/dist/elements/dock.js +149 -0
- package/dist/elements/dropdown.d.ts +1 -0
- package/dist/elements/dropdown.js +1 -0
- package/dist/elements/editable-label.js +1 -1
- package/dist/elements/element-diagnostics.d.ts +81 -0
- package/dist/elements/embed.js +1 -1
- package/dist/elements/empty.js +1 -1
- package/dist/elements/feedback-bar.js +1 -1
- package/dist/elements/file-tree.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/form.js +1 -1
- package/dist/elements/hover-card.js +1 -1
- package/dist/elements/icon.js +1 -1
- package/dist/elements/image.js +1 -1
- package/dist/elements/input.js +1 -1
- package/dist/elements/kbd.js +1 -1
- package/dist/elements/link-preview.js +1 -1
- package/dist/elements/loader.js +1 -1
- package/dist/elements/markdown.js +1 -1
- package/dist/elements/menu.js +1 -1
- package/dist/elements/message-skills.js +1 -1
- package/dist/elements/message.js +1 -1
- package/dist/elements/model-switcher.js +1 -1
- package/dist/elements/nav.js +1 -1
- package/dist/elements/notice.js +1 -1
- package/dist/elements/pane-group.js +1 -1
- package/dist/elements/pane.js +1 -1
- package/dist/elements/popover.js +1 -1
- package/dist/elements/progress-bar.js +1 -1
- package/dist/elements/prompt-dock.js +1 -1
- package/dist/elements/prompt-input.js +1 -1
- package/dist/elements/prompt-suggestions.js +1 -1
- package/dist/elements/reasoning.js +1 -1
- package/dist/elements/register.d.ts +2 -0
- package/dist/elements/remote.js +1 -1
- package/dist/elements/resizable.js +1 -1
- package/dist/elements/response-stream.js +3 -3
- package/dist/elements/screen.js +1 -1
- package/dist/elements/scroll-area.js +1 -1
- package/dist/elements/scroll-button.js +1 -1
- package/dist/elements/search.js +1 -1
- package/dist/elements/segmented.js +1 -1
- package/dist/elements/separator.js +1 -1
- package/dist/elements/setting-item.js +1 -1
- package/dist/elements/settings-group.js +1 -1
- package/dist/elements/skeleton.js +1 -1
- package/dist/elements/slots.d.ts +35 -3
- package/dist/elements/source.js +1 -1
- package/dist/elements/status.js +1 -1
- package/dist/elements/switch.js +1 -1
- package/dist/elements/tabs.js +1 -1
- package/dist/elements/tasks.js +1 -1
- package/dist/elements/text-shimmer.js +1 -1
- package/dist/elements/thinking-bar.js +1 -1
- package/dist/elements/thread.js +1 -1
- package/dist/elements/toast.js +1 -1
- package/dist/elements/tool.js +1 -1
- package/dist/elements/tooltip.js +1 -1
- package/dist/elements/voice-input.js +1 -1
- package/dist/elements/voice-output.js +1 -1
- package/dist/elements.d.ts +232 -148
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5456 -4931
- package/dist/index.server.js +4142 -3669
- package/dist/kai-provider.es.js +40 -40
- package/dist/kai.es.js +1 -1
- package/dist/llms/llms-full.txt +184 -69
- package/dist/llms/llms.txt +3 -3
- package/dist/mcp.es.js +2669 -546
- package/dist/primitives/use-resize-observer.d.ts +23 -0
- package/dist/primitives/use-speech-recognition.d.ts +8 -0
- package/dist/primitives/use-text-stream.d.ts +5 -0
- package/dist/react/index.d.ts +115 -266
- package/dist/react.js +247 -221
- package/dist/register-impl-DpE7icIb.js +293 -0
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/tool-defs.d.ts +35 -0
- package/dist/schemas.js +403 -310
- package/dist/{solid-BMuDmFo_.js → solid-BtdqNZI7.js} +8435 -7394
- package/dist/{solid-CGYNdLRR.js → solid-CnavwBye.js} +5987 -5035
- package/dist/solid.d.ts +5 -0
- package/dist/solid.js +210 -203
- package/dist/solid.server.js +210 -203
- package/dist/state/index.d.ts +4 -0
- package/dist/state/persistence.d.ts +51 -0
- package/dist/state/stream.d.ts +50 -0
- package/dist/state/threads.d.ts +71 -0
- package/dist/state.js +269 -169
- package/dist/types.d.ts +4 -2
- package/dist/ui/dock.d.ts +109 -0
- package/dist/ui/hover-card.d.ts +44 -0
- package/dist/{variant-aurora-DwSOfraQ.js → variant-aurora-B8II3-I1.js} +27 -27
- package/dist/{variant-aurora-wKvAQ5wp.js → variant-aurora-CMNWOSJR.js} +28 -28
- package/dist/{variant-aurora-XEkPT0_z.js → variant-aurora-CcuDMWwQ.js} +2 -2
- package/dist/{variant-custom-eFIg2XWQ.js → variant-custom-BdvYC7af.js} +40 -40
- package/dist/{variant-custom-BhS9wIpU.js → variant-custom-C714mqKl.js} +65 -65
- package/dist/variant-custom-L16JLLZy.js +1 -0
- package/dist/{elements/chunks/variant-wave-DDCwhLas.js → variant-wave-2S4La2Ka.js} +2 -2
- package/dist/{variant-wave-CbVsmgwT.js → variant-wave-D7ruiA1W.js} +25 -25
- package/dist/{variant-wave-CUWGx9Bn.js → variant-wave-ecwykeyL.js} +21 -21
- package/dist/wire/chunk.d.ts +47 -0
- package/dist/wire/diagnostics.d.ts +575 -0
- package/dist/wire/encode-probe.d.ts +70 -0
- package/dist/wire/encode.d.ts +24 -0
- package/dist/wire/index.d.ts +2 -0
- package/dist/wire/sse.d.ts +10 -2
- package/dist/wire.js +917 -480
- package/frameworks/react/index.tsx +132 -93
- package/llms-full.txt +184 -69
- package/llms.txt +3 -3
- package/package.json +26 -6
- package/src/agent-tooling/archetypes.ts +41 -9
- package/src/agent-tooling/catalog/README.md +676 -0
- package/src/agent-tooling/catalog/catalog-types.ts +231 -0
- package/src/agent-tooling/catalog/fabrications.ts +96 -0
- package/src/agent-tooling/catalog/invariants.ts +284 -0
- package/src/agent-tooling/catalog/labs-titles.ts +114 -0
- package/src/agent-tooling/catalog/scenarios.ts +87 -0
- package/src/agent-tooling/catalog/surfaces.ts +275 -0
- package/src/agent-tooling/integrations/anthropic.ts +7 -1
- package/src/agent-tooling/integrations/cloudflare.ts +43 -2
- package/src/agent-tooling/integrations/langgraph.ts +7 -1
- package/src/agent-tooling/integrations/mastra.ts +7 -1
- package/src/agent-tooling/integrations/mock.ts +92 -17
- package/src/agent-tooling/integrations/ollama.ts +7 -1
- package/src/agent-tooling/integrations/openai.ts +7 -1
- package/src/agent-tooling/integrations/openrouter.ts +7 -1
- package/src/agent-tooling/integrations/pi.ts +56 -1
- package/src/agent-tooling/integrations/vercel-ai-sdk.ts +7 -1
- package/src/agent-tooling/mcp/css-raw.d.ts +12 -0
- package/src/agent-tooling/mcp/manifest.ts +100 -1
- package/src/agent-tooling/mcp/server.ts +61 -5
- package/src/agent-tooling/mcp/tools/reference.ts +440 -4
- package/src/agent-tooling/mcp/tools/scaffold.ts +1834 -307
- package/src/agent-tooling/mcp/tools/theme.ts +146 -30
- package/src/agent-tooling/mcp/validate-args.ts +141 -0
- package/src/agent-tooling/registry.ts +19 -7
- package/src/agent-tooling/route-emit.ts +26 -2
- package/src/components/attachment-types.ts +47 -1
- package/src/components/attachments.tsx +169 -22
- package/src/components/audio-visualizer/fit-scale.ts +112 -0
- package/src/components/audio-visualizer/index.tsx +80 -3
- package/src/components/audio-visualizer/variant-aurora.tsx +10 -4
- package/src/components/audio-visualizer/variant-bar.tsx +38 -6
- package/src/components/audio-visualizer/variant-custom.tsx +8 -2
- package/src/components/audio-visualizer/variant-grid.tsx +16 -11
- package/src/components/audio-visualizer/variant-radial.tsx +15 -10
- package/src/components/audio-visualizer/variant-wave.tsx +7 -2
- package/src/components/chat-container.tsx +4 -1
- package/src/components/chat-thread.tsx +36 -7
- package/src/components/code-block.tsx +79 -3
- package/src/components/composer.tsx +8 -1
- package/src/components/conversation-item.tsx +114 -4
- package/src/components/conversation-list.tsx +212 -12
- package/src/components/embed.tsx +4 -0
- package/src/components/form.tsx +41 -14
- package/src/components/message.tsx +75 -5
- package/src/components/response-stream.tsx +13 -7
- package/src/components/screen.tsx +5 -0
- package/src/components/scroll-button.tsx +7 -0
- package/src/components/toast.tsx +11 -5
- package/src/components/voice-input.tsx +35 -3
- package/src/components/voice-output.tsx +46 -6
- package/src/components/workspace-shell.tsx +285 -0
- package/src/diagnostics/hook.ts +352 -0
- package/src/diagnostics/index.ts +82 -0
- package/src/diagnostics/report-request.ts +296 -0
- package/src/elements/attachments.tsx +6 -7
- package/src/elements/audio-visualizer.tsx +48 -4
- package/src/elements/chat-workspace.tsx +159 -287
- package/src/elements/chat.tsx +12 -9
- package/src/elements/code-block.tsx +19 -1
- package/src/elements/command.tsx +4 -1
- package/src/elements/compiled.css +1 -1
- package/src/elements/conversation-item.tsx +80 -0
- package/src/elements/conversation-list.tsx +60 -11
- package/src/elements/default-input.tsx +5 -5
- package/src/elements/define.tsx +168 -8
- package/src/elements/diagnostic-events.ts +114 -0
- package/src/elements/dialog.tsx +47 -0
- package/src/elements/disclosure.ts +34 -12
- package/src/elements/dock.tsx +204 -0
- package/src/elements/dropdown.tsx +123 -0
- package/src/elements/element-diagnostics.ts +392 -0
- package/src/elements/element-manifest.json +12 -0
- package/src/elements/element-meta.json +672 -439
- package/src/elements/element-nonscalar.json +170 -0
- package/src/elements/element-types.d.ts +232 -148
- package/src/elements/menu.tsx +13 -0
- package/src/elements/prompt-dock.tsx +2 -1
- package/src/elements/prompt-input.tsx +8 -8
- package/src/elements/register-impl.ts +46 -0
- package/src/elements/register.ts +28 -0
- package/src/elements/resizable.tsx +52 -25
- package/src/elements/scroll-button.tsx +5 -0
- package/src/elements/slots.ts +107 -12
- package/src/elements/styles.css +30 -6
- package/src/elements/thinking-bar.tsx +16 -7
- package/src/elements/toast.tsx +3 -0
- package/src/elements/tool.tsx +26 -5
- package/src/elements/voice-input.tsx +11 -2
- package/src/elements/voice-output.tsx +16 -3
- package/src/index.ts +2 -0
- package/src/primitives/use-resize-observer.ts +33 -0
- package/src/primitives/use-speech-recognition.ts +12 -1
- package/src/primitives/use-stick-to-bottom.ts +34 -2
- package/src/primitives/use-text-stream.ts +33 -2
- package/src/remote/provider-runtime.ts +6 -3
- package/src/schemas/index.ts +1 -0
- package/src/schemas/tool-defs.ts +297 -6
- package/src/solid.ts +5 -0
- package/src/state/index.ts +12 -0
- package/src/state/persistence.ts +180 -0
- package/src/state/stream.ts +71 -4
- package/src/state/threads.ts +142 -0
- package/src/types.ts +4 -2
- package/src/ui/dialog.tsx +6 -0
- package/src/ui/dock.tsx +445 -0
- package/src/ui/dropdown.tsx +67 -23
- package/src/ui/hover-card.tsx +156 -6
- package/src/ui/input.tsx +29 -10
- package/src/ui/resizable.tsx +11 -0
- package/src/wire/chunk.ts +47 -0
- package/src/wire/consume.ts +185 -26
- package/src/wire/diagnostics.ts +727 -0
- package/src/wire/encode-probe.ts +214 -0
- package/src/wire/encode.ts +267 -14
- package/src/wire/formats/anthropic.ts +8 -1
- package/src/wire/formats/openai.ts +5 -0
- package/src/wire/index.ts +22 -0
- package/src/wire/read.ts +250 -6
- package/src/wire/sse.ts +16 -3
- package/dist/elements/chunks/action-icons-DW9muWrY.js +0 -1
- package/dist/elements/chunks/arrow-left-iJVnQmxA.js +0 -1
- package/dist/elements/chunks/attachments-CL6TaqIb.js +0 -1
- package/dist/elements/chunks/audio-visualizer-BuUqQmaO.js +0 -1
- package/dist/elements/chunks/chat-thread-DxoKaStk.js +0 -1
- package/dist/elements/chunks/check-MdLzbxrm.js +0 -1
- package/dist/elements/chunks/chevron-down-MK7IzDqe.js +0 -1
- package/dist/elements/chunks/chevron-right-C5jXhmtV.js +0 -1
- package/dist/elements/chunks/circle-BG7VkPep.js +0 -1
- package/dist/elements/chunks/circle-check-GqvO2UBs.js +0 -1
- package/dist/elements/chunks/code-block-ClhQO9RW.js +0 -1
- package/dist/elements/chunks/collapsible-DBcBkFPh.js +0 -1
- package/dist/elements/chunks/context-hKjNqiyQ.js +0 -1
- package/dist/elements/chunks/conversation-list-CsGJjSKG.js +0 -1
- package/dist/elements/chunks/default-input-B1Mem5wu.js +0 -1
- package/dist/elements/chunks/define-B_tXMYRm.js +0 -1
- package/dist/elements/chunks/disclosure-DAoYOKew.js +0 -1
- package/dist/elements/chunks/dropdown-CmNna5lT.js +0 -1
- package/dist/elements/chunks/embed-CjAD2jSi.js +0 -1
- package/dist/elements/chunks/form-Cp8Y00Wn.js +0 -1
- package/dist/elements/chunks/hover-card-6KtXpLwT.js +0 -1
- package/dist/elements/chunks/icon-97R8SR2p.js +0 -1
- package/dist/elements/chunks/input-DrseEjhp.js +0 -1
- package/dist/elements/chunks/message-CE4k-qaj.js +0 -1
- package/dist/elements/chunks/message-square-C9ovmtOo.js +0 -1
- package/dist/elements/chunks/reasoning-BrxQfpfI.js +0 -1
- package/dist/elements/chunks/scroll-button-CrTo7wm4.js +0 -1
- package/dist/elements/chunks/share-DgNbkJKY.js +0 -1
- package/dist/elements/chunks/slots-CIw9RlAe.js +0 -1
- package/dist/elements/chunks/store-HIPGhNz9.js +0 -1
- package/dist/elements/chunks/textarea-DPfuhswM.js +0 -1
- package/dist/elements/chunks/variant-custom-D5ZlmziF.js +0 -1
- package/dist/elements/chunks/video-Bb86fVYI.js +0 -1
- package/dist/elements/chunks/x-BOhjBeOd.js +0 -1
- package/dist/primitives/card-validate-generator.testlib.d.ts +0 -20
- package/dist/register-impl-DGmGzrEM.js +0 -145
- package/dist/variant-custom-MkTNTycd.js +0 -1
- package/src/primitives/card-validate-generator.testlib.ts +0 -38
|
@@ -21,6 +21,12 @@ import { chatRoutePreamble, defaultModelFor } from '../../route-emit';
|
|
|
21
21
|
// so the Node MCP pass typechecks and bundles it unchanged. See
|
|
22
22
|
// `ATTACHMENT_ACCEPT`.
|
|
23
23
|
import { encodableMediaTypes } from '../../../wire/media-types';
|
|
24
|
+
// The composition catalog, read for one fact: where a companion element GOES.
|
|
25
|
+
// The RAW literal rather than `listSurfaceRecipes()`, matching `reference.ts` —
|
|
26
|
+
// the accessor re-runs a zod parse over the whole catalog on every call, and
|
|
27
|
+
// these records are already validated by their own tests and by
|
|
28
|
+
// `lint:catalog-drift`.
|
|
29
|
+
import { surfaceRecipes } from '../../catalog/surfaces';
|
|
24
30
|
|
|
25
31
|
/**
|
|
26
32
|
* scaffold — the keystone tool. Composes a working chat surface from four axes:
|
|
@@ -101,21 +107,34 @@ const FLEX_FILL = 'flex: 1; min-height: 0;';
|
|
|
101
107
|
* stops consuming height. `text-align: start` is still needed because text-align
|
|
102
108
|
* INHERITS regardless of positioning.
|
|
103
109
|
*
|
|
104
|
-
* `z-index` matters as much as the positioning
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
+
* `z-index` matters as much as the positioning. Without it the surface stacks at
|
|
111
|
+
* `auto`, and the TanStack starter's header is `sticky top-0 z-50` — so the chat
|
|
112
|
+
* would sit at the right geometry and still have the top 73px of its thread
|
|
113
|
+
* painted over. Half-covered is the worst outcome available: either the chat owns
|
|
114
|
+
* the viewport or it does not.
|
|
115
|
+
*
|
|
116
|
+
* WHY 90 AND NOT 1000 (F-20's scaffold-side sibling). The kit's `kai-toast-region`
|
|
117
|
+
* — which `toast()` mounts as a body-level SIBLING of the app root — paints its
|
|
118
|
+
* stack at `z-index: var(--kai-toast-z, 100)`, and both layers live in the root
|
|
119
|
+
* stacking context. Every fixed placement here used to stamp `z-index: 1000`, so
|
|
120
|
+
* every scaffolded app buried every toast (Undo included) under its own wrapper —
|
|
121
|
+
* the exact defect the corpus workspace app's IVP caught in its builder CSS.
|
|
122
|
+
* 90 still outranks any sticky starter chrome (z-50 is the highest observed) and
|
|
123
|
+
* stays below the toast layer; the emitted comment states the contract so a
|
|
124
|
+
* consumer raising the wrapper knows what they are burying.
|
|
110
125
|
*
|
|
111
126
|
* The trade is stated in the emitted comment rather than hidden: this overlays
|
|
112
127
|
* whatever the starter draws around it, nav included. A consumer who wants the chat
|
|
113
128
|
* inside their layout wants `placement: 'inline'`, which is what that placement is for.
|
|
114
129
|
*/
|
|
130
|
+
const TOAST_LAYER_NOTE =
|
|
131
|
+
'The z-index stays BELOW 100: the kit\'s kai-toast-region (a body-level sibling toast() mounts) ' +
|
|
132
|
+
'paints at z-index var(--kai-toast-z, 100), so a wrapper at or above it buries every toast.';
|
|
133
|
+
|
|
115
134
|
const FULL_PAGE: PlacementStyle = {
|
|
116
135
|
style:
|
|
117
136
|
'position: fixed; inset: 0; display: flex; flex-direction: column; ' +
|
|
118
|
-
'text-align: start; z-index:
|
|
137
|
+
'text-align: start; z-index: 90;',
|
|
119
138
|
chatFill: FLEX_FILL,
|
|
120
139
|
note: 'fills the viewport (fixed, inset 0)',
|
|
121
140
|
altNote: [
|
|
@@ -125,6 +144,7 @@ const FULL_PAGE: PlacementStyle = {
|
|
|
125
144
|
'TanStack Start starter wraps every route in a Header + Footer that pushed the composer 13px',
|
|
126
145
|
'below the fold at 1280x800. Fixed positioning escapes both, `text-align: start` undoes the',
|
|
127
146
|
'inherited centring, and z-index keeps a sticky header from painting over the thread.',
|
|
147
|
+
TOAST_LAYER_NOTE,
|
|
128
148
|
'This DOES cover the chrome around it (nav included) — that is what full-page means here.',
|
|
129
149
|
'Want the chat to sit INSIDE your own layout instead? Use placement: "inline".',
|
|
130
150
|
],
|
|
@@ -146,23 +166,27 @@ function placementStyle(placement: string): PlacementStyle {
|
|
|
146
166
|
return {
|
|
147
167
|
style:
|
|
148
168
|
'position: fixed; top: 0; inset-inline-end: 0; height: 100dvh; width: 380px; ' +
|
|
149
|
-
'border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index:
|
|
169
|
+
'border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 90;',
|
|
150
170
|
chatFill: FLEX_FILL,
|
|
151
171
|
note: 'full-height side panel, docked to the trailing edge (100dvh)',
|
|
152
172
|
altNote: [
|
|
173
|
+
TOAST_LAYER_NOTE,
|
|
153
174
|
'In-flow alternative (push content instead of overlay): drop `position`/`z-index` and ' +
|
|
154
175
|
'make this a `flex: 0 0 380px` column inside a `display: flex` row at `height: 100dvh`.',
|
|
155
176
|
],
|
|
156
177
|
};
|
|
157
178
|
case 'docked-widget':
|
|
158
|
-
// The bottom-right floating bubble — rounded, elevated, fixed size.
|
|
179
|
+
// The bottom-right floating bubble — rounded, elevated, fixed size. Its
|
|
180
|
+
// corner is exactly where the toast stack lives, so the z-index contract
|
|
181
|
+
// matters MOST here.
|
|
159
182
|
return {
|
|
160
183
|
style:
|
|
161
184
|
'position: fixed; bottom: 1.5rem; inset-inline-end: 1.5rem; width: 380px; height: 600px; ' +
|
|
162
185
|
'max-height: calc(100dvh - 3rem); border-radius: 16px; overflow: hidden; ' +
|
|
163
|
-
'box-shadow: 0 12px 32px var(--kai-shadow-color, rgba(0,0,0,0.18)); display: flex; flex-direction: column; z-index:
|
|
186
|
+
'box-shadow: 0 12px 32px var(--kai-shadow-color, rgba(0,0,0,0.18)); display: flex; flex-direction: column; z-index: 90;',
|
|
164
187
|
chatFill: FLEX_FILL,
|
|
165
188
|
note: 'fixed, floating bottom-right widget',
|
|
189
|
+
altNote: [TOAST_LAYER_NOTE],
|
|
166
190
|
};
|
|
167
191
|
default:
|
|
168
192
|
// Unknown placement falls back to full-page (full height) rather than the bubble,
|
|
@@ -323,6 +347,17 @@ function realStreamBody(opts: {
|
|
|
323
347
|
valueSource?: string;
|
|
324
348
|
/** lines emitted right after the value is read and guarded */
|
|
325
349
|
afterValue?: string[];
|
|
350
|
+
/**
|
|
351
|
+
* Lines emitted in the `finally` block, after the turn has settled.
|
|
352
|
+
*
|
|
353
|
+
* `finally` rather than after the `try`, and that is the whole reason this hook
|
|
354
|
+
* exists rather than each renderer appending its own lines: anything that keeps a
|
|
355
|
+
* SECOND view of the thread in step with it (a conversation row's count, its
|
|
356
|
+
* title, its timestamps) has to run on the error path too, or a failed turn
|
|
357
|
+
* leaves the two views disagreeing — the user's own message is on the thread
|
|
358
|
+
* whether or not the model answered.
|
|
359
|
+
*/
|
|
360
|
+
afterTurn?: string[];
|
|
326
361
|
/**
|
|
327
362
|
* The attachments capability: an EXPRESSION producing this turn's staged
|
|
328
363
|
* attachments, which become `file` parts ahead of the message's text part.
|
|
@@ -343,10 +378,19 @@ function realStreamBody(opts: {
|
|
|
343
378
|
* identical. That identity is the point: see `mockRequest`.
|
|
344
379
|
*/
|
|
345
380
|
mock?: boolean;
|
|
381
|
+
/**
|
|
382
|
+
* The workspace block's cancellation story: an expression for the turn's
|
|
383
|
+
* AbortSignal, put on the fetch. The controller comes from
|
|
384
|
+
* `createThreadSessions().begin(id)` in the block's prelude, so switching
|
|
385
|
+
* threads or deleting one can abort the outgoing request rather than only
|
|
386
|
+
* dropping its deltas. Ignored on the mock path (there is no request).
|
|
387
|
+
*/
|
|
388
|
+
fetchSignal?: string;
|
|
346
389
|
}): string {
|
|
347
390
|
const {
|
|
348
391
|
pad, read, commitSet, setterAdapter, setLoading, bodyPayload, strictRoles = false, toolLoop, thread,
|
|
349
|
-
cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false, filesExpr,
|
|
392
|
+
cards = false, valueSource = 'e.detail.value', afterValue = [], afterTurn = [], mock = false, filesExpr,
|
|
393
|
+
fetchSignal,
|
|
350
394
|
} = opts;
|
|
351
395
|
const asConst = strictRoles ? ' as const' : '';
|
|
352
396
|
// Under strict TS an un-annotated array literal widens the part's `type` to
|
|
@@ -409,6 +453,13 @@ function realStreamBody(opts: {
|
|
|
409
453
|
`${indent} method: 'POST',`,
|
|
410
454
|
`${indent} headers: { 'Content-Type': 'application/json' },`,
|
|
411
455
|
`${indent} body: JSON.stringify(${bodyPayload(threadExpr)}),`,
|
|
456
|
+
...(fetchSignal
|
|
457
|
+
? [
|
|
458
|
+
`${indent} // The turn's own AbortSignal (from sessions.begin above): switching`,
|
|
459
|
+
`${indent} // threads or deleting this one can cancel the request itself.`,
|
|
460
|
+
`${indent} signal: ${fetchSignal},`,
|
|
461
|
+
]
|
|
462
|
+
: []),
|
|
412
463
|
`${indent}});`,
|
|
413
464
|
`${indent}// The finished turn: text, reasoning, tool calls, stop reason, usage. An`,
|
|
414
465
|
`${indent}// error FRAME inside a 200 stream lands on turn.error, and whatever`,
|
|
@@ -435,17 +486,31 @@ function realStreamBody(opts: {
|
|
|
435
486
|
`${pad} if (turn.error) console.error('Model error:', turn.error.message);`,
|
|
436
487
|
]),
|
|
437
488
|
`${pad}} catch (err) {`,
|
|
489
|
+
...(fetchSignal && !mock
|
|
490
|
+
? [
|
|
491
|
+
`${pad} // A DELIBERATE abort (the thread was deleted, or a new turn began on it)`,
|
|
492
|
+
`${pad} // is not a failure and has no one left to report it to.`,
|
|
493
|
+
`${pad} if (${fetchSignal}.aborted) return;`,
|
|
494
|
+
]
|
|
495
|
+
: []),
|
|
438
496
|
`${pad} // Without this a bad key is a permanently blank assistant bubble plus an`,
|
|
439
|
-
`${pad} // unhandled rejection. abort() settles the message
|
|
440
|
-
`${pad} //
|
|
441
|
-
`${pad} //
|
|
442
|
-
`${pad}
|
|
443
|
-
`${pad}
|
|
497
|
+
`${pad} // unhandled rejection. abort(reason) settles the message AND puts the`,
|
|
498
|
+
`${pad} // reason where the reader can see it: onto any tool panel still waiting`,
|
|
499
|
+
`${pad} // on a result (output-error, so nothing spins forever), or — on a`,
|
|
500
|
+
`${pad} // text-only turn, which has no panel to carry it — as a new text part in`,
|
|
501
|
+
`${pad} // the thread. Text that already streamed stays put.`,
|
|
502
|
+
`${pad} // The reason is SHOWN TO THE USER. err.message is the honest default;`,
|
|
503
|
+
`${pad} // swap in a sentence a visitor can read once you know your failure modes.`,
|
|
504
|
+
`${pad} // The && is load-bearing: an Error is free to carry an empty message, and`,
|
|
505
|
+
`${pad} // an empty reason is nothing to show — back to the blank bubble.`,
|
|
506
|
+
`${pad} stream.abort(err instanceof Error && err.message ? err.message : 'Request failed');`,
|
|
507
|
+
`${pad} console.error(err); // the console is for you; the thread is for them`,
|
|
444
508
|
`${pad}} finally {`,
|
|
445
509
|
`${pad} // done() SETTLES the message: every sink call after it is dropped, which`,
|
|
446
510
|
`${pad} // is why the whole loop runs above it and not after.`,
|
|
447
511
|
`${pad} stream.done();`,
|
|
448
512
|
`${pad} ${setLoading('false')}`,
|
|
513
|
+
...afterTurn.map((l) => `${pad} ${l}`),
|
|
449
514
|
`${pad}}`,
|
|
450
515
|
].join('\n');
|
|
451
516
|
}
|
|
@@ -742,13 +807,17 @@ function wireImportLines(opts: {
|
|
|
742
807
|
* needs this one from the state entry.
|
|
743
808
|
*/
|
|
744
809
|
attachments?: boolean;
|
|
810
|
+
/** the workspace BLOCK → the thread/persistence helpers from the state entry */
|
|
811
|
+
workspaceBlock?: boolean;
|
|
745
812
|
}): string[] {
|
|
746
|
-
const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false, attachments = false } = opts;
|
|
813
|
+
const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false, attachments = false, workspaceBlock = false } = opts;
|
|
747
814
|
const stateNames = [
|
|
815
|
+
...(workspaceBlock ? ['bindThreadMessages'] : []),
|
|
748
816
|
'createAssistantStream',
|
|
749
817
|
// The mock's canned reply comes from the kit, not from a copy pasted into
|
|
750
818
|
// this file. One implementation, shared with create-kai and the starters.
|
|
751
819
|
...(mock ? ['createMockResponder'] : []),
|
|
820
|
+
...(workspaceBlock ? ['createSaveScheduler', 'createThreadSessions', 'parseStoredThread'] : []),
|
|
752
821
|
...(attachments ? ['type AttachmentData'] : []),
|
|
753
822
|
...(typed ? ['type ChatMessage'] : []),
|
|
754
823
|
...(typed && setMessagesType ? ['type SetMessages'] : []),
|
|
@@ -983,6 +1052,13 @@ function jsxComment(lines: readonly string[], pad: string): string[] {
|
|
|
983
1052
|
return lines.map((l) => `${pad}{/* ${l.replace(/^\/\/ ?/, '')} */}`);
|
|
984
1053
|
}
|
|
985
1054
|
|
|
1055
|
+
/** Re-indent already-built emitted lines (some entries hold multiple lines
|
|
1056
|
+
* joined with \n) so a wrapper — the block's shell — can nest them without
|
|
1057
|
+
* every builder above knowing about the wrapping. Blank lines stay blank. */
|
|
1058
|
+
function indentEmitted(lines: readonly string[], by: string): string[] {
|
|
1059
|
+
return lines.flatMap((l) => l.split('\n')).map((l) => (l === '' ? l : `${by}${l}`));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
986
1062
|
// ── SCAF-9: message-embedded companion logic ──────────────────────────────────
|
|
987
1063
|
|
|
988
1064
|
/**
|
|
@@ -992,21 +1068,380 @@ function jsxComment(lines: readonly string[], pad: string): string[] {
|
|
|
992
1068
|
*/
|
|
993
1069
|
const MESSAGE_EMBEDDED_TAGS = new Set(['kai-tool', 'kai-reasoning']);
|
|
994
1070
|
|
|
995
|
-
// ── SCAF-14:
|
|
1071
|
+
// ── SCAF-14: artifact-split structural/layout logic ───────────────────────────
|
|
996
1072
|
|
|
997
1073
|
/**
|
|
998
|
-
* Tags that participate in the
|
|
999
|
-
* container (needs kai-resizable-item children), kai-artifact is the
|
|
1000
|
-
* Neither should be emitted as a bare sibling of kai-chat — the
|
|
1001
|
-
* is a resizable split with chat in one pane and artifact
|
|
1074
|
+
* Tags that participate in the artifact-split layout structure — kai-resizable
|
|
1075
|
+
* is the container (needs kai-resizable-item children), kai-artifact is the
|
|
1076
|
+
* preview pane. Neither should be emitted as a bare sibling of kai-chat — the
|
|
1077
|
+
* idiomatic structure is a resizable split with chat in one pane and artifact
|
|
1078
|
+
* in another.
|
|
1079
|
+
*
|
|
1080
|
+
* (This predicate was named `isWorkspace` until the recast: the `workspace`
|
|
1081
|
+
* preset now names the shell + rail BLOCK below, and a predicate about the
|
|
1082
|
+
* artifact pair claiming that name would lie about both.)
|
|
1002
1083
|
*/
|
|
1003
|
-
const
|
|
1084
|
+
const ARTIFACT_SPLIT_TAGS = new Set(['kai-resizable', 'kai-artifact']);
|
|
1004
1085
|
|
|
1005
|
-
/** True when the surface
|
|
1006
|
-
function
|
|
1086
|
+
/** True when the surface renders the resizable split (chat + artifact preview). */
|
|
1087
|
+
function isArtifactSplit(components: readonly string[]): boolean {
|
|
1007
1088
|
return components.includes('kai-resizable') && components.includes('kai-artifact');
|
|
1008
1089
|
}
|
|
1009
1090
|
|
|
1091
|
+
// ── the workspace BLOCK (recast spec 2026-08-20 § 3b; F-16) ──────────────────
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Tags the block composes structurally: the shell wraps the surface and the
|
|
1095
|
+
* rail sits in its `start` slot, so neither is ever a bare sibling.
|
|
1096
|
+
*/
|
|
1097
|
+
const BLOCK_TAGS = new Set(['kai-workspace', 'kai-conversations']);
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* True when the surface is the workspace BLOCK: the `kai-workspace` layout
|
|
1101
|
+
* shell with a wired `kai-conversations` rail in its start aside, `kai-chat`
|
|
1102
|
+
* in the main region, and multi-thread state built on the `@kitn.ai/ui/state`
|
|
1103
|
+
* helpers (bindThreadMessages / createThreadSessions / createSaveScheduler /
|
|
1104
|
+
* parseStoredThread). BOTH tags, for the same reason `isArtifactSplit` needs
|
|
1105
|
+
* both: a shell with nothing in its aside is an empty column, and a rail with
|
|
1106
|
+
* no thread store behind it is the bare-sibling defect the rail wiring exists
|
|
1107
|
+
* to prevent. They are one capability group in `archetypes.ts`, so in practice
|
|
1108
|
+
* a request carries both or neither.
|
|
1109
|
+
*
|
|
1110
|
+
* The block is CODE THE CONSUMER OWNS from generation time (spec § Taxonomy):
|
|
1111
|
+
* the persistence policy, the fetch line and the record shape are exactly what
|
|
1112
|
+
* a consumer should be editing without asking us. The kit-owned mechanics ride
|
|
1113
|
+
* in the npm-updateable helpers instead, which is the split the emitted
|
|
1114
|
+
* comments below spell out.
|
|
1115
|
+
*/
|
|
1116
|
+
function isWorkspaceBlock(components: readonly string[]): boolean {
|
|
1117
|
+
return components.includes('kai-workspace') && components.includes('kai-conversations');
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The block's module-scope thread store: the record shape the APP owns, plus
|
|
1122
|
+
* load/save with the persistence POLICY stated as the consumer's. Framework-
|
|
1123
|
+
* independent by construction — every target below emits these lines verbatim
|
|
1124
|
+
* (module scope in all of them), so the boundary comment cannot drift between
|
|
1125
|
+
* frameworks.
|
|
1126
|
+
*
|
|
1127
|
+
* `parseStoredThread` is the kit-owned half: it validates the stored messages
|
|
1128
|
+
* back into `ChatMessage[]` with the variant list DERIVED from the MessagePart
|
|
1129
|
+
* union, and REPORTS what it dropped. The verb — warn, discard the record,
|
|
1130
|
+
* telemetry — stays out here, in the consumer's line.
|
|
1131
|
+
*/
|
|
1132
|
+
function blockThreadStoreLines(pad = ''): string[] {
|
|
1133
|
+
return [
|
|
1134
|
+
`${pad}// ── The workspace block's thread store — YOUR code, from here down ─────────`,
|
|
1135
|
+
`${pad}//`,
|
|
1136
|
+
`${pad}// The record shape is yours (title policy, timestamps, whatever you add);`,
|
|
1137
|
+
`${pad}// the kit's helpers only require { id, messages } (ThreadLike).`,
|
|
1138
|
+
`${pad}interface Thread {`,
|
|
1139
|
+
`${pad} id: string;`,
|
|
1140
|
+
`${pad} title: string;`,
|
|
1141
|
+
`${pad} updatedAt: string;`,
|
|
1142
|
+
`${pad} messages: ChatMessage[];`,
|
|
1143
|
+
`${pad}}`,
|
|
1144
|
+
``,
|
|
1145
|
+
`${pad}const THREADS_KEY = 'kai-workspace/threads/v1';`,
|
|
1146
|
+
``,
|
|
1147
|
+
`${pad}// PERSISTENCE IS YOURS. localStorage here is a starting policy, not kit`,
|
|
1148
|
+
`${pad}// behavior: the backend, the quota, what is retained and for how long are`,
|
|
1149
|
+
`${pad}// application decisions. Swap these two functions for your API and nothing`,
|
|
1150
|
+
`${pad}// else in the block changes.`,
|
|
1151
|
+
`${pad}function loadThreads(): Thread[] {`,
|
|
1152
|
+
`${pad} let raw: string | null = null;`,
|
|
1153
|
+
`${pad} // Private-mode Safari throws on ACCESS: no storage is a working app with`,
|
|
1154
|
+
`${pad} // no history, not a crash on first paint.`,
|
|
1155
|
+
`${pad} try { raw = localStorage.getItem(THREADS_KEY); } catch { return []; }`,
|
|
1156
|
+
`${pad} if (!raw) return [];`,
|
|
1157
|
+
`${pad} try {`,
|
|
1158
|
+
`${pad} const stored: unknown = JSON.parse(raw);`,
|
|
1159
|
+
`${pad} if (!Array.isArray(stored)) return [];`,
|
|
1160
|
+
`${pad} return stored.flatMap((record): Thread[] => {`,
|
|
1161
|
+
`${pad} if (typeof record !== 'object' || record === null) return [];`,
|
|
1162
|
+
`${pad} const r = record as { id?: unknown; title?: unknown; updatedAt?: unknown; messages?: unknown };`,
|
|
1163
|
+
`${pad} if (typeof r.id !== 'string' || r.id === '') return [];`,
|
|
1164
|
+
`${pad} // The kit validates the MESSAGES (the shape it owns — the MessagePart`,
|
|
1165
|
+
`${pad} // variant list is derived, not hand-typed) and reports the drops;`,
|
|
1166
|
+
`${pad} // deciding what a drop means is this line, and it is yours.`,
|
|
1167
|
+
`${pad} const { messages, dropped } = parseStoredThread(r.messages);`,
|
|
1168
|
+
`${pad} if (dropped.length > 0) console.warn('[threads] dropped unreadable stored entries', dropped);`,
|
|
1169
|
+
`${pad} return [{`,
|
|
1170
|
+
`${pad} id: r.id,`,
|
|
1171
|
+
`${pad} title: typeof r.title === 'string' ? r.title : 'New conversation',`,
|
|
1172
|
+
`${pad} updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : new Date().toISOString(),`,
|
|
1173
|
+
`${pad} messages,`,
|
|
1174
|
+
`${pad} }];`,
|
|
1175
|
+
`${pad} });`,
|
|
1176
|
+
`${pad} } catch {`,
|
|
1177
|
+
`${pad} // Unparseable history is ignored, loudly enough to notice in dev.`,
|
|
1178
|
+
`${pad} console.warn('[threads] stored history could not be parsed and was ignored');`,
|
|
1179
|
+
`${pad} return [];`,
|
|
1180
|
+
`${pad} }`,
|
|
1181
|
+
`${pad}}`,
|
|
1182
|
+
``,
|
|
1183
|
+
`${pad}function saveThreads(threads: Thread[]): void {`,
|
|
1184
|
+
`${pad} // QuotaExceededError is the realistic failure. Warn-and-continue is a`,
|
|
1185
|
+
`${pad} // POLICY (yours): the alternative — throwing out of a save mid-stream —`,
|
|
1186
|
+
`${pad} // would take the thread down with it.`,
|
|
1187
|
+
`${pad} try { localStorage.setItem(THREADS_KEY, JSON.stringify(threads)); }`,
|
|
1188
|
+
`${pad} catch (err) { console.warn('[threads] history could not be saved', err); }`,
|
|
1189
|
+
`${pad}}`,
|
|
1190
|
+
``,
|
|
1191
|
+
`${pad}// First line of the first user turn, clipped — the fallback titling policy.`,
|
|
1192
|
+
`${pad}// Titles are what the rail's built-in search matches, so this is also the`,
|
|
1193
|
+
`${pad}// search index; swap in your own (a summariser, a user rename).`,
|
|
1194
|
+
`${pad}function deriveTitle(text: string): string {`,
|
|
1195
|
+
`${pad} const firstLine = text.trim().split('\\n', 1)[0] ?? '';`,
|
|
1196
|
+
`${pad} if (firstLine === '') return 'New conversation';`,
|
|
1197
|
+
`${pad} return firstLine.length > 48 ? firstLine.slice(0, 47) + '…' : firstLine;`,
|
|
1198
|
+
`${pad}}`,
|
|
1199
|
+
];
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* The rail's comment, one wording for every target (the html-comment targets
|
|
1204
|
+
* wrap it in `htmlComment`, the JSX targets in `jsxComment`): what the rows
|
|
1205
|
+
* are, and the item-mode escape hatch by name.
|
|
1206
|
+
*/
|
|
1207
|
+
const BLOCK_RAIL_NOTE = [
|
|
1208
|
+
`The rail renders the ROWS it is handed (batteries mode) — a view of your`,
|
|
1209
|
+
`records, no message bodies. To own the row markup instead, slot your own`,
|
|
1210
|
+
`<kai-conversation-item> loop as light-DOM children (item mode): data rows`,
|
|
1211
|
+
`stop rendering while any item child is present, and selection still arrives`,
|
|
1212
|
+
`as kai-conversation-select.`,
|
|
1213
|
+
];
|
|
1214
|
+
|
|
1215
|
+
/** The same note where the rows are the Solid `ConversationList` (which takes
|
|
1216
|
+
* `items` instead of light-DOM children). Mirrored wording, Solid names. */
|
|
1217
|
+
const BLOCK_RAIL_NOTE_SOLID = [
|
|
1218
|
+
`The rail renders the ROWS it is handed (batteries mode) — a view of your`,
|
|
1219
|
+
`records, no message bodies. To own the row markup instead, pass your own`,
|
|
1220
|
+
`SlottedConversationItem loop via the \`items\` prop (item mode): data rows`,
|
|
1221
|
+
`stop rendering while items are present, and selection still arrives through`,
|
|
1222
|
+
`onSelect.`,
|
|
1223
|
+
];
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* The submit prelude's comments, shared so the wording tests pin ONE copy.
|
|
1227
|
+
* Each target interleaves these with its own state idiom's lines.
|
|
1228
|
+
*/
|
|
1229
|
+
const BLOCK_TARGET_ID_NOTE = [
|
|
1230
|
+
`The thread that was open when the user hit send — not whichever is open`,
|
|
1231
|
+
`when the tokens land. Everything below binds to this id, which is what`,
|
|
1232
|
+
`makes switching away mid-reply safe.`,
|
|
1233
|
+
];
|
|
1234
|
+
const BLOCK_BIND_NOTE = [
|
|
1235
|
+
`bindThreadMessages routes every stream delta to THIS thread, with the`,
|
|
1236
|
+
`reactivity two-halves (new array + new object) handled by the helper.`,
|
|
1237
|
+
`\`touch\` is your policy hook — here it stamps updatedAt on every delta.`,
|
|
1238
|
+
];
|
|
1239
|
+
const BLOCK_SESSIONS_NOTE = [
|
|
1240
|
+
`One in-flight turn per thread: begin() aborts a prior turn on the same`,
|
|
1241
|
+
`thread and hands back its AbortController. Deleting a thread mid-stream is`,
|
|
1242
|
+
`sessions.abort(id) — the aborted turn's late deltas are dropped by the`,
|
|
1243
|
+
`bound sink instead of resurrecting the record.`,
|
|
1244
|
+
];
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* The block's tool-loop thread binding for every target whose store reads back
|
|
1248
|
+
* synchronously (signals, refs, runes, a plain module `let`): the STORE is the
|
|
1249
|
+
* thread. The id-bound sink writes each delta into the record, and each loop
|
|
1250
|
+
* round re-encodes the live value read back out — no turn-local copy to keep
|
|
1251
|
+
* in sync. React is the exception (async state) and keeps its own copy binding.
|
|
1252
|
+
*/
|
|
1253
|
+
function blockLiveThread(readExpr: string): ThreadBinding {
|
|
1254
|
+
return {
|
|
1255
|
+
open: ({ pad, userMessage }) => [
|
|
1256
|
+
`${pad}// The STORE is the thread: the id-bound sink writes each delta back into`,
|
|
1257
|
+
`${pad}// the record, so every round below re-encodes the live, current value.`,
|
|
1258
|
+
`${pad}setMessages(() => [...${readExpr}, ${userMessage}]);`,
|
|
1259
|
+
],
|
|
1260
|
+
live: readExpr,
|
|
1261
|
+
setter: 'setMessages',
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ── composition: where a companion GOES ──────────────────────────────────────
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* The catalog's nesting decision for one tag, or null.
|
|
1269
|
+
*
|
|
1270
|
+
* READ from the surface recipes rather than typed here, so the slot name and the
|
|
1271
|
+
* parent live in exactly one place. Before this, the scaffolder emitted the
|
|
1272
|
+
* conversation rail as a sibling BELOW the chat in a `flex-direction: column`,
|
|
1273
|
+
* while `<kai-chat>` documented a `sidebar` slot described as "left column (your
|
|
1274
|
+
* nav / conversation list)" — two answers, no way to tell which the kit meant.
|
|
1275
|
+
* Now there is one answer and this reads it; revising the recipe moves the
|
|
1276
|
+
* emitters with it.
|
|
1277
|
+
*
|
|
1278
|
+
* Deliberately not a Map built once at module scope: the recipes are a handful
|
|
1279
|
+
* of records, this runs a few times per request, and a lazily-built cache is a
|
|
1280
|
+
* second place for the answer to live.
|
|
1281
|
+
*/
|
|
1282
|
+
function slotPlacementFor(tag: string): { parent: string; slot: string } | null {
|
|
1283
|
+
for (const r of surfaceRecipes) {
|
|
1284
|
+
for (const c of r.composition ?? []) {
|
|
1285
|
+
if (c.child === tag) return { parent: c.parent, slot: c.slot };
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return null;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* The tag every front end emits whether or not the caller asked for it.
|
|
1293
|
+
*
|
|
1294
|
+
* The input schema only ADVISES including it ("usually kai-chat plus …"), and
|
|
1295
|
+
* every renderer here builds its surface around the chat element regardless. So
|
|
1296
|
+
* "is `kai-chat` in `components`" is a question about the REQUEST, never about
|
|
1297
|
+
* what gets emitted, and the two must not be confused — see `slottedInChat`.
|
|
1298
|
+
*/
|
|
1299
|
+
const ALWAYS_EMITTED_TAG = 'kai-chat';
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* The companions this surface SLOTS into `<kai-chat>`, in `components` order.
|
|
1303
|
+
*
|
|
1304
|
+
* ★ THIS GATED ON `components.includes('kai-chat')` AND THAT WAS THE BUG BACK.
|
|
1305
|
+
* The rationale read well — do not point a `slot=` at an element that is not
|
|
1306
|
+
* there — and it was false for the only parent this function admits: the chat is
|
|
1307
|
+
* emitted unconditionally, so the check could never protect anything and could
|
|
1308
|
+
* only ever fire on a request that omitted a tag the output contains anyway.
|
|
1309
|
+
* Measured on `components: ['kai-conversations']`, it reproduced the original
|
|
1310
|
+
* defect verbatim — `<kai-chat></kai-chat>` above a bare `<kai-conversations>`
|
|
1311
|
+
* under "wire data props", the exact emit this whole change exists to remove.
|
|
1312
|
+
* `scaffold.test.ts` drives both shapes now.
|
|
1313
|
+
*
|
|
1314
|
+
* A future placement under a parent that is NOT always emitted would need that
|
|
1315
|
+
* check back, scoped to that parent. It does not need it for this one.
|
|
1316
|
+
*/
|
|
1317
|
+
function slottedInChat(components: readonly string[]): { tag: string; slot: string }[] {
|
|
1318
|
+
return components.flatMap((tag) => {
|
|
1319
|
+
const placement = slotPlacementFor(tag);
|
|
1320
|
+
if (!placement || placement.parent !== ALWAYS_EMITTED_TAG) return [];
|
|
1321
|
+
return [{ tag, slot: placement.slot }];
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* What the React family says instead, because it cannot say the other thing.
|
|
1327
|
+
*
|
|
1328
|
+
* The catalog composes the rail INSIDE `<kai-chat>` on the `sidebar` slot, and
|
|
1329
|
+
* the generated React wrappers cannot express that: `WebComponentProps`
|
|
1330
|
+
* (frameworks/react/runtime.tsx) declares `theme`, `className`, `style`, `id`
|
|
1331
|
+
* and `children` and nothing else, and the runtime's `createElement` forwards
|
|
1332
|
+
* exactly those four — so `<Conversations slot="sidebar" />` is
|
|
1333
|
+
* `error TS2322: Property 'slot' does not exist`, measured with the same tsc
|
|
1334
|
+
* `verify:scaffold` runs, and would not reach the DOM even if it compiled.
|
|
1335
|
+
*
|
|
1336
|
+
* So this target emits the sibling it CAN emit and says which one it is. The
|
|
1337
|
+
* alternative — quietly emitting the layout the catalog does not describe, under
|
|
1338
|
+
* a comment claiming the composition — is how the original defect read.
|
|
1339
|
+
*/
|
|
1340
|
+
function railSiblingNote(tag: string, placement: { parent: string; slot: string }): string[] {
|
|
1341
|
+
const w = conversationsWiring();
|
|
1342
|
+
// The wrapper's handler prop, DERIVED the way the wrapper generator derives it:
|
|
1343
|
+
// drop the `kai-` prefix, PascalCase the rest, prefix `on`. That is what makes
|
|
1344
|
+
// `kai-conversation-select` into `onConversationSelect` in react/index.tsx.
|
|
1345
|
+
const handler = `on${toPascalCase(w.event)}`;
|
|
1346
|
+
return [
|
|
1347
|
+
` {/* NOT WIRED, and NOT SLOTTED — both deliberate, both fixable here.`,
|
|
1348
|
+
` COMPOSITION: the kit composes this as <${tag} slot="${placement.slot}"> INSIDE`,
|
|
1349
|
+
` <${placement.parent}>, which renders it into a fixed-width ::part(${placement.slot}) column.`,
|
|
1350
|
+
` The generated React wrappers take no \`slot\` prop (see WebComponentProps`,
|
|
1351
|
+
` in @kitn.ai/ui/react), so this renders as a sibling instead. Drop to the`,
|
|
1352
|
+
` raw <${placement.parent}> custom element if you want the slotted layout.`,
|
|
1353
|
+
` WIRING: set ${w.property}={...} and ${handler}={...} — the handler`,
|
|
1354
|
+
` reads event.detail.id and calls setMessages with that thread. */}`,
|
|
1355
|
+
];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Wrap prose in one `<!-- … -->` block at `pad`, closing on the LAST line
|
|
1359
|
+
* whatever the length — an index-counted closer silently stops closing the day
|
|
1360
|
+
* the prose grows a line. */
|
|
1361
|
+
function htmlComment(lines: string[], pad: string): string[] {
|
|
1362
|
+
return lines.map(
|
|
1363
|
+
(l, i) => `${pad}${i === 0 ? '<!-- ' : ' '}${l}${i === lines.length - 1 ? ' -->' : ''}`,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The slotted companions as markup, for every target that writes real tags:
|
|
1369
|
+
* html, vue, svelte and angular all spell an element and an attribute the same
|
|
1370
|
+
* way, so they share this rather than carrying four copies of the decision.
|
|
1371
|
+
*
|
|
1372
|
+
* `wired` is what the target actually DID, not what it should have done. The
|
|
1373
|
+
* html target emits the full wiring in `htmlModule` and passes true; the others
|
|
1374
|
+
* pass false and get the notice, because the alternative — a rail that renders
|
|
1375
|
+
* and does nothing, under a comment that says "wire data props" — is the defect
|
|
1376
|
+
* this whole change is about. The React family is absent from this list on
|
|
1377
|
+
* purpose: `WebComponentProps` in frameworks/react/runtime.tsx has no `slot`,
|
|
1378
|
+
* and the runtime does not forward one, so a `<Conversations slot="sidebar" />`
|
|
1379
|
+
* would neither typecheck nor render there. See `railSiblingNote`.
|
|
1380
|
+
*/
|
|
1381
|
+
function slottedChildMarkup(
|
|
1382
|
+
components: readonly string[],
|
|
1383
|
+
pad: string,
|
|
1384
|
+
opts: { wired: boolean; ids: boolean },
|
|
1385
|
+
): string[] {
|
|
1386
|
+
const w = conversationsWiring();
|
|
1387
|
+
return slottedInChat(components).flatMap((s) => [
|
|
1388
|
+
// What the shell DOES, and — the half a comment gets wrong by being generous
|
|
1389
|
+
// — what it does not. `<kai-chat>`'s sidebar region is one fixed-width aside
|
|
1390
|
+
// (`chat-thread.tsx`), exposed as a part and nothing more: it does not listen
|
|
1391
|
+
// for the rail's collapse, and it has no responsive behaviour, so a rail that
|
|
1392
|
+
// collapses inside it leaves the column exactly as wide as it was.
|
|
1393
|
+
...htmlComment(
|
|
1394
|
+
[
|
|
1395
|
+
`SLOTTED, not a sibling: <kai-chat> renders this into its own`,
|
|
1396
|
+
`::part(${s.slot}) aside — a fixed-width column, and that is the whole of`,
|
|
1397
|
+
`what the shell does with it. Collapsing the rail does NOT narrow the`,
|
|
1398
|
+
`column: use the <kai-workspace> layout shell (start aside: startCollapsed /`,
|
|
1399
|
+
`collapseBelow / drawer-below) or your own layout if it has to collapse or resize.${opts.wired ? ' src/main.ts drives the rail.' : ''}`,
|
|
1400
|
+
],
|
|
1401
|
+
pad,
|
|
1402
|
+
),
|
|
1403
|
+
...(opts.wired ? [] : htmlComment(notWiredNote(s.tag, w.property, w.event), pad)),
|
|
1404
|
+
`${pad}<${s.tag}${opts.ids ? ` id="${s.tag.replace(/^kai-/, '')}"` : ''} slot="${s.slot}" style="display:block;height:100%"></${s.tag}>`,
|
|
1405
|
+
]);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* What an emitted-but-unwired companion says about itself.
|
|
1410
|
+
*
|
|
1411
|
+
* The bar is the one the previous text failed: a builder pasted
|
|
1412
|
+
* `<!-- wire data props — see the component_reference MCP tool -->`, got a
|
|
1413
|
+
* working chat beside an empty grey column, and could not tell from the output
|
|
1414
|
+
* whether the rail was broken, unwired, or waiting on data — so it names the
|
|
1415
|
+
* PROPERTY and the EVENT, which is the whole of what is missing.
|
|
1416
|
+
*
|
|
1417
|
+
* "NOT WIRED" is the greppable half and the tests key on it. Keep the words.
|
|
1418
|
+
*/
|
|
1419
|
+
function notWiredNote(tag: string, property: string, event: string): string[] {
|
|
1420
|
+
return [
|
|
1421
|
+
`NOT WIRED: <${tag}> needs el.${property} (a JS property — arrays are never`,
|
|
1422
|
+
`attributes) and a ${event} listener that sets kai-chat.messages from your own`,
|
|
1423
|
+
`thread store. The html target emits that wiring in full; ask the`,
|
|
1424
|
+
`component_reference MCP tool for the workspace-chat recipe to see the shape.`,
|
|
1425
|
+
];
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* The rail's contract, in one place: the property to set and the event to hear.
|
|
1430
|
+
*
|
|
1431
|
+
* The EVENT is read off the recipe — that is precisely what a wiring edge is —
|
|
1432
|
+
* so renaming the event in the catalog renames it in every emitted comment. The
|
|
1433
|
+
* PROPERTY is not: a wiring edge names the property the event WRITES (`messages`
|
|
1434
|
+
* on `<kai-chat>`), never the one that feeds the source element, so `conversations`
|
|
1435
|
+
* is a copy of the element's own API and is registered here as one. The
|
|
1436
|
+
* fall-through keeps the emitted note truthful if the edge is ever removed.
|
|
1437
|
+
*/
|
|
1438
|
+
function conversationsWiring(): { property: string; event: string } {
|
|
1439
|
+
const edge = surfaceRecipes
|
|
1440
|
+
.flatMap((r) => r.wiring)
|
|
1441
|
+
.find((w) => w.from === 'kai-conversations' && w.to === 'kai-chat' && w.property === 'messages');
|
|
1442
|
+
return { property: 'conversations', event: edge?.event ?? 'kai-conversation-select' };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1010
1445
|
// ── attachments ───────────────────────────────────────────────────────────────
|
|
1011
1446
|
|
|
1012
1447
|
/**
|
|
@@ -1024,7 +1459,7 @@ const ATTACHMENT_TAGS = new Set(['kai-file-upload', 'kai-attachments']);
|
|
|
1024
1459
|
|
|
1025
1460
|
/**
|
|
1026
1461
|
* True when the surface stages attachments — BOTH tags, for the same reason
|
|
1027
|
-
* `
|
|
1462
|
+
* `isArtifactSplit` needs both.
|
|
1028
1463
|
*
|
|
1029
1464
|
* They are one capability group in `archetypes.ts`, so in practice a request
|
|
1030
1465
|
* carries both or neither. Requiring both here is what keeps a hand-built
|
|
@@ -1393,13 +1828,37 @@ interface RenderCtx {
|
|
|
1393
1828
|
* block of its own, above the chat, with ids the emitted module wires together.
|
|
1394
1829
|
*/
|
|
1395
1830
|
function componentTags(components: readonly string[], chatFill: string): string {
|
|
1831
|
+
const block = isWorkspaceBlock(components);
|
|
1832
|
+
// The catalog's nesting decision, applied: a slotted companion is a CHILD of
|
|
1833
|
+
// <kai-chat> and never a sibling in the column below it, so it comes out of the
|
|
1834
|
+
// companion loop the same way the workspace and attachment pairs do. In BLOCK
|
|
1835
|
+
// mode the rail moves to the SHELL's start aside instead, so the chat keeps no
|
|
1836
|
+
// sidebar child.
|
|
1837
|
+
const slotted = slottedInChat(components);
|
|
1838
|
+
const slottedTags = new Set(slotted.map((s) => s.tag));
|
|
1839
|
+
const chatSlotted = block ? slotted.filter((s) => !BLOCK_TAGS.has(s.tag)) : slotted;
|
|
1396
1840
|
const companionTags = components.filter(
|
|
1397
1841
|
(t) =>
|
|
1398
1842
|
t !== 'kai-chat' &&
|
|
1399
1843
|
!MESSAGE_EMBEDDED_TAGS.has(t) &&
|
|
1400
|
-
!
|
|
1844
|
+
!ARTIFACT_SPLIT_TAGS.has(t) &&
|
|
1845
|
+
!(block && BLOCK_TAGS.has(t)) &&
|
|
1846
|
+
!slottedTags.has(t) &&
|
|
1401
1847
|
!(hasAttachments(components) && ATTACHMENT_TAGS.has(t)),
|
|
1402
1848
|
);
|
|
1849
|
+
// `display:block;height:100%` because a custom element is inline by default and
|
|
1850
|
+
// an inline rail collapses to its text height in the shell's fixed-width
|
|
1851
|
+
// column. The same two declarations the chat-slots story puts on it.
|
|
1852
|
+
const chatBlock = (pad: string): string[] =>
|
|
1853
|
+
chatSlotted.length === 0
|
|
1854
|
+
? [`${pad}<kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`]
|
|
1855
|
+
: [
|
|
1856
|
+
`${pad}<kai-chat id="chat" suggestion-mode="submit" style="${chatFill}">`,
|
|
1857
|
+
// wired: `htmlModule` below emits the property, the two listeners and
|
|
1858
|
+
// the thread map. This is the one target that does.
|
|
1859
|
+
...slottedChildMarkup(components, `${pad} `, { wired: true, ids: true }),
|
|
1860
|
+
`${pad}</kai-chat>`,
|
|
1861
|
+
];
|
|
1403
1862
|
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1404
1863
|
const hasStandaloneCompanions = companionTags.length > 0;
|
|
1405
1864
|
|
|
@@ -1427,13 +1886,13 @@ function componentTags(components: readonly string[], chatFill: string): string
|
|
|
1427
1886
|
// unreachable until the surface axis became a components list. Building the
|
|
1428
1887
|
// chat block into `lines` instead means the companion loop below runs either
|
|
1429
1888
|
// way, which is the property that was missing rather than a special case.
|
|
1430
|
-
if (
|
|
1889
|
+
if (isArtifactSplit(components)) {
|
|
1431
1890
|
lines.push(
|
|
1432
1891
|
` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
|
|
1433
1892
|
` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
|
|
1434
1893
|
` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
|
|
1435
1894
|
` <kai-resizable-item size="40%" min="240px">`,
|
|
1436
|
-
|
|
1895
|
+
...chatBlock(' '),
|
|
1437
1896
|
` </kai-resizable-item>`,
|
|
1438
1897
|
` <kai-resizable-item min="280px">`,
|
|
1439
1898
|
` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
|
|
@@ -1442,7 +1901,7 @@ function componentTags(components: readonly string[], chatFill: string): string
|
|
|
1442
1901
|
` </kai-resizable>`,
|
|
1443
1902
|
);
|
|
1444
1903
|
} else {
|
|
1445
|
-
lines.push(
|
|
1904
|
+
lines.push(...chatBlock(' '));
|
|
1446
1905
|
}
|
|
1447
1906
|
|
|
1448
1907
|
if (hasEmbedded) {
|
|
@@ -1469,6 +1928,30 @@ function componentTags(components: readonly string[], chatFill: string): string
|
|
|
1469
1928
|
lines.push(` <!-- wire data props — see the component_reference MCP tool -->`);
|
|
1470
1929
|
}
|
|
1471
1930
|
|
|
1931
|
+
// The workspace BLOCK wrap: the shell around everything above, the rail in
|
|
1932
|
+
// its start aside, the rest projected into its main region.
|
|
1933
|
+
if (block) {
|
|
1934
|
+
return [
|
|
1935
|
+
` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
|
|
1936
|
+
...htmlComment(
|
|
1937
|
+
[
|
|
1938
|
+
`<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
|
|
1939
|
+
`end/footer slots, aside resize + collapse, and a mobile drawer below`,
|
|
1940
|
+
`the drawer-below width. It knows nothing about chat.`,
|
|
1941
|
+
],
|
|
1942
|
+
' ',
|
|
1943
|
+
),
|
|
1944
|
+
` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
|
|
1945
|
+
...htmlComment([...BLOCK_RAIL_NOTE, `src/main.ts drives the rail.`], ' '),
|
|
1946
|
+
` <kai-conversations id="conversations" slot="start" style="display:block;height:100%"></kai-conversations>`,
|
|
1947
|
+
` <!-- Unnamed children project into the shell's MAIN region. -->`,
|
|
1948
|
+
` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
|
|
1949
|
+
...lines.map((l) => (l === '' ? l : ` ${l}`)),
|
|
1950
|
+
` </div>`,
|
|
1951
|
+
` </kai-workspace>`,
|
|
1952
|
+
].join('\n');
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1472
1955
|
return lines.join('\n');
|
|
1473
1956
|
}
|
|
1474
1957
|
|
|
@@ -1503,6 +1986,10 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
|
1503
1986
|
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1504
1987
|
const hasSources = components.includes('kai-sources');
|
|
1505
1988
|
const attachments = hasAttachments(components);
|
|
1989
|
+
const block = isWorkspaceBlock(components);
|
|
1990
|
+
// In BLOCK mode the rail is the shell's start aside driven by the block's
|
|
1991
|
+
// thread store below, not the in-memory slotted-in-chat wiring.
|
|
1992
|
+
const conversations = !block && slottedInChat(components).some((s) => s.tag === 'kai-conversations');
|
|
1506
1993
|
|
|
1507
1994
|
// MODULE scope, like `model`/`runTool`: the staged list has to outlive `init()`
|
|
1508
1995
|
// so the submit handler can read it, and `Staged` is used by the module-scope
|
|
@@ -1565,6 +2052,184 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
|
1565
2052
|
]
|
|
1566
2053
|
: [];
|
|
1567
2054
|
|
|
2055
|
+
// The conversation rail, WIRED — the whole point of emitting it. The rail
|
|
2056
|
+
// renders what its `conversations` property holds and reports what the user
|
|
2057
|
+
// picked; swapping the thread is the host's job and nothing does it by itself
|
|
2058
|
+
// (host-coordinates). Both recipe edges are here: `kai-conversation-select`
|
|
2059
|
+
// loads a thread, `kai-new-chat` starts an empty one.
|
|
2060
|
+
//
|
|
2061
|
+
// NOTHING IS FABRICATED IN THE THREAD. `threads` starts EMPTY and fills with
|
|
2062
|
+
// what the user actually says, so selecting a seeded row opens a blank chat
|
|
2063
|
+
// rather than a conversation they never had — the rule `SAMPLE_AGENTIC_MESSAGE`
|
|
2064
|
+
// states for the message list, applied to the list of lists. The conversation
|
|
2065
|
+
// ROWS are placeholder chrome, marked as such, like `sampleSources`.
|
|
2066
|
+
//
|
|
2067
|
+
// ★ THE ROW IS ALSO KEPT IN STEP, and it was not. This wiring shipped with the
|
|
2068
|
+
// thread swap correct and the row inert: written once at creation and never
|
|
2069
|
+
// touched again, so after a real turn the rail read "New chat / 0 messages /
|
|
2070
|
+
// just now" beside a thread holding two messages — four dead fields, on the one
|
|
2071
|
+
// surface a consumer copies wholesale. `syncActiveRow` below is the fix, and it
|
|
2072
|
+
// is emitted as a worked example of the `reactivity-two-halves` invariant
|
|
2073
|
+
// because a consumer who copies HALF of it gets a rail that is silently stale
|
|
2074
|
+
// rather than one that is obviously broken. Run-guarded by
|
|
2075
|
+
// `tests/agent-tooling/emitted-conversation-rail.live.test.ts`, which reads the
|
|
2076
|
+
// rail's SHADOW DOM — no string assertion can tell the two halves apart.
|
|
2077
|
+
const conversationsSetupLines = conversations
|
|
2078
|
+
? [
|
|
2079
|
+
` const conversationsEl = document.getElementById('conversations') as KaiConversationsElement;`,
|
|
2080
|
+
` // Same upgrade rule as <kai-chat>: 'conversations' is an array, so it is a`,
|
|
2081
|
+
` // PROPERTY, and a property set before the element upgrades is dropped on upgrade.`,
|
|
2082
|
+
` await customElements.whenDefined('kai-conversations');`,
|
|
2083
|
+
` // The row shape, DERIVED from the property it is assigned to — it cannot`,
|
|
2084
|
+
` // drift out of step with the element the way a hand-written copy would.`,
|
|
2085
|
+
` type Conversation = KaiConversationsElement['conversations'][number];`,
|
|
2086
|
+
` // Replace this list with your own. Your store owns titles, grouping and order;`,
|
|
2087
|
+
` // the rail renders what it is given, in the order it is given.`,
|
|
2088
|
+
` const now = new Date().toISOString();`,
|
|
2089
|
+
` let conversationRows: Conversation[] = [`,
|
|
2090
|
+
` { id: 'c1', title: 'New chat', scope: { type: 'document' }, messageCount: 0, lastMessageAt: now, updatedAt: now },`,
|
|
2091
|
+
` ];`,
|
|
2092
|
+
` let activeId = conversationRows[0].id;`,
|
|
2093
|
+
` // The threads you have open, keyed by conversation id. It starts EMPTY on`,
|
|
2094
|
+
` // purpose: seeding it would put words in the user's mouth. Load a thread`,
|
|
2095
|
+
` // from your backend in the select handler below and this fills itself.`,
|
|
2096
|
+
` const threads: Record<string, KaiChatElement['messages']> = {};`,
|
|
2097
|
+
` // A NEW array per write — the array reference is what notifies.`,
|
|
2098
|
+
` const showConversations = () => {`,
|
|
2099
|
+
` conversationsEl.conversations = [...conversationRows];`,
|
|
2100
|
+
` conversationsEl.activeId = activeId;`,
|
|
2101
|
+
` };`,
|
|
2102
|
+
` showConversations();`,
|
|
2103
|
+
``,
|
|
2104
|
+
` // The row's title, derived the way every chat app derives it: the first`,
|
|
2105
|
+
` // thing the user said, on one line. Swap in your own — a summariser, or`,
|
|
2106
|
+
` // whatever the user renamed the thread to — this is the fallback, not a`,
|
|
2107
|
+
` // policy. Returns undefined for an empty thread so the row keeps the`,
|
|
2108
|
+
` // placeholder it was created with rather than losing its label.`,
|
|
2109
|
+
` const TITLE_MAX = 48;`,
|
|
2110
|
+
` const titleFrom = (messages: KaiChatElement['messages']): string | undefined => {`,
|
|
2111
|
+
` const first = messages.find((message) => message.role === 'user');`,
|
|
2112
|
+
` // flatMap, not filter: a message can open with file parts, and this`,
|
|
2113
|
+
` // narrows the part union without needing a type predicate.`,
|
|
2114
|
+
` const text = (first?.parts ?? [])`,
|
|
2115
|
+
` .flatMap((part) => (part.type === 'text' ? [part.text] : []))`,
|
|
2116
|
+
` .join(' ')`,
|
|
2117
|
+
` .replace(/\\s+/g, ' ')`,
|
|
2118
|
+
` .trim();`,
|
|
2119
|
+
` if (!text) return undefined;`,
|
|
2120
|
+
` return text.length > TITLE_MAX ? \`\${text.slice(0, TITLE_MAX).trimEnd()}…\` : text;`,
|
|
2121
|
+
` };`,
|
|
2122
|
+
``,
|
|
2123
|
+
` // The row and the thread are two views of one conversation, and only the`,
|
|
2124
|
+
` // HOST keeps them in step: <kai-conversations> renders what it is handed and`,
|
|
2125
|
+
` // reports what was clicked, nothing more. Without this the rail reads`,
|
|
2126
|
+
` // "New chat / 0 messages / just now" forever, whatever the user does.`,
|
|
2127
|
+
` const syncActiveRow = () => {`,
|
|
2128
|
+
` const messages = chat.messages ?? [];`,
|
|
2129
|
+
` const at = new Date().toISOString();`,
|
|
2130
|
+
` // BOTH HALVES OF THE CONTRACT, and the rail is silently stale without`,
|
|
2131
|
+
` // either. A NEW ARRAY is what NOTIFIES — handing the same array back is a`,
|
|
2132
|
+
` // no-op even when a row inside it changed. A NEW OBJECT for the row that`,
|
|
2133
|
+
` // changed is what makes it VISIBLE, because the rail renders rows through`,
|
|
2134
|
+
` // a reference-keyed list and never re-renders a row whose identity held.`,
|
|
2135
|
+
` // So: map to a fresh row. Never \`row.messageCount = …\` — that mutation`,
|
|
2136
|
+
` // updates the data and leaves the screen showing the old numbers.`,
|
|
2137
|
+
` conversationRows = conversationRows.map((row) =>`,
|
|
2138
|
+
` row.id === activeId`,
|
|
2139
|
+
` ? {`,
|
|
2140
|
+
` ...row,`,
|
|
2141
|
+
` title: titleFrom(messages) ?? row.title,`,
|
|
2142
|
+
` messageCount: messages.length,`,
|
|
2143
|
+
` lastMessageAt: at,`,
|
|
2144
|
+
` updatedAt: at,`,
|
|
2145
|
+
` }`,
|
|
2146
|
+
` : row,`,
|
|
2147
|
+
` );`,
|
|
2148
|
+
` showConversations();`,
|
|
2149
|
+
` };`,
|
|
2150
|
+
``,
|
|
2151
|
+
` conversationsEl.addEventListener('kai-conversation-select', (event: Event) => {`,
|
|
2152
|
+
` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
|
|
2153
|
+
` // Keep what is on screen before switching away from it.`,
|
|
2154
|
+
` threads[activeId] = chat.messages ?? [];`,
|
|
2155
|
+
` activeId = id;`,
|
|
2156
|
+
` // ...and load the one picked. Fetch it here if your store is remote.`,
|
|
2157
|
+
` chat.messages = [...(threads[id] ?? [])];`,
|
|
2158
|
+
` showConversations();`,
|
|
2159
|
+
` });`,
|
|
2160
|
+
` conversationsEl.addEventListener('kai-new-chat', () => {`,
|
|
2161
|
+
` // The event carries no detail by design: it IS the whole signal.`,
|
|
2162
|
+
` threads[activeId] = chat.messages ?? [];`,
|
|
2163
|
+
` const at = new Date().toISOString();`,
|
|
2164
|
+
` activeId = \`c\${conversationRows.length + 1}\`;`,
|
|
2165
|
+
` conversationRows = [`,
|
|
2166
|
+
` { id: activeId, title: 'New chat', scope: { type: 'document' }, messageCount: 0, lastMessageAt: at, updatedAt: at },`,
|
|
2167
|
+
` ...conversationRows,`,
|
|
2168
|
+
` ];`,
|
|
2169
|
+
` chat.messages = [];`,
|
|
2170
|
+
` showConversations();`,
|
|
2171
|
+
` });`,
|
|
2172
|
+
``,
|
|
2173
|
+
]
|
|
2174
|
+
: [];
|
|
2175
|
+
|
|
2176
|
+
// The BLOCK's init-scope wiring: the thread store projected onto the rail and
|
|
2177
|
+
// the chat, the helpers owning the mechanics, every policy line labelled as
|
|
2178
|
+
// the consumer's. `showWorkspace` is a function declaration so the sessions
|
|
2179
|
+
// callback above it can name it before it is reached.
|
|
2180
|
+
const blockSetupLines = block
|
|
2181
|
+
? [
|
|
2182
|
+
` // ── The block's state: your records + the kit's session/persistence helpers ─`,
|
|
2183
|
+
` const conversationsEl = document.getElementById('conversations') as KaiConversationsElement;`,
|
|
2184
|
+
` // Same upgrade rule as <kai-chat>: 'conversations' is an array, so it is a`,
|
|
2185
|
+
` // PROPERTY, and a property set before the element upgrades is dropped on upgrade.`,
|
|
2186
|
+
` await customElements.whenDefined('kai-conversations');`,
|
|
2187
|
+
``,
|
|
2188
|
+
` let threads: Thread[] = loadThreads();`,
|
|
2189
|
+
` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
|
|
2190
|
+
` // record is created on its first turn, so the rail never fills with empties.`,
|
|
2191
|
+
` let activeId: string | null = threads[0]?.id ?? null;`,
|
|
2192
|
+
...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
|
|
2193
|
+
` const sessions = createThreadSessions(() => showWorkspace());`,
|
|
2194
|
+
` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
|
|
2195
|
+
` // the save target and what a failed save means are YOURS (see saveThreads).`,
|
|
2196
|
+
` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
|
|
2197
|
+
` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
|
|
2198
|
+
` window.addEventListener('beforeunload', () => saver.flush());`,
|
|
2199
|
+
` // Every write goes through this: schedule the save and re-project the views.`,
|
|
2200
|
+
` const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
|
|
2201
|
+
` threads = updater(threads);`,
|
|
2202
|
+
` saver.schedule(threads);`,
|
|
2203
|
+
` showWorkspace();`,
|
|
2204
|
+
` };`,
|
|
2205
|
+
``,
|
|
2206
|
+
` // Project the records onto the elements. NEW arrays per write — the array`,
|
|
2207
|
+
` // reference is what notifies (reactivity-two-halves; the changed objects`,
|
|
2208
|
+
` // inside come from the helpers' folds).`,
|
|
2209
|
+
` function showWorkspace(): void {`,
|
|
2210
|
+
` // The rail's row shape is a VIEW of your record — titles and counts, no`,
|
|
2211
|
+
` // message bodies. The rail renders the array in the order it is given.`,
|
|
2212
|
+
` conversationsEl.conversations = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`,
|
|
2213
|
+
` conversationsEl.activeId = activeId ?? undefined;`,
|
|
2214
|
+
` chat.messages = [...(threads.find((t) => t.id === activeId)?.messages ?? [])];`,
|
|
2215
|
+
` chat.loading = activeId !== null && sessions.isStreaming(activeId);`,
|
|
2216
|
+
` }`,
|
|
2217
|
+
` showWorkspace();`,
|
|
2218
|
+
``,
|
|
2219
|
+
` conversationsEl.addEventListener('kai-conversation-select', (event: Event) => {`,
|
|
2220
|
+
` activeId = (event as CustomEvent<{ id: string }>).detail.id;`,
|
|
2221
|
+
` showWorkspace();`,
|
|
2222
|
+
` });`,
|
|
2223
|
+
` conversationsEl.addEventListener('kai-new-chat', () => {`,
|
|
2224
|
+
` // The event carries no detail by design: it IS the whole signal. A DRAFT,`,
|
|
2225
|
+
` // not a record — see activeId above.`,
|
|
2226
|
+
` activeId = null;`,
|
|
2227
|
+
` showWorkspace();`,
|
|
2228
|
+
` });`,
|
|
2229
|
+
``,
|
|
2230
|
+
]
|
|
2231
|
+
: [];
|
|
2232
|
+
|
|
1568
2233
|
// Module scope, like vue/angular: the handler below closes over all three, and
|
|
1569
2234
|
// `runTool` is a function declaration rather than something wedged into init().
|
|
1570
2235
|
const modelLines = ctx.defaultModel
|
|
@@ -1596,6 +2261,10 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
|
1596
2261
|
'KaiChatElement',
|
|
1597
2262
|
...(hasSources ? ['KaiSourcesElement'] : []),
|
|
1598
2263
|
...(attachments ? ['KaiAttachmentsElement'] : []),
|
|
2264
|
+
// Same rule again: only when the rail is really on this surface. It types the
|
|
2265
|
+
// property assignments AND supplies the row type, so it is used twice over.
|
|
2266
|
+
// The block drives the same element from its own thread store.
|
|
2267
|
+
...(conversations || block ? ['KaiConversationsElement'] : []),
|
|
1599
2268
|
].join(', ');
|
|
1600
2269
|
|
|
1601
2270
|
/**
|
|
@@ -1607,8 +2276,11 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
|
1607
2276
|
* `const history: ChatMessage[]`; the tool-loop shape's thread IS
|
|
1608
2277
|
* `chat.messages`, already typed by `KaiChatElement`, and importing the type
|
|
1609
2278
|
* there is a TS6133 that fails `npm run build` in a stock app.
|
|
2279
|
+
*
|
|
2280
|
+
* The BLOCK always references it: the `Thread` record's `messages` field is
|
|
2281
|
+
* `ChatMessage[]`, so in block mode the import is used whatever the loop shape.
|
|
1610
2282
|
*/
|
|
1611
|
-
const annotatesChatMessage = !ctx.emitToolLoop;
|
|
2283
|
+
const annotatesChatMessage = !ctx.emitToolLoop || block;
|
|
1612
2284
|
|
|
1613
2285
|
const head = [
|
|
1614
2286
|
`// src/main.ts — the page's logic, in a module YOUR build type-checks.`,
|
|
@@ -1629,12 +2301,14 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
|
1629
2301
|
cardTools: ctx.cardProvider !== null,
|
|
1630
2302
|
mock: ctx.isMock,
|
|
1631
2303
|
attachments,
|
|
2304
|
+
workspaceBlock: block,
|
|
1632
2305
|
}),
|
|
1633
2306
|
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
1634
2307
|
``,
|
|
1635
2308
|
...(ctx.isMock ? [...mockResponderInit(), ``] : []),
|
|
1636
2309
|
...modelLines,
|
|
1637
2310
|
...attachmentModuleLines,
|
|
2311
|
+
...(block ? [...blockThreadStoreLines(''), ``] : []),
|
|
1638
2312
|
...cardsLines,
|
|
1639
2313
|
...toolsLines,
|
|
1640
2314
|
...runnerLines,
|
|
@@ -1652,6 +2326,8 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
|
1652
2326
|
...seedLines,
|
|
1653
2327
|
...sourcesSetupLines,
|
|
1654
2328
|
...attachmentSetupLines,
|
|
2329
|
+
...conversationsSetupLines,
|
|
2330
|
+
...blockSetupLines,
|
|
1655
2331
|
];
|
|
1656
2332
|
|
|
1657
2333
|
// `Event`, not `CustomEvent`: addEventListener with a custom event name hands
|
|
@@ -1675,34 +2351,76 @@ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
|
1675
2351
|
`void init();`,
|
|
1676
2352
|
];
|
|
1677
2353
|
|
|
2354
|
+
// The block's submit prelude: bind the turn to ONE thread id, create the
|
|
2355
|
+
// record on its first turn, and open the session.
|
|
2356
|
+
const blockPrelude = block
|
|
2357
|
+
? [
|
|
2358
|
+
...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
|
|
2359
|
+
`const targetId = activeId ?? crypto.randomUUID();`,
|
|
2360
|
+
`// The record is born on its first turn — with YOUR title policy.`,
|
|
2361
|
+
`setThreads((prev) => prev.some((t) => t.id === targetId)`,
|
|
2362
|
+
` ? prev`,
|
|
2363
|
+
` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
|
|
2364
|
+
`activeId = targetId;`,
|
|
2365
|
+
...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
|
|
2366
|
+
`const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
|
|
2367
|
+
` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
|
|
2368
|
+
`});`,
|
|
2369
|
+
`const controller = sessions.begin(targetId);`,
|
|
2370
|
+
`showWorkspace();`,
|
|
2371
|
+
]
|
|
2372
|
+
: [];
|
|
1678
2373
|
return [
|
|
1679
2374
|
...head,
|
|
1680
2375
|
` // messages is a JS PROPERTY (objects can't be HTML attributes)`,
|
|
1681
2376
|
...listenerOpen,
|
|
1682
2377
|
realStreamBody({
|
|
1683
2378
|
pad: ' ',
|
|
1684
|
-
read: 'chat.messages',
|
|
1685
|
-
commitSet:
|
|
1686
|
-
|
|
1687
|
-
|
|
2379
|
+
read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : 'chat.messages',
|
|
2380
|
+
commitSet: block
|
|
2381
|
+
? (expr) => `setMessages(() => ${expr});`
|
|
2382
|
+
: (expr) => `chat.messages = ${expr};`,
|
|
2383
|
+
setterAdapter: block ? 'setMessages' : '(fn) => { chat.messages = fn(chat.messages); }',
|
|
2384
|
+
setLoading: block
|
|
2385
|
+
? (v) =>
|
|
2386
|
+
v === 'true'
|
|
2387
|
+
? `// loading for THIS thread now derives from the sessions map (showWorkspace).`
|
|
2388
|
+
: `sessions.end(targetId, controller); showWorkspace();`
|
|
2389
|
+
: (v) => `chat.loading = ${v};`,
|
|
1688
2390
|
bodyPayload: realBodyPayload({ defaultModel: ctx.defaultModel, tools: ctx.emitTools }),
|
|
1689
2391
|
strictRoles: true,
|
|
1690
2392
|
toolLoop: ctx.emitToolLoop,
|
|
1691
2393
|
cards: ctx.emitCards,
|
|
1692
|
-
thread:
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
2394
|
+
thread: block
|
|
2395
|
+
? blockLiveThread(`(threads.find((t) => t.id === targetId)?.messages ?? [])`)
|
|
2396
|
+
: liveThreadBinding(
|
|
2397
|
+
'chat.messages',
|
|
2398
|
+
'(fn) => { chat.messages = fn(chat.messages); }',
|
|
2399
|
+
'chat.messages ?? []',
|
|
2400
|
+
),
|
|
1697
2401
|
mock: ctx.isMock,
|
|
2402
|
+
...(block ? { fetchSignal: 'controller.signal', afterValue: blockPrelude } : {}),
|
|
2403
|
+
// ONE call site, in the `finally`, and deliberately not also on submit. The
|
|
2404
|
+
// finally sees the WHOLE turn — the user's message and the assistant's — so
|
|
2405
|
+
// one call cannot leave the row disagreeing with the thread, and it runs on
|
|
2406
|
+
// the error path too, where the user's message is on the thread regardless.
|
|
2407
|
+
// The cost is that the title appears when the reply settles rather than the
|
|
2408
|
+
// instant the user hits send; call it right after `chat.messages = history`
|
|
2409
|
+
// as well if you want it sooner.
|
|
2410
|
+
afterTurn: conversations ? [`syncActiveRow(); // keep the rail's row in step with the thread`] : [],
|
|
1698
2411
|
...(attachments
|
|
1699
2412
|
? {
|
|
1700
2413
|
filesExpr: 'files',
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2414
|
+
// The attachment capture must read the staging list before anything
|
|
2415
|
+
// clears it, so it precedes the block's prelude.
|
|
2416
|
+
afterValue: [
|
|
2417
|
+
...attachmentTurnLines({
|
|
2418
|
+
stagedExpr: 'staged',
|
|
2419
|
+
clear: ['staged = [];', 'showStaged();'],
|
|
2420
|
+
fromSubmitEvent: true,
|
|
2421
|
+
}),
|
|
2422
|
+
...blockPrelude,
|
|
2423
|
+
],
|
|
1706
2424
|
}
|
|
1707
2425
|
: {}),
|
|
1708
2426
|
}),
|
|
@@ -1768,11 +2486,22 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1768
2486
|
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
1769
2487
|
|
|
1770
2488
|
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1771
|
-
const workspace =
|
|
2489
|
+
const workspace = isArtifactSplit(components);
|
|
2490
|
+
const block = isWorkspaceBlock(components);
|
|
1772
2491
|
|
|
1773
2492
|
// SCAF-9: exclude message-embedded tags from import list.
|
|
1774
2493
|
// SCAF-14: workspace uses Resizable+ResizableItem+Artifact — keep them in the import list.
|
|
1775
|
-
|
|
2494
|
+
//
|
|
2495
|
+
// `ALWAYS_EMITTED_TAG` is unioned in for the reason it exists: this target
|
|
2496
|
+
// renders `<Chat …>` whether or not the caller listed `kai-chat`, so keying
|
|
2497
|
+
// the imports off `components` alone emitted a file that USES `Chat` and never
|
|
2498
|
+
// imports it. Measured on `components: ['kai-conversations']` — a TS2304 in
|
|
2499
|
+
// the consumer's own build. Every cell `verify:scaffold` compiles carries
|
|
2500
|
+
// kai-chat already (it is `BASE_COMPONENT`), so this changes no gated output;
|
|
2501
|
+
// it fixes the ungated shape the gate cannot see.
|
|
2502
|
+
const renderableTags = [
|
|
2503
|
+
...new Set([ALWAYS_EMITTED_TAG, ...components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t))]),
|
|
2504
|
+
];
|
|
1776
2505
|
// For workspace: replace 'kai-resizable' with 'kai-resizable-item' so we get ResizableItem too.
|
|
1777
2506
|
const importTags = workspace
|
|
1778
2507
|
? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
|
|
@@ -1783,13 +2512,15 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1783
2512
|
const attachments = hasAttachments(components);
|
|
1784
2513
|
|
|
1785
2514
|
// SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not
|
|
1786
|
-
// workspace-structural,
|
|
1787
|
-
// block of its own with
|
|
2515
|
+
// workspace-structural, not the block's shell/rail pair — both structural —
|
|
2516
|
+
// and not the attachment pair — that gets a composed block of its own with
|
|
2517
|
+
// state behind it).
|
|
1788
2518
|
const standaloneCompanionTags = components.filter(
|
|
1789
2519
|
(t) =>
|
|
1790
2520
|
t !== 'kai-chat' &&
|
|
1791
2521
|
!MESSAGE_EMBEDDED_TAGS.has(t) &&
|
|
1792
|
-
!
|
|
2522
|
+
!ARTIFACT_SPLIT_TAGS.has(t) &&
|
|
2523
|
+
!(block && BLOCK_TAGS.has(t)) &&
|
|
1793
2524
|
!(attachments && ATTACHMENT_TAGS.has(t)),
|
|
1794
2525
|
);
|
|
1795
2526
|
|
|
@@ -1808,6 +2539,12 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1808
2539
|
` {/* Replace sampleSources with your real data. */}`,
|
|
1809
2540
|
` <Sources sources={sampleSources} />`,
|
|
1810
2541
|
);
|
|
2542
|
+
} else if (slotPlacementFor(t)) {
|
|
2543
|
+
// The catalog says where this one goes, and this target cannot put it
|
|
2544
|
+
// there — so it says so, rather than emitting a layout nothing describes.
|
|
2545
|
+
// The placement is PASSED IN, not restated: the note names the parent and
|
|
2546
|
+
// the slot, and both belong to the record.
|
|
2547
|
+
companionJsxLines.push(...railSiblingNote(t, slotPlacementFor(t)!), ` <${toPascalCase(t)} />`);
|
|
1811
2548
|
} else {
|
|
1812
2549
|
companionJsxLines.push(` {/* wire data props — see the component_reference MCP tool */}`);
|
|
1813
2550
|
companionJsxLines.push(` <${toPascalCase(t)} />`);
|
|
@@ -1871,16 +2608,69 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1871
2608
|
const mockInit = isMock ? mockResponderInit() : [];
|
|
1872
2609
|
|
|
1873
2610
|
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE for the three ways
|
|
1874
|
-
// one broke a real app.
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
])
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
]
|
|
2611
|
+
// one broke a real app. In BLOCK mode the thread state is the block's
|
|
2612
|
+
// multi-thread store below, so the single-thread pair is not emitted (and the
|
|
2613
|
+
// sample seed with it — the store starts from what localStorage really holds).
|
|
2614
|
+
const sampleMessagesInit = block
|
|
2615
|
+
? [
|
|
2616
|
+
` // ── The block's state: your records + the kit's session/persistence helpers ─`,
|
|
2617
|
+
` const [threads, setThreads] = useState<Thread[]>(loadThreads);`,
|
|
2618
|
+
` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
|
|
2619
|
+
` // record is created on its first turn, so the rail never fills with empties.`,
|
|
2620
|
+
` const [activeId, setActiveId] = useState<string | null>(threads[0]?.id ?? null);`,
|
|
2621
|
+
` // One id per in-flight reply — a stream keeps running when you switch away,`,
|
|
2622
|
+
` // so this cannot be a single boolean.`,
|
|
2623
|
+
` const [streamingIds, setStreamingIds] = useState<string[]>([]);`,
|
|
2624
|
+
...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
|
|
2625
|
+
` const [sessions] = useState(() => createThreadSessions(setStreamingIds));`,
|
|
2626
|
+
` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
|
|
2627
|
+
` // the save target and what a failed save means are YOURS (see saveThreads).`,
|
|
2628
|
+
` const [saver] = useState(() => createSaveScheduler(saveThreads, { delayMs: 250 }));`,
|
|
2629
|
+
` useEffect(() => { saver.schedule(threads); }, [saver, threads]);`,
|
|
2630
|
+
` useEffect(() => {`,
|
|
2631
|
+
` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
|
|
2632
|
+
` const flush = () => saver.flush();`,
|
|
2633
|
+
` window.addEventListener('beforeunload', flush);`,
|
|
2634
|
+
` return () => window.removeEventListener('beforeunload', flush);`,
|
|
2635
|
+
` }, [saver]);`,
|
|
2636
|
+
``,
|
|
2637
|
+
` const active = threads.find((t) => t.id === activeId) ?? null;`,
|
|
2638
|
+
` const messages = active?.messages ?? NO_MESSAGES;`,
|
|
2639
|
+
` const loading = activeId !== null && streamingIds.includes(activeId);`,
|
|
2640
|
+
` // The rail's row shape is a VIEW of your record — titles and counts, no`,
|
|
2641
|
+
` // message bodies. Newest-first is your ordering; the rail renders the array`,
|
|
2642
|
+
` // in the order it is given.`,
|
|
2643
|
+
` const rows = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`,
|
|
2644
|
+
].join('\n')
|
|
2645
|
+
: [
|
|
2646
|
+
...(hasEmbedded
|
|
2647
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
2648
|
+
`const sampleMessages: ChatMessage[] = [${literal}];`,
|
|
2649
|
+
`const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
|
|
2650
|
+
])
|
|
2651
|
+
: []),
|
|
2652
|
+
` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
|
|
2653
|
+
].join('\n');
|
|
2654
|
+
|
|
2655
|
+
// The block's module-scope pieces: the thread store (the consumer-owned
|
|
2656
|
+
// persistence boundary) plus the stable empty-thread reference.
|
|
2657
|
+
const blockModuleInit = block
|
|
2658
|
+
? [
|
|
2659
|
+
// One joined entry so the JSX branches' blank-line filter cannot squash
|
|
2660
|
+
// the store's own paragraph breaks.
|
|
2661
|
+
[
|
|
2662
|
+
`/** A stable reference for the empty thread. \`messages\` is diffed by`,
|
|
2663
|
+
` * reference, so a fresh \`[]\` every render would re-notify for nothing. */`,
|
|
2664
|
+
`const NO_MESSAGES: ChatMessage[] = [];`,
|
|
2665
|
+
``,
|
|
2666
|
+
...blockThreadStoreLines(''),
|
|
2667
|
+
].join('\n'),
|
|
2668
|
+
]
|
|
2669
|
+
: [];
|
|
2670
|
+
|
|
2671
|
+
// In block mode `loading` derives from the sessions map, so the single
|
|
2672
|
+
// boolean is not declared (and would fail noUnusedLocals if it were).
|
|
2673
|
+
const loadingInit = block ? [] : [` const [loading, setLoading] = useState(false);`];
|
|
1884
2674
|
|
|
1885
2675
|
// SCAF-9: sample sources data for knowledge-base archetype.
|
|
1886
2676
|
const sampleSourcesInit =
|
|
@@ -1916,24 +2706,139 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1916
2706
|
const cardPropsNote = (pad: string): string[] =>
|
|
1917
2707
|
ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
|
|
1918
2708
|
|
|
2709
|
+
// The block's submit prelude: bind the turn to ONE thread id, create the
|
|
2710
|
+
// record on its first turn, and open the session. Rides in `afterValue` so it
|
|
2711
|
+
// lands after the value guard (and after the attachment capture, which must
|
|
2712
|
+
// read the staging list before anything clears it).
|
|
2713
|
+
const blockPrelude = block
|
|
2714
|
+
? [
|
|
2715
|
+
...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
|
|
2716
|
+
`const targetId = activeId ?? crypto.randomUUID();`,
|
|
2717
|
+
`// The record is born on its first turn — with YOUR title policy.`,
|
|
2718
|
+
`setThreads((prev) => prev.some((t) => t.id === targetId)`,
|
|
2719
|
+
` ? prev`,
|
|
2720
|
+
` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
|
|
2721
|
+
`setActiveId(targetId);`,
|
|
2722
|
+
...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
|
|
2723
|
+
`const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
|
|
2724
|
+
` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
|
|
2725
|
+
`});`,
|
|
2726
|
+
`const controller = sessions.begin(targetId);`,
|
|
2727
|
+
]
|
|
2728
|
+
: [];
|
|
2729
|
+
|
|
2730
|
+
// The turn-scoped React thread binding, seeded from the TARGET thread's
|
|
2731
|
+
// stored messages — the line that makes continuing an old conversation after
|
|
2732
|
+
// a reload a real continuation rather than a fresh start.
|
|
2733
|
+
const blockReactThread: ThreadBinding = {
|
|
2734
|
+
open: ({ pad, userMessage }) => [
|
|
2735
|
+
`${pad}// THE TURN OWNS THE THREAD (same rule as the single-thread scaffold): the`,
|
|
2736
|
+
`${pad}// loop below re-encodes it every round, and React state cannot be read`,
|
|
2737
|
+
`${pad}// back mid-turn. setMessages (the id-bound sink) just projects it.`,
|
|
2738
|
+
`${pad}let thread: ChatMessage[] = [...(threads.find((t) => t.id === targetId)?.messages ?? []), ${userMessage}];`,
|
|
2739
|
+
`${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(() => thread); };`,
|
|
2740
|
+
`${pad}setMessages(() => thread);`,
|
|
2741
|
+
],
|
|
2742
|
+
live: 'thread',
|
|
2743
|
+
setter: 'set',
|
|
2744
|
+
};
|
|
2745
|
+
|
|
1919
2746
|
// onSubmit body. The mock and the real backend differ by ONE expression — the
|
|
1920
2747
|
// stream's source — and share every other line: see `realStreamBody`.
|
|
1921
2748
|
const onSubmitBody = realStreamBody({
|
|
1922
2749
|
pad: ' ',
|
|
1923
|
-
read: 'messages',
|
|
1924
|
-
commitSet: (expr) => `setMessages(${expr});`,
|
|
1925
|
-
// useState's setter IS a SetMessages: both are (updater) => void
|
|
2750
|
+
read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : 'messages',
|
|
2751
|
+
commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `setMessages(${expr});`,
|
|
2752
|
+
// useState's setter IS a SetMessages: both are (updater) => void — and in
|
|
2753
|
+
// block mode the id-bound sink from bindThreadMessages is one too.
|
|
1926
2754
|
setterAdapter: 'setMessages',
|
|
1927
|
-
setLoading:
|
|
2755
|
+
setLoading: block
|
|
2756
|
+
? (v) =>
|
|
2757
|
+
v === 'true'
|
|
2758
|
+
? `// loading for THIS thread now derives from the sessions map (streamingIds).`
|
|
2759
|
+
: `sessions.end(targetId, controller);`
|
|
2760
|
+
: (v) => `setLoading(${v});`,
|
|
1928
2761
|
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
1929
2762
|
strictRoles: true,
|
|
1930
2763
|
toolLoop: emitToolLoop,
|
|
1931
2764
|
cards: ctx.emitCards,
|
|
1932
|
-
thread: REACT_THREAD,
|
|
2765
|
+
thread: block ? blockReactThread : REACT_THREAD,
|
|
1933
2766
|
mock: isMock,
|
|
2767
|
+
...(block ? { fetchSignal: 'controller.signal' } : {}),
|
|
1934
2768
|
...attachmentSubmitOpts,
|
|
2769
|
+
...(block
|
|
2770
|
+
? {
|
|
2771
|
+
afterValue: [
|
|
2772
|
+
...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
|
|
2773
|
+
...blockPrelude,
|
|
2774
|
+
],
|
|
2775
|
+
}
|
|
2776
|
+
: {}),
|
|
1935
2777
|
});
|
|
1936
2778
|
|
|
2779
|
+
// The surface JSX, shared by the next and plain-react branches below: the
|
|
2780
|
+
// attachment strip, the chat (or the artifact split around it) and the
|
|
2781
|
+
// companions — wrapped by the shell when this is the workspace BLOCK.
|
|
2782
|
+
const chatJsx = (pad: string): string[] => [
|
|
2783
|
+
...cardPropsNote(pad),
|
|
2784
|
+
`${pad}<Chat`,
|
|
2785
|
+
`${pad} messages={messages}`,
|
|
2786
|
+
`${pad} loading={loading}`,
|
|
2787
|
+
`${pad} suggestions={suggestions}`,
|
|
2788
|
+
`${pad} suggestionMode="submit"`,
|
|
2789
|
+
...cardProps(`${pad} `),
|
|
2790
|
+
`${pad} onSubmit={onSubmit}`,
|
|
2791
|
+
`${pad} style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2792
|
+
`${pad}/>`,
|
|
2793
|
+
];
|
|
2794
|
+
const splitJsx: string[] = [
|
|
2795
|
+
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
2796
|
+
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
2797
|
+
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
2798
|
+
` <ResizableItem size="40%" min="240px">`,
|
|
2799
|
+
...chatJsx(' '),
|
|
2800
|
+
` </ResizableItem>`,
|
|
2801
|
+
` <ResizableItem min="280px">`,
|
|
2802
|
+
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
2803
|
+
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
2804
|
+
` </ResizableItem>`,
|
|
2805
|
+
` </Resizable>`,
|
|
2806
|
+
];
|
|
2807
|
+
// Standalone companions are siblings of the SPLIT (or of the chat): the split
|
|
2808
|
+
// owns chat + artifact, and a sources panel or a voice input belongs beside
|
|
2809
|
+
// it. Dropping them here is what the old workspace branch used to do.
|
|
2810
|
+
const mainJsx: string[] = [
|
|
2811
|
+
...(attachmentJsx ? [attachmentJsx] : []),
|
|
2812
|
+
...(workspace ? [...splitJsx, companions] : [...chatJsx(' '), companions]),
|
|
2813
|
+
];
|
|
2814
|
+
const surfaceJsx: string[] = block
|
|
2815
|
+
? [
|
|
2816
|
+
` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
|
|
2817
|
+
` {/* <Workspace> is the chat-agnostic layout shell (header/start/main/end/`,
|
|
2818
|
+
` footer slots, aside resize + collapse, a mobile drawer below`,
|
|
2819
|
+
` drawerBelow px). It knows nothing about chat. */}`,
|
|
2820
|
+
` <Workspace drawerBelow={720} style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
2821
|
+
...jsxComment(BLOCK_RAIL_NOTE, ' '),
|
|
2822
|
+
` {/* The React wrappers forward no slot prop, so a plain div carries the`,
|
|
2823
|
+
` slot attribute into the shell's start aside. */}`,
|
|
2824
|
+
` <div slot="start" style={{ height: '100%' }}>`,
|
|
2825
|
+
` {/* New chat = a DRAFT (activeId null): the record appears on the first turn. */}`,
|
|
2826
|
+
` <Conversations`,
|
|
2827
|
+
` conversations={rows}`,
|
|
2828
|
+
` activeId={activeId ?? undefined}`,
|
|
2829
|
+
` onConversationSelect={(e) => setActiveId(e.detail.id)}`,
|
|
2830
|
+
` onNewChat={() => setActiveId(null)}`,
|
|
2831
|
+
` style={{ display: 'block', height: '100%' }}`,
|
|
2832
|
+
` />`,
|
|
2833
|
+
` </div>`,
|
|
2834
|
+
` {/* Unnamed children project into the shell's MAIN region. */}`,
|
|
2835
|
+
` <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>`,
|
|
2836
|
+
...indentEmitted(mainJsx, ' '),
|
|
2837
|
+
` </div>`,
|
|
2838
|
+
` </Workspace>`,
|
|
2839
|
+
]
|
|
2840
|
+
: mainJsx;
|
|
2841
|
+
|
|
1937
2842
|
// SCAF-2: Next.js App Router requires 'use client' for components that use hooks/interactivity.
|
|
1938
2843
|
const useClientDirective = framework === 'next' ? [`'use client';`, ``] : [];
|
|
1939
2844
|
|
|
@@ -1957,7 +2862,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1957
2862
|
return [
|
|
1958
2863
|
// 'use client' must be the very first line for Next.js App Router.
|
|
1959
2864
|
...useClientDirective,
|
|
1960
|
-
`import { useState } from 'react';`,
|
|
2865
|
+
`import { ${block ? 'useEffect, useState' : 'useState'} } from 'react';`,
|
|
1961
2866
|
`import dynamic from 'next/dynamic';`,
|
|
1962
2867
|
// The adapter is pure parsing + pure state; both entries are SSR-import-safe,
|
|
1963
2868
|
// so they stay static imports even though the ELEMENTS have to be dynamic.
|
|
@@ -1969,6 +2874,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1969
2874
|
cardTools: ctx.cardProvider !== null,
|
|
1970
2875
|
mock: isMock,
|
|
1971
2876
|
attachments,
|
|
2877
|
+
workspaceBlock: block,
|
|
1972
2878
|
}),
|
|
1973
2879
|
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
1974
2880
|
`// <kai-*> are client-only custom elements (the server has no customElements`,
|
|
@@ -1982,10 +2888,11 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1982
2888
|
...mockInit,
|
|
1983
2889
|
``,
|
|
1984
2890
|
...attachmentModuleInit,
|
|
2891
|
+
...blockModuleInit,
|
|
1985
2892
|
...cardsInit,
|
|
1986
2893
|
`export default function App() {`,
|
|
1987
2894
|
sampleMessagesInit,
|
|
1988
|
-
|
|
2895
|
+
...loadingInit,
|
|
1989
2896
|
` const suggestions = ${jsArray(suggestions)};`,
|
|
1990
2897
|
...(attachmentStateInit ? [attachmentStateInit] : []),
|
|
1991
2898
|
...(sampleSourcesInit ? [sampleSourcesInit] : []),
|
|
@@ -1999,48 +2906,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
1999
2906
|
``,
|
|
2000
2907
|
` return (`,
|
|
2001
2908
|
` <div style={{ ${jsxStyle(p.style)} }}>`,
|
|
2002
|
-
...
|
|
2003
|
-
...(workspace
|
|
2004
|
-
? [
|
|
2005
|
-
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
2006
|
-
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
2007
|
-
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
2008
|
-
` <ResizableItem size="40%" min="240px">`,
|
|
2009
|
-
...cardPropsNote(' '),
|
|
2010
|
-
` <Chat`,
|
|
2011
|
-
` messages={messages}`,
|
|
2012
|
-
` loading={loading}`,
|
|
2013
|
-
` suggestions={suggestions}`,
|
|
2014
|
-
` suggestionMode="submit"`,
|
|
2015
|
-
...cardProps(' '),
|
|
2016
|
-
` onSubmit={onSubmit}`,
|
|
2017
|
-
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2018
|
-
` />`,
|
|
2019
|
-
` </ResizableItem>`,
|
|
2020
|
-
` <ResizableItem min="280px">`,
|
|
2021
|
-
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
2022
|
-
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
2023
|
-
` </ResizableItem>`,
|
|
2024
|
-
` </Resizable>`,
|
|
2025
|
-
// Standalone companions are siblings of the SPLIT, not of the chat:
|
|
2026
|
-
// the split owns chat + artifact, and a sources panel or a voice input
|
|
2027
|
-
// belongs beside it. Dropping them here is what the workspace branch
|
|
2028
|
-
// used to do — see WORKSPACE_STRUCTURAL_TAGS.
|
|
2029
|
-
companions,
|
|
2030
|
-
]
|
|
2031
|
-
: [
|
|
2032
|
-
...cardPropsNote(' '),
|
|
2033
|
-
` <Chat`,
|
|
2034
|
-
` messages={messages}`,
|
|
2035
|
-
` loading={loading}`,
|
|
2036
|
-
` suggestions={suggestions}`,
|
|
2037
|
-
` suggestionMode="submit"`,
|
|
2038
|
-
...cardProps(' '),
|
|
2039
|
-
` onSubmit={onSubmit}`,
|
|
2040
|
-
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2041
|
-
` />`,
|
|
2042
|
-
companions,
|
|
2043
|
-
]),
|
|
2909
|
+
...surfaceJsx,
|
|
2044
2910
|
` </div>`,
|
|
2045
2911
|
` );`,
|
|
2046
2912
|
`}`,
|
|
@@ -2058,7 +2924,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
2058
2924
|
// (1) REQUIRED: registers <kai-*> — the react wrappers do NOT auto-register.
|
|
2059
2925
|
// Must come BEFORE importing the wrappers, or <kai-chat> renders empty.
|
|
2060
2926
|
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
2061
|
-
`import { useState } from 'react';`,
|
|
2927
|
+
`import { ${block ? 'useEffect, useState' : 'useState'} } from 'react';`,
|
|
2062
2928
|
`import { ${importList} } from '@kitn.ai/ui/react';`,
|
|
2063
2929
|
...wireImportLines({
|
|
2064
2930
|
typed: true,
|
|
@@ -2068,6 +2934,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
2068
2934
|
cardTools: ctx.cardProvider !== null,
|
|
2069
2935
|
mock: isMock,
|
|
2070
2936
|
attachments,
|
|
2937
|
+
workspaceBlock: block,
|
|
2071
2938
|
}),
|
|
2072
2939
|
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
2073
2940
|
``,
|
|
@@ -2077,10 +2944,11 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
2077
2944
|
...mockInit,
|
|
2078
2945
|
``,
|
|
2079
2946
|
...attachmentModuleInit,
|
|
2947
|
+
...blockModuleInit,
|
|
2080
2948
|
...cardsInit,
|
|
2081
2949
|
`export default function App() {`,
|
|
2082
2950
|
sampleMessagesInit,
|
|
2083
|
-
|
|
2951
|
+
...loadingInit,
|
|
2084
2952
|
` const suggestions = ${jsArray(suggestions)};`,
|
|
2085
2953
|
...(attachmentStateInit ? [attachmentStateInit] : []),
|
|
2086
2954
|
...(sampleSourcesInit ? [sampleSourcesInit] : []),
|
|
@@ -2094,45 +2962,7 @@ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: str
|
|
|
2094
2962
|
``,
|
|
2095
2963
|
` return (`,
|
|
2096
2964
|
` <div style={{ ${jsxStyle(p.style)} }}>`,
|
|
2097
|
-
...
|
|
2098
|
-
...(workspace
|
|
2099
|
-
? [
|
|
2100
|
-
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
2101
|
-
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
2102
|
-
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
2103
|
-
` <ResizableItem size="40%" min="240px">`,
|
|
2104
|
-
...cardPropsNote(' '),
|
|
2105
|
-
` <Chat`,
|
|
2106
|
-
` messages={messages}`,
|
|
2107
|
-
` loading={loading}`,
|
|
2108
|
-
` suggestions={suggestions}`,
|
|
2109
|
-
` suggestionMode="submit"`,
|
|
2110
|
-
...cardProps(' '),
|
|
2111
|
-
` onSubmit={onSubmit}`,
|
|
2112
|
-
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2113
|
-
` />`,
|
|
2114
|
-
` </ResizableItem>`,
|
|
2115
|
-
` <ResizableItem min="280px">`,
|
|
2116
|
-
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
2117
|
-
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
2118
|
-
` </ResizableItem>`,
|
|
2119
|
-
` </Resizable>`,
|
|
2120
|
-
// Siblings of the SPLIT — see the same line in the other JSX branch.
|
|
2121
|
-
companions,
|
|
2122
|
-
]
|
|
2123
|
-
: [
|
|
2124
|
-
...cardPropsNote(' '),
|
|
2125
|
-
` <Chat`,
|
|
2126
|
-
` messages={messages}`,
|
|
2127
|
-
` loading={loading}`,
|
|
2128
|
-
` suggestions={suggestions}`,
|
|
2129
|
-
` suggestionMode="submit"`,
|
|
2130
|
-
...cardProps(' '),
|
|
2131
|
-
` onSubmit={onSubmit}`,
|
|
2132
|
-
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2133
|
-
` />`,
|
|
2134
|
-
companions,
|
|
2135
|
-
]),
|
|
2965
|
+
...surfaceJsx,
|
|
2136
2966
|
` </div>`,
|
|
2137
2967
|
` );`,
|
|
2138
2968
|
`}`,
|
|
@@ -2147,13 +2977,22 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2147
2977
|
|
|
2148
2978
|
// SCAF-9: exclude message-embedded tags from companion rendering.
|
|
2149
2979
|
// SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
|
|
2150
|
-
const workspace =
|
|
2980
|
+
const workspace = isArtifactSplit(components);
|
|
2981
|
+
const block = isWorkspaceBlock(components);
|
|
2151
2982
|
const attachments = hasAttachments(components);
|
|
2983
|
+
// Slotted companions are children of <kai-chat> (see `slottedChildMarkup`), so
|
|
2984
|
+
// they leave the sibling loop the same way they do in the html target. In
|
|
2985
|
+
// BLOCK mode the rail lives in the SHELL's start aside instead.
|
|
2986
|
+
const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
|
|
2987
|
+
const vueSlotted = (pad: string) =>
|
|
2988
|
+
block ? [] : slottedChildMarkup(components, pad, { wired: false, ids: false });
|
|
2152
2989
|
const standaloneCompanionTags = components.filter(
|
|
2153
2990
|
(t) =>
|
|
2154
2991
|
t !== 'kai-chat' &&
|
|
2155
2992
|
!MESSAGE_EMBEDDED_TAGS.has(t) &&
|
|
2156
|
-
!
|
|
2993
|
+
!ARTIFACT_SPLIT_TAGS.has(t) &&
|
|
2994
|
+
!(block && BLOCK_TAGS.has(t)) &&
|
|
2995
|
+
!slottedTags.has(t) &&
|
|
2157
2996
|
!(attachments && ATTACHMENT_TAGS.has(t)),
|
|
2158
2997
|
);
|
|
2159
2998
|
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
@@ -2224,19 +3063,57 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2224
3063
|
}
|
|
2225
3064
|
: {};
|
|
2226
3065
|
|
|
3066
|
+
// The block's submit prelude — same shape as the other targets; refs instead
|
|
3067
|
+
// of React state.
|
|
3068
|
+
const blockRead = `(threads.value.find((t) => t.id === targetId)?.messages ?? [])`;
|
|
3069
|
+
const blockPrelude = block
|
|
3070
|
+
? [
|
|
3071
|
+
...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
|
|
3072
|
+
`const targetId = activeId.value ?? crypto.randomUUID();`,
|
|
3073
|
+
`// The record is born on its first turn — with YOUR title policy.`,
|
|
3074
|
+
`setThreads((prev) => prev.some((t) => t.id === targetId)`,
|
|
3075
|
+
` ? prev`,
|
|
3076
|
+
` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
|
|
3077
|
+
`activeId.value = targetId;`,
|
|
3078
|
+
...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
|
|
3079
|
+
`const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
|
|
3080
|
+
` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
|
|
3081
|
+
`});`,
|
|
3082
|
+
`const controller = sessions.begin(targetId);`,
|
|
3083
|
+
]
|
|
3084
|
+
: [];
|
|
3085
|
+
|
|
2227
3086
|
const onSubmitBody = realStreamBody({
|
|
2228
3087
|
pad: ' ',
|
|
2229
|
-
read: 'messages.value',
|
|
2230
|
-
commitSet:
|
|
2231
|
-
|
|
2232
|
-
|
|
3088
|
+
read: block ? blockRead : 'messages.value',
|
|
3089
|
+
commitSet: block
|
|
3090
|
+
? (expr) => `setMessages(() => ${expr});`
|
|
3091
|
+
: (expr) => `messages.value = ${expr};`,
|
|
3092
|
+
setterAdapter: block ? 'setMessages' : '(fn) => { messages.value = fn(messages.value); }',
|
|
3093
|
+
setLoading: block
|
|
3094
|
+
? (v) =>
|
|
3095
|
+
v === 'true'
|
|
3096
|
+
? `// loading for THIS thread now derives from the sessions map (streamingIds).`
|
|
3097
|
+
: `sessions.end(targetId, controller);`
|
|
3098
|
+
: (v) => `loading.value = ${v};`,
|
|
2233
3099
|
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
2234
3100
|
strictRoles: true,
|
|
2235
3101
|
toolLoop: emitToolLoop,
|
|
2236
3102
|
cards: ctx.emitCards,
|
|
2237
|
-
thread:
|
|
3103
|
+
thread: block
|
|
3104
|
+
? blockLiveThread(blockRead)
|
|
3105
|
+
: liveThreadBinding('messages.value', '(fn) => { messages.value = fn(messages.value); }'),
|
|
2238
3106
|
mock: isMock,
|
|
3107
|
+
...(block ? { fetchSignal: 'controller.signal' } : {}),
|
|
2239
3108
|
...attachmentSubmitOpts,
|
|
3109
|
+
...(block
|
|
3110
|
+
? {
|
|
3111
|
+
afterValue: [
|
|
3112
|
+
...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
|
|
3113
|
+
...blockPrelude,
|
|
3114
|
+
],
|
|
3115
|
+
}
|
|
3116
|
+
: {}),
|
|
2240
3117
|
});
|
|
2241
3118
|
|
|
2242
3119
|
// SCAF-10: ChatMessage declaration for strict-TS Vue consumers.
|
|
@@ -2266,15 +3143,51 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2266
3143
|
// Same scope again: the loop in onSubmit calls runTool.
|
|
2267
3144
|
const runnerLines = emitToolLoop ? toolRunnerLines('', true) : [];
|
|
2268
3145
|
|
|
2269
|
-
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
3146
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE. In BLOCK mode the
|
|
3147
|
+
// thread state is the block's multi-thread store; `messages`/`loading` become
|
|
3148
|
+
// computed views of it.
|
|
3149
|
+
const sampleSeed = block
|
|
3150
|
+
? [
|
|
3151
|
+
`// ── The block's state: your records + the kit's session/persistence helpers ──`,
|
|
3152
|
+
`const threads = ref<Thread[]>(loadThreads());`,
|
|
3153
|
+
`// null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
|
|
3154
|
+
`// record is created on its first turn, so the rail never fills with empties.`,
|
|
3155
|
+
`const activeId = ref<string | null>(threads.value[0]?.id ?? null);`,
|
|
3156
|
+
`// One id per in-flight reply — a stream keeps running when you switch away.`,
|
|
3157
|
+
`const streamingIds = ref<string[]>([]);`,
|
|
3158
|
+
...BLOCK_SESSIONS_NOTE.map((l) => `// ${l}`),
|
|
3159
|
+
`const sessions = createThreadSessions((ids) => { streamingIds.value = ids; });`,
|
|
3160
|
+
`// The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
|
|
3161
|
+
`// the save target and what a failed save means are YOURS (see saveThreads).`,
|
|
3162
|
+
`const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
|
|
3163
|
+
`watch(threads, () => saver.schedule(threads.value));`,
|
|
3164
|
+
`// Every write goes through this — a NEW array per write is what notifies.`,
|
|
3165
|
+
`const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
|
|
3166
|
+
` threads.value = updater(threads.value);`,
|
|
3167
|
+
`};`,
|
|
3168
|
+
`const active = computed(() => threads.value.find((t) => t.id === activeId.value) ?? null);`,
|
|
3169
|
+
`const messages = computed(() => active.value?.messages ?? []);`,
|
|
3170
|
+
`const loading = computed(() => { const id = activeId.value; return id !== null && streamingIds.value.includes(id); });`,
|
|
3171
|
+
`// The rail's row shape is a VIEW of your record — titles and counts, no`,
|
|
3172
|
+
`// message bodies. The rail renders the array in the order it is given.`,
|
|
3173
|
+
`const rows = computed(() => threads.value.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
|
|
3174
|
+
``,
|
|
3175
|
+
`function onSelect(event: Event) {`,
|
|
3176
|
+
` activeId.value = (event as CustomEvent<{ id: string }>).detail.id;`,
|
|
3177
|
+
`}`,
|
|
3178
|
+
`function onNewChat() {`,
|
|
3179
|
+
` // A DRAFT, not a record: it becomes a thread on its first turn.`,
|
|
3180
|
+
` activeId.value = null;`,
|
|
3181
|
+
`}`,
|
|
3182
|
+
]
|
|
3183
|
+
: [
|
|
3184
|
+
...(hasEmbedded
|
|
3185
|
+
? sampleSeedComment(isMock, '', (literal) => [
|
|
3186
|
+
`const messages = ref<ChatMessage[]>([${literal}]);`,
|
|
3187
|
+
])
|
|
3188
|
+
: []),
|
|
3189
|
+
`const messages = ref<ChatMessage[]>([]);`,
|
|
3190
|
+
];
|
|
2278
3191
|
|
|
2279
3192
|
// SCAF-9: sample sources setup.
|
|
2280
3193
|
const sourcesSeed = standaloneCompanionTags.includes('kai-sources')
|
|
@@ -2293,7 +3206,10 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2293
3206
|
|
|
2294
3207
|
// SCAF-15: always import onMounted — we re-apply props after the element upgrades
|
|
2295
3208
|
// (the .prop bindings can apply before the async element registration resolves).
|
|
2296
|
-
|
|
3209
|
+
// The block adds computed (the derived views) and watch (the save schedule).
|
|
3210
|
+
const vueImports = block
|
|
3211
|
+
? `import { computed, onMounted, ref, watch } from 'vue';`
|
|
3212
|
+
: `import { ref, onMounted } from 'vue';`;
|
|
2297
3213
|
|
|
2298
3214
|
// SCAF-14: workspace template block — resizable split with chat + artifact panes.
|
|
2299
3215
|
const workspaceTemplate = workspace
|
|
@@ -2309,7 +3225,9 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2309
3225
|
` suggestion-mode="submit"`,
|
|
2310
3226
|
` style="${p.chatFill}"`,
|
|
2311
3227
|
` @kai-submit="onSubmit"`,
|
|
2312
|
-
`
|
|
3228
|
+
` >`,
|
|
3229
|
+
...vueSlotted(' '),
|
|
3230
|
+
` </kai-chat>`,
|
|
2313
3231
|
` </kai-resizable-item>`,
|
|
2314
3232
|
` <kai-resizable-item min="280px">`,
|
|
2315
3233
|
` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
|
|
@@ -2327,7 +3245,9 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2327
3245
|
` suggestion-mode="submit"`,
|
|
2328
3246
|
` style="${p.chatFill}"`,
|
|
2329
3247
|
` @kai-submit="onSubmit"`,
|
|
2330
|
-
`
|
|
3248
|
+
` >`,
|
|
3249
|
+
...vueSlotted(' '),
|
|
3250
|
+
` </kai-chat>`,
|
|
2331
3251
|
companions,
|
|
2332
3252
|
];
|
|
2333
3253
|
|
|
@@ -2347,15 +3267,17 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2347
3267
|
cardTools: ctx.cardProvider !== null,
|
|
2348
3268
|
mock: isMock,
|
|
2349
3269
|
attachments,
|
|
3270
|
+
workspaceBlock: block,
|
|
2350
3271
|
}),
|
|
2351
3272
|
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
2352
3273
|
vueImports,
|
|
2353
3274
|
``,
|
|
2354
3275
|
...mockInit,
|
|
2355
3276
|
``,
|
|
3277
|
+
...(block ? [blockThreadStoreLines('').join('\n'), ``] : []),
|
|
2356
3278
|
...cardsInit,
|
|
2357
3279
|
...sampleSeed,
|
|
2358
|
-
`const loading = ref(false)
|
|
3280
|
+
...(block ? [] : [`const loading = ref(false);`]),
|
|
2359
3281
|
`const suggestions = ${jsArray(suggestions)};`,
|
|
2360
3282
|
...modelInit,
|
|
2361
3283
|
...toolsLines,
|
|
@@ -2371,6 +3293,16 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2371
3293
|
...(ctx.emitCards ? CARD_PROP_COMMENT.map((l) => ` ${l}`) : []),
|
|
2372
3294
|
` const el = document.querySelector('kai-chat');`,
|
|
2373
3295
|
` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(', ')}` : ''} });`,
|
|
3296
|
+
...(block
|
|
3297
|
+
? [
|
|
3298
|
+
` // The rail's array props follow the same upgrade rule.`,
|
|
3299
|
+
` await customElements.whenDefined('kai-conversations');`,
|
|
3300
|
+
` const rail = document.querySelector('kai-conversations');`,
|
|
3301
|
+
` if (rail) Object.assign(rail, { conversations: rows.value, activeId: activeId.value ?? undefined });`,
|
|
3302
|
+
` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
|
|
3303
|
+
` window.addEventListener('beforeunload', saver.flush);`,
|
|
3304
|
+
]
|
|
3305
|
+
: []),
|
|
2374
3306
|
`});`,
|
|
2375
3307
|
``,
|
|
2376
3308
|
`async function onSubmit(e: ${attachments
|
|
@@ -2382,8 +3314,38 @@ function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2382
3314
|
``,
|
|
2383
3315
|
`<template>`,
|
|
2384
3316
|
` <div style="${p.style}">`,
|
|
2385
|
-
...(
|
|
2386
|
-
|
|
3317
|
+
...(block
|
|
3318
|
+
? [
|
|
3319
|
+
` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
|
|
3320
|
+
...htmlComment(
|
|
3321
|
+
[
|
|
3322
|
+
`<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
|
|
3323
|
+
`end/footer slots, aside resize + collapse, and a mobile drawer below`,
|
|
3324
|
+
`the drawer-below width. It knows nothing about chat.`,
|
|
3325
|
+
],
|
|
3326
|
+
' ',
|
|
3327
|
+
),
|
|
3328
|
+
` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
|
|
3329
|
+
...htmlComment(BLOCK_RAIL_NOTE, ' '),
|
|
3330
|
+
` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
|
|
3331
|
+
` <kai-conversations`,
|
|
3332
|
+
` slot="start"`,
|
|
3333
|
+
` :conversations.prop="rows"`,
|
|
3334
|
+
` :activeId.prop="activeId ?? undefined"`,
|
|
3335
|
+
` style="display:block;height:100%"`,
|
|
3336
|
+
` @kai-conversation-select="onSelect"`,
|
|
3337
|
+
` @kai-new-chat="onNewChat"`,
|
|
3338
|
+
` />`,
|
|
3339
|
+
` <!-- Unnamed children project into the shell's MAIN region. -->`,
|
|
3340
|
+
` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
|
|
3341
|
+
...indentEmitted(
|
|
3342
|
+
[...(attachmentTemplate ? [attachmentTemplate] : []), ...workspaceTemplate],
|
|
3343
|
+
' ',
|
|
3344
|
+
),
|
|
3345
|
+
` </div>`,
|
|
3346
|
+
` </kai-workspace>`,
|
|
3347
|
+
]
|
|
3348
|
+
: [...(attachmentTemplate ? [attachmentTemplate] : []), ...workspaceTemplate]),
|
|
2387
3349
|
` </div>`,
|
|
2388
3350
|
`</template>`,
|
|
2389
3351
|
]
|
|
@@ -2397,13 +3359,22 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2397
3359
|
|
|
2398
3360
|
// SCAF-9: exclude message-embedded tags from companion rendering.
|
|
2399
3361
|
// SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
|
|
2400
|
-
const workspace =
|
|
3362
|
+
const workspace = isArtifactSplit(components);
|
|
3363
|
+
const block = isWorkspaceBlock(components);
|
|
2401
3364
|
const attachments = hasAttachments(components);
|
|
3365
|
+
// Slotted companions are children of <kai-chat>, not siblings — same rule the
|
|
3366
|
+
// html and vue targets follow, read from the same catalog record. In BLOCK
|
|
3367
|
+
// mode the rail lives in the SHELL's start aside instead.
|
|
3368
|
+
const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
|
|
3369
|
+
const svelteSlotted = (pad: string) =>
|
|
3370
|
+
block ? [] : slottedChildMarkup(components, pad, { wired: false, ids: false });
|
|
2402
3371
|
const standaloneCompanionTags = components.filter(
|
|
2403
3372
|
(t) =>
|
|
2404
3373
|
t !== 'kai-chat' &&
|
|
2405
3374
|
!MESSAGE_EMBEDDED_TAGS.has(t) &&
|
|
2406
|
-
!
|
|
3375
|
+
!ARTIFACT_SPLIT_TAGS.has(t) &&
|
|
3376
|
+
!(block && BLOCK_TAGS.has(t)) &&
|
|
3377
|
+
!slottedTags.has(t) &&
|
|
2407
3378
|
!(attachments && ATTACHMENT_TAGS.has(t)),
|
|
2408
3379
|
);
|
|
2409
3380
|
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
@@ -2437,19 +3408,54 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2437
3408
|
}
|
|
2438
3409
|
: {};
|
|
2439
3410
|
|
|
3411
|
+
// The block's submit prelude — runes state instead of refs; same shape.
|
|
3412
|
+
const blockRead = `(threads.find((t) => t.id === targetId)?.messages ?? [])`;
|
|
3413
|
+
const blockPrelude = block
|
|
3414
|
+
? [
|
|
3415
|
+
...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
|
|
3416
|
+
`const targetId = activeId ?? crypto.randomUUID();`,
|
|
3417
|
+
`// The record is born on its first turn — with YOUR title policy.`,
|
|
3418
|
+
`setThreads((prev) => prev.some((t) => t.id === targetId)`,
|
|
3419
|
+
` ? prev`,
|
|
3420
|
+
` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
|
|
3421
|
+
`activeId = targetId;`,
|
|
3422
|
+
...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
|
|
3423
|
+
`const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
|
|
3424
|
+
` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
|
|
3425
|
+
`});`,
|
|
3426
|
+
`const controller = sessions.begin(targetId);`,
|
|
3427
|
+
]
|
|
3428
|
+
: [];
|
|
3429
|
+
|
|
2440
3430
|
const onSubmitBody = realStreamBody({
|
|
2441
3431
|
pad: ' ',
|
|
2442
|
-
read: 'messages',
|
|
2443
|
-
commitSet: (expr) => `messages = ${expr};`,
|
|
2444
|
-
setterAdapter: '(fn) => { messages = fn(messages); }',
|
|
2445
|
-
setLoading:
|
|
3432
|
+
read: block ? blockRead : 'messages',
|
|
3433
|
+
commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `messages = ${expr};`,
|
|
3434
|
+
setterAdapter: block ? 'setMessages' : '(fn) => { messages = fn(messages); }',
|
|
3435
|
+
setLoading: block
|
|
3436
|
+
? (v) =>
|
|
3437
|
+
v === 'true'
|
|
3438
|
+
? `// loading for THIS thread now derives from the sessions map (streamingIds).`
|
|
3439
|
+
: `sessions.end(targetId, controller);`
|
|
3440
|
+
: (v) => `loading = ${v};`,
|
|
2446
3441
|
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
2447
3442
|
strictRoles: true,
|
|
2448
3443
|
toolLoop: emitToolLoop,
|
|
2449
3444
|
cards: ctx.emitCards,
|
|
2450
|
-
thread:
|
|
3445
|
+
thread: block
|
|
3446
|
+
? blockLiveThread(blockRead)
|
|
3447
|
+
: liveThreadBinding('messages', '(fn) => { messages = fn(messages); }'),
|
|
2451
3448
|
mock: isMock,
|
|
3449
|
+
...(block ? { fetchSignal: 'controller.signal' } : {}),
|
|
2452
3450
|
...attachmentSubmitOpts,
|
|
3451
|
+
...(block
|
|
3452
|
+
? {
|
|
3453
|
+
afterValue: [
|
|
3454
|
+
...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
|
|
3455
|
+
...blockPrelude,
|
|
3456
|
+
],
|
|
3457
|
+
}
|
|
3458
|
+
: {}),
|
|
2453
3459
|
});
|
|
2454
3460
|
|
|
2455
3461
|
// SCAF-10: ChatMessage declaration for strict-TS Svelte consumers.
|
|
@@ -2483,14 +3489,55 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2483
3489
|
// chunk (mutating in place does not re-render), which is exactly what raw state
|
|
2484
3490
|
// tracks. Deep state would also proxy every message object on its way into a
|
|
2485
3491
|
// Solid-backed custom element, for reactivity this code never relies on.
|
|
2486
|
-
const sampleMessagesInit =
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
3492
|
+
const sampleMessagesInit = block
|
|
3493
|
+
? [
|
|
3494
|
+
` // ── The block's state: your records + the kit's session/persistence helpers ─`,
|
|
3495
|
+
` // $state.raw for the same reason messages was: the kit's contract is a NEW`,
|
|
3496
|
+
` // array reference per write, which is exactly what raw state tracks.`,
|
|
3497
|
+
` let threads = $state.raw<Thread[]>(loadThreads());`,
|
|
3498
|
+
` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
|
|
3499
|
+
` // record is created on its first turn, so the rail never fills with empties.`,
|
|
3500
|
+
` let activeId = $state<string | null>(threads[0]?.id ?? null);`,
|
|
3501
|
+
` // One id per in-flight reply — a stream keeps running when you switch away.`,
|
|
3502
|
+
` let streamingIds = $state.raw<string[]>([]);`,
|
|
3503
|
+
...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
|
|
3504
|
+
` const sessions = createThreadSessions((ids) => { streamingIds = ids; });`,
|
|
3505
|
+
` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
|
|
3506
|
+
` // the save target and what a failed save means are YOURS (see saveThreads).`,
|
|
3507
|
+
` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
|
|
3508
|
+
` $effect(() => { saver.schedule(threads); });`,
|
|
3509
|
+
` // Every write goes through this — a NEW array per write is what notifies.`,
|
|
3510
|
+
` const setThreads = (updater: (prev: Thread[]) => Thread[]): void => {`,
|
|
3511
|
+
` threads = updater(threads);`,
|
|
3512
|
+
` };`,
|
|
3513
|
+
` const active = $derived(threads.find((t) => t.id === activeId) ?? null);`,
|
|
3514
|
+
` const messages = $derived(active?.messages ?? []);`,
|
|
3515
|
+
` const loading = $derived.by(() => { const id = activeId; return id !== null && streamingIds.includes(id); });`,
|
|
3516
|
+
` // The rail's row shape is a VIEW of your record — titles and counts, no`,
|
|
3517
|
+
` // message bodies. The rail renders the array in the order it is given.`,
|
|
3518
|
+
` const rows = $derived(threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
|
|
3519
|
+
` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
|
|
3520
|
+
` let railEl = $state<KaiConversationsElement | undefined>(undefined);`,
|
|
3521
|
+
` $effect(() => {`,
|
|
3522
|
+
` if (railEl && defined) { railEl.conversations = rows; railEl.activeId = activeId ?? undefined; }`,
|
|
3523
|
+
` });`,
|
|
3524
|
+
``,
|
|
3525
|
+
` function onSelect(e: CustomEvent<{ id: string }>) {`,
|
|
3526
|
+
` activeId = e.detail.id;`,
|
|
3527
|
+
` }`,
|
|
3528
|
+
` function onNewChat() {`,
|
|
3529
|
+
` // A DRAFT, not a record: it becomes a thread on its first turn.`,
|
|
3530
|
+
` activeId = null;`,
|
|
3531
|
+
` }`,
|
|
3532
|
+
]
|
|
3533
|
+
: [
|
|
3534
|
+
...(hasEmbedded
|
|
3535
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
3536
|
+
`let messages = $state.raw<ChatMessage[]>([${literal}]);`,
|
|
3537
|
+
])
|
|
3538
|
+
: []),
|
|
3539
|
+
` let messages = $state.raw<ChatMessage[]>([]);`,
|
|
3540
|
+
];
|
|
2494
3541
|
|
|
2495
3542
|
// SCAF-9: sources element ref + sample data. Typed as the kit's own element
|
|
2496
3543
|
// interface (not HTMLElement) so the `.sources =` assignment below typechecks
|
|
@@ -2554,7 +3601,9 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2554
3601
|
` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
|
|
2555
3602
|
` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
|
|
2556
3603
|
` <kai-resizable-item size="40%" min="240px">`,
|
|
2557
|
-
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}
|
|
3604
|
+
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}>`,
|
|
3605
|
+
...svelteSlotted(' '),
|
|
3606
|
+
` </kai-chat>`,
|
|
2558
3607
|
` </kai-resizable-item>`,
|
|
2559
3608
|
` <kai-resizable-item min="280px">`,
|
|
2560
3609
|
` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
|
|
@@ -2565,7 +3614,9 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2565
3614
|
companionLines,
|
|
2566
3615
|
]
|
|
2567
3616
|
: [
|
|
2568
|
-
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}
|
|
3617
|
+
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}>`,
|
|
3618
|
+
...svelteSlotted(' '),
|
|
3619
|
+
` </kai-chat>`,
|
|
2569
3620
|
companionLines,
|
|
2570
3621
|
];
|
|
2571
3622
|
|
|
@@ -2583,6 +3634,7 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2583
3634
|
// on every archetype without kai-sources.
|
|
2584
3635
|
` import type { ${[
|
|
2585
3636
|
'KaiChatElement',
|
|
3637
|
+
...(block ? ['KaiConversationsElement'] : []),
|
|
2586
3638
|
...(hasSourcesCompanion ? ['KaiSourcesElement'] : []),
|
|
2587
3639
|
...(attachments ? ['KaiAttachmentsElement'] : []),
|
|
2588
3640
|
].join(', ')} } from '@kitn.ai/ui/elements';`,
|
|
@@ -2594,20 +3646,37 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2594
3646
|
cardTools: ctx.cardProvider !== null,
|
|
2595
3647
|
mock: isMock,
|
|
2596
3648
|
attachments,
|
|
3649
|
+
workspaceBlock: block,
|
|
2597
3650
|
}),
|
|
2598
3651
|
` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
2599
3652
|
` import { onMount } from 'svelte';`,
|
|
2600
3653
|
...mockInit,
|
|
3654
|
+
...(block ? [blockThreadStoreLines(' ').join('\n'), ``] : []),
|
|
2601
3655
|
` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
|
|
2602
3656
|
` let chatEl = $state<KaiChatElement | undefined>(undefined);`,
|
|
2603
3657
|
` // SCAF-15: kai-* register via an async dynamic import (SSR-safety). Gate the`,
|
|
2604
3658
|
` // property $effect on the upgrade so the first application isn't dropped`,
|
|
2605
3659
|
` // (props set on a not-yet-upgraded element are lost on upgrade).`,
|
|
2606
3660
|
` let defined = $state(false);`,
|
|
2607
|
-
|
|
3661
|
+
...(block
|
|
3662
|
+
? [
|
|
3663
|
+
` onMount(() => {`,
|
|
3664
|
+
` void Promise.all([`,
|
|
3665
|
+
` customElements.whenDefined('kai-chat'),`,
|
|
3666
|
+
` customElements.whenDefined('kai-conversations'),`,
|
|
3667
|
+
` ]).then(() => { defined = true; });`,
|
|
3668
|
+
` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
|
|
3669
|
+
` const flush = () => saver.flush();`,
|
|
3670
|
+
` window.addEventListener('beforeunload', flush);`,
|
|
3671
|
+
` return () => window.removeEventListener('beforeunload', flush);`,
|
|
3672
|
+
` });`,
|
|
3673
|
+
]
|
|
3674
|
+
: [
|
|
3675
|
+
` onMount(async () => { await customElements.whenDefined('kai-chat'); defined = true; });`,
|
|
3676
|
+
]),
|
|
2608
3677
|
...sourcesEl,
|
|
2609
3678
|
...sampleMessagesInit,
|
|
2610
|
-
` let loading = $state(false)
|
|
3679
|
+
...(block ? [] : [` let loading = $state(false);`]),
|
|
2611
3680
|
` const suggestions: string[] = ${jsArray(suggestions)};`,
|
|
2612
3681
|
...modelInit,
|
|
2613
3682
|
...cardsInit,
|
|
@@ -2629,8 +3698,34 @@ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
|
2629
3698
|
`</script>`,
|
|
2630
3699
|
``,
|
|
2631
3700
|
`<div style="${p.style}">`,
|
|
2632
|
-
...
|
|
2633
|
-
|
|
3701
|
+
...(block
|
|
3702
|
+
? [
|
|
3703
|
+
` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
|
|
3704
|
+
...htmlComment(
|
|
3705
|
+
[
|
|
3706
|
+
`<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
|
|
3707
|
+
`end/footer slots, aside resize + collapse, and a mobile drawer below`,
|
|
3708
|
+
`the drawer-below width. It knows nothing about chat.`,
|
|
3709
|
+
],
|
|
3710
|
+
' ',
|
|
3711
|
+
),
|
|
3712
|
+
` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
|
|
3713
|
+
...htmlComment(BLOCK_RAIL_NOTE, ' '),
|
|
3714
|
+
` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
|
|
3715
|
+
` <kai-conversations`,
|
|
3716
|
+
` bind:this={railEl}`,
|
|
3717
|
+
` slot="start"`,
|
|
3718
|
+
` style="display:block;height:100%"`,
|
|
3719
|
+
` onkai-conversation-select={onSelect}`,
|
|
3720
|
+
` onkai-new-chat={onNewChat}`,
|
|
3721
|
+
` ></kai-conversations>`,
|
|
3722
|
+
` <!-- Unnamed children project into the shell's MAIN region. -->`,
|
|
3723
|
+
` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
|
|
3724
|
+
...indentEmitted([...attachmentMarkup, ...workspaceMarkup], ' '),
|
|
3725
|
+
` </div>`,
|
|
3726
|
+
` </kai-workspace>`,
|
|
3727
|
+
]
|
|
3728
|
+
: [...attachmentMarkup, ...workspaceMarkup]),
|
|
2634
3729
|
`</div>`,
|
|
2635
3730
|
]
|
|
2636
3731
|
.filter((l, i, arr) => !(l === '' && arr[i - 1] === '' && i === arr.length - 1))
|
|
@@ -2677,9 +3772,16 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2677
3772
|
// is not needed here — the library is SSR-import-safe, but we include elements for safety)
|
|
2678
3773
|
|
|
2679
3774
|
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
2680
|
-
const workspace =
|
|
2681
|
-
|
|
2682
|
-
|
|
3775
|
+
const workspace = isArtifactSplit(components);
|
|
3776
|
+
const block = isWorkspaceBlock(components);
|
|
3777
|
+
|
|
3778
|
+
// Same union as renderJsx, for the same measured reason: this target renders
|
|
3779
|
+
// `<Chat …>` unconditionally, so the import list cannot come from `components`
|
|
3780
|
+
// alone or a caller who omitted kai-chat gets a file that uses `Chat` without
|
|
3781
|
+
// importing it.
|
|
3782
|
+
const renderableTags = [
|
|
3783
|
+
...new Set([ALWAYS_EMITTED_TAG, ...components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t))]),
|
|
3784
|
+
];
|
|
2683
3785
|
const importTags = workspace
|
|
2684
3786
|
? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
|
|
2685
3787
|
: renderableTags;
|
|
@@ -2692,7 +3794,8 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2692
3794
|
(t) =>
|
|
2693
3795
|
t !== 'kai-chat' &&
|
|
2694
3796
|
!MESSAGE_EMBEDDED_TAGS.has(t) &&
|
|
2695
|
-
!
|
|
3797
|
+
!ARTIFACT_SPLIT_TAGS.has(t) &&
|
|
3798
|
+
!(block && BLOCK_TAGS.has(t)) &&
|
|
2696
3799
|
!(attachments && ATTACHMENT_TAGS.has(t)),
|
|
2697
3800
|
);
|
|
2698
3801
|
|
|
@@ -2709,6 +3812,12 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2709
3812
|
` {/* Replace sampleSources with your real data. */}`,
|
|
2710
3813
|
` <Sources sources={sampleSources} />`,
|
|
2711
3814
|
);
|
|
3815
|
+
} else if (slotPlacementFor(t)) {
|
|
3816
|
+
// The catalog says where this one goes, and this target cannot put it
|
|
3817
|
+
// there — so it says so, rather than emitting a layout nothing describes.
|
|
3818
|
+
// The placement is PASSED IN, not restated: the note names the parent and
|
|
3819
|
+
// the slot, and both belong to the record.
|
|
3820
|
+
companionJsxLines.push(...railSiblingNote(t, slotPlacementFor(t)!), ` <${toPascalCase(t)} />`);
|
|
2712
3821
|
} else {
|
|
2713
3822
|
companionJsxLines.push(` {/* wire data props — see the component_reference MCP tool */}`);
|
|
2714
3823
|
companionJsxLines.push(` <${toPascalCase(t)} />`);
|
|
@@ -2766,16 +3875,63 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2766
3875
|
|
|
2767
3876
|
const mockInit = isMock ? mockResponderInit() : [];
|
|
2768
3877
|
|
|
2769
|
-
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
3878
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE. In BLOCK mode the
|
|
3879
|
+
// thread state is the block's multi-thread store (same shape as renderJsx).
|
|
3880
|
+
const sampleMessagesInit = block
|
|
3881
|
+
? [
|
|
3882
|
+
` // ── The block's state: your records + the kit's session/persistence helpers ─`,
|
|
3883
|
+
` const [threads, setThreads] = useState<Thread[]>(loadThreads);`,
|
|
3884
|
+
` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
|
|
3885
|
+
` // record is created on its first turn, so the rail never fills with empties.`,
|
|
3886
|
+
` const [activeId, setActiveId] = useState<string | null>(threads[0]?.id ?? null);`,
|
|
3887
|
+
` // One id per in-flight reply — a stream keeps running when you switch away,`,
|
|
3888
|
+
` // so this cannot be a single boolean.`,
|
|
3889
|
+
` const [streamingIds, setStreamingIds] = useState<string[]>([]);`,
|
|
3890
|
+
...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
|
|
3891
|
+
` const [sessions] = useState(() => createThreadSessions(setStreamingIds));`,
|
|
3892
|
+
` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
|
|
3893
|
+
` // the save target and what a failed save means are YOURS (see saveThreads).`,
|
|
3894
|
+
` const [saver] = useState(() => createSaveScheduler(saveThreads, { delayMs: 250 }));`,
|
|
3895
|
+
` useEffect(() => { saver.schedule(threads); }, [saver, threads]);`,
|
|
3896
|
+
` useEffect(() => {`,
|
|
3897
|
+
` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
|
|
3898
|
+
` const flush = () => saver.flush();`,
|
|
3899
|
+
` window.addEventListener('beforeunload', flush);`,
|
|
3900
|
+
` return () => window.removeEventListener('beforeunload', flush);`,
|
|
3901
|
+
` }, [saver]);`,
|
|
3902
|
+
``,
|
|
3903
|
+
` const active = threads.find((t) => t.id === activeId) ?? null;`,
|
|
3904
|
+
` const messages = active?.messages ?? NO_MESSAGES;`,
|
|
3905
|
+
` const loading = activeId !== null && streamingIds.includes(activeId);`,
|
|
3906
|
+
` // The rail's row shape is a VIEW of your record — titles and counts, no`,
|
|
3907
|
+
` // message bodies. Newest-first is your ordering; the rail renders the array`,
|
|
3908
|
+
` // in the order it is given.`,
|
|
3909
|
+
` const rows = threads.map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt }));`,
|
|
3910
|
+
].join('\n')
|
|
3911
|
+
: [
|
|
3912
|
+
...(hasEmbedded
|
|
3913
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
3914
|
+
`const sampleMessages: ChatMessage[] = [${literal}];`,
|
|
3915
|
+
`const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
|
|
3916
|
+
])
|
|
3917
|
+
: []),
|
|
3918
|
+
` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
|
|
3919
|
+
].join('\n');
|
|
3920
|
+
|
|
3921
|
+
// The block's module-scope pieces (one joined entry — see renderJsx).
|
|
3922
|
+
const blockModuleInit = block
|
|
3923
|
+
? [
|
|
3924
|
+
[
|
|
3925
|
+
`/** A stable reference for the empty thread. \`messages\` is diffed by`,
|
|
3926
|
+
` * reference, so a fresh \`[]\` every render would re-notify for nothing. */`,
|
|
3927
|
+
`const NO_MESSAGES: ChatMessage[] = [];`,
|
|
3928
|
+
``,
|
|
3929
|
+
...blockThreadStoreLines(''),
|
|
3930
|
+
].join('\n'),
|
|
3931
|
+
]
|
|
3932
|
+
: [];
|
|
3933
|
+
|
|
3934
|
+
const loadingInit = block ? [] : [` const [loading, setLoading] = useState(false);`];
|
|
2779
3935
|
|
|
2780
3936
|
const sampleSourcesInit =
|
|
2781
3937
|
standaloneCompanionTags.includes('kai-sources')
|
|
@@ -2803,20 +3959,66 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2803
3959
|
const cardPropsNote = (pad: string): string[] =>
|
|
2804
3960
|
ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
|
|
2805
3961
|
|
|
3962
|
+
// The block's submit prelude + turn-scoped thread binding — same shape as
|
|
3963
|
+
// renderJsx (this target IS React); see there for the comments' reasoning.
|
|
3964
|
+
const blockPrelude = block
|
|
3965
|
+
? [
|
|
3966
|
+
...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
|
|
3967
|
+
`const targetId = activeId ?? crypto.randomUUID();`,
|
|
3968
|
+
`// The record is born on its first turn — with YOUR title policy.`,
|
|
3969
|
+
`setThreads((prev) => prev.some((t) => t.id === targetId)`,
|
|
3970
|
+
` ? prev`,
|
|
3971
|
+
` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
|
|
3972
|
+
`setActiveId(targetId);`,
|
|
3973
|
+
...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
|
|
3974
|
+
`const setMessages = bindThreadMessages<Thread>(setThreads, targetId, {`,
|
|
3975
|
+
` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
|
|
3976
|
+
`});`,
|
|
3977
|
+
`const controller = sessions.begin(targetId);`,
|
|
3978
|
+
]
|
|
3979
|
+
: [];
|
|
3980
|
+
const blockReactThread: ThreadBinding = {
|
|
3981
|
+
open: ({ pad, userMessage }) => [
|
|
3982
|
+
`${pad}// THE TURN OWNS THE THREAD (same rule as the single-thread scaffold): the`,
|
|
3983
|
+
`${pad}// loop below re-encodes it every round, and React state cannot be read`,
|
|
3984
|
+
`${pad}// back mid-turn. setMessages (the id-bound sink) just projects it.`,
|
|
3985
|
+
`${pad}let thread: ChatMessage[] = [...(threads.find((t) => t.id === targetId)?.messages ?? []), ${userMessage}];`,
|
|
3986
|
+
`${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(() => thread); };`,
|
|
3987
|
+
`${pad}setMessages(() => thread);`,
|
|
3988
|
+
],
|
|
3989
|
+
live: 'thread',
|
|
3990
|
+
setter: 'set',
|
|
3991
|
+
};
|
|
3992
|
+
|
|
2806
3993
|
const onSubmitBody = realStreamBody({
|
|
2807
3994
|
pad: ' ',
|
|
2808
|
-
read: 'messages',
|
|
2809
|
-
commitSet: (expr) => `setMessages(${expr});`,
|
|
2810
|
-
// useState's setter IS a SetMessages: both are (updater) => void
|
|
3995
|
+
read: block ? `(threads.find((t) => t.id === targetId)?.messages ?? [])` : 'messages',
|
|
3996
|
+
commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => `setMessages(${expr});`,
|
|
3997
|
+
// useState's setter IS a SetMessages: both are (updater) => void — and in
|
|
3998
|
+
// block mode the id-bound sink from bindThreadMessages is one too.
|
|
2811
3999
|
setterAdapter: 'setMessages',
|
|
2812
|
-
setLoading:
|
|
4000
|
+
setLoading: block
|
|
4001
|
+
? (v) =>
|
|
4002
|
+
v === 'true'
|
|
4003
|
+
? `// loading for THIS thread now derives from the sessions map (streamingIds).`
|
|
4004
|
+
: `sessions.end(targetId, controller);`
|
|
4005
|
+
: (v) => `setLoading(${v});`,
|
|
2813
4006
|
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
2814
4007
|
strictRoles: true,
|
|
2815
4008
|
toolLoop: emitToolLoop,
|
|
2816
4009
|
cards: ctx.emitCards,
|
|
2817
|
-
thread: REACT_THREAD,
|
|
4010
|
+
thread: block ? blockReactThread : REACT_THREAD,
|
|
2818
4011
|
mock: isMock,
|
|
4012
|
+
...(block ? { fetchSignal: 'controller.signal' } : {}),
|
|
2819
4013
|
...attachmentSubmitOpts,
|
|
4014
|
+
...(block
|
|
4015
|
+
? {
|
|
4016
|
+
afterValue: [
|
|
4017
|
+
...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
|
|
4018
|
+
...blockPrelude,
|
|
4019
|
+
],
|
|
4020
|
+
}
|
|
4021
|
+
: {}),
|
|
2820
4022
|
});
|
|
2821
4023
|
|
|
2822
4024
|
// File path guidance for TanStack Start (file-based routing)
|
|
@@ -2835,11 +4037,72 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2835
4037
|
``,
|
|
2836
4038
|
];
|
|
2837
4039
|
|
|
4040
|
+
// The surface JSX — same shapes as renderJsx (this target IS React),
|
|
4041
|
+
// including the block's shell wrap.
|
|
4042
|
+
const chatJsx = (pad: string): string[] => [
|
|
4043
|
+
...cardPropsNote(pad),
|
|
4044
|
+
`${pad}<Chat`,
|
|
4045
|
+
`${pad} messages={messages}`,
|
|
4046
|
+
`${pad} loading={loading}`,
|
|
4047
|
+
`${pad} suggestions={suggestions}`,
|
|
4048
|
+
`${pad} suggestionMode="submit"`,
|
|
4049
|
+
...cardProps(`${pad} `),
|
|
4050
|
+
`${pad} onSubmit={onSubmit}`,
|
|
4051
|
+
`${pad} style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
4052
|
+
`${pad}/>`,
|
|
4053
|
+
];
|
|
4054
|
+
const splitJsx: string[] = [
|
|
4055
|
+
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
4056
|
+
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
4057
|
+
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
4058
|
+
` <ResizableItem size="40%" min="240px">`,
|
|
4059
|
+
...chatJsx(' '),
|
|
4060
|
+
` </ResizableItem>`,
|
|
4061
|
+
` <ResizableItem min="280px">`,
|
|
4062
|
+
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
4063
|
+
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
4064
|
+
` </ResizableItem>`,
|
|
4065
|
+
` </Resizable>`,
|
|
4066
|
+
];
|
|
4067
|
+
// Siblings of the SPLIT (or of the chat) — dropping them here is what the old
|
|
4068
|
+
// workspace branch used to do.
|
|
4069
|
+
const mainJsx: string[] = [
|
|
4070
|
+
...(attachmentJsx ? [attachmentJsx] : []),
|
|
4071
|
+
...(workspace ? [...splitJsx, companions] : [...chatJsx(' '), companions]),
|
|
4072
|
+
];
|
|
4073
|
+
const surfaceJsx: string[] = block
|
|
4074
|
+
? [
|
|
4075
|
+
` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
|
|
4076
|
+
` {/* <Workspace> is the chat-agnostic layout shell (header/start/main/end/`,
|
|
4077
|
+
` footer slots, aside resize + collapse, a mobile drawer below`,
|
|
4078
|
+
` drawerBelow px). It knows nothing about chat. */}`,
|
|
4079
|
+
` <Workspace drawerBelow={720} style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
4080
|
+
...jsxComment(BLOCK_RAIL_NOTE, ' '),
|
|
4081
|
+
` {/* The React wrappers forward no slot prop, so a plain div carries the`,
|
|
4082
|
+
` slot attribute into the shell's start aside. */}`,
|
|
4083
|
+
` <div slot="start" style={{ height: '100%' }}>`,
|
|
4084
|
+
` {/* New chat = a DRAFT (activeId null): the record appears on the first turn. */}`,
|
|
4085
|
+
` <Conversations`,
|
|
4086
|
+
` conversations={rows}`,
|
|
4087
|
+
` activeId={activeId ?? undefined}`,
|
|
4088
|
+
` onConversationSelect={(e) => setActiveId(e.detail.id)}`,
|
|
4089
|
+
` onNewChat={() => setActiveId(null)}`,
|
|
4090
|
+
` style={{ display: 'block', height: '100%' }}`,
|
|
4091
|
+
` />`,
|
|
4092
|
+
` </div>`,
|
|
4093
|
+
` {/* Unnamed children project into the shell's MAIN region. */}`,
|
|
4094
|
+
` <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>`,
|
|
4095
|
+
...indentEmitted(mainJsx, ' '),
|
|
4096
|
+
` </div>`,
|
|
4097
|
+
` </Workspace>`,
|
|
4098
|
+
]
|
|
4099
|
+
: mainJsx;
|
|
4100
|
+
|
|
2838
4101
|
return [
|
|
2839
4102
|
...filePathNote,
|
|
2840
4103
|
// TanStack Start uses @tanstack/react-router's createFileRoute
|
|
2841
4104
|
`import { createFileRoute } from '@tanstack/react-router'`,
|
|
2842
|
-
`import { useState } from 'react'`,
|
|
4105
|
+
`import { ${block ? 'useEffect, useState' : 'useState'} } from 'react'`,
|
|
2843
4106
|
// Elements registration: the library is SSR-import-safe; top-level import is safe here
|
|
2844
4107
|
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
2845
4108
|
`import { ${importList} } from '@kitn.ai/ui/react'`,
|
|
@@ -2851,6 +4114,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2851
4114
|
cardTools: ctx.cardProvider !== null,
|
|
2852
4115
|
mock: isMock,
|
|
2853
4116
|
attachments,
|
|
4117
|
+
workspaceBlock: block,
|
|
2854
4118
|
}),
|
|
2855
4119
|
`import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
|
|
2856
4120
|
``,
|
|
@@ -2859,6 +4123,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2859
4123
|
...mockInit,
|
|
2860
4124
|
``,
|
|
2861
4125
|
...attachmentModuleInit,
|
|
4126
|
+
...blockModuleInit,
|
|
2862
4127
|
...cardsInit,
|
|
2863
4128
|
`// ssr: false keeps the Solid-based web component client-only.`,
|
|
2864
4129
|
`// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
|
|
@@ -2869,7 +4134,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2869
4134
|
``,
|
|
2870
4135
|
`function ChatPage() {`,
|
|
2871
4136
|
sampleMessagesInit,
|
|
2872
|
-
|
|
4137
|
+
...loadingInit,
|
|
2873
4138
|
` const suggestions = ${jsArray(suggestions)};`,
|
|
2874
4139
|
...(attachmentStateInit ? [attachmentStateInit] : []),
|
|
2875
4140
|
...(sampleSourcesInit ? [sampleSourcesInit] : []),
|
|
@@ -2883,45 +4148,7 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2883
4148
|
``,
|
|
2884
4149
|
` return (`,
|
|
2885
4150
|
` <main style={{ ${jsxStyle(p.style)} }}>`,
|
|
2886
|
-
...
|
|
2887
|
-
...(workspace
|
|
2888
|
-
? [
|
|
2889
|
-
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
2890
|
-
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
2891
|
-
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
2892
|
-
` <ResizableItem size="40%" min="240px">`,
|
|
2893
|
-
...cardPropsNote(' '),
|
|
2894
|
-
` <Chat`,
|
|
2895
|
-
` messages={messages}`,
|
|
2896
|
-
` loading={loading}`,
|
|
2897
|
-
` suggestions={suggestions}`,
|
|
2898
|
-
` suggestionMode="submit"`,
|
|
2899
|
-
...cardProps(' '),
|
|
2900
|
-
` onSubmit={onSubmit}`,
|
|
2901
|
-
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2902
|
-
` />`,
|
|
2903
|
-
` </ResizableItem>`,
|
|
2904
|
-
` <ResizableItem min="280px">`,
|
|
2905
|
-
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
2906
|
-
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
2907
|
-
` </ResizableItem>`,
|
|
2908
|
-
` </Resizable>`,
|
|
2909
|
-
// Siblings of the SPLIT — see the same line in the other JSX branch.
|
|
2910
|
-
companions,
|
|
2911
|
-
]
|
|
2912
|
-
: [
|
|
2913
|
-
...cardPropsNote(' '),
|
|
2914
|
-
` <Chat`,
|
|
2915
|
-
` messages={messages}`,
|
|
2916
|
-
` loading={loading}`,
|
|
2917
|
-
` suggestions={suggestions}`,
|
|
2918
|
-
` suggestionMode="submit"`,
|
|
2919
|
-
...cardProps(' '),
|
|
2920
|
-
` onSubmit={onSubmit}`,
|
|
2921
|
-
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2922
|
-
` />`,
|
|
2923
|
-
companions,
|
|
2924
|
-
]),
|
|
4151
|
+
...surfaceJsx,
|
|
2925
4152
|
` </main>`,
|
|
2926
4153
|
` );`,
|
|
2927
4154
|
`}`,
|
|
@@ -2959,13 +4186,20 @@ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): str
|
|
|
2959
4186
|
function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
2960
4187
|
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
2961
4188
|
|
|
2962
|
-
const workspace =
|
|
4189
|
+
const workspace = isArtifactSplit(components);
|
|
4190
|
+
const block = isWorkspaceBlock(components);
|
|
2963
4191
|
const attachments = hasAttachments(components);
|
|
4192
|
+
// Slotted companions are children of <kai-chat> (see `chatTag`), so they leave
|
|
4193
|
+
// the sibling loop the way the workspace and attachment pairs do. In BLOCK
|
|
4194
|
+
// mode the rail lives in the SHELL's start aside instead.
|
|
4195
|
+
const slottedTags = new Set(slottedInChat(components).map((s) => s.tag));
|
|
2964
4196
|
const standaloneCompanionTags = components.filter(
|
|
2965
4197
|
(t) =>
|
|
2966
4198
|
t !== 'kai-chat' &&
|
|
2967
4199
|
!MESSAGE_EMBEDDED_TAGS.has(t) &&
|
|
2968
|
-
!
|
|
4200
|
+
!ARTIFACT_SPLIT_TAGS.has(t) &&
|
|
4201
|
+
!(block && BLOCK_TAGS.has(t)) &&
|
|
4202
|
+
!slottedTags.has(t) &&
|
|
2969
4203
|
!(attachments && ATTACHMENT_TAGS.has(t)),
|
|
2970
4204
|
);
|
|
2971
4205
|
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
@@ -3050,19 +4284,52 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3050
4284
|
const commit = (value: string) => `this.messages.set(${value});`;
|
|
3051
4285
|
const setter = '(fn) => this.messages.set(fn(this.messages()))';
|
|
3052
4286
|
|
|
4287
|
+
// The block's submit prelude — signals instead of refs; same shape.
|
|
4288
|
+
const blockRead = `(this.threads().find((t) => t.id === targetId)?.messages ?? [])`;
|
|
4289
|
+
const blockPrelude = block
|
|
4290
|
+
? [
|
|
4291
|
+
...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
|
|
4292
|
+
`const targetId = this.activeId() ?? crypto.randomUUID();`,
|
|
4293
|
+
`// The record is born on its first turn — with YOUR title policy.`,
|
|
4294
|
+
`this.setThreads((prev) => prev.some((t) => t.id === targetId)`,
|
|
4295
|
+
` ? prev`,
|
|
4296
|
+
` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
|
|
4297
|
+
`this.activeId.set(targetId);`,
|
|
4298
|
+
...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
|
|
4299
|
+
`const setMessages = bindThreadMessages<Thread>(this.setThreads, targetId, {`,
|
|
4300
|
+
` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
|
|
4301
|
+
`});`,
|
|
4302
|
+
`const controller = this.sessions.begin(targetId);`,
|
|
4303
|
+
]
|
|
4304
|
+
: [];
|
|
4305
|
+
|
|
3053
4306
|
const onSubmitBody = realStreamBody({
|
|
3054
4307
|
pad: ' ',
|
|
3055
|
-
read,
|
|
3056
|
-
commitSet: (expr) => commit(expr),
|
|
3057
|
-
setterAdapter: setter,
|
|
3058
|
-
setLoading:
|
|
4308
|
+
read: block ? blockRead : read,
|
|
4309
|
+
commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => commit(expr),
|
|
4310
|
+
setterAdapter: block ? 'setMessages' : setter,
|
|
4311
|
+
setLoading: block
|
|
4312
|
+
? (v) =>
|
|
4313
|
+
v === 'true'
|
|
4314
|
+
? `// loading for THIS thread now derives from the sessions map (streamingIds).`
|
|
4315
|
+
: `this.sessions.end(targetId, controller);`
|
|
4316
|
+
: (v) => `this.loading.set(${v});`,
|
|
3059
4317
|
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
3060
4318
|
strictRoles: true,
|
|
3061
4319
|
toolLoop: emitToolLoop,
|
|
3062
4320
|
cards: ctx.emitCards,
|
|
3063
|
-
thread: accessorThreadBinding(read, commit, setter),
|
|
4321
|
+
thread: block ? blockLiveThread(blockRead) : accessorThreadBinding(read, commit, setter),
|
|
3064
4322
|
mock: isMock,
|
|
4323
|
+
...(block ? { fetchSignal: 'controller.signal' } : {}),
|
|
3065
4324
|
...attachmentSubmitOpts,
|
|
4325
|
+
...(block
|
|
4326
|
+
? {
|
|
4327
|
+
afterValue: [
|
|
4328
|
+
...((attachmentSubmitOpts as { afterValue?: string[] }).afterValue ?? []),
|
|
4329
|
+
...blockPrelude,
|
|
4330
|
+
],
|
|
4331
|
+
}
|
|
4332
|
+
: {}),
|
|
3066
4333
|
});
|
|
3067
4334
|
|
|
3068
4335
|
// Module scope, exactly like vue: a class can hold neither a bare `const` nor a
|
|
@@ -3091,15 +4358,43 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3091
4358
|
]
|
|
3092
4359
|
: [];
|
|
3093
4360
|
|
|
3094
|
-
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
4361
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE. In BLOCK mode the
|
|
4362
|
+
// thread state is the block's multi-thread store; `messages`/`loading` are
|
|
4363
|
+
// computed views of it.
|
|
4364
|
+
const sampleSeed = block
|
|
4365
|
+
? [
|
|
4366
|
+
` // ── The block's state: your records + the kit's session/persistence helpers ─`,
|
|
4367
|
+
` readonly threads = signal<Thread[]>(loadThreads());`,
|
|
4368
|
+
` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
|
|
4369
|
+
` // record is created on its first turn, so the rail never fills with empties.`,
|
|
4370
|
+
` readonly activeId = signal<string | null>(this.threads()[0]?.id ?? null);`,
|
|
4371
|
+
` // One id per in-flight reply — a stream keeps running when you switch away.`,
|
|
4372
|
+
` readonly streamingIds = signal<string[]>([]);`,
|
|
4373
|
+
...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
|
|
4374
|
+
` private readonly sessions = createThreadSessions((ids) => this.streamingIds.set(ids));`,
|
|
4375
|
+
` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
|
|
4376
|
+
` // the save target and what a failed save means are YOURS (see saveThreads).`,
|
|
4377
|
+
` private readonly saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
|
|
4378
|
+
` // Every write goes through this — a NEW array per write is what notifies.`,
|
|
4379
|
+
` private readonly setThreads = (updater: (prev: Thread[]) => Thread[]): void =>`,
|
|
4380
|
+
` this.threads.update(updater);`,
|
|
4381
|
+
` readonly active = computed(() => this.threads().find((t) => t.id === this.activeId()) ?? null);`,
|
|
4382
|
+
` readonly messages = computed(() => this.active()?.messages ?? []);`,
|
|
4383
|
+
` readonly loading = computed(() => { const id = this.activeId(); return id !== null && this.streamingIds().includes(id); });`,
|
|
4384
|
+
` // The rail's row shape is a VIEW of your record — titles and counts, no`,
|
|
4385
|
+
` // message bodies. The rail renders the array in the order it is given.`,
|
|
4386
|
+
` readonly rows = computed(() => this.threads().map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
|
|
4387
|
+
` // The template cannot spell \`undefined\` portably, so the ?? lives here.`,
|
|
4388
|
+
` readonly activeIdOrUndefined = computed(() => this.activeId() ?? undefined);`,
|
|
4389
|
+
]
|
|
4390
|
+
: [
|
|
4391
|
+
...(hasEmbedded
|
|
4392
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
4393
|
+
`readonly messages = signal<ChatMessage[]>([${literal}]);`,
|
|
4394
|
+
])
|
|
4395
|
+
: []),
|
|
4396
|
+
` readonly messages = signal<ChatMessage[]>([]);`,
|
|
4397
|
+
];
|
|
3103
4398
|
|
|
3104
4399
|
const sourcesField = hasSourcesCompanion
|
|
3105
4400
|
? [
|
|
@@ -3115,8 +4410,9 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3115
4410
|
? [` Object.assign(this.sourcesEl().nativeElement, { sources: this.sampleSources });`]
|
|
3116
4411
|
: [];
|
|
3117
4412
|
|
|
3118
|
-
const chatTag = (pad: string) =>
|
|
3119
|
-
[
|
|
4413
|
+
const chatTag = (pad: string) => {
|
|
4414
|
+
const slotted = block ? [] : slottedChildMarkup(components, ' ', { wired: false, ids: false });
|
|
4415
|
+
return [
|
|
3120
4416
|
`<kai-chat`,
|
|
3121
4417
|
` #chat`,
|
|
3122
4418
|
` [messages]="messages()"`,
|
|
@@ -3125,8 +4421,9 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3125
4421
|
` suggestion-mode="submit"`,
|
|
3126
4422
|
` style="${p.chatFill}"`,
|
|
3127
4423
|
` (kai-submit)="onSubmit($event)"`,
|
|
3128
|
-
`></kai-chat
|
|
4424
|
+
...(slotted.length === 0 ? [`></kai-chat>`] : [`>`, ...slotted, `</kai-chat>`]),
|
|
3129
4425
|
].map((l) => `${pad}${l}`);
|
|
4426
|
+
};
|
|
3130
4427
|
|
|
3131
4428
|
const templateBody = workspace
|
|
3132
4429
|
? [
|
|
@@ -3149,11 +4446,48 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3149
4446
|
// KaiSourcesElement is imported only when a kai-sources companion is really
|
|
3150
4447
|
// declared: an always-on import is unused on every other archetype, and a stock
|
|
3151
4448
|
// Angular tsconfig turns on the checks that make that a build error.
|
|
3152
|
-
const elementTypes =
|
|
4449
|
+
const elementTypes = [
|
|
4450
|
+
'KaiChatElement',
|
|
4451
|
+
...(block ? ['KaiConversationsElement'] : []),
|
|
4452
|
+
...(hasSourcesCompanion ? ['KaiSourcesElement'] : []),
|
|
4453
|
+
].join(', ');
|
|
3153
4454
|
// No KaiAttachmentsElement here: unlike svelte/html this target never holds an
|
|
3154
4455
|
// element reference for the list — `[items]` is a template binding, so nothing
|
|
3155
4456
|
// in the class is typed by it.
|
|
3156
4457
|
|
|
4458
|
+
// The BLOCK's template wrap: the shell around the whole surface, the rail in
|
|
4459
|
+
// its start aside, the rest projected into the main region.
|
|
4460
|
+
const surfaceTemplate = block
|
|
4461
|
+
? [
|
|
4462
|
+
` <!-- ── The workspace BLOCK: the shell + the rail + your thread records ── -->`,
|
|
4463
|
+
...htmlComment(
|
|
4464
|
+
[
|
|
4465
|
+
`<kai-workspace> is the chat-agnostic layout shell: header/start/main/`,
|
|
4466
|
+
`end/footer slots, aside resize + collapse, and a mobile drawer below`,
|
|
4467
|
+
`the drawer-below width. It knows nothing about chat.`,
|
|
4468
|
+
],
|
|
4469
|
+
' ',
|
|
4470
|
+
),
|
|
4471
|
+
` <kai-workspace drawer-below="720" style="display:block;width:100%;height:100%">`,
|
|
4472
|
+
...htmlComment(BLOCK_RAIL_NOTE, ' '),
|
|
4473
|
+
` <!-- New chat = a DRAFT (activeId null): the record appears on the first turn. -->`,
|
|
4474
|
+
` <kai-conversations`,
|
|
4475
|
+
` #rail`,
|
|
4476
|
+
` slot="start"`,
|
|
4477
|
+
` [conversations]="rows()"`,
|
|
4478
|
+
` [activeId]="activeIdOrUndefined()"`,
|
|
4479
|
+
` style="display:block;height:100%"`,
|
|
4480
|
+
` (kai-conversation-select)="onSelect($event)"`,
|
|
4481
|
+
` (kai-new-chat)="onNewChat()"`,
|
|
4482
|
+
` ></kai-conversations>`,
|
|
4483
|
+
` <!-- Unnamed children project into the shell's MAIN region. -->`,
|
|
4484
|
+
` <div style="display:flex;flex-direction:column;height:100%;min-height:0">`,
|
|
4485
|
+
...indentEmitted([...attachmentTemplate, ...templateBody], ' '),
|
|
4486
|
+
` </div>`,
|
|
4487
|
+
` </kai-workspace>`,
|
|
4488
|
+
]
|
|
4489
|
+
: [...attachmentTemplate, ...templateBody];
|
|
4490
|
+
|
|
3157
4491
|
return [
|
|
3158
4492
|
`// Angular standalone component — save as: src/app/chat.component.ts`,
|
|
3159
4493
|
`// Render it: put <app-chat /> in your root template and add ChatComponent to`,
|
|
@@ -3165,7 +4499,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3165
4499
|
`// "styles": ["node_modules/@kitn.ai/ui/dist/theme.tokens.css", "src/styles.css"]`,
|
|
3166
4500
|
`// (@kitn.ai/ui/theme.tokens.css is the compiled token file; theme.css is`,
|
|
3167
4501
|
`// Tailwind source and is only for apps that compile Tailwind themselves.)`,
|
|
3168
|
-
`import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, signal, viewChild } from '@angular/core';`,
|
|
4502
|
+
`import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, ${block ? 'computed, effect, ' : ''}signal, viewChild } from '@angular/core';`,
|
|
3169
4503
|
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
3170
4504
|
`import type { ${elementTypes} } from '@kitn.ai/ui/elements';`,
|
|
3171
4505
|
...wireImportLines({
|
|
@@ -3175,6 +4509,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3175
4509
|
cardTools: ctx.cardProvider !== null,
|
|
3176
4510
|
mock: isMock,
|
|
3177
4511
|
attachments,
|
|
4512
|
+
workspaceBlock: block,
|
|
3178
4513
|
}),
|
|
3179
4514
|
``,
|
|
3180
4515
|
`// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
|
|
@@ -3183,6 +4518,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3183
4518
|
``,
|
|
3184
4519
|
...modelInit,
|
|
3185
4520
|
...attachmentModuleInit,
|
|
4521
|
+
...(block ? [...blockThreadStoreLines(''), ``] : []),
|
|
3186
4522
|
...cardsInit,
|
|
3187
4523
|
...toolsLines,
|
|
3188
4524
|
...runnerLines,
|
|
@@ -3194,8 +4530,7 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3194
4530
|
` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
|
|
3195
4531
|
` template: \``,
|
|
3196
4532
|
` <div style="${p.style}">`,
|
|
3197
|
-
...
|
|
3198
|
-
...templateBody,
|
|
4533
|
+
...surfaceTemplate,
|
|
3199
4534
|
` </div>`,
|
|
3200
4535
|
` \`,`,
|
|
3201
4536
|
`})`,
|
|
@@ -3203,12 +4538,21 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3203
4538
|
` // Every write assigns a NEW array. That reference change is what re-renders`,
|
|
3204
4539
|
` // <kai-chat> — mutating the array in place does nothing.`,
|
|
3205
4540
|
...sampleSeed,
|
|
3206
|
-
` readonly loading = signal(false)
|
|
4541
|
+
...(block ? [] : [` readonly loading = signal(false);`]),
|
|
3207
4542
|
` readonly suggestions = ${jsArray(suggestions)};`,
|
|
3208
4543
|
...sourcesField,
|
|
3209
4544
|
` private readonly chatEl = viewChild.required<ElementRef<KaiChatElement>>('chat');`,
|
|
4545
|
+
...(block
|
|
4546
|
+
? [` private readonly railEl = viewChild.required<ElementRef<KaiConversationsElement>>('rail');`]
|
|
4547
|
+
: []),
|
|
3210
4548
|
``,
|
|
3211
4549
|
` constructor() {`,
|
|
4550
|
+
...(block
|
|
4551
|
+
? [
|
|
4552
|
+
` // The save scheduler rides an effect: every threads() write schedules a save.`,
|
|
4553
|
+
` effect(() => this.saver.schedule(this.threads()));`,
|
|
4554
|
+
]
|
|
4555
|
+
: []),
|
|
3212
4556
|
` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
|
|
3213
4557
|
` // element may not be upgraded when Angular first applies the bindings above —`,
|
|
3214
4558
|
` // and a property set on a not-yet-upgraded element is dropped on upgrade.`,
|
|
@@ -3223,10 +4567,34 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3223
4567
|
` suggestions: this.suggestions,`,
|
|
3224
4568
|
...cardPropAssign,
|
|
3225
4569
|
` });`,
|
|
4570
|
+
...(block
|
|
4571
|
+
? [
|
|
4572
|
+
` // The rail's array props follow the same upgrade rule.`,
|
|
4573
|
+
` await customElements.whenDefined('kai-conversations');`,
|
|
4574
|
+
` Object.assign(this.railEl().nativeElement, {`,
|
|
4575
|
+
` conversations: this.rows(),`,
|
|
4576
|
+
` activeId: this.activeIdOrUndefined(),`,
|
|
4577
|
+
` });`,
|
|
4578
|
+
` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
|
|
4579
|
+
` window.addEventListener('beforeunload', () => this.saver.flush());`,
|
|
4580
|
+
]
|
|
4581
|
+
: []),
|
|
3226
4582
|
...sourcesReapply,
|
|
3227
4583
|
` });`,
|
|
3228
4584
|
` }`,
|
|
3229
4585
|
``,
|
|
4586
|
+
...(block
|
|
4587
|
+
? [
|
|
4588
|
+
` onSelect(event: Event) {`,
|
|
4589
|
+
` this.activeId.set((event as CustomEvent<{ id: string }>).detail.id);`,
|
|
4590
|
+
` }`,
|
|
4591
|
+
` onNewChat() {`,
|
|
4592
|
+
` // A DRAFT, not a record: it becomes a thread on its first turn.`,
|
|
4593
|
+
` this.activeId.set(null);`,
|
|
4594
|
+
` }`,
|
|
4595
|
+
``,
|
|
4596
|
+
]
|
|
4597
|
+
: []),
|
|
3230
4598
|
...attachmentFields,
|
|
3231
4599
|
``,
|
|
3232
4600
|
` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
|
|
@@ -3287,17 +4655,25 @@ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3287
4655
|
function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
3288
4656
|
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
3289
4657
|
|
|
3290
|
-
const workspace =
|
|
4658
|
+
const workspace = isArtifactSplit(components);
|
|
4659
|
+
const block = isWorkspaceBlock(components);
|
|
3291
4660
|
const attachments = hasAttachments(components);
|
|
3292
4661
|
const standaloneCompanionTags = components.filter(
|
|
3293
4662
|
(t) =>
|
|
3294
4663
|
t !== 'kai-chat' &&
|
|
3295
4664
|
!MESSAGE_EMBEDDED_TAGS.has(t) &&
|
|
3296
|
-
!
|
|
4665
|
+
!ARTIFACT_SPLIT_TAGS.has(t) &&
|
|
4666
|
+
!(block && BLOCK_TAGS.has(t)) &&
|
|
3297
4667
|
!(attachments && ATTACHMENT_TAGS.has(t)),
|
|
3298
4668
|
);
|
|
3299
4669
|
const hasSources = standaloneCompanionTags.includes('kai-sources');
|
|
3300
4670
|
const hasVoice = standaloneCompanionTags.includes('kai-voice-input');
|
|
4671
|
+
// The two above are the only companions this renderer draws. Anything else in
|
|
4672
|
+
// the list reaches the emitted file as nothing at all, so it is named in the
|
|
4673
|
+
// header rather than dropped in silence.
|
|
4674
|
+
const unrenderedCompanions = standaloneCompanionTags.filter(
|
|
4675
|
+
(t) => t !== 'kai-sources' && t !== 'kai-voice-input',
|
|
4676
|
+
);
|
|
3301
4677
|
|
|
3302
4678
|
// Every name here is referenced by the emitted tree below — `noUnusedLocals` is
|
|
3303
4679
|
// on in a stock Solid app (`npm run build` runs `tsc` first), so an extra one
|
|
@@ -3336,6 +4712,9 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3336
4712
|
'SourceTrigger',
|
|
3337
4713
|
'Tool',
|
|
3338
4714
|
...(workspace ? ['Artifact', 'ResizableHandle', 'ResizablePanel', 'ResizablePanelGroup'] : []),
|
|
4715
|
+
// The BLOCK's structural pair: the Solid shell + the Solid rail (this target
|
|
4716
|
+
// renders components, not <kai-*> tags — same rule as everything above).
|
|
4717
|
+
...(block ? ['ConversationList', 'WorkspaceShell'] : []),
|
|
3339
4718
|
...(hasVoice ? ['VoiceInput'] : []),
|
|
3340
4719
|
// `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo` are already
|
|
3341
4720
|
// unconditional above — `renderPart` draws `file` PARTS with them whatever the
|
|
@@ -3353,17 +4732,43 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3353
4732
|
// exactly the `SetMessages` shape createAssistantStream wants.
|
|
3354
4733
|
const setter = '(fn) => setMessages((prev) => fn(prev))';
|
|
3355
4734
|
|
|
4735
|
+
// The block's submit prelude — signals; same shape as every other target.
|
|
4736
|
+
// `setThreads` is Solid's own setter: its function-argument overload IS the
|
|
4737
|
+
// SetThreads updater shape bindThreadMessages wants.
|
|
4738
|
+
const blockRead = `(threads().find((t) => t.id === targetId)?.messages ?? [])`;
|
|
4739
|
+
const blockPrelude = block
|
|
4740
|
+
? [
|
|
4741
|
+
...BLOCK_TARGET_ID_NOTE.map((l) => `// ${l}`),
|
|
4742
|
+
`const targetId = activeId() ?? crypto.randomUUID();`,
|
|
4743
|
+
`// The record is born on its first turn — with YOUR title policy.`,
|
|
4744
|
+
`setThreads((prev) => prev.some((t) => t.id === targetId)`,
|
|
4745
|
+
` ? prev`,
|
|
4746
|
+
` : [{ id: targetId, title: deriveTitle(value), updatedAt: new Date().toISOString(), messages: [] }, ...prev]);`,
|
|
4747
|
+
`setActiveId(targetId);`,
|
|
4748
|
+
...BLOCK_BIND_NOTE.map((l) => `// ${l}`),
|
|
4749
|
+
`const setMessages = bindThreadMessages<Thread>((fn) => { setThreads((prev) => fn(prev)); }, targetId, {`,
|
|
4750
|
+
` touch: (t) => ({ ...t, updatedAt: new Date().toISOString() }),`,
|
|
4751
|
+
`});`,
|
|
4752
|
+
`const controller = sessions.begin(targetId);`,
|
|
4753
|
+
]
|
|
4754
|
+
: [];
|
|
4755
|
+
|
|
3356
4756
|
const onSubmitBody = realStreamBody({
|
|
3357
4757
|
pad: ' ',
|
|
3358
|
-
read,
|
|
3359
|
-
commitSet: (expr) => commit(expr),
|
|
3360
|
-
setterAdapter: setter,
|
|
3361
|
-
setLoading:
|
|
4758
|
+
read: block ? blockRead : read,
|
|
4759
|
+
commitSet: block ? (expr) => `setMessages(() => ${expr});` : (expr) => commit(expr),
|
|
4760
|
+
setterAdapter: block ? 'setMessages' : setter,
|
|
4761
|
+
setLoading: block
|
|
4762
|
+
? (v) =>
|
|
4763
|
+
v === 'true'
|
|
4764
|
+
? `// loading for THIS thread now derives from the sessions map (streamingIds).`
|
|
4765
|
+
: `sessions.end(targetId, controller);`
|
|
4766
|
+
: (v) => `setLoading(${v});`,
|
|
3362
4767
|
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
3363
4768
|
strictRoles: true,
|
|
3364
4769
|
toolLoop: emitToolLoop,
|
|
3365
4770
|
cards: ctx.emitCards,
|
|
3366
|
-
thread: accessorThreadBinding(read, commit, setter),
|
|
4771
|
+
thread: block ? blockLiveThread(blockRead) : accessorThreadBinding(read, commit, setter),
|
|
3367
4772
|
valueSource: 'input()',
|
|
3368
4773
|
// The staged files are captured BEFORE either list is cleared. `setInput('')`
|
|
3369
4774
|
// stays last so the order reads the way it executes.
|
|
@@ -3375,9 +4780,11 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3375
4780
|
fromSubmitEvent: false,
|
|
3376
4781
|
})
|
|
3377
4782
|
: []),
|
|
4783
|
+
...blockPrelude,
|
|
3378
4784
|
`setInput('');`,
|
|
3379
4785
|
],
|
|
3380
4786
|
...(attachments ? { filesExpr: 'files' } : {}),
|
|
4787
|
+
...(block ? { fetchSignal: 'controller.signal' } : {}),
|
|
3381
4788
|
mock: isMock,
|
|
3382
4789
|
});
|
|
3383
4790
|
|
|
@@ -3593,6 +5000,23 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3593
5000
|
return [
|
|
3594
5001
|
`// SolidJS + Vite — save as: src/App.tsx`,
|
|
3595
5002
|
`//`,
|
|
5003
|
+
// What this target DROPS, said out loud. It renders Solid components for the
|
|
5004
|
+
// companions it knows (sources, voice input) and nothing at all for the rest,
|
|
5005
|
+
// so a `components` list naming one of those used to produce a file with no
|
|
5006
|
+
// trace of it — a silent drop, and the quiet half of the same defect the
|
|
5007
|
+
// conversation rail had. Naming them is not a fix; it is the honest record
|
|
5008
|
+
// until this target grows a branch for them.
|
|
5009
|
+
...(unrenderedCompanions.length > 0
|
|
5010
|
+
? [
|
|
5011
|
+
`// NOT WIRED, and not rendered: ${unrenderedCompanions.join(', ')}.`,
|
|
5012
|
+
`// You asked for ${unrenderedCompanions.length === 1 ? 'it' : 'them'} and this target has no branch for`,
|
|
5013
|
+
`// ${unrenderedCompanions.length === 1 ? 'it' : 'them'} — see the note below on why this file renders Solid`,
|
|
5014
|
+
`// components rather than <kai-*> elements. Compose the Solid component`,
|
|
5015
|
+
`// yourself (ask the component_reference MCP tool for what it exports), or`,
|
|
5016
|
+
`// scaffold the html target, which drives the custom elements directly.`,
|
|
5017
|
+
`//`,
|
|
5018
|
+
]
|
|
5019
|
+
: []),
|
|
3596
5020
|
`// This target does NOT use the <kai-*> custom elements, and that is deliberate:`,
|
|
3597
5021
|
`// the kit is AUTHORED in SolidJS, so a Solid app renders the real components`,
|
|
3598
5022
|
`// with real props and real fine-grained reactivity. Going through the`,
|
|
@@ -3609,7 +5033,7 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3609
5033
|
`// The @source line is NOT optional: without it Tailwind scans only src/, strips`,
|
|
3610
5034
|
`// every kit utility class as unused, and the whole UI renders unstyled.`,
|
|
3611
5035
|
`// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
|
|
3612
|
-
`import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
|
|
5036
|
+
`import { For, Index, Match, Show, Switch, ${block ? 'createEffect, ' : ''}createMemo, createSignal${block ? ', onCleanup' : ''} } from 'solid-js';`,
|
|
3613
5037
|
// '@kitn.ai/ui/solid', NOT the root '@kitn.ai/ui', and the difference is
|
|
3614
5038
|
// invisible to every compiler on this repo's critical path: src/solid.ts is
|
|
3615
5039
|
// `export * from './index'`, so ./solid is a strict SUPERSET of the root and
|
|
@@ -3637,12 +5061,14 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3637
5061
|
cards: ctx.emitCards,
|
|
3638
5062
|
cardTools: ctx.cardProvider !== null,
|
|
3639
5063
|
mock: isMock,
|
|
5064
|
+
workspaceBlock: block,
|
|
3640
5065
|
}),
|
|
3641
5066
|
``,
|
|
3642
5067
|
`// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
|
|
3643
5068
|
...(p.altNote ?? []).map((l) => `// ${l}`),
|
|
3644
5069
|
``,
|
|
3645
5070
|
...(isMock ? [...mockResponderInit(), ``] : []),
|
|
5071
|
+
...(block ? [...blockThreadStoreLines(''), ``] : []),
|
|
3646
5072
|
...cardsInit,
|
|
3647
5073
|
`// Narrow a part to ONE variant, or false. One read, one cast — and the JSX`,
|
|
3648
5074
|
`// below re-runs it on every delta, which is what keeps a growing text or`,
|
|
@@ -3820,8 +5246,36 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3820
5246
|
` // streaming message as a NEW OBJECT on every delta: a new reference IS the`,
|
|
3821
5247
|
` // re-render signal. Which is exactly why the thread below is keyed by message`,
|
|
3822
5248
|
` // id rather than by the message objects — see messageKeys.`,
|
|
3823
|
-
|
|
3824
|
-
|
|
5249
|
+
...(block
|
|
5250
|
+
? [
|
|
5251
|
+
` // ── The block's state: your records + the kit's session/persistence helpers ─`,
|
|
5252
|
+
` const [threads, setThreads] = createSignal<Thread[]>(loadThreads());`,
|
|
5253
|
+
` // null means a DRAFT: "New chat" was pressed and nothing is stored yet. A`,
|
|
5254
|
+
` // record is created on its first turn, so the rail never fills with empties.`,
|
|
5255
|
+
` const [activeId, setActiveId] = createSignal<string | null>(threads()[0]?.id ?? null);`,
|
|
5256
|
+
` // One id per in-flight reply — a stream keeps running when you switch away.`,
|
|
5257
|
+
` const [streamingIds, setStreamingIds] = createSignal<string[]>([]);`,
|
|
5258
|
+
...BLOCK_SESSIONS_NOTE.map((l) => ` // ${l}`),
|
|
5259
|
+
` const sessions = createThreadSessions((ids) => setStreamingIds(ids));`,
|
|
5260
|
+
` // The save scheduler: the debounce/flush MECHANICS are the kit's; the delay,`,
|
|
5261
|
+
` // the save target and what a failed save means are YOURS (see saveThreads).`,
|
|
5262
|
+
` const saver = createSaveScheduler(saveThreads, { delayMs: 250 });`,
|
|
5263
|
+
` createEffect(() => saver.schedule(threads()));`,
|
|
5264
|
+
` // Flush the pending write when the tab closes mid-debounce (or mid-stream).`,
|
|
5265
|
+
` const flushOnUnload = () => saver.flush();`,
|
|
5266
|
+
` window.addEventListener('beforeunload', flushOnUnload);`,
|
|
5267
|
+
` onCleanup(() => window.removeEventListener('beforeunload', flushOnUnload));`,
|
|
5268
|
+
` const active = createMemo(() => threads().find((t) => t.id === activeId()) ?? null);`,
|
|
5269
|
+
` const messages = createMemo(() => active()?.messages ?? []);`,
|
|
5270
|
+
` const loading = createMemo(() => { const id = activeId(); return id !== null && streamingIds().includes(id); });`,
|
|
5271
|
+
` // The rail's row shape is a VIEW of your record — titles and counts, no`,
|
|
5272
|
+
` // message bodies. The rail renders the array in the order it is given.`,
|
|
5273
|
+
` const rows = createMemo(() => threads().map((t) => ({ id: t.id, title: t.title, messageCount: t.messages.length, updatedAt: t.updatedAt })));`,
|
|
5274
|
+
]
|
|
5275
|
+
: [
|
|
5276
|
+
` const [messages, setMessages] = createSignal<ChatMessage[]>([]);`,
|
|
5277
|
+
` const [loading, setLoading] = createSignal(false);`,
|
|
5278
|
+
]),
|
|
3825
5279
|
` // PromptInput is CONTROLLED here, so this signal — not a kai-submit event — is`,
|
|
3826
5280
|
` // where the submitted text comes from.`,
|
|
3827
5281
|
` const [input, setInput] = createSignal('');`,
|
|
@@ -3849,7 +5303,33 @@ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
|
3849
5303
|
` // component below it. Mount it once, at the top.`,
|
|
3850
5304
|
` <ChatConfig>`,
|
|
3851
5305
|
` <div style={{ ${solidStyle(p.style)} }}>`,
|
|
3852
|
-
...
|
|
5306
|
+
...(block
|
|
5307
|
+
? [
|
|
5308
|
+
` {/* ── The workspace BLOCK: the shell + the rail + your thread records ── */}`,
|
|
5309
|
+
` {/* WorkspaceShell is the chat-agnostic layout shell (header/start/main/`,
|
|
5310
|
+
` end/footer regions, aside resize + collapse, a mobile drawer below`,
|
|
5311
|
+
` drawerBelow px). It knows nothing about chat. */}`,
|
|
5312
|
+
` <WorkspaceShell`,
|
|
5313
|
+
` drawerBelow={720}`,
|
|
5314
|
+
` class="h-full w-full"`,
|
|
5315
|
+
` start={`,
|
|
5316
|
+
// A JS line comment, not a {/* */} pair: inside an attribute's
|
|
5317
|
+
// expression container the braces form is a parse error.
|
|
5318
|
+
...BLOCK_RAIL_NOTE_SOLID.map((l) => ` // ${l}`),
|
|
5319
|
+
` <ConversationList`,
|
|
5320
|
+
` groups={[]}`,
|
|
5321
|
+
` conversations={rows()}`,
|
|
5322
|
+
` activeId={activeId() ?? undefined}`,
|
|
5323
|
+
` onSelect={(id) => setActiveId(id)}`,
|
|
5324
|
+
` onNewChat={() => setActiveId(null)}`,
|
|
5325
|
+
` class="h-full"`,
|
|
5326
|
+
` />`,
|
|
5327
|
+
` }`,
|
|
5328
|
+
` >`,
|
|
5329
|
+
...indentEmitted(tree, ' '),
|
|
5330
|
+
` </WorkspaceShell>`,
|
|
5331
|
+
]
|
|
5332
|
+
: tree),
|
|
3853
5333
|
` </div>`,
|
|
3854
5334
|
` </ChatConfig>`,
|
|
3855
5335
|
` );`,
|
|
@@ -4169,17 +5649,38 @@ function viteMiddlewareAdapter(plugin: string): WebRouteAdapter {
|
|
|
4169
5649
|
` name: 'chat-api',`,
|
|
4170
5650
|
` configureServer(server) {`,
|
|
4171
5651
|
` server.middlewares.use('/api/chat', async (req, res) => {`,
|
|
5652
|
+
` // The METHOD has to survive the bridge, like the status does below.`,
|
|
5653
|
+
` // Hard-coding 'POST' here meant a bare GET /api/chat arrived at`,
|
|
5654
|
+
` // readChatRequest as an empty POST and came back 400 "not valid JSON":`,
|
|
5655
|
+
` // the 405 guard was emitted, compiled, and unreachable on this host.`,
|
|
5656
|
+
` const method = req.method ?? 'POST';`,
|
|
5657
|
+
` // undici THROWS on \`new Request(url, { method: 'GET', body })\`, so only`,
|
|
5658
|
+
` // read a body from the methods that can carry one.`,
|
|
5659
|
+
` const hasBody = method !== 'GET' && method !== 'HEAD';`,
|
|
4172
5660
|
` let body = '';`,
|
|
4173
|
-
`
|
|
4174
|
-
`
|
|
5661
|
+
` if (hasBody) {`,
|
|
5662
|
+
` req.setEncoding('utf8');`,
|
|
5663
|
+
` for await (const chunk of req) body += chunk;`,
|
|
5664
|
+
` }`,
|
|
4175
5665
|
``,
|
|
4176
|
-
`
|
|
4177
|
-
`
|
|
4178
|
-
`
|
|
4179
|
-
`
|
|
4180
|
-
`
|
|
4181
|
-
`
|
|
4182
|
-
`
|
|
5666
|
+
` let response: Response;`,
|
|
5667
|
+
` try {`,
|
|
5668
|
+
` response = await chatHandler(`,
|
|
5669
|
+
` new Request('http://localhost/api/chat', {`,
|
|
5670
|
+
` method,`,
|
|
5671
|
+
` headers: { 'Content-Type': 'application/json' },`,
|
|
5672
|
+
` body: hasBody ? body : undefined,`,
|
|
5673
|
+
` }),`,
|
|
5674
|
+
` );`,
|
|
5675
|
+
` } catch (error) {`,
|
|
5676
|
+
` // An unhandled rejection in an async connect middleware EXITS Node 22 —`,
|
|
5677
|
+
` // this catch is the guard findings F-10 exists for. The browser gets`,
|
|
5678
|
+
` // eight generic words, so the TERMINAL has to get the real thing:`,
|
|
5679
|
+
` // swallowing it here would make a dev server strictly less`,
|
|
5680
|
+
` // diagnosable than the crash it replaced.`,
|
|
5681
|
+
` console.error('[chat-api] chatHandler failed:', error);`,
|
|
5682
|
+
` response = Response.json({ error: 'Chat handler failed.' }, { status: 500 });`,
|
|
5683
|
+
` }`,
|
|
4183
5684
|
``,
|
|
4184
5685
|
` // The STATUS has to survive the bridge: a 401 from the provider that`,
|
|
4185
5686
|
` // arrives at the browser as a 200 is a blank bubble and no error.`,
|
|
@@ -4553,7 +6054,15 @@ function cannotHostWarning(integration: Integration, route: RouteChoice, framewo
|
|
|
4553
6054
|
// and reaches localhost, so the page can call it. That advice was emitted
|
|
4554
6055
|
// identically for every integration because nothing read the flag that
|
|
4555
6056
|
// distinguishes them.
|
|
4556
|
-
...(integration.
|
|
6057
|
+
...(integration.id === 'mock'
|
|
6058
|
+
? [
|
|
6059
|
+
// For mock the honest first option is to do nothing: the block is
|
|
6060
|
+
// already labelled OPTIONAL, and "call it directly" would be advice
|
|
6061
|
+
// about an upstream that does not exist.
|
|
6062
|
+
`# • or ignore it: the front end above already streams the mock locally, so`,
|
|
6063
|
+
`# nothing in this scaffold needs the route to run.`,
|
|
6064
|
+
]
|
|
6065
|
+
: integration.keyExposure === 'frontend-safe'
|
|
4557
6066
|
? [
|
|
4558
6067
|
`# • or drop the route entirely: this integration needs no server hop — see the`,
|
|
4559
6068
|
`# run note — so the page may call it directly (CORS applies).`,
|
|
@@ -4566,7 +6075,10 @@ function cannotHostWarning(integration: Integration, route: RouteChoice, framewo
|
|
|
4566
6075
|
`# /api/chat to it, or point the fetch at http://localhost:8000/api/chat.`,
|
|
4567
6076
|
]
|
|
4568
6077
|
: [`# • or run it where it belongs: framework: "${route.framework}".`]),
|
|
4569
|
-
|
|
6078
|
+
// Not emitted for mock itself: "use mock" is where the reader already is.
|
|
6079
|
+
...(integration.id === 'mock'
|
|
6080
|
+
? []
|
|
6081
|
+
: [`# • or use integration: "mock" for a zero-config local stream (no backend, no key).`]),
|
|
4570
6082
|
];
|
|
4571
6083
|
return [
|
|
4572
6084
|
`#`,
|
|
@@ -4787,7 +6299,11 @@ function compose(
|
|
|
4787
6299
|
audience,
|
|
4788
6300
|
});
|
|
4789
6301
|
const isMock = integration.id === 'mock';
|
|
4790
|
-
|
|
6302
|
+
// `mock` takes the same path as every real integration (G-04): its webRoute
|
|
6303
|
+
// streams createMockResponder() frames and the adapters wrap it per framework.
|
|
6304
|
+
// What stays special is the CLAIM, made in the preface below: the front end
|
|
6305
|
+
// streams locally, so this is the one block (2) nothing in the app requires.
|
|
6306
|
+
const route = chooseRoute(integration, framework);
|
|
4791
6307
|
|
|
4792
6308
|
const header = [
|
|
4793
6309
|
`# AI/UI scaffold — ${preset ? preset.title : surfaceLabel(components)} × ${integration.title}`,
|
|
@@ -4803,13 +6319,24 @@ function compose(
|
|
|
4803
6319
|
|
|
4804
6320
|
const block2Parts: string[] = [`=== (2) BACKEND ROUTE ===`, ``];
|
|
4805
6321
|
if (isMock) {
|
|
4806
|
-
// The
|
|
6322
|
+
// The claim that keeps the zero-config story honest, made BEFORE the code:
|
|
6323
|
+
// the front end above never fetches, so this route is the one block (2)
|
|
6324
|
+
// nothing in the emitted app requires. It exists because two clean-room
|
|
6325
|
+
// builds in a row (rung-2 finding G-04) were asked for "a local dev endpoint
|
|
6326
|
+
// that streams a mocked response" and found prose here where every real
|
|
6327
|
+
// integration ships code.
|
|
4807
6328
|
block2Parts.push(
|
|
4808
|
-
`#
|
|
4809
|
-
`#
|
|
4810
|
-
`#
|
|
6329
|
+
`# OPTIONAL — the front end above streams its reply locally, so this scaffold`,
|
|
6330
|
+
`# RUNS with no backend and no key. The route below is the mock's server half:`,
|
|
6331
|
+
`# the same createMockResponder() frames served over HTTP, so you can stand up`,
|
|
6332
|
+
`# the real /api/chat seam before any provider exists. To use it, swap the`,
|
|
6333
|
+
`# onSubmit's \`const res = mockResponse(value);\` for a request to /api/chat and`,
|
|
6334
|
+
`# hand its response body to the same reader. Going live later then means`,
|
|
6335
|
+
`# editing THIS file only — replace the responder with a provider call, or`,
|
|
6336
|
+
`# re-scaffold with a real integration and this block becomes its route.`,
|
|
4811
6337
|
);
|
|
4812
|
-
}
|
|
6338
|
+
}
|
|
6339
|
+
if (route) {
|
|
4813
6340
|
if (!route.exact) {
|
|
4814
6341
|
block2Parts.push(
|
|
4815
6342
|
`# Note: ${integration.title} has no route for "${framework}". Emitting its native`,
|
|
@@ -5070,8 +6597,8 @@ function rejectUseCase(id: string): string {
|
|
|
5070
6597
|
// so the rejection that teaches the id list is the right place to say so.
|
|
5071
6598
|
`These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
|
|
5072
6599
|
`preset names, pass the list directly, e.g. components: ["kai-chat", "kai-tool",`,
|
|
5073
|
-
`"kai-reasoning", "kai-artifact", "kai-resizable"] for
|
|
5074
|
-
`tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`,
|
|
6600
|
+
`"kai-reasoning", "kai-artifact", "kai-resizable"] for an artifact split that also renders`,
|
|
6601
|
+
`its tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`,
|
|
5075
6602
|
].join('\n');
|
|
5076
6603
|
}
|
|
5077
6604
|
|