Skip to content
kitn AI/UI

Artifacts canvas beside chat

A coding-agent layout in the shape of Claude Artifacts or v0: the conversation drives the work on the left, and the right side pairs a <kai-file-tree> with a <kai-artifact> canvas. Pick a file in the tree and it loads into the canvas — HTML pages frame live in the sandboxed preview, every file opens its source on the Code tab.

Three elements compose the shell. <kai-resizable> splits chat from the canvas column; a nested vertical <kai-resizable> stacks the tree above the artifact. Selecting a file in <kai-file-tree> fires kai-select; the handler sets the artifact’s activeFile (and, for files with a hosted url, its src) so the canvas follows the tree.

<kai-resizable orientation="horizontal" style="display:block;height:620px">
<!-- left: the conversation -->
<kai-resizable-item size="42%" min="280px">
<kai-chat id="chat" chat-title="Build agent"></kai-chat>
</kai-resizable-item>
<!-- right: file tree above the artifact canvas -->
<kai-resizable-item min="320px">
<kai-resizable orientation="vertical" style="display:block;height:100%">
<kai-resizable-item size="34%" min="120px" max="60%">
<kai-file-tree id="tree"></kai-file-tree>
</kai-resizable-item>
<kai-resizable-item min="200px">
<kai-artifact
id="canvas"
iframe-title="Project preview"
open-in-tab
></kai-artifact>
</kai-resizable-item>
</kai-resizable>
</kai-resizable-item>
</kai-resizable>
<script type="module">
import '@kitn.ai/ui/elements'; // registers the custom elements
await customElements.whenDefined('kai-file-tree');
const tree = document.getElementById('tree');
const canvas = document.getElementById('canvas');
// Each file: { path, url?, code?, language?, type? }. `url` points at a page
// your backend hosts; `code` feeds the Code tab. Folders derive from `/`.
const files = [
{ path: 'index.html', type: 'html', language: 'html', code: '<!DOCTYPE …', url: 'https://your-backend.example/artifacts/abc/index.html' },
{ path: 'about.html', type: 'html', language: 'html', code: '<!DOCTYPE …', url: 'https://your-backend.example/artifacts/abc/about.html' },
{ path: 'styles.css', type: 'other', language: 'css', code: ':root { … }', url: 'https://your-backend.example/artifacts/abc/styles.css' },
{ path: 'src/theme.ts', type: 'other', language: 'ts', code: 'export const theme = …' },
];
tree.files = files; // both take the same array, set in JS
tree.activeFile = 'index.html';
canvas.files = files;
canvas.activeFile = 'index.html';
canvas.src = files[0].url; // the preview frames this URL
// Tree → canvas: load the picked file into the artifact.
tree.addEventListener('kai-select', (e) => {
const file = files.find((f) => f.path === e.detail.path);
tree.activeFile = e.detail.path;
canvas.activeFile = e.detail.path;
if (file.url) { canvas.src = file.url; canvas.tab = 'preview'; }
else { canvas.tab = 'code'; } // code-only file → show the source
});
</script>

<kai-artifact> self-navigates its sandboxed iframe. The back/forward/reload/home toolbar and the address field always track navigations the canvas starts — picking a file, editing the path, home, reload. In-frame relative-link clicks (clicking “About →” inside the preview) are the one thing it cannot see: the default allow-scripts allow-forms sandbox gives the framed document an opaque origin, and reading its location across that boundary throws.

Do not reach for allow-same-origin to close that gap. It gives the framed document your page’s origin, which means the framed document gets your cookies, your storage and your DOM. If the src can come from a model — and on the artifact card src and files[].url are model-supplied — a hostile URL then executes in your origin with no click at all. “It’s an artifact I trust” is not a premise that survives tool output. Losing in-frame click tracking is the right trade; the component filters javascript: and vbscript: before framing either way, but that is a backstop, not permission.