feat: add interactive canvas artifacts to session detail view

Adds Cursor-style "canvases" — interactive artifacts (markdown / diff /
table / stats) rendered in a right-side rail next to the session terminal
so structured agent output (diffs, test results, stats) doesn't get buried
in scrollback.

Architecture is plugin-friendly without inventing a new plugin slot:
agents emit JSON files at `{workspacePath}/.ao/canvases/{id}.json`, core
validates and serves them via `GET /api/sessions/[id]/canvases`, and the
web layer ships a fixed renderer set so third parties can produce data
without shipping React components into the supervisor dashboard. The
`CanvasProducer` interface is declared for v0.2 plugin invocation but not
yet called.

v0.1 also synthesizes a `core-git-diff` canvas from `git diff` against
the merge-base with `origin/<default>` (with local-ref + uncommitted +
untracked-file fallbacks), so the feature is useful for every session
even before any agent integration.

Hardened across 10 codex review passes:
- working-tree diffs (not just committed changes)
- merge-base against origin/<base>, not stale local ref
- untracked files included with file/byte/lstat caps
- `.ao/` files excluded from synthesized diffs
- reserved `core-` id prefix for synthesized canvases
- per-effect cancellation + sequence-guarded poll responses
- lstat (not stat) on canvas files to reject FIFO/symlink bypass
- partial-stdout recovery on oversized diffs
- session.projectId (not prefix-matching) for project resolution
- empty canvases for sessions without a workspacePath
- diff parser preserves `+++`/`---` content lines, strips marker prefix
- size caps: 256 KB per canvas, 32 canvases per session, 200 files /
  5000 lines per diff, 50 untracked files / 4 MB budget

Tests: 17 core + 8 web, all passing. Docs at docs/canvases.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ashish Huddar 2026-05-05 17:01:31 +05:30
parent 8744231ccd
commit ca0b7ec2ce
16 changed files with 1458 additions and 1 deletions

120
docs/canvases.md Normal file
View File

@ -0,0 +1,120 @@
# Canvases
Canvases are interactive artifacts rendered next to the terminal in the session detail view. Instead of scrolling through terminal scrollback to find a diff, a test summary, or PR status, agents and plugins emit structured artifacts that the dashboard renders as standalone panels.
This is AO's answer to Cursor's [canvases](https://cursor.com/docs/agent/tools/canvas), shaped for an open-source plugin ecosystem.
## Design principles
1. **Expressive data, constrained UI.** Producers supply data in one of a fixed set of renderer types. They do not ship React components. This keeps the dashboard install one-step, avoids running third-party JS in the supervisor, and keeps the rendered surface consistent across every AO instance.
2. **No new plugin slot.** Canvases are session output, not infrastructure. Existing plugins (agent, SCM, tracker) opt in by implementing `CanvasProducer`. Core also synthesizes canvases from PR / CI / git data so the feature works without per-agent integration.
3. **Workspace is the source of truth.** Agents write canvases to `{workspacePath}/.ao/canvases/{id}.json` — same pattern as `activity.jsonl`. Core reads, validates, size-caps, and exposes through the dashboard API. The dashboard never reads agent files directly.
4. **Pull, then push.** v0.1 polls a REST endpoint on the session detail page. Live updates ride on the existing mux WebSocket later — no new SSE channel.
## Renderer types
Defined in [`packages/core/src/types.ts`](../packages/core/src/types.ts) as `CanvasArtifact`. Current set:
| Type | Use for | Payload shape |
|------|---------|---------------|
| `markdown` | Notes, summaries, READMEs, plain text reports | `{ markdown: string }` |
| `diff` | File changes, patches, reviews | `{ files: CanvasDiffFile[] }` |
| `table` | Test results, dependency lists, anything tabular | `{ columns, rows }` |
| `stats` | Cost, token counts, durations, pass/fail counts | `{ metrics: CanvasStatMetric[] }` |
If your data doesn't fit, **shape it to fit one of these** before reaching for a new type. A "test results" canvas is a `table`. A "deployment status" canvas is `stats` plus `markdown`. Adding a new renderer type requires a core PR — propose it as an issue first with at least two real use cases.
## Producing canvases
### From an agent (file-based)
Write a JSON file to `{workspacePath}/.ao/canvases/{id}.json` matching `CanvasArtifact`. Use a stable `id` if you want to overwrite the same canvas across runs; use a fresh `id` to append.
```json
{
"version": 1,
"id": "test-results",
"type": "table",
"title": "Test results",
"createdAt": "2026-05-04T17:00:00Z",
"updatedAt": "2026-05-04T17:00:00Z",
"source": "agent",
"payload": {
"columns": [
{ "key": "name", "label": "Test" },
{ "key": "status", "label": "Status" },
{ "key": "duration_ms", "label": "Duration", "align": "right" }
],
"rows": [
{ "name": "auth.test.ts", "status": "pass", "duration_ms": 142 },
{ "name": "billing.test.ts", "status": "fail", "duration_ms": 89 }
]
}
}
```
The directory is gitignored and travels with the worktree.
### From a plugin (programmatic)
Implement `CanvasProducer` on any existing plugin (agent, SCM, tracker). Core calls `listCanvases` when the session detail view loads.
```ts
import type { CanvasProducer, CanvasArtifact, Session, ProjectConfig } from "@aoagents/ao-core";
const producer: CanvasProducer = {
async listCanvases(session: Session, project: ProjectConfig): Promise<CanvasArtifact[]> {
return [
{
version: 1,
id: "pr-status",
type: "stats",
title: "PR status",
createdAt: session.createdAt,
updatedAt: new Date().toISOString(),
source: "scm-github",
payload: {
metrics: [
{ label: "CI", value: "passing", tone: "good" },
{ label: "Reviews", value: 2 },
{ label: "Mergeable", value: "yes", tone: "good" },
],
},
},
];
},
};
```
Returned artifacts are validated and size-capped by core before reaching the dashboard.
## Validation rules
Canvases that fail validation are dropped silently and logged. Rules:
- `version` must be `1`.
- `type` must be a known `CanvasType`.
- `id` must be `[a-z0-9-]{1,64}`.
- The `core-` id prefix is reserved for canvases synthesized by AO core (e.g. `core-git-diff`). File canvases using this prefix are dropped — pick a different id.
- Total serialized size capped at 256 KB per canvas.
- Per-session count capped at 32 canvases (oldest by `updatedAt` evicted).
- Payload must structurally match the type.
## Storage layout
```
{workspacePath}/.ao/canvases/
test-results.json
diff-summary.json
```
Synthesized canvases (PR, CI, cost) are computed on read and not persisted. If a canvas needs to survive workspace cleanup, persist it via the session metadata directory in core — not from the producer.
## Roadmap
- **v0.1 (shipped)** — file reader, `GET /api/sessions/[id]/canvases`, four built-in renderers, `core.git-diff` synthesized canvas, right-rail in `SessionDetail` (auto-expands when canvases exist), 5s REST poll with visibility-aware pause.
- **v0.2**`CanvasProducer` invoked on agent / SCM / tracker plugins.
- **v0.3** — mux topic for live updates, replacing poll.
- **Later, only if justified** — sandboxed iframe escape hatch for custom UI, build-time allowlisted renderer packages.
Out of scope indefinitely: dynamic React imports from third-party plugins, write APIs from the dashboard back into canvases, action buttons that mutate session state.

View File

@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
readCanvases,
parseUnifiedDiff,
getCanvasDir,
CANVAS_MAX_FILE_BYTES,
CANVAS_MAX_PER_SESSION,
} from "../canvas-log.js";
import { CanvasArtifactSchema } from "../canvas-schema.js";
import type { CanvasArtifact } from "../types.js";
let dir: string;
let canvasDir: string;
const validMarkdown: CanvasArtifact = {
version: 1,
id: "hello",
type: "markdown",
title: "Hello",
createdAt: "2026-05-05T00:00:00Z",
updatedAt: "2026-05-05T00:00:00Z",
payload: { markdown: "hi" },
};
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "canvas-test-"));
canvasDir = getCanvasDir(dir);
await mkdir(canvasDir, { recursive: true });
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(dir, { recursive: true, force: true });
});
describe("readCanvases", () => {
it("returns empty array when canvas dir does not exist", async () => {
const fresh = await mkdtemp(join(tmpdir(), "canvas-empty-"));
expect(await readCanvases(fresh)).toEqual([]);
await rm(fresh, { recursive: true, force: true });
});
it("reads valid canvases", async () => {
await writeFile(join(canvasDir, "hello.json"), JSON.stringify(validMarkdown));
const result = await readCanvases(dir);
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("hello");
});
it("drops invalid JSON without throwing", async () => {
await writeFile(join(canvasDir, "bad.json"), "{ not json");
await writeFile(join(canvasDir, "good.json"), JSON.stringify(validMarkdown));
const result = await readCanvases(dir);
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("hello");
});
it("drops canvases with unknown type", async () => {
const broken = { ...validMarkdown, type: "unknown" };
await writeFile(join(canvasDir, "broken.json"), JSON.stringify(broken));
expect(await readCanvases(dir)).toEqual([]);
});
it("drops canvases with missing required fields", async () => {
const broken = { version: 1, type: "markdown", id: "x" };
await writeFile(join(canvasDir, "broken.json"), JSON.stringify(broken));
expect(await readCanvases(dir)).toEqual([]);
});
it("drops files larger than the per-file cap", async () => {
const oversized = "x".repeat(CANVAS_MAX_FILE_BYTES + 100);
await writeFile(join(canvasDir, "big.json"), oversized);
expect(await readCanvases(dir)).toEqual([]);
});
it("caps to CANVAS_MAX_PER_SESSION, evicting oldest by updatedAt", async () => {
for (let i = 0; i < CANVAS_MAX_PER_SESSION + 5; i++) {
const c: CanvasArtifact = {
...validMarkdown,
id: `c-${i}`,
updatedAt: new Date(2026, 0, 1, 0, 0, i).toISOString(),
};
await writeFile(join(canvasDir, `c-${i}.json`), JSON.stringify(c));
}
const result = await readCanvases(dir);
expect(result).toHaveLength(CANVAS_MAX_PER_SESSION);
expect(result[0]?.id).toBe(`c-${CANVAS_MAX_PER_SESSION + 4}`);
});
it("drops file canvases that use the reserved core- prefix", async () => {
const reserved: CanvasArtifact = { ...validMarkdown, id: "core-git-diff" };
await writeFile(join(canvasDir, "evil.json"), JSON.stringify(reserved));
expect(await readCanvases(dir)).toEqual([]);
});
it("ignores non-json files", async () => {
await writeFile(join(canvasDir, "notes.txt"), "ignore me");
await writeFile(join(canvasDir, "good.json"), JSON.stringify(validMarkdown));
const result = await readCanvases(dir);
expect(result).toHaveLength(1);
});
});
describe("CanvasArtifactSchema", () => {
it("accepts a valid table canvas", () => {
const ok = CanvasArtifactSchema.safeParse({
version: 1,
id: "tests",
type: "table",
title: "Tests",
createdAt: "2026-05-05T00:00:00Z",
updatedAt: "2026-05-05T00:00:00Z",
payload: {
columns: [{ key: "name", label: "Name" }],
rows: [{ name: "auth.test.ts" }],
},
});
expect(ok.success).toBe(true);
});
it("rejects unknown payload fields (strict)", () => {
const result = CanvasArtifactSchema.safeParse({
...validMarkdown,
payload: { markdown: "hi", extra: "no" },
});
expect(result.success).toBe(false);
});
it("rejects bad id format", () => {
const result = CanvasArtifactSchema.safeParse({ ...validMarkdown, id: "BAD ID" });
expect(result.success).toBe(false);
});
});
describe("parseUnifiedDiff", () => {
it("returns no files for empty diff", () => {
expect(parseUnifiedDiff("")).toEqual({ files: [], truncated: false });
});
it("parses a single-file modification with one hunk", () => {
const diff = [
"diff --git a/foo.ts b/foo.ts",
"index abc..def 100644",
"--- a/foo.ts",
"+++ b/foo.ts",
"@@ -1,3 +1,3 @@",
" context",
"-old",
"+new",
"",
].join("\n");
const result = parseUnifiedDiff(diff);
expect(result.truncated).toBe(false);
expect(result.files).toHaveLength(1);
const file = result.files[0]!;
expect(file.path).toBe("foo.ts");
expect(file.status).toBe("modified");
expect(file.hunks[0]?.lines).toEqual([
{ kind: "context", text: "context" },
{ kind: "del", text: "old" },
{ kind: "add", text: "new" },
]);
});
it("preserves hunk lines whose content starts with +++ or ---", () => {
const diff = [
"diff --git a/notes.md b/notes.md",
"--- a/notes.md",
"+++ b/notes.md",
"@@ -1,3 +1,3 @@",
" heading",
"---",
"+++added text",
"",
].join("\n");
const result = parseUnifiedDiff(diff);
expect(result.files).toHaveLength(1);
const lines = result.files[0]!.hunks[0]!.lines;
expect(lines).toEqual([
{ kind: "context", text: "heading" },
{ kind: "del", text: "--" },
{ kind: "add", text: "++added text" },
]);
});
it("ignores file-header lines emitted before the first hunk", () => {
const diff = [
"diff --git a/foo.ts b/foo.ts",
"--- a/foo.ts",
"+++ b/foo.ts",
"@@ -1,1 +1,1 @@",
"-old",
"+new",
].join("\n");
const result = parseUnifiedDiff(diff);
const lines = result.files[0]!.hunks[0]!.lines;
expect(lines).toEqual([
{ kind: "del", text: "old" },
{ kind: "add", text: "new" },
]);
});
it("detects added and deleted files", () => {
const diff = [
"diff --git a/new.ts b/new.ts",
"new file mode 100644",
"@@ -0,0 +1,1 @@",
"+hello",
"diff --git a/gone.ts b/gone.ts",
"deleted file mode 100644",
"@@ -1,1 +0,0 @@",
"-bye",
].join("\n");
const result = parseUnifiedDiff(diff);
expect(result.files.map((f) => f.status)).toEqual(["added", "deleted"]);
});
});

View File

@ -0,0 +1,281 @@
import { execFile } from "node:child_process";
import { readdir, readFile, lstat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { CanvasArtifactSchema } from "./canvas-schema.js";
import type {
CanvasArtifact,
CanvasDiffFile,
ProjectConfig,
Session,
} from "./types.js";
const execFileAsync = promisify(execFile);
export const CANVAS_MAX_FILE_BYTES = 256 * 1024;
export const CANVAS_MAX_PER_SESSION = 32;
const DIFF_MAX_FILES = 200;
const DIFF_MAX_LINES = 5000;
const UNTRACKED_FILE_LIMIT = 50;
const UNTRACKED_BYTE_BUDGET = 4 * 1024 * 1024;
export function getCanvasDir(workspacePath: string): string {
return join(workspacePath, ".ao", "canvases");
}
export async function readCanvases(workspacePath: string): Promise<CanvasArtifact[]> {
const dir = getCanvasDir(workspacePath);
let entries: string[];
try {
entries = await readdir(dir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
throw err;
}
const canvases: CanvasArtifact[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const filePath = join(dir, entry);
let size: number;
try {
const st = await lstat(filePath);
// Reject anything that isn't a regular file. lstat (not stat) avoids
// following symlinks so a symlink to /dev/zero or a FIFO can't slip past
// the size check and hang the reader.
if (!st.isFile()) {
console.warn(`canvas: dropping ${entry} (not a regular file)`);
continue;
}
size = st.size;
} catch {
continue;
}
if (size > CANVAS_MAX_FILE_BYTES) {
console.warn(`canvas: dropping ${entry} (${size} bytes exceeds ${CANVAS_MAX_FILE_BYTES})`);
continue;
}
let raw: string;
try {
raw = await readFile(filePath, "utf-8");
} catch {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
console.warn(`canvas: dropping ${entry} (invalid JSON)`);
continue;
}
const result = CanvasArtifactSchema.safeParse(parsed);
if (!result.success) {
console.warn(`canvas: dropping ${entry} (schema invalid: ${result.error.issues[0]?.message ?? "unknown"})`);
continue;
}
// The "core-" id prefix is reserved for canvases synthesized by core
// (e.g. core-git-diff). Dropping it here prevents an agent file canvas
// from shadowing the trusted synthesized version in API responses.
if (result.data.id.startsWith("core-")) {
console.warn(`canvas: dropping ${entry} (id "${result.data.id}" uses reserved "core-" prefix)`);
continue;
}
canvases.push(result.data);
}
canvases.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
return canvases.slice(0, CANVAS_MAX_PER_SESSION);
}
export async function synthesizeGitDiffCanvas(
session: Session,
project: ProjectConfig,
): Promise<CanvasArtifact | null> {
if (!session.workspacePath) return null;
const baseBranch = project.defaultBranch;
// Find the merge-base so the diff doesn't misattribute upstream changes.
// `git diff <base>` against working tree would show commits added to main
// after the session branched as "deletions" by the agent.
// Prefer origin/<base> since the workspace plugin fetches and branches from
// there. Falling back to the local ref keeps offline / no-remote repos working.
const baseCandidates = [`origin/${baseBranch}`, baseBranch];
let mergeBase = "";
for (const candidate of baseCandidates) {
try {
const result = await execFileAsync("git", ["merge-base", candidate, "HEAD"], {
cwd: session.workspacePath,
timeout: 5_000,
});
const sha = result.stdout.trim();
if (sha) {
mergeBase = sha;
break;
}
} catch {
// try next candidate
}
}
if (!mergeBase) return null;
let stdout: string;
try {
const result = await execFileAsync("git", ["diff", "--no-color", mergeBase], {
cwd: session.workspacePath,
maxBuffer: 8 * 1024 * 1024,
timeout: 5_000,
});
stdout = result.stdout;
} catch (err) {
// On `maxBuffer` overflow, execFile still hands back the partial stdout it
// captured — keep it so parseUnifiedDiff's file/line caps can produce a
// truncated canvas instead of the user seeing the diff disappear entirely.
const e = err as { stdout?: string };
if (typeof e.stdout === "string" && e.stdout.length > 0) {
stdout = e.stdout;
} else {
return null;
}
}
// Append untracked files. `git diff` skips them, but they're a common case
// (agent creates a file without `git add`). For each untracked file, run
// `git diff --no-index /dev/null <file>` to synthesize a unified-add diff.
// Cap to UNTRACKED_FILE_LIMIT files and a total size budget so a workspace
// with thousands of generated/build artifacts can't make every poll slow.
try {
const { stdout: untrackedRaw } = await execFileAsync(
"git",
["ls-files", "--others", "--exclude-standard", "-z"],
{ cwd: session.workspacePath, maxBuffer: 1 * 1024 * 1024, timeout: 5_000 },
);
const untracked = untrackedRaw
.split("\0")
.filter(Boolean)
// Skip AO's own workspace files. If the host repo doesn't ignore .ao/
// (likely, since it's not part of the project), agent-emitted canvases
// and AGENTS.md would otherwise appear as added files in the diff and
// consume the untracked-file budget.
.filter((p) => !p.startsWith(".ao/") && p !== ".ao")
.slice(0, UNTRACKED_FILE_LIMIT);
for (const path of untracked) {
if (stdout.length >= UNTRACKED_BYTE_BUDGET) break;
// Skip FIFOs, sockets, devices — `git diff --no-index` would block on
// them until our 5s timeout fires, stacking polls.
try {
const st = await lstat(join(session.workspacePath, path));
if (!st.isFile()) continue;
} catch {
continue;
}
try {
const { stdout: addDiff } = await execFileAsync(
"git",
["diff", "--no-color", "--no-index", "--", "/dev/null", path],
{ cwd: session.workspacePath, maxBuffer: 1 * 1024 * 1024, timeout: 5_000 },
);
stdout += addDiff;
} catch (err) {
// `git diff --no-index` exits 1 when files differ — that's the success
// case, and execFile rejects on non-zero. Capture stdout from the error.
const e = err as { stdout?: string };
if (typeof e.stdout === "string") stdout += e.stdout;
}
}
} catch {
// ls-files failed (not a repo, etc.) — fall through with whatever diff we have.
}
if (!stdout.trim()) return null;
const { files, truncated } = parseUnifiedDiff(stdout);
if (files.length === 0) return null;
const now = new Date().toISOString();
const fileCount = files.length;
const titleSuffix = truncated ? " (truncated)" : "";
return {
version: 1,
id: "core-git-diff",
type: "diff",
title: `Diff vs ${baseBranch} (${fileCount} file${fileCount === 1 ? "" : "s"})${titleSuffix}`,
createdAt: now,
updatedAt: now,
source: "core",
payload: { files },
};
}
export function parseUnifiedDiff(diff: string): { files: CanvasDiffFile[]; truncated: boolean } {
const lines = diff.split("\n");
const files: CanvasDiffFile[] = [];
let current: CanvasDiffFile | null = null;
let currentHunk: CanvasDiffFile["hunks"][number] | null = null;
let totalLines = 0;
let truncated = false;
const finalizeFile = () => {
if (current) {
if (currentHunk) current.hunks.push(currentHunk);
files.push(current);
}
current = null;
currentHunk = null;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
if (line.startsWith("diff --git ")) {
finalizeFile();
if (files.length >= DIFF_MAX_FILES) {
truncated = true;
break;
}
const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
const path = match?.[2] ?? "";
const oldPath = match?.[1];
current = {
path,
oldPath: oldPath !== path ? oldPath : undefined,
status: "modified",
hunks: [],
};
continue;
}
if (!current) continue;
if (line.startsWith("new file mode")) current.status = "added";
else if (line.startsWith("deleted file mode")) current.status = "deleted";
else if (line.startsWith("rename from") || line.startsWith("rename to")) current.status = "renamed";
else if (line.startsWith("@@")) {
if (currentHunk) current.hunks.push(currentHunk);
currentHunk = { header: line, lines: [] };
} else if (currentHunk) {
if (line === "") continue;
const kind: "add" | "del" | "context" = line.startsWith("+")
? "add"
: line.startsWith("-")
? "del"
: "context";
// Strip the unified-diff prefix character (`+`, `-`, or ` `) so the
// renderer can show its own marker column without doubling the indent.
const text = line.slice(1);
currentHunk.lines.push({ kind, text });
totalLines++;
if (totalLines >= DIFF_MAX_LINES) {
truncated = true;
break;
}
}
}
finalizeFile();
return { files, truncated };
}

View File

@ -0,0 +1,89 @@
import { z } from "zod";
const idPattern = /^[a-z0-9][a-z0-9-]{0,63}$/;
const baseFields = {
version: z.literal(1),
id: z.string().regex(idPattern),
title: z.string().min(1).max(200),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
source: z.string().min(1).max(64).optional(),
};
const markdownPayload = z
.object({
markdown: z.string().max(64_000),
})
.strict();
const diffLine = z
.object({
kind: z.enum(["context", "add", "del"]),
text: z.string(),
})
.strict();
const diffHunk = z
.object({
header: z.string(),
lines: z.array(diffLine),
})
.strict();
const diffFile = z
.object({
path: z.string().min(1),
oldPath: z.string().optional(),
status: z.enum(["added", "modified", "deleted", "renamed"]),
hunks: z.array(diffHunk),
})
.strict();
const diffPayload = z
.object({
files: z.array(diffFile),
})
.strict();
const tableColumn = z
.object({
key: z.string().min(1),
label: z.string(),
align: z.enum(["left", "right", "center"]).optional(),
})
.strict();
const tableRow = z.record(
z.string(),
z.union([z.string(), z.number(), z.boolean(), z.null()]),
);
const tablePayload = z
.object({
columns: z.array(tableColumn).min(1).max(32),
rows: z.array(tableRow).max(1000),
})
.strict();
const statMetric = z
.object({
label: z.string().min(1).max(80),
value: z.union([z.string().max(80), z.number()]),
delta: z.string().max(40).optional(),
tone: z.enum(["neutral", "good", "warn", "bad"]).optional(),
})
.strict();
const statsPayload = z
.object({
metrics: z.array(statMetric).min(1).max(32),
})
.strict();
export const CanvasArtifactSchema = z.discriminatedUnion("type", [
z.object({ ...baseFields, type: z.literal("markdown"), payload: markdownPayload }).strict(),
z.object({ ...baseFields, type: z.literal("diff"), payload: diffPayload }).strict(),
z.object({ ...baseFields, type: z.literal("table"), payload: tablePayload }).strict(),
z.object({ ...baseFields, type: z.literal("stats"), payload: statsPayload }).strict(),
]);

View File

@ -8,6 +8,17 @@
// Types — everything plugins and consumers need
export * from "./types.js";
// Canvases — interactive artifacts rendered next to the terminal in SessionDetail
export {
readCanvases,
synthesizeGitDiffCanvas,
parseUnifiedDiff,
getCanvasDir,
CANVAS_MAX_FILE_BYTES,
CANVAS_MAX_PER_SESSION,
} from "./canvas-log.js";
export { CanvasArtifactSchema } from "./canvas-schema.js";
// Config — YAML loader + validation
export {
loadConfig,

View File

@ -1664,6 +1664,80 @@ export interface PluginModule<T = unknown> {
detect?(): boolean;
}
// =============================================================================
// CANVASES
// =============================================================================
/**
* Interactive artifacts rendered next to the terminal in SessionDetail.
*
* Two production paths:
* 1. Agents write `{workspacePath}/.ao/canvases/{id}.json` directly.
* 2. Plugins (agent, scm, tracker) implement `CanvasProducer.listCanvases`
* and return synthesized artifacts (e.g. PR status, CI summary).
*
* Renderer types are fixed in core. Third parties pick a type and supply data;
* they do not ship React components. New renderer types require a core PR.
*/
export type CanvasType = "markdown" | "diff" | "table" | "stats";
export interface CanvasDiffFile {
path: string;
oldPath?: string;
status: "added" | "modified" | "deleted" | "renamed";
hunks: Array<{
header: string;
lines: Array<{ kind: "context" | "add" | "del"; text: string }>;
}>;
}
export interface CanvasTableColumn {
key: string;
label: string;
align?: "left" | "right" | "center";
}
export interface CanvasStatMetric {
label: string;
value: string | number;
delta?: string;
tone?: "neutral" | "good" | "warn" | "bad";
}
interface CanvasBase {
version: 1;
id: string;
type: CanvasType;
title: string;
createdAt: string;
updatedAt: string;
/** Optional source identifier (plugin name, "agent", "core"). Display only. */
source?: string;
}
export type CanvasArtifact =
| (CanvasBase & { type: "markdown"; payload: { markdown: string } })
| (CanvasBase & { type: "diff"; payload: { files: CanvasDiffFile[] } })
| (CanvasBase & {
type: "table";
payload: {
columns: CanvasTableColumn[];
rows: Record<string, string | number | boolean | null>[];
};
})
| (CanvasBase & { type: "stats"; payload: { metrics: CanvasStatMetric[] } });
/**
* Optional capability for plugins that can synthesize canvases from session data.
* Implemented by agent / scm / tracker plugins as needed not a new plugin slot.
*
* Return value is treated as untrusted: core validates and size-caps before exposing
* via the dashboard API.
*/
export interface CanvasProducer {
listCanvases(session: Session, project: ProjectConfig): Promise<CanvasArtifact[]>;
}
// =============================================================================
// SESSION METADATA
// =============================================================================

View File

@ -0,0 +1,93 @@
import { type NextRequest } from "next/server";
import { validateIdentifier } from "@/lib/validation";
import { getServices } from "@/lib/services";
import {
readCanvases,
synthesizeGitDiffCanvas,
SessionNotFoundError,
type CanvasArtifact,
} from "@aoagents/ao-core";
import {
getCorrelationId,
jsonWithCorrelation,
recordApiObservation,
resolveProjectIdForSessionId,
} from "@/lib/observability";
/** GET /api/sessions/:id/canvases — List canvas artifacts for a session */
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const correlationId = getCorrelationId(_request);
const startedAt = Date.now();
const { id } = await params;
const idErr = validateIdentifier(id, "id");
if (idErr) {
return jsonWithCorrelation({ error: idErr }, { status: 400 }, correlationId);
}
try {
const { config, sessionManager } = await getServices();
const session = await sessionManager.get(id);
if (!session) {
return jsonWithCorrelation({ error: "Session not found" }, { status: 404 }, correlationId);
}
// Trust the SessionManager's authoritative projectId. Falling back to
// resolveProjectIdForSessionId would mis-route sessions when two project
// prefixes share a leading substring (e.g. "app" vs "app-api").
const projectId = session.projectId;
const project = projectId ? config.projects[projectId] : undefined;
// A session without a workspacePath (workspace cleaned up, never created)
// is still a valid session — just one with no canvases. Return empty rather
// than 404 so the always-mounted CanvasRail shows the empty state.
const [fileCanvases, synthesized] = session.workspacePath
? await Promise.all([
readCanvases(session.workspacePath),
project ? synthesizeGitDiffCanvas(session, project) : Promise.resolve(null),
])
: [[], null];
const merged = new Map<string, CanvasArtifact>();
if (synthesized) merged.set(synthesized.id, synthesized);
for (const c of fileCanvases) merged.set(c.id, c);
const canvases = Array.from(merged.values()).sort((a, b) =>
a.updatedAt < b.updatedAt ? 1 : -1,
);
recordApiObservation({
config,
method: "GET",
path: "/api/sessions/[id]/canvases",
correlationId,
startedAt,
outcome: "success",
statusCode: 200,
projectId,
sessionId: id,
});
return jsonWithCorrelation({ canvases }, { status: 200 }, correlationId);
} catch (err) {
if (err instanceof SessionNotFoundError) {
return jsonWithCorrelation({ error: err.message }, { status: 404 }, correlationId);
}
const { config } = await getServices().catch(() => ({ config: undefined }));
const projectId = config ? resolveProjectIdForSessionId(config, id) : undefined;
if (config) {
recordApiObservation({
config,
method: "GET",
path: "/api/sessions/[id]/canvases",
correlationId,
startedAt,
outcome: "failure",
statusCode: 500,
projectId,
sessionId: id,
reason: err instanceof Error ? err.message : "Failed to list canvases",
});
}
const msg = err instanceof Error ? err.message : "Failed to list canvases";
return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId);
}
}

View File

@ -22,6 +22,7 @@ import {
} from "./SessionDetailHeader";
import { SessionEndedSummary } from "./SessionEndedSummary";
import { sessionActivityMeta } from "./session-detail-utils";
import { CanvasRail } from "./canvas/CanvasRail";
export type { OrchestratorZones } from "./SessionDetailHeader";
@ -194,7 +195,7 @@ export function SessionDetail({
)}
<div className="dashboard-main dashboard-main--desktop">
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
<main className="session-detail-page flex-1 min-h-0 flex bg-[var(--color-bg-base)]">
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
@ -220,6 +221,7 @@ export function SessionDetail({
/>
)}
</div>
{!isMobile && <CanvasRail sessionId={session.id} />}
</main>
</div>
</div>

View File

@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import type { CanvasArtifact } from "@aoagents/ao-core";
import { useSessionCanvases } from "@/hooks/useSessionCanvases";
import { MarkdownCanvas } from "./renderers/MarkdownCanvas";
import { DiffCanvas } from "./renderers/DiffCanvas";
import { TableCanvas } from "./renderers/TableCanvas";
import { StatsCanvas } from "./renderers/StatsCanvas";
type Props = { sessionId: string };
export function CanvasRail({ sessionId }: Props) {
const { canvases, loading, error } = useSessionCanvases(sessionId);
const [open, setOpen] = useState<boolean | null>(null);
// Auto-expand the first time a canvas appears for this session. Once the user
// explicitly toggles, `open` is no longer null and this effect is a no-op.
useEffect(() => {
if (open !== null) return;
if (canvases.length > 0) setOpen(true);
}, [open, canvases.length]);
// Reset on session change so a new session starts in its own auto-expand state.
useEffect(() => {
setOpen(null);
}, [sessionId]);
const isOpen = open ?? false;
if (!isOpen) {
return (
<div className="flex flex-col border-l border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]">
<button
type="button"
onClick={() => setOpen(true)}
className="h-full w-6 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]"
aria-label="Open canvas rail"
title={canvases.length > 0 ? `${canvases.length} canvas${canvases.length === 1 ? "" : "es"}` : "Canvases"}
>
<span className="block -rotate-90 whitespace-nowrap">
Canvases{canvases.length > 0 ? ` (${canvases.length})` : ""}
</span>
</button>
</div>
);
}
return (
<aside className="flex w-[480px] shrink-0 flex-col border-l border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]">
<header className="flex items-center justify-between border-b border-[var(--color-border-subtle)] px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
Canvases
</h2>
<button
type="button"
onClick={() => setOpen(false)}
className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
aria-label="Close canvas rail"
>
</button>
</header>
<div className="flex flex-1 flex-col gap-3 overflow-auto p-3">
{error && (
<div className="text-xs text-[var(--color-status-error)]">Failed to load canvases: {error}</div>
)}
{!error && !loading && canvases.length === 0 && (
<div className="text-xs text-[var(--color-text-secondary)]">
No canvases yet. Agents can write artifacts to{" "}
<code className="font-mono">.ao/canvases/&lt;id&gt;.json</code>.
</div>
)}
{canvases.map((canvas) => (
<CanvasPanel key={canvas.id} canvas={canvas} />
))}
</div>
</aside>
);
}
function CanvasPanel({ canvas }: { canvas: CanvasArtifact }) {
return (
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-3">
<header className="mb-2 flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">{canvas.title}</h3>
<span className="text-[10px] uppercase tracking-wide text-[var(--color-text-secondary)]">
{canvas.source ?? canvas.type}
</span>
</header>
<CanvasBody canvas={canvas} />
</section>
);
}
function CanvasBody({ canvas }: { canvas: CanvasArtifact }) {
switch (canvas.type) {
case "markdown":
return <MarkdownCanvas canvas={canvas} />;
case "diff":
return <DiffCanvas canvas={canvas} />;
case "table":
return <TableCanvas canvas={canvas} />;
case "stats":
return <StatsCanvas canvas={canvas} />;
}
}

View File

@ -0,0 +1,76 @@
import { render, screen, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { CanvasArtifact } from "@aoagents/ao-core";
import { CanvasRail } from "../CanvasRail";
const markdownCanvas: CanvasArtifact = {
version: 1,
id: "notes",
type: "markdown",
title: "Notes",
createdAt: "2026-05-05T00:00:00Z",
updatedAt: "2026-05-05T00:00:00Z",
payload: { markdown: "Hello canvas" },
};
const statsCanvas: CanvasArtifact = {
version: 1,
id: "stats",
type: "stats",
title: "Run stats",
createdAt: "2026-05-05T00:00:00Z",
updatedAt: "2026-05-05T00:01:00Z",
payload: { metrics: [{ label: "Pass", value: 7, tone: "good" }] },
};
function mockFetch(canvases: CanvasArtifact[]) {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ canvases }),
});
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("CanvasRail", () => {
it("starts collapsed when no canvases exist", async () => {
vi.stubGlobal("fetch", mockFetch([]));
render(<CanvasRail sessionId="ao-1" />);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByLabelText("Open canvas rail")).toBeInTheDocument();
expect(screen.queryByText("Notes")).not.toBeInTheDocument();
});
it("auto-expands and renders title when canvases arrive", async () => {
vi.stubGlobal("fetch", mockFetch([markdownCanvas]));
render(<CanvasRail sessionId="ao-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Notes")).toBeInTheDocument();
expect(screen.getByText("Hello canvas")).toBeInTheDocument();
});
it("dispatches to the stats renderer for stats canvases", async () => {
vi.stubGlobal("fetch", mockFetch([statsCanvas]));
render(<CanvasRail sessionId="ao-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Run stats")).toBeInTheDocument();
expect(screen.getByText("Pass")).toBeInTheDocument();
expect(screen.getByText("7")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import type { CanvasArtifact, CanvasDiffFile } from "@aoagents/ao-core";
type Props = { canvas: Extract<CanvasArtifact, { type: "diff" }> };
export function DiffCanvas({ canvas }: Props) {
return (
<div className="flex flex-col gap-3">
{canvas.payload.files.map((file) => (
<DiffFileBlock key={`${file.oldPath ?? ""}::${file.path}`} file={file} />
))}
</div>
);
}
function DiffFileBlock({ file }: { file: CanvasDiffFile }) {
const [open, setOpen] = useState(true);
const statusBadge = statusLabel(file.status);
return (
<div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-mono text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]"
>
<span className="truncate">
{file.oldPath && file.oldPath !== file.path ? `${file.oldPath}${file.path}` : file.path}
</span>
<span className="shrink-0 text-[10px] uppercase tracking-wide text-[var(--color-text-secondary)]">
{statusBadge}
</span>
</button>
{open && (
<div className="border-t border-[var(--color-border-subtle)] font-mono text-xs">
{file.hunks.map((hunk, hi) => (
<div key={hi}>
<div className="bg-[var(--color-bg-surface)] px-3 py-1 text-[var(--color-text-secondary)]">
{hunk.header}
</div>
{hunk.lines.map((line, li) => (
<div
key={li}
className={[
"px-3 py-[1px] whitespace-pre",
line.kind === "add"
? "bg-[color-mix(in_srgb,var(--color-status-working)_18%,transparent)]"
: line.kind === "del"
? "bg-[color-mix(in_srgb,var(--color-status-error)_18%,transparent)]"
: "",
].join(" ")}
>
<span className="select-none pr-2 text-[var(--color-text-secondary)]">
{line.kind === "add" ? "+" : line.kind === "del" ? "-" : " "}
</span>
{line.text}
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
function statusLabel(s: CanvasDiffFile["status"]): string {
return s === "added" ? "added" : s === "deleted" ? "deleted" : s === "renamed" ? "renamed" : "modified";
}

View File

@ -0,0 +1,13 @@
"use client";
import type { CanvasArtifact } from "@aoagents/ao-core";
type Props = { canvas: Extract<CanvasArtifact, { type: "markdown" }> };
export function MarkdownCanvas({ canvas }: Props) {
return (
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-[var(--color-text-primary)]">
{canvas.payload.markdown}
</pre>
);
}

View File

@ -0,0 +1,40 @@
"use client";
import type { CanvasArtifact } from "@aoagents/ao-core";
type Props = { canvas: Extract<CanvasArtifact, { type: "stats" }> };
const toneClass: Record<NonNullable<Extract<CanvasArtifact, { type: "stats" }>["payload"]["metrics"][number]["tone"]>, string> = {
good: "text-[var(--color-status-working)]",
warn: "text-[var(--color-status-pending)]",
bad: "text-[var(--color-status-error)]",
neutral: "text-[var(--color-text-primary)]",
};
export function StatsCanvas({ canvas }: Props) {
return (
<div className="grid grid-cols-2 gap-2">
{canvas.payload.metrics.map((m, i) => (
<div
key={i}
className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] p-3"
>
<div className="text-[10px] uppercase tracking-wide text-[var(--color-text-secondary)]">
{m.label}
</div>
<div
className={[
"mt-1 text-lg font-semibold tabular-nums",
toneClass[m.tone ?? "neutral"],
].join(" ")}
>
{typeof m.value === "number" ? m.value.toLocaleString() : m.value}
</div>
{m.delta && (
<div className="mt-0.5 text-xs text-[var(--color-text-secondary)]">{m.delta}</div>
)}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import type { CanvasArtifact } from "@aoagents/ao-core";
type Props = { canvas: Extract<CanvasArtifact, { type: "table" }> };
export function TableCanvas({ canvas }: Props) {
const { columns, rows } = canvas.payload;
return (
<div className="overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-[var(--color-bg-surface)] text-left text-[var(--color-text-secondary)]">
<tr>
{columns.map((col) => (
<th
key={col.key}
className={[
"border-b border-[var(--color-border-subtle)] px-2 py-1.5 font-medium",
col.align === "right" ? "text-right" : col.align === "center" ? "text-center" : "text-left",
].join(" ")}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-[var(--color-border-subtle)]">
{columns.map((col) => {
const value = row[col.key];
const display =
value === null || value === undefined
? ""
: typeof value === "boolean"
? value ? "yes" : "no"
: String(value);
const isNumeric = typeof value === "number";
return (
<td
key={col.key}
className={[
"px-2 py-1.5 text-[var(--color-text-primary)]",
col.align === "right" || isNumeric ? "text-right tabular-nums" : "",
col.align === "center" ? "text-center" : "",
isNumeric ? "font-mono" : "",
].join(" ")}
>
{display}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,107 @@
import { renderHook, waitFor, act } from "@testing-library/react";
import { describe, it, expect, afterEach, vi } from "vitest";
import type { CanvasArtifact } from "@aoagents/ao-core";
import { useSessionCanvases } from "../useSessionCanvases";
const sample: CanvasArtifact = {
version: 1,
id: "x",
type: "markdown",
title: "X",
createdAt: "2026-05-05T00:00:00Z",
updatedAt: "2026-05-05T00:00:00Z",
payload: { markdown: "hi" },
};
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("useSessionCanvases", () => {
it("fetches once on mount", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ canvases: [sample] }),
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSessionCanvases("ao-1"));
await waitFor(() => expect(result.current.canvases).toHaveLength(1));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith("/api/sessions/ao-1/canvases");
});
it("polls every 5 seconds", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ canvases: [] }),
});
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers({ shouldAdvanceTime: true });
renderHook(() => useSessionCanvases("ao-1"));
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("surfaces non-OK responses as errors", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({}),
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSessionCanvases("ao-1"));
await waitFor(() => expect(result.current.error).toBe("HTTP 500"));
});
it("returns empty when sessionId is null", () => {
const { result } = renderHook(() => useSessionCanvases(null));
expect(result.current.canvases).toEqual([]);
expect(result.current.loading).toBe(false);
});
it("ignores in-flight responses from a previous sessionId", async () => {
let resolveOne: ((v: { canvases: CanvasArtifact[] }) => void) | null = null;
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes("ao-1")) {
return new Promise((resolve) => {
resolveOne = (v) => resolve({ ok: true, status: 200, json: async () => v });
});
}
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ canvases: [{ ...sample, id: "two" }] }),
});
});
vi.stubGlobal("fetch", fetchMock);
const { result, rerender } = renderHook(
({ id }: { id: string }) => useSessionCanvases(id),
{ initialProps: { id: "ao-1" } },
);
rerender({ id: "ao-2" });
await waitFor(() => expect(result.current.canvases).toEqual([{ ...sample, id: "two" }]));
// Resolve the stale ao-1 fetch — must not overwrite ao-2's canvases.
resolveOne?.({ canvases: [{ ...sample, id: "one-stale" }] });
await new Promise((r) => setTimeout(r, 10));
expect(result.current.canvases).toEqual([{ ...sample, id: "two" }]);
});
});

View File

@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import type { CanvasArtifact } from "@aoagents/ao-core";
const POLL_INTERVAL_MS = 5000;
export function useSessionCanvases(sessionId: string | null): {
canvases: CanvasArtifact[];
loading: boolean;
error: string | null;
} {
const [canvases, setCanvases] = useState<CanvasArtifact[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Reset on session change so canvases from a previous session never bleed
// into the new view.
setCanvases([]);
setError(null);
if (!sessionId) {
setLoading(false);
return;
}
setLoading(true);
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
// Track in-flight requests so an older response can't overwrite a newer one
// when polls overlap (e.g. the API takes longer than POLL_INTERVAL_MS).
let nextSeq = 0;
let latestApplied = -1;
const fetchOnce = async () => {
const seq = nextSeq++;
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/canvases`);
if (cancelled || seq < latestApplied) return;
if (!res.ok) {
latestApplied = seq;
setError(`HTTP ${res.status}`);
setLoading(false);
return;
}
const data = (await res.json()) as { canvases?: CanvasArtifact[] };
if (cancelled || seq < latestApplied) return;
latestApplied = seq;
setCanvases(data.canvases ?? []);
setError(null);
setLoading(false);
} catch (err) {
if (cancelled || seq < latestApplied) return;
latestApplied = seq;
setError(err instanceof Error ? err.message : "fetch failed");
setLoading(false);
}
};
const schedule = () => {
timer = setTimeout(async () => {
if (cancelled) return;
if (typeof document === "undefined" || document.visibilityState === "visible") {
await fetchOnce();
}
if (!cancelled) schedule();
}, POLL_INTERVAL_MS);
};
void fetchOnce();
schedule();
const onVisibilityChange = () => {
if (cancelled) return;
if (typeof document !== "undefined" && document.visibilityState === "visible") {
void fetchOnce();
}
};
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibilityChange);
}
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibilityChange);
}
};
}, [sessionId]);
return { canvases, loading, error };
}