agent-canvas 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.github/workflows/ci.yml +25 -0
  2. package/.github/workflows/deploy-worker-prod.yml +20 -0
  3. package/.github/workflows/deploy-worker-stage.yml +22 -0
  4. package/.github/workflows/deploy-worker.yml +73 -0
  5. package/bin/lib/instructions/overview.md +1 -0
  6. package/bun.lock +193 -0
  7. package/daemon/build.ts +22 -0
  8. package/daemon/client/AnnotationEditor.tsx +12 -2
  9. package/daemon/client/AnnotationProvider.tsx +20 -6
  10. package/daemon/client/AnnotationSidebar.tsx +72 -35
  11. package/daemon/client/App.tsx +219 -10
  12. package/daemon/client/FileBrowser.tsx +16 -0
  13. package/daemon/client/FileViewer.tsx +18 -0
  14. package/daemon/client/PlanRenderer.tsx +3 -2
  15. package/daemon/client/ReviewerIdentityDialog.tsx +95 -0
  16. package/daemon/client/SessionSwitcher.tsx +5 -0
  17. package/daemon/client/ShareDialog.tsx +230 -0
  18. package/daemon/client/clientApi.ts +142 -0
  19. package/daemon/client/runtime.ts +22 -0
  20. package/daemon/dist/client.css +1 -1
  21. package/daemon/dist/client.js +74 -74
  22. package/daemon/dist/components.js +50 -50
  23. package/daemon/src/handlers/api.ts +63 -0
  24. package/daemon/src/remote-feedback.test.ts +179 -0
  25. package/daemon/src/remote-feedback.ts +128 -0
  26. package/daemon/src/server.ts +5 -0
  27. package/daemon/src/session.ts +139 -6
  28. package/daemon/src/share.test.ts +221 -0
  29. package/daemon/src/share.ts +187 -0
  30. package/daemon/src/websocket.ts +15 -2
  31. package/package.json +5 -1
  32. package/workers/canvas-share/README.md +159 -0
  33. package/workers/canvas-share/oblaka.ts +56 -0
  34. package/workers/canvas-share/package.json +25 -0
  35. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  36. package/workers/canvas-share/public/assets/client.css +2 -0
  37. package/workers/canvas-share/public/assets/client.js +91 -0
  38. package/workers/canvas-share/public/assets/components.js +63 -0
  39. package/workers/canvas-share/public/assets/jsx-runtime.js +3 -0
  40. package/workers/canvas-share/public/assets/preact-compat.js +1 -0
  41. package/workers/canvas-share/public/assets/runtime.js +1 -0
  42. package/workers/canvas-share/public/index.html +385 -0
  43. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  44. package/workers/canvas-share/public/screenshot-light.png +0 -0
  45. package/workers/canvas-share/src/index.test.ts +165 -0
  46. package/workers/canvas-share/src/index.ts +510 -0
  47. package/workers/canvas-share/src/types.ts +97 -0
  48. package/workers/canvas-share/src/util.ts +156 -0
  49. package/workers/canvas-share/src/validation.ts +117 -0
  50. package/workers/canvas-share/tsconfig.json +16 -0
@@ -9,6 +9,7 @@ import { FileIcon } from "./FileIcon";
9
9
  import { autoResizeTextarea, RESPONSE_ANNOTATION_PATH } from "./utils";
10
10
  import { AnnotationEditor, ImageThumbnails } from "./AnnotationEditor";
11
11
  import { findAnnotationElement, scrollToAnnotation } from "./annotationDom";
12
+ import { MODE, FS_AVAILABLE } from "./clientApi";
12
13
 
13
14
  interface AnnotationSidebarProps {
14
15
  onPreview: () => void;
@@ -32,11 +33,15 @@ export function AnnotationSidebar({ onPreview, onSubmit, collapseButton }: Annot
32
33
  }
33
34
 
34
35
  function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; feedbackConsumed?: boolean; agentWatching?: boolean; collapseButton?: React.ReactNode }) {
36
+ const isSharedMode = MODE.isShared;
35
37
  const [feedback, setFeedback] = useState<string | null>(null);
36
38
  const [loading, setLoading] = useState(true);
37
39
 
38
40
  useEffect(() => {
39
41
  setLoading(true);
42
+ // Shared mode has no prior local feedback to fetch — shared canvases
43
+ // are always one-off snapshots. Skip the call entirely.
44
+ if (!FS_AVAILABLE) { setFeedback(null); setLoading(false); return; }
40
45
  fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
41
46
  .then((r) => r.json())
42
47
  .then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
@@ -52,7 +57,7 @@ function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, feedbac
52
57
  {collapseButton}
53
58
  </div>
54
59
 
55
- {waitingForUpdate && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
60
+ {waitingForUpdate && !isSharedMode && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
56
61
 
57
62
  <div className="flex-1 overflow-y-auto px-4">
58
63
  {loading ? (
@@ -158,6 +163,9 @@ function FeedbackDisplayContent({ sessionId, revision }: { sessionId: string; re
158
163
 
159
164
  useEffect(() => {
160
165
  setLoading(true);
166
+ // Shared mode has no prior local feedback to fetch — shared canvases
167
+ // are always one-off snapshots. Skip the call entirely.
168
+ if (!FS_AVAILABLE) { setFeedback(null); setLoading(false); return; }
161
169
  fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
162
170
  .then((r) => r.json())
163
171
  .then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
@@ -352,14 +360,18 @@ function AnnotationSidebarInner({ onPreview, onSubmit, agentWatching, collapseBu
352
360
  const hasContent = annotations.length > 0 || generalNote.trim().length > 0 || hasResponses || hasFeedback;
353
361
  const [validationError, setValidationError] = useState<string | null>(null);
354
362
 
355
- const renderAnnotation = (ann: Annotation) => (
363
+ const renderAnnotation = (ann: Annotation) => {
364
+ const isRemote = ann.source === "remote";
365
+ return (
356
366
  <div
357
367
  key={ann.id}
358
368
  ref={(el) => { if (el) annRefs.current.set(ann.id, el); else annRefs.current.delete(ann.id); }}
359
369
  className={`group/ann relative px-3 py-2.5 transition-colors duration-150 ${
360
370
  activeAnnotationId === ann.id
361
371
  ? "bg-highlight-selected"
362
- : "odd:bg-bg-elevated-half hover:bg-bg-input"
372
+ : isRemote
373
+ ? "bg-accent-purple-muted/30 hover:bg-accent-purple-muted/50"
374
+ : "odd:bg-bg-elevated-half hover:bg-bg-input"
363
375
  }`}
364
376
  onMouseEnter={() => handleMouseEnter(ann.id)}
365
377
  onMouseLeave={() => handleMouseLeave(ann.id)}
@@ -378,28 +390,48 @@ function AnnotationSidebarInner({ onPreview, onSubmit, agentWatching, collapseBu
378
390
  }
379
391
  }}
380
392
  >
393
+ {isRemote && ann.author && (
394
+ <div className="flex items-center gap-1.5 mb-1.5">
395
+ <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent-purple text-white text-[9px] font-medium uppercase">
396
+ {ann.author.name.charAt(0)}
397
+ </span>
398
+ <span className="text-[11px] font-medium text-accent-purple font-body">
399
+ {ann.author.name}
400
+ </span>
401
+ <span className="text-[10px] text-text-tertiary font-body uppercase tracking-widest">
402
+ Remote
403
+ </span>
404
+ </div>
405
+ )}
406
+
381
407
  {/* Snippet quote */}
382
408
  <div className="text-[11px] text-text-tertiary italic line-clamp-2 mb-1.5 leading-snug font-body border-l-2 border-border-medium pl-2">
383
409
  {ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
384
410
  </div>
385
411
 
386
- {/* Editable note + images */}
387
- <div onClick={(e) => e.stopPropagation()}>
388
- <AnnotationEditor
389
- note={ann.note}
390
- onNoteChange={(note) => updateAnnotation(ann.id, note)}
391
- images={ann.images || []}
392
- onAddImage={(path) => addAnnotationImage(ann.id, path)}
393
- onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
394
- sessionId={sessionId}
395
- autoResize
396
- minHeight={20}
397
- textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
398
- textareaStyle={{ height: "auto", overflow: "hidden" }}
399
- placeholder="Add your note..."
400
- attachButton="on-focus"
401
- />
402
- </div>
412
+ {/* Editable note + images — only for local annotations. Remote are read-only. */}
413
+ {isRemote ? (
414
+ <div className="text-[13px] font-body text-text-primary leading-relaxed whitespace-pre-wrap">
415
+ {ann.note}
416
+ </div>
417
+ ) : (
418
+ <div onClick={(e) => e.stopPropagation()}>
419
+ <AnnotationEditor
420
+ note={ann.note}
421
+ onNoteChange={(note) => updateAnnotation(ann.id, note)}
422
+ images={ann.images || []}
423
+ onAddImage={(path) => addAnnotationImage(ann.id, path)}
424
+ onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
425
+ sessionId={sessionId}
426
+ autoResize
427
+ minHeight={20}
428
+ textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
429
+ textareaStyle={{ height: "auto", overflow: "hidden" }}
430
+ placeholder="Add your note..."
431
+ attachButton="on-focus"
432
+ />
433
+ </div>
434
+ )}
403
435
 
404
436
  {/* Actions — top right on hover */}
405
437
  <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100 flex items-center gap-0.5">
@@ -415,19 +447,22 @@ function AnnotationSidebarInner({ onPreview, onSubmit, agentWatching, collapseBu
415
447
  <circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
416
448
  </svg>
417
449
  </button>
418
- <button
419
- onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
420
- className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
421
- title="Delete annotation"
422
- >
423
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
424
- <polyline points="3 6 5 6 21 6"/>
425
- <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
426
- </svg>
427
- </button>
450
+ {!isRemote && (
451
+ <button
452
+ onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
453
+ className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
454
+ title="Delete annotation"
455
+ >
456
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
457
+ <polyline points="3 6 5 6 21 6"/>
458
+ <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
459
+ </svg>
460
+ </button>
461
+ )}
428
462
  </div>
429
463
  </div>
430
- );
464
+ );
465
+ };
431
466
 
432
467
  return (
433
468
  <div className="flex flex-col h-full">
@@ -438,10 +473,12 @@ function AnnotationSidebarInner({ onPreview, onSubmit, agentWatching, collapseBu
438
473
  {annotations.length > 0 && (
439
474
  <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-border-subtle text-[10px] font-medium text-text-secondary">{annotations.length}</span>
440
475
  )}
441
- <span
442
- className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${agentWatching ? "bg-accent-green" : "bg-accent-amber"}`}
443
- title={agentWatching ? "Agent connected" : "Agent disconnected"}
444
- />
476
+ {!MODE.isShared && (
477
+ <span
478
+ className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${agentWatching ? "bg-accent-green" : "bg-accent-amber"}`}
479
+ title={agentWatching ? "Agent connected" : "Agent disconnected"}
480
+ />
481
+ )}
445
482
  </span>
446
483
  {collapseButton}
447
484
  </div>
@@ -18,9 +18,49 @@ import { generateAnnotationId, RESPONSE_ANNOTATION_PATH } from "./utils";
18
18
  import { wrapRangeWithMark, restoreMarks, renameMarkId, unwrapMarks, updateAllMarkStates } from "./highlightRange";
19
19
  import { extractContext } from "./annotationContext";
20
20
  import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
21
+ import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
22
+ import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
23
+ import type { Annotation } from "#canvas/runtime";
24
+ import { MODE, metaUrl, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
21
25
 
22
26
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
23
27
 
28
+ /**
29
+ * Wire shape of a remote feedback entry as delivered by the daemon
30
+ * (polled from CF Worker). Mirrors RemoteFeedbackEntry on the server side.
31
+ */
32
+ interface RemoteFeedbackEntry {
33
+ id: string;
34
+ shareId: string;
35
+ revision: number;
36
+ submittedAt: string;
37
+ author: { id: string; name: string };
38
+ annotations: Array<Omit<Annotation, "source" | "author">>;
39
+ generalNote?: string;
40
+ }
41
+
42
+ /**
43
+ * Flatten an array of remote feedback entries into a list of Annotations
44
+ * tagged with author + source so they can be rendered read-only alongside
45
+ * the local author's annotations. The `generalNote` field is intentionally
46
+ * dropped for now — the MVP surfaces only spatial (highlight-anchored)
47
+ * feedback. General notes could be surfaced in a "remote feedback" panel
48
+ * in a later iteration.
49
+ */
50
+ function remoteFeedbackToAnnotations(entries: RemoteFeedbackEntry[]): Annotation[] {
51
+ const out: Annotation[] = [];
52
+ for (const entry of entries) {
53
+ for (const ann of entry.annotations) {
54
+ out.push({
55
+ ...ann,
56
+ source: "remote",
57
+ author: entry.author,
58
+ });
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
24
64
  export interface CanvasFileInfo {
25
65
  filename: string;
26
66
  diffStats?: { added: number; removed: number };
@@ -176,7 +216,11 @@ function RevisionSelector() {
176
216
  }
177
217
 
178
218
  function App() {
179
- const sessionId = window.location.pathname.replace("/s/", "") || "";
219
+ // In shared mode the "sessionId" as far as the annotation system cares is
220
+ // the shareId (stable identifier for localStorage scoping). In local mode
221
+ // it's the real daemon session id from the URL.
222
+ const sessionId = MODE.isShared ? MODE.shareId : MODE.sessionId;
223
+ const isSharedMode = MODE.isShared;
180
224
  const [currentRevision, setCurrentRevision] = useState(1);
181
225
  const [selectedRevision, setSelectedRevision] = useState(1);
182
226
  const [revisions, setRevisions] = useState<RevisionInfo[]>([]);
@@ -193,6 +237,21 @@ function App() {
193
237
  const [mobileSidebar, setMobileSidebar] = useState(false);
194
238
  const [leftCollapsed, setLeftCollapsed] = useState(false);
195
239
  const [rightCollapsed, setRightCollapsed] = useState(false);
240
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
241
+ const [shares, setShares] = useState<ShareEntry[]>([]);
242
+ const [shareEnabled, setShareEnabled] = useState(false);
243
+ // Remote annotations keyed by revision — merged read-only into the
244
+ // annotation provider so reviewer feedback shows up as extra annotations.
245
+ const [remoteAnnotationsByRev, setRemoteAnnotationsByRev] = useState<Map<number, Annotation[]>>(new Map());
246
+ // Shared-mode state
247
+ const [reviewerDialogOpen, setReviewerDialogOpen] = useState(false);
248
+ const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
249
+ const [toast, setToast] = useState<{ kind: "error" | "success"; message: string } | null>(null);
250
+ useEffect(() => {
251
+ if (!toast) return;
252
+ const t = setTimeout(() => setToast(null), 4000);
253
+ return () => clearTimeout(t);
254
+ }, [toast]);
196
255
 
197
256
  // Dynamic document title
198
257
  useEffect(() => {
@@ -251,7 +310,7 @@ function App() {
251
310
  // Fetch initial meta
252
311
  useEffect(() => {
253
312
  if (!sessionId) return;
254
- fetch(`/api/session/${sessionId}/meta`)
313
+ fetch(metaUrl())
255
314
  .then((r) => r.json())
256
315
  .then((data: any) => {
257
316
  if (data.currentRevision) {
@@ -274,12 +333,42 @@ function App() {
274
333
  });
275
334
  }
276
335
  if (data.projectRoot) setProjectRoot(data.projectRoot);
336
+ if (Array.isArray(data.shares)) setShares(data.shares);
337
+ setShareEnabled(!!data.shareEnabled);
277
338
  })
278
339
  .catch(() => {});
279
340
  }, [sessionId]);
280
341
 
342
+ // Load any already-persisted remote feedback for the currently selected
343
+ // revision on first render. New entries arrive via WebSocket broadcast.
344
+ // This only runs in local (daemon) mode — shared mode has no notion of
345
+ // "previously polled remote feedback": the reviewer IS the remote.
346
+ useEffect(() => {
347
+ if (!sessionId || !selectedRevision || isSharedMode) return;
348
+ fetch(`/api/session/${sessionId}/revision/${selectedRevision}/remote-feedback`)
349
+ .then((r) => r.json())
350
+ .then((data: any) => {
351
+ if (!Array.isArray(data.entries)) return;
352
+ const anns = remoteFeedbackToAnnotations(data.entries);
353
+ setRemoteAnnotationsByRev((prev) => {
354
+ const next = new Map(prev);
355
+ next.set(selectedRevision, anns);
356
+ return next;
357
+ });
358
+ })
359
+ .catch(() => {});
360
+ }, [sessionId, selectedRevision, isSharedMode]);
361
+
281
362
  useEffect(() => {
282
363
  if (!sessionId) return;
364
+ if (!WS_AVAILABLE) {
365
+ // In shared mode there is no WebSocket — the worker only exposes HTTP.
366
+ // Live updates and watcher status are unavailable. The canvas is a
367
+ // static snapshot from the author's perspective, so "connected" is
368
+ // effectively always true.
369
+ setConnected(true);
370
+ return;
371
+ }
283
372
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
284
373
  const connect = () => {
285
374
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -321,6 +410,19 @@ function App() {
321
410
  if (data.type === "watcher-status") {
322
411
  setAgentWatching(!!data.watching);
323
412
  }
413
+ if (data.type === "remote-feedback") {
414
+ const rev = data.revision as number;
415
+ const entries = (data.entries || []) as RemoteFeedbackEntry[];
416
+ const anns = remoteFeedbackToAnnotations(entries);
417
+ setRemoteAnnotationsByRev((prev) => {
418
+ const next = new Map(prev);
419
+ const existing = next.get(rev) || [];
420
+ const seenIds = new Set(existing.map((a) => a.id));
421
+ const merged = [...existing, ...anns.filter((a) => !seenIds.has(a.id))];
422
+ next.set(rev, merged);
423
+ return next;
424
+ });
425
+ }
324
426
  } catch {}
325
427
  };
326
428
  };
@@ -328,14 +430,50 @@ function App() {
328
430
  return () => { if (reconnectTimer) clearTimeout(reconnectTimer); wsRef.current?.close(); };
329
431
  }, [sessionId]);
330
432
 
331
- const handleSubmit = useCallback((feedback: string) => {
433
+ const submitSharedFeedbackWithIdentity = useCallback(async (feedback: string, identity: { id: string; name: string }) => {
434
+ try {
435
+ const raw = localStorage.getItem(`canvas:${sessionId}:rev:${selectedRevision}`);
436
+ let structuredAnnotations: any[] = [];
437
+ if (raw) {
438
+ try {
439
+ const parsed = JSON.parse(raw);
440
+ structuredAnnotations = (parsed.annotations || []).filter((a: any) => a.source !== "remote");
441
+ } catch {}
442
+ }
443
+ await submitSharedFeedback({
444
+ author: identity,
445
+ revision: selectedRevision,
446
+ annotations: structuredAnnotations,
447
+ generalNote: feedback,
448
+ });
449
+ setPreviewOpen(false);
450
+ setToast({ kind: "success", message: "Feedback submitted. The canvas author will see it shortly." });
451
+ } catch (e: any) {
452
+ setToast({ kind: "error", message: `Failed to submit feedback: ${e.message || e}` });
453
+ }
454
+ }, [sessionId, selectedRevision]);
455
+
456
+ const handleSubmit = useCallback(async (feedback: string) => {
457
+ if (isSharedMode) {
458
+ // Shared mode: POST feedback to the worker via the reviewer identity
459
+ // modal flow. If identity already cached, skip the modal.
460
+ const identity = getReviewerIdentity();
461
+ if (!identity) {
462
+ setPendingFeedback(feedback);
463
+ setReviewerDialogOpen(true);
464
+ return;
465
+ }
466
+ await submitSharedFeedbackWithIdentity(feedback, identity);
467
+ return;
468
+ }
469
+
332
470
  if (wsRef.current?.readyState === WebSocket.OPEN) {
333
471
  wsRef.current.send(JSON.stringify({ type: "submit", feedback }));
334
472
  setPreviewOpen(false);
335
473
  // Server will broadcast revision-updated with hasFeedback: true
336
474
  // which triggers isReadOnly via revisions state
337
475
  }
338
- }, []);
476
+ }, [isSharedMode, submitSharedFeedbackWithIdentity]);
339
477
 
340
478
  if (!sessionId) {
341
479
  return <div className="flex items-center justify-center h-screen text-text-tertiary font-body">No session selected.</div>;
@@ -344,7 +482,13 @@ function App() {
344
482
  return (
345
483
  <SessionContext.Provider value={sessionId}>
346
484
  <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
347
- <AnnotationProvider key={`${sessionId}:${selectedRevision}`} sessionId={sessionId} revision={selectedRevision} isReadOnly={isReadOnly}>
485
+ <AnnotationProvider
486
+ key={`${sessionId}:${selectedRevision}`}
487
+ sessionId={sessionId}
488
+ revision={selectedRevision}
489
+ isReadOnly={isReadOnly}
490
+ remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
491
+ >
348
492
  <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
349
493
  <ActiveViewCtx.Provider value={{ setActiveView }}>
350
494
  <div className="min-h-screen bg-bg-base">
@@ -371,8 +515,9 @@ function App() {
371
515
  {/* Mobile top bar — only visible < lg */}
372
516
  <div className="lg:hidden sticky top-0 z-20 flex items-center justify-between px-4 py-2 border-b border-border-subtle bg-bg-surface">
373
517
  <div className="flex items-center gap-2">
374
- <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
375
- <span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
518
+ {!isSharedMode && <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />}
519
+ {isSharedMode && <span className="text-[11px] text-text-tertiary font-body">Shared canvas</span>}
520
+ {!isSharedMode && <span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-accent-green" : "bg-accent-red"}`} />}
376
521
  </div>
377
522
  <div className="flex items-center gap-1">
378
523
  <RevisionSelector />
@@ -400,6 +545,15 @@ function App() {
400
545
  {/* Individual canvas view — only mounted when active */}
401
546
  {activeView.type === "canvas" && canvasFiles.includes(activeView.filename) && (
402
547
  <div className="relative max-w-[720px] mx-auto px-6 pt-12 pb-32">
548
+ {shareEnabled && (
549
+ <div className="absolute top-3 right-12 z-10">
550
+ <ShareButton
551
+ onClick={() => setShareDialogOpen(true)}
552
+ hasShare={shares.some((s) => s.revision === selectedRevision)}
553
+ title={shares.some((s) => s.revision === selectedRevision) ? "Manage share link" : "Share this revision"}
554
+ />
555
+ </div>
556
+ )}
403
557
  <button
404
558
  onClick={() => {
405
559
  const planContent = document.querySelector(`[data-canvas-file="${activeView.filename}"] .plan-content`);
@@ -459,6 +613,54 @@ function App() {
459
613
  )}
460
614
 
461
615
  <ResponsePreview open={previewOpen} onClose={() => setPreviewOpen(false)} onSubmit={handleSubmit} />
616
+
617
+ <ShareDialog
618
+ sessionId={sessionId}
619
+ revision={selectedRevision}
620
+ open={shareDialogOpen}
621
+ shareEnabled={shareEnabled}
622
+ existingShares={shares}
623
+ onClose={() => setShareDialogOpen(false)}
624
+ onShareCreated={(share) => {
625
+ setShares((prev) => [...prev.filter((s) => s.shareId !== share.shareId), share]);
626
+ setToast({ kind: "success", message: "Share link created. Copy and send it to reviewers." });
627
+ }}
628
+ onShareRevoked={(shareId) => {
629
+ setShares((prev) => prev.filter((s) => s.shareId !== shareId));
630
+ setToast({ kind: "success", message: "Share link revoked." });
631
+ }}
632
+ />
633
+
634
+ <ReviewerIdentityDialog
635
+ open={reviewerDialogOpen}
636
+ onClose={() => { setReviewerDialogOpen(false); setPendingFeedback(null); }}
637
+ onSubmit={async (name) => {
638
+ const identity = setReviewerIdentity(name);
639
+ setReviewerDialogOpen(false);
640
+ if (pendingFeedback !== null) {
641
+ await submitSharedFeedbackWithIdentity(pendingFeedback, identity);
642
+ setPendingFeedback(null);
643
+ }
644
+ }}
645
+ />
646
+
647
+ {toast && (
648
+ <div
649
+ className={`fixed bottom-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg shadow-lg text-[13px] font-body font-medium flex items-center gap-2 ${
650
+ toast.kind === "error"
651
+ ? "bg-accent-red text-white"
652
+ : "bg-accent-green text-white"
653
+ }`}
654
+ >
655
+ {toast.message}
656
+ <button
657
+ onClick={() => setToast(null)}
658
+ className="ml-2 opacity-70 hover:opacity-100"
659
+ >
660
+ &#x2715;
661
+ </button>
662
+ </div>
663
+ )}
462
664
  </div>
463
665
  </ActiveViewCtx.Provider>
464
666
  </ActiveViewContext.Provider>
@@ -469,6 +671,7 @@ function App() {
469
671
  }
470
672
 
471
673
  function LeftPanel({ sessionId, projectRoot, connected, onMobileSidebar, collapsed, onToggle }: { sessionId: string; projectRoot?: string; connected: boolean; onMobileSidebar: () => void; collapsed: boolean; onToggle: () => void }) {
674
+ const isSharedMode = MODE.isShared;
472
675
  return (
473
676
  <>
474
677
  {/* Collapsed toggle button */}
@@ -491,8 +694,14 @@ function LeftPanel({ sessionId, projectRoot, connected, onMobileSidebar, collaps
491
694
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-tertiary flex-shrink-0">
492
695
  <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
493
696
  </svg>
494
- <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
495
- <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
697
+ {isSharedMode ? (
698
+ <span className="text-[12px] font-body text-text-secondary truncate flex-1">Shared canvas</span>
699
+ ) : (
700
+ <>
701
+ <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
702
+ <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
703
+ </>
704
+ )}
496
705
  <button
497
706
  onClick={onToggle}
498
707
  className="ml-auto w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
@@ -871,7 +1080,7 @@ function ContentTabs() {
871
1080
 
872
1081
  {/* Right-aligned actions */}
873
1082
  <div className="ml-auto flex items-center gap-1 flex-shrink-0">
874
- {activeView.type === "file" && (
1083
+ {activeView.type === "file" && FS_AVAILABLE && (
875
1084
  <button
876
1085
  onClick={async () => {
877
1086
  const path = activeView.path;
@@ -4,6 +4,7 @@ import { useAnnotations } from "./AnnotationProvider";
4
4
  import { ActiveViewContext } from "./App";
5
5
  import { FileIcon } from "./FileIcon";
6
6
  import { RESPONSE_ANNOTATION_PATH } from "./utils";
7
+ import { FS_AVAILABLE } from "./clientApi";
7
8
 
8
9
  interface TreeEntry { name: string; type: "file" | "dir"; }
9
10
  interface DirState { entries: TreeEntry[]; expanded: boolean; loaded: boolean; }
@@ -15,6 +16,21 @@ export function FileBrowser({ embedded }: { embedded?: boolean } = {}) {
15
16
  const [collapsed, setCollapsed] = useState(false);
16
17
  const [dirs, setDirs] = useState<Record<string, DirState>>({});
17
18
 
19
+ // Shared-mode canvases have no filesystem backing — the daemon's /api/tree
20
+ // endpoint isn't reachable. Render a simple placeholder so reviewers see
21
+ // something informative instead of a broken tree.
22
+ if (!FS_AVAILABLE) {
23
+ return (
24
+ <div className={embedded ? "px-4 py-3" : "p-4"}>
25
+ <div className="text-[11px] uppercase tracking-widest text-text-tertiary font-body mb-2">Files</div>
26
+ <p className="text-[11px] text-text-tertiary font-body leading-relaxed">
27
+ File browsing is not available in shared view. File previews embedded
28
+ in the canvas still work.
29
+ </p>
30
+ </div>
31
+ );
32
+ }
33
+
18
34
  const { annCountMap, annotatedFiles } = useMemo(() => {
19
35
  const map = new Map<string, number>();
20
36
  for (const a of annotations) {
@@ -6,6 +6,7 @@ import { wrapRangeWithMark } from "./highlightRange";
6
6
  import { LANG_MAP } from "../langMap";
7
7
  import { generateAnnotationId } from "./utils";
8
8
  import { useTextAnnotation } from "./useTextAnnotation";
9
+ import { FS_AVAILABLE } from "./clientApi";
9
10
 
10
11
  /** Walk up from a node to find the parent line div with data-line-num */
11
12
  function findLineDiv(node: Node): HTMLElement | null {
@@ -47,6 +48,23 @@ interface FileViewerProps {
47
48
  }
48
49
 
49
50
  export function FileViewer({ path }: FileViewerProps) {
51
+ if (!FS_AVAILABLE) {
52
+ return (
53
+ <div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
54
+ <div className="p-4 rounded-lg border border-border-subtle bg-bg-surface">
55
+ <div className="text-[11px] uppercase tracking-widest text-text-tertiary font-body mb-2">
56
+ File not available
57
+ </div>
58
+ <p className="text-[13px] text-text-secondary font-body leading-relaxed">
59
+ Opening <code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">{path}</code> requires
60
+ filesystem access, which isn't available in shared view. If the
61
+ canvas author wanted this file visible, they should embed it as
62
+ a <code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">&lt;FilePreview&gt;</code> in the canvas.
63
+ </p>
64
+ </div>
65
+ </div>
66
+ );
67
+ }
50
68
  const sessionId = useContext(SessionContext);
51
69
  const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
52
70
  const [content, setContent] = useState<string | null>(null);
@@ -6,6 +6,7 @@ import { extractContext } from "./annotationContext";
6
6
  import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
7
7
  import { generateAnnotationId } from "./utils";
8
8
  import { useTextAnnotation } from "./useTextAnnotation";
9
+ import { canvasJsUrl } from "./clientApi";
9
10
 
10
11
  /** All navigable blocks (keyboard arrows) */
11
12
  const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
@@ -40,8 +41,8 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
40
41
  setLoading(true);
41
42
  setError(null);
42
43
  setFocusedBlockIndex(null);
43
- const jsName = filename.replace(/\.jsx$/, ".js");
44
- import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
44
+ void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
45
+ import(/* @vite-ignore */ canvasJsUrl(filename, revision))
45
46
  .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
46
47
  .catch((e) => { setError(e.message); setLoading(false); });
47
48
  }, [sessionId, revision, filename]);