feat(canvas): persist artifacts and surface UI
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user