feat(canvas): persist artifacts and surface UI
This commit is contained in:
@@ -1229,6 +1229,8 @@ Push tokens are returned as masked previews (`tokenPreview`) and never exposed i
|
|||||||
|
|
||||||
### Canvas Methods
|
### Canvas Methods
|
||||||
|
|
||||||
|
Canvas artifacts are stored per session and persisted to the gateway data directory so they survive daemon restarts.
|
||||||
|
|
||||||
#### `canvas.put`
|
#### `canvas.put`
|
||||||
|
|
||||||
Upsert a session-scoped canvas artifact.
|
Upsert a session-scoped canvas artifact.
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ Gateway streaming UX signals:
|
|||||||
- WebSocket `agent.send` emits `run_state` lifecycle events (`start`, `cancel_requested`, `cancelled`, `complete`, `error`) for UI/state rendering.
|
- WebSocket `agent.send` emits `run_state` lifecycle events (`start`, `cancel_requested`, `cancelled`, `complete`, `error`) for UI/state rendering.
|
||||||
- Routing applies reaction rules with deterministic priority/cooldown (and recursion guard) before intent routing.
|
- Routing applies reaction rules with deterministic priority/cooldown (and recursion guard) before intent routing.
|
||||||
- Companion nodes re-register `node.*` capabilities after reconnect; runtime clients can auto-reconnect and surface connection events.
|
- Companion nodes re-register `node.*` capabilities after reconnect; runtime clients can auto-reconnect and surface connection events.
|
||||||
|
- Canvas artifacts are persisted by the gateway so session UI surfaces can recover after daemon restarts.
|
||||||
|
|
||||||
Key files:
|
Key files:
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`.
|
|||||||
- Run lifecycle/cancel intent and reaction decisions are emitted to audit logs, and aggregated into `system.metrics` counters (runStates, cancelLatencyMs, reactions) for dashboards.
|
- Run lifecycle/cancel intent and reaction decisions are emitted to audit logs, and aggregated into `system.metrics` counters (runStates, cancelLatencyMs, reactions) for dashboards.
|
||||||
- Reaction matching is deterministic (priority + cooldown + recursion guard) before intent/agent routing.
|
- Reaction matching is deterministic (priority + cooldown + recursion guard) before intent/agent routing.
|
||||||
- Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods.
|
- Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods.
|
||||||
|
- Canvas artifacts are persisted per session under the gateway data directory for UI recovery across restarts.
|
||||||
|
|
||||||
## Component Map
|
## Component Map
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -6717,10 +6717,30 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/cli/companion.test.ts passing"
|
"test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/cli/companion.test.ts passing"
|
||||||
|
},
|
||||||
|
"deeper-surfaces-phase3-canvas-persistence": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-25",
|
||||||
|
"updated": "2026-02-25",
|
||||||
|
"summary": "Added durable canvas storage backed by the gateway data directory, surfaced a lightweight canvas inspection panel in the web chat UI, and documented persistence behavior with focused tests.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/gateway/canvas-store.ts",
|
||||||
|
"src/gateway/canvas-store.test.ts",
|
||||||
|
"src/gateway/server.ts",
|
||||||
|
"src/daemon/services.ts",
|
||||||
|
"src/daemon/index.ts",
|
||||||
|
"src/gateway/ui/pages/chat.js",
|
||||||
|
"src/gateway/ui/pages/chat.test.ts",
|
||||||
|
"docs/api/PROTOCOL.md",
|
||||||
|
"docs/architecture/AGENT_DIAGRAM.md",
|
||||||
|
"docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/gateway/canvas-store.test.ts src/gateway/ui/pages/chat.test.ts passing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 2020,
|
"total_test_count": 2021,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
|
|||||||
+1
-1
@@ -247,7 +247,7 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
|
|||||||
let channelAgents: ReturnType<typeof createMessageRouter>['agents'] | null = null;
|
let channelAgents: ReturnType<typeof createMessageRouter>['agents'] | null = null;
|
||||||
|
|
||||||
const gateway = createGateway({
|
const gateway = createGateway({
|
||||||
config, configPath: options?.persistConfigPath ?? options?.configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
|
config, configPath: options?.persistConfigPath ?? options?.configPath, dataDir, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
|
||||||
channelRegistry, pairingManager, lifecycle, memoryStore,
|
channelRegistry, pairingManager, lifecycle, memoryStore,
|
||||||
getBackendMode: () => backendMode,
|
getBackendMode: () => backendMode,
|
||||||
setBackendMode: (mode) => {
|
setBackendMode: (mode) => {
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ export function initPairingManager(config: Config, store?: PairingStore): Pairin
|
|||||||
export interface GatewayDeps {
|
export interface GatewayDeps {
|
||||||
config: Config;
|
config: Config;
|
||||||
configPath?: string;
|
configPath?: string;
|
||||||
|
dataDir: string;
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
modelRouter: ModelRouter;
|
modelRouter: ModelRouter;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
@@ -301,7 +302,7 @@ export interface GatewayDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createGateway(deps: GatewayDeps): GatewayServer {
|
export function createGateway(deps: GatewayDeps): GatewayServer {
|
||||||
const { config, configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, getChannelAgents } = deps;
|
const { config, configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, getChannelAgents, dataDir } = deps;
|
||||||
|
|
||||||
const gateway = new GatewayServer({
|
const gateway = new GatewayServer({
|
||||||
port: config.server.port,
|
port: config.server.port,
|
||||||
@@ -358,6 +359,9 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
canvas: {
|
||||||
|
persistDir: resolve(dataDir, 'canvas'),
|
||||||
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
enabled: config.server.nodes.enabled,
|
enabled: config.server.nodes.enabled,
|
||||||
allowedRoles: config.server.nodes.allowed_roles,
|
allowedRoles: config.server.nodes.allowed_roles,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import { CanvasStore } from './canvas-store.js';
|
import { CanvasStore } from './canvas-store.js';
|
||||||
|
|
||||||
describe('CanvasStore', () => {
|
describe('CanvasStore', () => {
|
||||||
@@ -55,4 +58,22 @@ describe('CanvasStore', () => {
|
|||||||
expect(store.get('ws:1', 'a1')).toBeUndefined();
|
expect(store.get('ws:1', 'a1')).toBeUndefined();
|
||||||
expect(store.list('ws:1').map((a) => a.id).sort()).toEqual(['a2', 'a3']);
|
expect(store.list('ws:1').map((a) => a.id).sort()).toEqual(['a2', 'a3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists artifacts when a persistDir is configured', () => {
|
||||||
|
const dir = mkdtempSync(resolve(tmpdir(), 'flynn-canvas-'));
|
||||||
|
try {
|
||||||
|
const store = new CanvasStore({ persistDir: dir });
|
||||||
|
store.put('ws:1', {
|
||||||
|
id: 'a1',
|
||||||
|
type: 'note',
|
||||||
|
content: { text: 'hello' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloaded = new CanvasStore({ persistDir: dir });
|
||||||
|
expect(reloaded.list('ws:1')).toHaveLength(1);
|
||||||
|
expect(reloaded.get('ws:1', 'a1')?.content).toEqual({ text: 'hello' });
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+129
-1
@@ -1,3 +1,6 @@
|
|||||||
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
export interface CanvasArtifact {
|
export interface CanvasArtifact {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -8,6 +11,11 @@ export interface CanvasArtifact {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CanvasStoreOptions {
|
||||||
|
maxArtifactsPerSession?: number;
|
||||||
|
persistDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CanvasPutInput {
|
interface CanvasPutInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -22,10 +30,25 @@ interface CanvasPutInput {
|
|||||||
*/
|
*/
|
||||||
export class CanvasStore {
|
export class CanvasStore {
|
||||||
private readonly sessions = new Map<string, Map<string, CanvasArtifact>>();
|
private readonly sessions = new Map<string, Map<string, CanvasArtifact>>();
|
||||||
|
private readonly hydratedSessions = new Set<string>();
|
||||||
|
private readonly maxArtifactsPerSession: number;
|
||||||
|
private readonly persistDir?: string;
|
||||||
|
|
||||||
constructor(private readonly maxArtifactsPerSession = 200) {}
|
constructor(options: CanvasStoreOptions | number = {}) {
|
||||||
|
if (typeof options === 'number') {
|
||||||
|
this.maxArtifactsPerSession = options;
|
||||||
|
} else {
|
||||||
|
this.maxArtifactsPerSession = options.maxArtifactsPerSession ?? 200;
|
||||||
|
this.persistDir = options.persistDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.persistDir) {
|
||||||
|
mkdirSync(this.persistDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
list(sessionId: string): CanvasArtifact[] {
|
list(sessionId: string): CanvasArtifact[] {
|
||||||
|
this.ensureHydrated(sessionId);
|
||||||
const entries = this.sessions.get(sessionId);
|
const entries = this.sessions.get(sessionId);
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
return [];
|
return [];
|
||||||
@@ -34,10 +57,12 @@ export class CanvasStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(sessionId: string, artifactId: string): CanvasArtifact | undefined {
|
get(sessionId: string, artifactId: string): CanvasArtifact | undefined {
|
||||||
|
this.ensureHydrated(sessionId);
|
||||||
return this.sessions.get(sessionId)?.get(artifactId);
|
return this.sessions.get(sessionId)?.get(artifactId);
|
||||||
}
|
}
|
||||||
|
|
||||||
put(sessionId: string, input: CanvasPutInput): CanvasArtifact {
|
put(sessionId: string, input: CanvasPutInput): CanvasArtifact {
|
||||||
|
this.ensureHydrated(sessionId);
|
||||||
const id = sanitizeArtifactId(input.id);
|
const id = sanitizeArtifactId(input.id);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let entries = this.sessions.get(sessionId);
|
let entries = this.sessions.get(sessionId);
|
||||||
@@ -64,10 +89,12 @@ export class CanvasStore {
|
|||||||
entries.delete(oldest.id);
|
entries.delete(oldest.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.persistSession(sessionId);
|
||||||
return artifact;
|
return artifact;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(sessionId: string, artifactId: string): boolean {
|
delete(sessionId: string, artifactId: string): boolean {
|
||||||
|
this.ensureHydrated(sessionId);
|
||||||
const entries = this.sessions.get(sessionId);
|
const entries = this.sessions.get(sessionId);
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
return false;
|
return false;
|
||||||
@@ -76,18 +103,90 @@ export class CanvasStore {
|
|||||||
if (entries.size === 0) {
|
if (entries.size === 0) {
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
this.persistSession(sessionId);
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(sessionId: string): number {
|
clear(sessionId: string): number {
|
||||||
|
this.ensureHydrated(sessionId);
|
||||||
const entries = this.sessions.get(sessionId);
|
const entries = this.sessions.get(sessionId);
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const count = entries.size;
|
const count = entries.size;
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
this.persistSession(sessionId);
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureHydrated(sessionId: string): void {
|
||||||
|
if (!this.persistDir || this.hydratedSessions.has(sessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.hydratedSessions.add(sessionId);
|
||||||
|
const filePath = resolve(this.persistDir, sessionFileName(sessionId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as { artifacts?: CanvasArtifact[] } | null;
|
||||||
|
const artifacts = Array.isArray(parsed?.artifacts) ? parsed?.artifacts ?? [] : [];
|
||||||
|
const entries = new Map<string, CanvasArtifact>();
|
||||||
|
const now = Date.now();
|
||||||
|
for (const item of artifacts) {
|
||||||
|
const normalized = normalizeArtifact(item, now);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.set(normalized.id, normalized);
|
||||||
|
}
|
||||||
|
if (entries.size > 0) {
|
||||||
|
this.trimEntries(entries);
|
||||||
|
this.sessions.set(sessionId, entries);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException;
|
||||||
|
if (err?.code !== 'ENOENT') {
|
||||||
|
// Ignore corrupt state; we'll rebuild as new artifacts arrive.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSession(sessionId: string): void {
|
||||||
|
if (!this.persistDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = resolve(this.persistDir, sessionFileName(sessionId));
|
||||||
|
const entries = this.sessions.get(sessionId);
|
||||||
|
try {
|
||||||
|
if (!entries || entries.size === 0) {
|
||||||
|
rmSync(filePath, { force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
sessionId,
|
||||||
|
artifacts: Array.from(entries.values()).sort((a, b) => b.updatedAt - a.updatedAt),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`Failed to persist canvas session ${sessionId}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimEntries(entries: Map<string, CanvasArtifact>): void {
|
||||||
|
if (entries.size <= this.maxArtifactsPerSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sorted = Array.from(entries.values()).sort((a, b) => a.updatedAt - b.updatedAt);
|
||||||
|
const excess = sorted.length - this.maxArtifactsPerSession;
|
||||||
|
for (let i = 0; i < excess; i += 1) {
|
||||||
|
const artifact = sorted[i];
|
||||||
|
if (artifact) {
|
||||||
|
entries.delete(artifact.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeArtifactId(raw?: string): string {
|
function sanitizeArtifactId(raw?: string): string {
|
||||||
@@ -97,3 +196,32 @@ function sanitizeArtifactId(raw?: string): string {
|
|||||||
}
|
}
|
||||||
return `art_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
return `art_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionFileName(sessionId: string): string {
|
||||||
|
const encoded = Buffer.from(sessionId).toString('base64url');
|
||||||
|
return `canvas_${encoded}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArtifact(raw: CanvasArtifact, now: number): CanvasArtifact | null {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
||||||
|
const type = typeof raw.type === 'string' ? raw.type.trim() : '';
|
||||||
|
if (!id || !type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const title = typeof raw.title === 'string' && raw.title.trim().length > 0 ? raw.title.trim() : undefined;
|
||||||
|
const createdAt = Number.isFinite(raw.createdAt) ? raw.createdAt : now;
|
||||||
|
const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : createdAt;
|
||||||
|
const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : undefined;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
content: raw.content,
|
||||||
|
metadata: metadata as Record<string, unknown> | undefined,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ export interface GatewayServerConfig {
|
|||||||
sessions?: Record<string, Partial<LaneQueueConfig>>;
|
sessions?: Record<string, Partial<LaneQueueConfig>>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
canvas?: {
|
||||||
|
persistDir?: string;
|
||||||
|
maxArtifactsPerSession?: number;
|
||||||
|
};
|
||||||
nodes?: {
|
nodes?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
allowedRoles: string[];
|
allowedRoles: string[];
|
||||||
@@ -203,7 +207,10 @@ export class GatewayServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.laneQueue = new LaneQueue(config.queue);
|
this.laneQueue = new LaneQueue(config.queue);
|
||||||
this.canvasStore = new CanvasStore();
|
this.canvasStore = new CanvasStore({
|
||||||
|
maxArtifactsPerSession: config.canvas?.maxArtifactsPerSession,
|
||||||
|
persistDir: config.canvas?.persistDir,
|
||||||
|
});
|
||||||
this.metrics = new MetricsCollector({
|
this.metrics = new MetricsCollector({
|
||||||
getQueueDepth: () => this.laneQueue.totalPending(),
|
getQueueDepth: () => this.laneQueue.totalPending(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ let _slashPopupIndex = -1;
|
|||||||
let _elements = {};
|
let _elements = {};
|
||||||
let _pendingAttachments = [];
|
let _pendingAttachments = [];
|
||||||
let _sessionSort = 'recent';
|
let _sessionSort = 'recent';
|
||||||
|
let _client = null;
|
||||||
|
let _canvasOpen = false;
|
||||||
|
let _canvasLoading = false;
|
||||||
|
let _canvasArtifacts = [];
|
||||||
|
let _canvasError = null;
|
||||||
|
|
||||||
// ── Slash Command Definitions ───────────────────────────────
|
// ── 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 ────────────────────────────────────────────
|
// ── Send Message ────────────────────────────────────────────
|
||||||
|
|
||||||
async function sendMessage(client, overrideText) {
|
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 = {
|
export const ChatPage = {
|
||||||
async render(el, client) {
|
async render(el, client) {
|
||||||
|
_client = client;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="flex flex-col h-[calc(100vh-6rem)] md:h-[calc(100vh-3rem)] max-w-3xl">
|
<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">
|
<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>
|
</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-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-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>
|
||||||
|
<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-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">
|
<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">
|
<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'),
|
fileInput: el.querySelector('#chat-file'),
|
||||||
attachments: el.querySelector('#chat-attachments'),
|
attachments: el.querySelector('#chat-attachments'),
|
||||||
slashPopup: el.querySelector('#slash-popup'),
|
slashPopup: el.querySelector('#slash-popup'),
|
||||||
|
canvasPanel: el.querySelector('#chat-canvas-panel'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load sessions into dropdown
|
// Load sessions into dropdown
|
||||||
@@ -890,6 +994,9 @@ export const ChatPage = {
|
|||||||
// Event: session change
|
// Event: session change
|
||||||
_elements.sessionSelect.addEventListener('change', () => {
|
_elements.sessionSelect.addEventListener('change', () => {
|
||||||
_currentSession = _elements.sessionSelect.value || null;
|
_currentSession = _elements.sessionSelect.value || null;
|
||||||
|
if (_canvasOpen) {
|
||||||
|
void loadCanvasArtifacts();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_elements.sessionSort.addEventListener('change', () => {
|
_elements.sessionSort.addEventListener('change', () => {
|
||||||
@@ -914,6 +1021,16 @@ export const ChatPage = {
|
|||||||
loadHistory(client);
|
loadHistory(client);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Event: load canvas
|
||||||
|
el.querySelector('#chat-load-canvas').addEventListener('click', () => {
|
||||||
|
_canvasOpen = !_canvasOpen;
|
||||||
|
if (_canvasOpen) {
|
||||||
|
void loadCanvasArtifacts();
|
||||||
|
} else {
|
||||||
|
renderCanvasPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Event: search button toggle
|
// Event: search button toggle
|
||||||
_elements.searchBtn.addEventListener('click', () => {
|
_elements.searchBtn.addEventListener('click', () => {
|
||||||
setSearchMode(!_searchMode);
|
setSearchMode(!_searchMode);
|
||||||
@@ -1040,5 +1157,10 @@ export const ChatPage = {
|
|||||||
_sessionSort = 'recent';
|
_sessionSort = 'recent';
|
||||||
_elements = {};
|
_elements = {};
|
||||||
_pendingAttachments = [];
|
_pendingAttachments = [];
|
||||||
|
_client = null;
|
||||||
|
_canvasOpen = false;
|
||||||
|
_canvasLoading = false;
|
||||||
|
_canvasArtifacts = [];
|
||||||
|
_canvasError = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,6 +89,20 @@ function createClient() {
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
stream(method: string, params?: Record<string, unknown>) {
|
stream(method: string, params?: Record<string, unknown>) {
|
||||||
@@ -164,6 +178,11 @@ describe('ChatPage wiring', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(calls.some((entry) => entry.method === 'sessions.history')).toBe(true);
|
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 }));
|
root.querySelector('#chat-search').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
|
||||||
const input = root.querySelector('#chat-input');
|
const input = root.querySelector('#chat-input');
|
||||||
input.value = 'status of flynn';
|
input.value = 'status of flynn';
|
||||||
|
|||||||
Reference in New Issue
Block a user