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:
parent
8744231ccd
commit
ca0b7ec2ce
|
|
@ -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.
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/<id>.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} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue