feat(canvas): persist artifacts and surface UI

This commit is contained in:
William Valentin
2026-02-25 11:18:53 -08:00
parent ac60fa5be3
commit e3e98058b0
11 changed files with 330 additions and 5 deletions
+122
View File
@@ -16,6 +16,11 @@ let _slashPopupIndex = -1;
let _elements = {};
let _pendingAttachments = [];
let _sessionSort = 'recent';
let _client = null;
let _canvasOpen = false;
let _canvasLoading = false;
let _canvasArtifacts = [];
let _canvasError = null;
// ── Slash Command Definitions ───────────────────────────────
@@ -675,6 +680,101 @@ async function loadHistory(client) {
}
}
// ── Canvas Surface ───────────────────────────────────────────
function renderCanvasPanel() {
const panel = _elements.canvasPanel;
if (!panel) {return;}
if (!_canvasOpen) {
panel.classList.add('hidden');
panel.innerHTML = '';
return;
}
panel.classList.remove('hidden');
panel.innerHTML = `
<div class="flex items-center justify-between px-3 py-2 border border-zinc-800 rounded-lg bg-zinc-900/70">
<div class="text-xs font-semibold text-zinc-200 uppercase tracking-wide">Canvas</div>
<button id="chat-canvas-refresh" class="text-xs text-zinc-400 hover:text-zinc-100 transition-colors">Refresh</button>
</div>
<div id="chat-canvas-body" class="mt-2 space-y-2"></div>
`;
const refreshBtn = panel.querySelector('#chat-canvas-refresh');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
void loadCanvasArtifacts();
});
}
const body = panel.querySelector('#chat-canvas-body');
if (!body) {return;}
if (!_currentSession) {
body.innerHTML = '<div class="text-xs text-zinc-500">Select a session to view canvas artifacts.</div>';
return;
}
if (_canvasLoading) {
body.innerHTML = '<div class="text-xs text-zinc-500">Loading canvas artifacts...</div>';
return;
}
if (_canvasError) {
body.innerHTML = `<div class="text-xs text-red-400">${escapeHtml(_canvasError)}</div>`;
return;
}
if (_canvasArtifacts.length === 0) {
body.innerHTML = '<div class="text-xs text-zinc-500">No canvas artifacts yet.</div>';
return;
}
for (const artifact of _canvasArtifacts) {
const card = document.createElement('div');
card.className = 'border border-zinc-800 rounded-lg bg-zinc-900 px-3 py-2';
const title = artifact.title ? escapeHtml(artifact.title) : 'Untitled';
const type = artifact.type ? escapeHtml(artifact.type) : 'unknown';
const updatedAt = typeof artifact.updatedAt === 'number' ? formatMessageTimestamp(artifact.updatedAt) : '';
const contentPreview = escapeHtml(JSON.stringify(artifact.content ?? '', null, 2).slice(0, 800));
card.innerHTML = `
<div class="flex items-center justify-between text-[11px] text-zinc-500">
<div>${title}</div>
<div>${type}${updatedAt ? ` · ${updatedAt}` : ''}</div>
</div>
<pre class="mt-2 text-[11px] leading-relaxed text-zinc-300 whitespace-pre-wrap break-words">${contentPreview}</pre>
`;
body.appendChild(card);
}
}
async function loadCanvasArtifacts() {
if (!_client) {return;}
_canvasLoading = true;
_canvasError = null;
renderCanvasPanel();
if (!_currentSession) {
_canvasArtifacts = [];
_canvasLoading = false;
renderCanvasPanel();
return;
}
try {
const result = await _client.call('canvas.list', { sessionId: _currentSession });
_canvasArtifacts = result?.artifacts ?? [];
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
_canvasArtifacts = [];
_canvasError = `Failed to load canvas: ${message}`;
} finally {
_canvasLoading = false;
renderCanvasPanel();
}
}
// ── Send Message ────────────────────────────────────────────
async function sendMessage(client, overrideText) {
@@ -836,6 +936,7 @@ const EDIT_ICON = '<svg class="w-3.5 h-3.5 fill-current" viewBox="0 0 16 16" xml
export const ChatPage = {
async render(el, client) {
_client = client;
el.innerHTML = `
<div class="flex flex-col h-[calc(100vh-6rem)] md:h-[calc(100vh-3rem)] max-w-3xl">
<div class="flex items-center gap-3 pb-3 border-b border-zinc-800 mb-3 flex-wrap">
@@ -847,7 +948,9 @@ export const ChatPage = {
</select>
<button id="chat-new-session" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">+ New</button>
<button id="chat-load-history" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">History</button>
<button id="chat-load-canvas" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">Canvas</button>
</div>
<div id="chat-canvas-panel" class="hidden mb-3"></div>
<div class="flex-1 overflow-y-auto flex flex-col gap-3 py-3" id="chat-messages"></div>
<div class="flex items-center gap-2 py-2 flex-wrap">
<button id="chat-search" class="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:text-zinc-50 hover:border-zinc-600 transition-colors cursor-pointer select-none" title="Search the web">
@@ -882,6 +985,7 @@ export const ChatPage = {
fileInput: el.querySelector('#chat-file'),
attachments: el.querySelector('#chat-attachments'),
slashPopup: el.querySelector('#slash-popup'),
canvasPanel: el.querySelector('#chat-canvas-panel'),
};
// Load sessions into dropdown
@@ -890,6 +994,9 @@ export const ChatPage = {
// Event: session change
_elements.sessionSelect.addEventListener('change', () => {
_currentSession = _elements.sessionSelect.value || null;
if (_canvasOpen) {
void loadCanvasArtifacts();
}
});
_elements.sessionSort.addEventListener('change', () => {
@@ -914,6 +1021,16 @@ export const ChatPage = {
loadHistory(client);
});
// Event: load canvas
el.querySelector('#chat-load-canvas').addEventListener('click', () => {
_canvasOpen = !_canvasOpen;
if (_canvasOpen) {
void loadCanvasArtifacts();
} else {
renderCanvasPanel();
}
});
// Event: search button toggle
_elements.searchBtn.addEventListener('click', () => {
setSearchMode(!_searchMode);
@@ -1040,5 +1157,10 @@ export const ChatPage = {
_sessionSort = 'recent';
_elements = {};
_pendingAttachments = [];
_client = null;
_canvasOpen = false;
_canvasLoading = false;
_canvasArtifacts = [];
_canvasError = null;
},
};
+19
View File
@@ -89,6 +89,20 @@ function createClient() {
}
return { ok: true };
}
if (method === 'canvas.list') {
return {
artifacts: [
{
id: 'a1',
type: 'note',
title: 'Example',
content: { text: 'hello' },
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
};
}
return null;
},
stream(method: string, params?: Record<string, unknown>) {
@@ -164,6 +178,11 @@ describe('ChatPage wiring', () => {
await Promise.resolve();
expect(calls.some((entry) => entry.method === 'sessions.history')).toBe(true);
root.querySelector('#chat-load-canvas').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const canvasCall = calls.find((entry) => entry.method === 'canvas.list');
expect(canvasCall?.params?.sessionId).toBe('ws:alpha');
root.querySelector('#chat-search').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
const input = root.querySelector('#chat-input');
input.value = 'status of flynn';