Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8bc4551
feat(projects): canvas schema + ProjectCanvasStore skeleton
jaylfc Apr 28, 2026
6b59e74
feat(projects): canvas add_element + kind validation
jaylfc Apr 28, 2026
582aaa6
feat(projects): canvas list_elements
jaylfc Apr 28, 2026
29f4d72
feat(projects): canvas update_element + permission check
jaylfc Apr 28, 2026
61155c6
feat(projects): canvas delete_element soft delete
jaylfc Apr 28, 2026
d704365
test(projects): assert canvas store publishes broker events
jaylfc Apr 28, 2026
f93fc14
feat(projects): canvas link unfurl
jaylfc Apr 28, 2026
12563a7
feat(projects): debounced canvas .tldr snapshotter
jaylfc Apr 28, 2026
f1e613b
feat(projects): canvas PNG renderer (Pillow, low-fidelity)
jaylfc Apr 28, 2026
cdd9b01
feat(projects): canvas REST routes — list/create elements + app wiring
jaylfc Apr 28, 2026
7d559e5
feat(projects): canvas PATCH/DELETE element routes
jaylfc Apr 28, 2026
acac2e5
feat(projects): canvas snapshot.png/.tldr + permissions routes
jaylfc Apr 28, 2026
b02ba0a
feat(projects): canvas SSE stream route
jaylfc Apr 28, 2026
e4b7e59
feat(projects): canvas agent handler functions
jaylfc Apr 28, 2026
d97e875
chore(canvas): install tldraw + zustand, scaffold module
jaylfc Apr 28, 2026
4fd6e3b
feat(canvas): REST API wrapper
jaylfc Apr 28, 2026
834010b
feat(canvas): zustand local element cache
jaylfc Apr 28, 2026
a6e4463
feat(canvas): SSE subscriber dispatching to local store
jaylfc Apr 28, 2026
25e7c03
feat(canvas): custom tldraw shapes — note, link, image
jaylfc Apr 28, 2026
d974232
feat(canvas): tldraw board mount + REST/SSE sync
jaylfc Apr 29, 2026
2b63848
feat(canvas): canvas tab integrated into ProjectWorkspace
jaylfc Apr 29, 2026
de987e9
feat(canvas): per-agent edit-canvas toggle in members UI
jaylfc Apr 29, 2026
044378c
test(projects): canvas REST/SSE/snapshot/permission integration
jaylfc Apr 29, 2026
45a38df
test(canvas): playwright e2e — user view + SSE live updates
jaylfc Apr 29, 2026
35ce519
fix(canvas): use project_event_broker state key (matches projects rou…
jaylfc Apr 29, 2026
b84d071
fix(canvas): add Pillow to main deps (CI was failing collection)
jaylfc Apr 29, 2026
d4674b4
fix(canvas): scope update_element by project_id; harden unfurl SSRF
jaylfc Apr 29, 2026
4cefae1
fix(canvas): treat any non-2xx unfurl response as fallback
jaylfc Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,153 changes: 2,390 additions & 763 deletions desktop/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tldraw/tldraw": "^4.5.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
Expand Down
44 changes: 32 additions & 12 deletions desktop/src/apps/ProjectsApp/ProjectMembers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { projectsApi, type Project, type ProjectMember } from "@/lib/projects";
import { AddAgentDialog } from "./AddAgentDialog";
import { canvasApi } from "./canvas/canvas-api";

export function ProjectMembers({ project, onChanged }: { project: Project; onChanged: () => void }) {
const [members, setMembers] = useState<ProjectMember[]>([]);
Expand Down Expand Up @@ -45,18 +46,37 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
{m.member_kind === "clone" ? ` · ${m.memory_seed}` : ""}
</div>
</div>
<button
type="button"
onClick={async () => {
await projectsApi.members.remove(project.id, m.member_id);
refresh();
onChanged();
}}
className="text-xs text-red-400 hover:underline"
aria-label={`Remove ${m.member_id}`}
>
Remove
</button>
<div className="flex items-center gap-2">
{(m.member_kind === "native" || m.member_kind === "clone") && (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
title="When off, this agent can add new elements but cannot modify or delete existing ones."
>
<input
type="checkbox"
checked={!!m.can_edit_canvas}
onChange={async (e) => {
await canvasApi.setPermission(project.id, m.member_id, e.target.checked);
refresh();
onChanged();
}}
/>
<span className="text-xs">Can edit canvas</span>
</label>
)}
<button
type="button"
onClick={async () => {
await projectsApi.members.remove(project.id, m.member_id);
refresh();
onChanged();
}}
Comment on lines +58 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle async failures and await refresh to prevent stale member state.

Both action handlers can reject before refresh runs, and refresh() is currently fire-and-forget. This can leave permission/member UI out of sync.

Diff
                   <input
                     type="checkbox"
                     checked={!!m.can_edit_canvas}
                     onChange={async (e) => {
-                      await canvasApi.setPermission(project.id, m.member_id, e.target.checked);
-                      refresh();
-                      onChanged();
+                      const checked = e.currentTarget.checked;
+                      try {
+                        await canvasApi.setPermission(project.id, m.member_id, checked);
+                      } finally {
+                        await refresh();
+                        onChanged();
+                      }
                     }}
                   />
@@
               <button
                 type="button"
                 onClick={async () => {
-                  await projectsApi.members.remove(project.id, m.member_id);
-                  refresh();
-                  onChanged();
+                  try {
+                    await projectsApi.members.remove(project.id, m.member_id);
+                  } finally {
+                    await refresh();
+                    onChanged();
+                  }
                 }}
                 className="text-xs text-red-400 hover:underline"
                 aria-label={`Remove ${m.member_id}`}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/ProjectMembers.tsx` around lines 58 - 73, The
handlers calling canvasApi.setPermission and projectsApi.members.remove should
await refresh() and handle async failures to avoid stale UI: wrap the async work
in try/catch inside the onChange and delete button callbacks, await the API
call, then await refresh(), call onChanged() only after successful refresh, and
in the catch log or surface the error (e.g., show toast or processLogger) so
failures don't silently leave the UI out of sync; update the anonymous handlers
that call canvasApi.setPermission, projectsApi.members.remove, refresh, and
onChanged accordingly.

className="text-xs text-red-400 hover:underline"
aria-label={`Remove ${m.member_id}`}
>
Remove
</button>
</div>
</li>
))}
</ul>
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { ProjectBoard } from "./board/ProjectBoard";
import { TaskModal } from "./board/TaskModal";
import { FilesApp } from "@/apps/FilesApp";
import { MessagesApp } from "@/apps/MessagesApp";
import { CanvasView } from "./canvas/CanvasView";

type Tab = "board" | "tasks" | "files" | "messages" | "members" | "activity";
const TABS: Tab[] = ["board", "tasks", "files", "messages", "members", "activity"];
type Tab = "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity";
const TABS: Tab[] = ["board", "canvas", "tasks", "files", "messages", "members", "activity"];

function readTaskParam(): string | null {
if (typeof window === "undefined") return null;
Expand Down Expand Up @@ -94,6 +95,7 @@ export function ProjectWorkspace({ project, onChanged }: { project: Project; onC
)}
</>
)}
{tab === "canvas" && <CanvasView projectId={project.id} projectSlug={project.slug} />}
{tab === "tasks" && <ProjectTaskList projectId={project.id} />}
{tab === "files" && (
<FilesApp
Expand Down
38 changes: 38 additions & 0 deletions desktop/src/apps/ProjectsApp/__tests__/canvas-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { canvasApi } from "../canvas/canvas-api";

beforeEach(() => {
global.fetch = vi.fn();
});

describe("canvasApi", () => {
it("listElements GETs the right URL", async () => {
(fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ elements: [] }),
});
const r = await canvasApi.listElements("prj-1");
expect(fetch).toHaveBeenCalledWith("/api/projects/prj-1/canvas/elements");
expect(r).toEqual([]);
});

it("addElement POSTs body", async () => {
(fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ element: { id: "cve-1", kind: "note" } }),
});
const r = await canvasApi.addElement("prj-1", {
kind: "note", x: 1, y: 2, w: 3, h: 4, payload: { text: "x" },
});
expect(r.id).toBe("cve-1");
const call = (fetch as any).mock.calls[0];
expect(call[0]).toBe("/api/projects/prj-1/canvas/elements");
expect(call[1].method).toBe("POST");
});

it("deleteElement returns true on 204", async () => {
(fetch as any).mockResolvedValue({ ok: true, status: 204 });
const r = await canvasApi.deleteElement("prj-1", "cve-1");
expect(r).toBe(true);
});
});
29 changes: 29 additions & 0 deletions desktop/src/apps/ProjectsApp/__tests__/canvas-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { createCanvasStore } from "../canvas/canvas-store";

describe("canvas-store", () => {
it("seeds elements", () => {
const store = createCanvasStore();
store.getState().seed([
{ id: "cve-1", kind: "note", x: 0, y: 0, w: 1, h: 1,
rotation: 0, z_index: 0, payload: {}, project_id: "p",
author_kind: "user", author_id: "u",
created_at: 0, updated_at: 0, deleted_at: null } as any,
]);
expect(store.getState().elements["cve-1"].kind).toBe("note");
});

it("upsert replaces by id", () => {
const store = createCanvasStore();
store.getState().upsert({ id: "x", x: 1 } as any);
store.getState().upsert({ id: "x", x: 99 } as any);
expect(store.getState().elements["x"].x).toBe(99);
});

it("remove drops the element", () => {
const store = createCanvasStore();
store.getState().upsert({ id: "x" } as any);
store.getState().remove("x");
expect(store.getState().elements["x"]).toBeUndefined();
});
});
163 changes: 163 additions & 0 deletions desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useEffect, useMemo, useRef } from "react";
import { Tldraw, Editor, createTLStore, defaultShapeUtils, TLShape } from "@tldraw/tldraw";
import "@tldraw/tldraw/tldraw.css";

import { canvasApi, CanvasElement } from "./canvas-api";
import { createCanvasStore } from "./canvas-store";
import { subscribeCanvasStream } from "./canvas-sse";
import { TaosNoteShapeUtil } from "./shapes/NoteShape";
import { TaosLinkShapeUtil } from "./shapes/LinkShape";
import { TaosImageShapeUtil } from "./shapes/ImageShape";

interface CanvasBoardProps {
projectId: string;
projectSlug: string;
}

const CUSTOM_SHAPE_UTILS = [TaosNoteShapeUtil, TaosLinkShapeUtil, TaosImageShapeUtil];

export function CanvasBoard({ projectId, projectSlug }: CanvasBoardProps) {
const cacheRef = useRef(createCanvasStore());
const editorRef = useRef<Editor | null>(null);

// In tldraw v4, shapeUtils are passed to <Tldraw>, not createTLStore
const store = useMemo(
() => createTLStore({ defaultName: `canvas-${projectId}` }),
[projectId],
);

// Initial load + SSE subscription
useEffect(() => {
let cancelled = false;
(async () => {
const elements = await canvasApi.listElements(projectId);
if (cancelled) return;
cacheRef.current.getState().seed(elements);
hydrateEditor(editorRef.current, elements, projectSlug);
})();
const unsub = subscribeCanvasStream(projectId, cacheRef.current);
Comment on lines +30 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid clobbering live SSE updates with the initial fetch result.

The subscription is opened before listElements() settles, but seed(elements) still replaces the cache when that older HTTP response arrives. If another client edits the board in that window, the later seed rolls the local state back.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx` around lines 30 - 38,
The initial HTTP fetch (canvasApi.listElements) can overwrite live SSE updates
because subscribeCanvasStream is started before the fetch settles; to fix, after
the fetch resolves (and after the cancelled check) verify the current cache
state and only call cacheRef.current.getState().seed(elements) and
hydrateEditor(editorRef.current, elements, projectSlug) if the cache is still
empty/unseeded (e.g., check cacheRef.current.getState().items.length === 0 or an
isSeeded flag on the cache state); keep the subscribeCanvasStream(projectId,
cacheRef.current) call as-is and retain cancelled handling so late fetch
responses don’t clobber live updates from subscribeCanvasStream.

return () => {
cancelled = true;
unsub();
};
}, [projectId, projectSlug]);

// Re-hydrate on local cache changes (SSE-driven updates from agents)
useEffect(() => {
const unsub = cacheRef.current.subscribe((s) => {
hydrateEditor(editorRef.current, Object.values(s.elements), projectSlug);
});
return unsub;
}, [projectSlug]);

return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<Tldraw
store={store}
shapeUtils={[...defaultShapeUtils, ...CUSTOM_SHAPE_UTILS] as any}
onMount={(editor) => {
editorRef.current = editor;
// Send local user edits to backend
editor.store.listen(
(entry) => {
if (entry.source !== "user") return;
const added = entry.changes.added as Record<string, TLShape | undefined>;
for (const shape of Object.values(added)) {
if (!shape || !shape.id.startsWith("shape:")) continue;
pushAdd(projectId, shape).catch(console.warn);
}
const updated = entry.changes.updated as Record<string, [TLShape, TLShape] | undefined>;
for (const pair of Object.values(updated)) {
if (!pair) continue;
const next = pair[1];
if (!next.id.startsWith("shape:")) continue;
pushUpdate(projectId, next).catch(console.warn);
}
const removed = entry.changes.removed as Record<string, TLShape | undefined>;
for (const shape of Object.values(removed)) {
if (!shape || !shape.id.startsWith("shape:")) continue;
const elementId = shape.id.replace(/^shape:/, "");
canvasApi.deleteElement(projectId, elementId).catch(console.warn);
}
},
{ source: "user", scope: "document" },
);
}}
/>
</div>
);
}

function hydrateEditor(
editor: Editor | null,
elements: CanvasElement[],
projectSlug: string,
) {
if (!editor) return;
editor.run(() => {
const wantedIds = new Set(elements.map((e) => `shape:${e.id}`));
const existing = editor.getCurrentPageShapes();
const toRemove = existing.filter((s) => !wantedIds.has(s.id) && s.id.toString().startsWith("shape:"));
if (toRemove.length) editor.deleteShapes(toRemove.map((s) => s.id) as any);

for (const el of elements) {
const id = `shape:${el.id}` as TLShape["id"];
const existingShape = editor.getShape(id);
const newShape = elementToShape(el, projectSlug);
if (existingShape) {
editor.updateShape(newShape);
} else {
editor.createShape(newShape);
}
}
});
}

function elementToShape(el: CanvasElement, projectSlug: string): any {
const baseProps = {
w: el.w, h: el.h,
taos_kind: el.kind,
taos_payload: el.payload,
taos_author_id: el.author_id,
taos_author_kind: el.author_kind,
};
return {
id: `shape:${el.id}`,
type: shapeType(el.kind),
x: el.x, y: el.y, rotation: el.rotation,
props: el.kind === "image"
? { ...baseProps, project_slug: projectSlug }
: baseProps,
};
Comment on lines +116 to +131
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist stacking order too.

CanvasElement.z_index never gets mapped into the editor or sent back on updates. Overlapping items will come back in a default order after reload/SSE hydration, so users can lose intentional layering.

Also applies to: 141-162

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx` around lines 116 - 131,
elementToShape currently omits CanvasElement.z_index so stacking is lost; map
z_index into the shape and round-trip it in the inverse mapping/update handlers
too. Add a top-level stacking field (e.g. z or z_index) on the returned shape
object in elementToShape: set it to el.z_index (for images and non-images
alike), and update the corresponding reverse conversion/update logic (the
function that converts editor shapes back to CanvasElement at 141-162 and any
payload builders for server updates) to read that shape field and populate
CanvasElement.z_index when sending saves/SSE updates. Ensure the same symbol
name (z_index) is used consistently across mappings and update payloads.

⚠️ Potential issue | 🔴 Critical

user_shape does not round-trip through persistence.

pushAdd() stores native shapes as kind: "user_shape" plus payload.tldraw_shape, but elementToShape() ignores that record and always recreates a generic "geo" shape. After reload/SSE, arrows, drawings, and other non-custom shapes lose their original type and props.

Also applies to: 141-151

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx` around lines 116 - 131,
elementToShape currently maps every element with kind "user_shape" to a generic
"geo" type and drops the original tldraw shape, so shapes stored via pushAdd
(which writes kind: "user_shape" + payload.tldraw_shape) don't round-trip;
modify elementToShape to detect el.kind === "user_shape" and, when
el.payload?.tldraw_shape exists, return the stored tldraw_shape (using its type
and props) merged with the baseProps and the id/position/rotation fields (and
include project_slug for images) instead of forcing shapeType(el.kind); also
update the corresponding inverse/persistence conversion used by pushAdd to
ensure it writes payload.tldraw_shape unchanged so arrows/drawings retain their
original type and props on reload/SSE.

}

function shapeType(kind: string): string {
if (kind === "note") return "taos-note";
if (kind === "link") return "taos-link";
if (kind === "image") return "taos-image";
return "geo";
}

async function pushAdd(projectId: string, shape: TLShape) {
if (!shape.id.toString().startsWith("shape:")) return;
const props: any = shape.props;
await canvasApi.addElement(projectId, {
id: shape.id.toString().replace(/^shape:/, ""),
kind: (props.taos_kind ?? "user_shape") as any,
x: shape.x, y: shape.y,
w: props.w ?? 100, h: props.h ?? 100,
rotation: shape.rotation,
payload: props.taos_payload ?? { tldraw_shape: shape },
});
}

async function pushUpdate(projectId: string, shape: TLShape) {
const elementId = shape.id.toString().replace(/^shape:/, "");
const props: any = shape.props;
await canvasApi.updateElement(projectId, elementId, {
x: shape.x, y: shape.y,
w: props.w, h: props.h,
rotation: shape.rotation,
payload: props.taos_payload,
});
}
11 changes: 11 additions & 0 deletions desktop/src/apps/ProjectsApp/canvas/CanvasView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { CanvasBoard } from "./CanvasBoard";

export function CanvasView({
projectId, projectSlug,
}: { projectId: string; projectSlug: string }) {
return (
<div style={{ height: "calc(100vh - 100px)", padding: 0 }}>
<CanvasBoard projectId={projectId} projectSlug={projectSlug} />
</div>
);
}
Loading
Loading