agent-canvas 0.2.0 → 0.6.0

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 (98) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -105
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
@@ -1,56 +1,62 @@
1
- import React, { useEffect, useLayoutEffect, useState, useCallback, useRef, createContext, useMemo } from "react";
2
- import { createRoot } from "react-dom/client";
3
- import { marked } from "marked";
4
- import { SessionContext, ActiveViewCtx } from "#canvas/runtime";
1
+ import { ActiveViewCtx, SessionContext } from '#canvas/runtime'
2
+ import type { Annotation, AnnotationContext } from '#canvas/runtime'
5
3
  import {
6
- ActiveViewContext,
7
- AnnotationProvider,
8
- AnnotationSidebar,
9
- CanvasHostContext,
10
- FileIcon,
11
- PlanRenderer,
12
- RenderErrorContext,
13
- RevisionContext,
14
- carryUnsubmittedDraft,
15
- clearPersistedDraft,
16
- useAnnotations,
17
- type ActiveView,
18
- type AnnotationDraftPhase,
19
- type CanvasFileInfo,
20
- type CanvasRenderError,
21
- type RevisionInfo,
22
- } from "@fabrika/canvas-kernel/client";
4
+ AnnotationCreatePopover,
5
+ AnnotationEditPopover,
6
+ extractContext,
7
+ generateAnnotationId,
8
+ renameMarkId,
9
+ RESPONSE_ANNOTATION_PATH,
10
+ restoreMarks,
11
+ unwrapMarks,
12
+ updateAllMarkStates,
13
+ wrapRangeWithMark,
14
+ } from '@fabrika/annotations'
23
15
  import {
24
- AnnotationCreatePopover, AnnotationEditPopover, RESPONSE_ANNOTATION_PATH,
25
- extractContext, generateAnnotationId, renameMarkId, restoreMarks,
26
- unwrapMarks, updateAllMarkStates, wrapRangeWithMark,
27
- } from "@fabrika/annotations";
28
- import { FileBrowser } from "./FileBrowser";
29
- import { FileViewer } from "./FileViewer";
30
- import { SessionSwitcher } from "./SessionSwitcher";
31
- import { exportCanvasToMarkdown } from "./exportMarkdown";
32
- import { CompareView } from "./CompareView";
33
- import { OverviewView, categorizeChanges, getAffectedFiles } from "./OverviewView";
34
- import { RevisionSelect } from "./RevisionSelect";
35
- import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
36
- import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
37
- import type { Annotation } from "#canvas/runtime";
38
- import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity, canvasHost } from "./clientApi";
39
-
40
-
41
- /**
42
- * Wire shape of a remote feedback entry as delivered by the daemon
43
- * (polled from CF Worker). Mirrors RemoteFeedbackEntry on the server side.
44
- */
45
- interface RemoteFeedbackEntry {
46
- id: string;
47
- shareId: string;
48
- revision: number;
49
- submittedAt: string;
50
- author: { id: string; name: string };
51
- annotations: Array<Omit<Annotation, "source" | "author">>;
52
- generalNote?: string;
53
- }
16
+ type ActiveView,
17
+ ActiveViewContext,
18
+ type AnnotationDraftPhase,
19
+ AnnotationProvider,
20
+ AnnotationSidebar,
21
+ type CanvasFileInfo,
22
+ CanvasHostContext,
23
+ type CanvasRenderError,
24
+ carryUnsubmittedDraft,
25
+ clearPersistedDraft,
26
+ FileIcon,
27
+ filePreviewResponseSchema,
28
+ parsePersistedState,
29
+ PlanRenderer,
30
+ RenderErrorContext,
31
+ RevisionContext,
32
+ type RevisionInfo,
33
+ useAnnotations,
34
+ } from '@fabrika/canvas-kernel/client'
35
+ import { marked } from 'marked'
36
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
37
+ import { createRoot } from 'react-dom/client'
38
+ import { type RemoteFeedbackEntry, remoteFeedbackResponseSchema, type ShareEntry } from './apiSchemas'
39
+ import {
40
+ canvasHost,
41
+ fetchMeta,
42
+ FS_AVAILABLE,
43
+ getReviewerIdentity,
44
+ MODE,
45
+ setReviewerIdentity,
46
+ type SharedFeedbackPayload,
47
+ submitSharedFeedback,
48
+ WS_AVAILABLE,
49
+ } from './clientApi'
50
+ import { CompareView } from './CompareView'
51
+ import { errorMessage } from './errorMessage'
52
+ import { exportCanvasToMarkdown } from './exportMarkdown'
53
+ import { FileBrowser } from './FileBrowser'
54
+ import { FileViewer } from './FileViewer'
55
+ import { categorizeChanges, getAffectedFiles, OverviewView } from './OverviewView'
56
+ import { ReviewerIdentityDialog } from './ReviewerIdentityDialog'
57
+ import { RevisionSelect } from './RevisionSelect'
58
+ import { SessionSwitcher } from './SessionSwitcher'
59
+ import { ShareButton, ShareDialog } from './ShareDialog'
54
60
 
55
61
  /**
56
62
  * Flatten an array of remote feedback entries into a list of Annotations
@@ -61,1058 +67,1180 @@ interface RemoteFeedbackEntry {
61
67
  * in a later iteration.
62
68
  */
63
69
  function remoteFeedbackToAnnotations(entries: RemoteFeedbackEntry[]): Annotation[] {
64
- const out: Annotation[] = [];
65
- for (const entry of entries) {
66
- for (const ann of entry.annotations) {
67
- out.push({
68
- ...ann,
69
- source: "remote",
70
- author: entry.author,
71
- });
72
- }
73
- }
74
- return out;
70
+ const out: Annotation[] = []
71
+ for (const entry of entries) {
72
+ for (const ann of entry.annotations) {
73
+ out.push({
74
+ ...ann,
75
+ source: 'remote',
76
+ author: entry.author,
77
+ })
78
+ }
79
+ }
80
+ return out
75
81
  }
76
82
 
77
- function resolveTheme(pref: string): "light" | "dark" {
78
- if (pref === "auto") return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
79
- return pref as "light" | "dark";
83
+ function resolveTheme(pref: string): 'light' | 'dark' {
84
+ if (pref === 'auto') return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
85
+ return pref as 'light' | 'dark'
80
86
  }
81
87
 
82
88
  function applyTheme(pref: string) {
83
- document.documentElement.dataset.theme = resolveTheme(pref);
89
+ document.documentElement.dataset.theme = resolveTheme(pref)
84
90
  }
85
91
 
86
92
  function ThemeSwitcher() {
87
- const [pref, setPref] = useState<"auto" | "light" | "dark">(() => {
88
- const stored = localStorage.getItem("canvas-theme");
89
- if (stored === "light" || stored === "dark" || stored === "auto") return stored;
90
- return "auto";
91
- });
92
-
93
- useEffect(() => {
94
- applyTheme(pref);
95
- if (pref !== "auto") return;
96
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
97
- const handler = () => applyTheme("auto");
98
- mq.addEventListener("change", handler);
99
- return () => mq.removeEventListener("change", handler);
100
- }, [pref]);
101
-
102
- const toggle = () => {
103
- const next = pref === "auto" ? "light" : pref === "light" ? "dark" : "auto";
104
- localStorage.setItem("canvas-theme", next);
105
- setPref(next);
106
- };
107
-
108
- const titles = { auto: "Theme: system (click for light)", light: "Theme: light (click for dark)", dark: "Theme: dark (click for system)" };
109
-
110
- return (
111
- <button
112
- onClick={toggle}
113
- className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
114
- title={titles[pref]}
115
- >
116
- {pref === "auto" ? (
117
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
118
- <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
119
- <line x1="8" y1="21" x2="16" y2="21" />
120
- <line x1="12" y1="17" x2="12" y2="21" />
121
- </svg>
122
- ) : pref === "dark" ? (
123
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
124
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
125
- </svg>
126
- ) : (
127
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
128
- <circle cx="12" cy="12" r="5" />
129
- <line x1="12" y1="1" x2="12" y2="3" />
130
- <line x1="12" y1="21" x2="12" y2="23" />
131
- <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
132
- <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
133
- <line x1="1" y1="12" x2="3" y2="12" />
134
- <line x1="21" y1="12" x2="23" y2="12" />
135
- <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
136
- <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
137
- </svg>
138
- )}
139
- </button>
140
- );
93
+ const [pref, setPref] = useState<'auto' | 'light' | 'dark'>(() => {
94
+ const stored = localStorage.getItem('canvas-theme')
95
+ if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
96
+ return 'auto'
97
+ })
98
+
99
+ useEffect(() => {
100
+ applyTheme(pref)
101
+ if (pref !== 'auto') return
102
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
103
+ const handler = () => applyTheme('auto')
104
+ mq.addEventListener('change', handler)
105
+ return () => mq.removeEventListener('change', handler)
106
+ }, [pref])
107
+
108
+ const toggle = () => {
109
+ const next = pref === 'auto' ? 'light' : pref === 'light' ? 'dark' : 'auto'
110
+ localStorage.setItem('canvas-theme', next)
111
+ setPref(next)
112
+ }
113
+
114
+ const titles = { auto: 'Theme: system (click for light)', light: 'Theme: light (click for dark)', dark: 'Theme: dark (click for system)' }
115
+
116
+ return (
117
+ <button
118
+ onClick={toggle}
119
+ className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
120
+ title={titles[pref]}
121
+ >
122
+ {pref === 'auto'
123
+ ? (
124
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
125
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
126
+ <line x1="8" y1="21" x2="16" y2="21" />
127
+ <line x1="12" y1="17" x2="12" y2="21" />
128
+ </svg>
129
+ )
130
+ : pref === 'dark'
131
+ ? (
132
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
133
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
134
+ </svg>
135
+ )
136
+ : (
137
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
138
+ <circle cx="12" cy="12" r="5" />
139
+ <line x1="12" y1="1" x2="12" y2="3" />
140
+ <line x1="12" y1="21" x2="12" y2="23" />
141
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
142
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
143
+ <line x1="1" y1="12" x2="3" y2="12" />
144
+ <line x1="21" y1="12" x2="23" y2="12" />
145
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
146
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
147
+ </svg>
148
+ )}
149
+ </button>
150
+ )
141
151
  }
142
152
 
143
153
  function RevisionSelector() {
144
- const { currentRevision, selectedRevision, revisions, setSelectedRevision, setCompareRevision } = React.useContext(RevisionContext);
145
-
146
- if (revisions.length <= 1) {
147
- const only = revisions[0];
148
- const label = only?.label || `Round ${currentRevision}`;
149
- return <span className="text-[11px] text-text-tertiary font-body">{label}</span>;
150
- }
151
-
152
- const isLatest = selectedRevision === currentRevision;
153
-
154
- return (
155
- <div className="flex flex-col gap-1">
156
- <RevisionSelect
157
- value={selectedRevision}
158
- onChange={setSelectedRevision}
159
- accent={isLatest ? "default" : "amber"}
160
- />
161
- <button
162
- onClick={() => {
163
- let left: number;
164
- if (selectedRevision === currentRevision) {
165
- const prev = [...revisions]
166
- .reverse()
167
- .find((r) => r.revision < currentRevision);
168
- left = prev?.revision ?? Math.max(1, currentRevision - 1);
169
- } else {
170
- left = selectedRevision;
171
- }
172
- setCompareRevision({ left, right: currentRevision });
173
- }}
174
- className="text-[11px] font-body font-medium px-2 py-0.5 rounded-md text-accent-blue hover:bg-accent-blue-muted transition-colors self-start"
175
- >
176
- Compare
177
- </button>
178
- </div>
179
- );
154
+ const { currentRevision, selectedRevision, revisions, setSelectedRevision, setCompareRevision } = React.useContext(RevisionContext)
155
+
156
+ if (revisions.length <= 1) {
157
+ const only = revisions[0]
158
+ const label = only?.label || `Round ${currentRevision}`
159
+ return <span className="text-[11px] text-text-tertiary font-body">{label}</span>
160
+ }
161
+
162
+ const isLatest = selectedRevision === currentRevision
163
+
164
+ return (
165
+ <div className="flex flex-col gap-1">
166
+ <RevisionSelect
167
+ value={selectedRevision}
168
+ onChange={setSelectedRevision}
169
+ accent={isLatest ? 'default' : 'amber'}
170
+ />
171
+ <button
172
+ onClick={() => {
173
+ let left: number
174
+ if (selectedRevision === currentRevision) {
175
+ const prev = [...revisions]
176
+ .reverse()
177
+ .find((r) => r.revision < currentRevision)
178
+ left = prev?.revision ?? Math.max(1, currentRevision - 1)
179
+ } else {
180
+ left = selectedRevision
181
+ }
182
+ setCompareRevision({ left, right: currentRevision })
183
+ }}
184
+ className="text-[11px] font-body font-medium px-2 py-0.5 rounded-md text-accent-blue hover:bg-accent-blue-muted transition-colors self-start"
185
+ >
186
+ Compare
187
+ </button>
188
+ </div>
189
+ )
180
190
  }
181
191
 
182
192
  function App() {
183
- // In shared mode the "sessionId" as far as the annotation system cares is
184
- // the shareId (stable identifier for localStorage scoping). In local mode
185
- // it's the real daemon session id from the URL.
186
- const sessionId = MODE.isShared ? MODE.shareId : MODE.sessionId;
187
- const isSharedMode = MODE.isShared;
188
- const [currentRevision, setCurrentRevision] = useState(1);
189
- const [selectedRevision, setSelectedRevision] = useState(1);
190
- const [revisions, setRevisions] = useState<RevisionInfo[]>([]);
191
- const [compareRevision, setCompareRevision] = useState<{ left: number; right: number } | null>(null);
192
- const [connected, setConnected] = useState(false);
193
- const [sharedFeedbackSubmitted, setSharedFeedbackSubmitted] = useState(false);
194
- const [agentWatching, setAgentWatching] = useState(false);
195
- const [canvasFiles, setCanvasFiles] = useState<string[]>([]);
196
- const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
197
- const [openFiles, setOpenFiles] = useState<string[]>([]);
198
- const wsRef = useRef<WebSocket | null>(null);
199
- const pendingRenderErrorsRef = useRef<CanvasRenderError[]>([]);
200
- const reportedRenderErrorsRef = useRef(new Set<string>());
201
-
202
- const [projectRoot, setProjectRoot] = useState<string | undefined>();
203
- const [mobileSidebar, setMobileSidebar] = useState(false);
204
- const [leftCollapsed, setLeftCollapsed] = useState(false);
205
- const [rightCollapsed, setRightCollapsed] = useState(false);
206
- const [shareDialogOpen, setShareDialogOpen] = useState(false);
207
- const [shares, setShares] = useState<ShareEntry[]>([]);
208
- const [shareEnabled, setShareEnabled] = useState(false);
209
- // Remote annotations keyed by revision — merged read-only into the
210
- // annotation provider so reviewer feedback shows up as extra annotations.
211
- const [remoteAnnotationsByRev, setRemoteAnnotationsByRev] = useState<Map<number, Annotation[]>>(new Map());
212
- // Shared-mode state
213
- const [reviewerDialogOpen, setReviewerDialogOpen] = useState(false);
214
- const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
215
- const [toast, setToast] = useState<{ kind: "error" | "success"; message: string } | null>(null);
216
-
217
- const reportRenderError = useCallback((error: CanvasRenderError) => {
218
- if (isSharedMode) return;
219
- const signature = `${error.revision}\0${error.filename}\0${error.message}`;
220
- if (reportedRenderErrorsRef.current.has(signature)) return;
221
- reportedRenderErrorsRef.current.add(signature);
222
-
223
- const payload = JSON.stringify({ type: "render-error", error });
224
- if (wsRef.current?.readyState === WebSocket.OPEN) {
225
- wsRef.current.send(payload);
226
- } else {
227
- pendingRenderErrorsRef.current.push(error);
228
- }
229
- }, [isSharedMode]);
230
-
231
- useEffect(() => {
232
- if (!toast) return;
233
- const t = setTimeout(() => setToast(null), 4000);
234
- return () => clearTimeout(t);
235
- }, [toast]);
236
-
237
- // Dynamic document title
238
- useEffect(() => {
239
- const parts = ["Agent Canvas"];
240
- if (projectRoot) {
241
- const segments = projectRoot.replace(/\/+$/, "").split("/");
242
- parts.push(segments[segments.length - 1] || sessionId);
243
- } else if (sessionId) {
244
- parts.push(sessionId);
245
- }
246
- if (activeView.type === "file") {
247
- parts.push(activeView.path.split("/").pop() || activeView.path);
248
- } else if (activeView.type === "overview") {
249
- parts.push("Overview");
250
- } else if (activeView.type === "canvas" && canvasFiles.length > 1 && activeView.filename) {
251
- parts.push(activeView.filename.replace(/\.jsx$/, ""));
252
- } else if (compareRevision) {
253
- parts.push("Compare");
254
- }
255
- document.title = parts.join(" — ");
256
- }, [sessionId, projectRoot, activeView, compareRevision]);
257
-
258
- // Scroll position restore per view
259
- const scrollPositions = useRef<Map<string, number>>(new Map());
260
-
261
- const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
262
- const isReadOnly = selectedRevision !== currentRevision || !!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted;
263
- // Keep feedback added after submission separate from the already-sent draft.
264
- const draftPhase: AnnotationDraftPhase = selectedRevision === currentRevision && (!!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted)
265
- ? "next"
266
- : "current";
267
-
268
- const setActiveView = useCallback((v: ActiveView) => {
269
- // Save current scroll position before switching
270
- const key = activeView.type === "overview" ? "overview" : activeView.type === "canvas" ? `canvas:${activeView.filename}` : `file:${activeView.path}`;
271
- scrollPositions.current.set(key, window.scrollY);
272
- setActiveViewRaw(v);
273
- if (v.type === "file") {
274
- setOpenFiles((prev) => prev.includes(v.path) ? prev : [...prev, v.path]);
275
- }
276
- }, [activeView]);
277
-
278
- const closeFile = useCallback((path: string) => {
279
- scrollPositions.current.delete(`file:${path}`);
280
- setOpenFiles((prev) => prev.filter((p) => p !== path));
281
- setActiveViewRaw((prev) => {
282
- if (prev.type === "file" && prev.path === path) {
283
- return canvasFiles.length > 1 ? { type: "overview" } : { type: "canvas", filename: canvasFiles[0] || "" };
284
- }
285
- return prev;
286
- });
287
- }, [canvasFiles]);
288
-
289
- // Restore scroll synchronously after DOM update (before paint)
290
- useLayoutEffect(() => {
291
- const key = activeView.type === "overview" ? "overview" : activeView.type === "canvas" ? `canvas:${activeView.filename}` : `file:${activeView.path}`;
292
- window.scrollTo(0, scrollPositions.current.get(key) || 0);
293
- }, [activeView]);
294
-
295
- // Fetch initial meta
296
- useEffect(() => {
297
- if (!sessionId) return;
298
- fetchMeta()
299
- .then((data: any) => {
300
- if (data.currentRevision) {
301
- const nextRevisions: RevisionInfo[] = data.revisions || [];
302
- for (const revision of nextRevisions) {
303
- if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
304
- }
305
- carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions);
306
- setCurrentRevision(data.currentRevision);
307
- setSelectedRevision(data.currentRevision);
308
- setRevisions(nextRevisions);
309
- }
310
- if (data.canvasFiles) {
311
- const files = (data.canvasFiles as string[]).sort();
312
- setCanvasFiles(files);
313
- setActiveViewRaw((prev) => {
314
- if (prev.type === "overview") {
315
- // On initial load: overview if multiple files, direct if single
316
- return files.length > 1 ? prev : { type: "canvas", filename: files[0] || "" };
317
- }
318
- if (prev.type === "canvas" && (!prev.filename || !files.includes(prev.filename))) {
319
- return { type: "canvas", filename: files[0] || "" };
320
- }
321
- return prev;
322
- });
323
- }
324
- if (data.projectRoot) setProjectRoot(data.projectRoot);
325
- if (Array.isArray(data.shares)) setShares(data.shares);
326
- setShareEnabled(!!data.shareEnabled);
327
- })
328
- .catch(() => {});
329
- }, [sessionId]);
330
-
331
- // Load any already-persisted remote feedback for the currently selected
332
- // revision on first render. New entries arrive via WebSocket broadcast.
333
- // This only runs in local (daemon) mode — shared mode has no notion of
334
- // "previously polled remote feedback": the reviewer IS the remote.
335
- useEffect(() => {
336
- if (!sessionId || !selectedRevision || isSharedMode) return;
337
- fetch(`/api/session/${sessionId}/revision/${selectedRevision}/remote-feedback`)
338
- .then((r) => r.json())
339
- .then((data: any) => {
340
- if (!Array.isArray(data.entries)) return;
341
- const anns = remoteFeedbackToAnnotations(data.entries);
342
- setRemoteAnnotationsByRev((prev) => {
343
- const next = new Map(prev);
344
- next.set(selectedRevision, anns);
345
- return next;
346
- });
347
- })
348
- .catch(() => {});
349
- }, [sessionId, selectedRevision, isSharedMode]);
350
-
351
- useEffect(() => {
352
- if (!sessionId) return;
353
- if (!WS_AVAILABLE) {
354
- // In shared mode there is no WebSocket — the worker only exposes HTTP.
355
- // Live updates and watcher status are unavailable. The canvas is a
356
- // static snapshot from the author's perspective, so "connected" is
357
- // effectively always true.
358
- setConnected(true);
359
- return;
360
- }
361
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
362
- const connect = () => {
363
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
364
- const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`);
365
- wsRef.current = ws;
366
- ws.onopen = () => {
367
- setConnected(true);
368
- for (const error of pendingRenderErrorsRef.current) {
369
- ws.send(JSON.stringify({ type: "render-error", error }));
370
- }
371
- pendingRenderErrorsRef.current = [];
372
- };
373
- ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 2000); };
374
- ws.onmessage = (event) => {
375
- try {
376
- const data = JSON.parse(event.data);
377
- if (data.type === "plan-updated") {
378
- const allRevisions: RevisionInfo[] = Array.isArray(data.revisions) ? data.revisions : [];
379
- carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, allRevisions);
380
- setCurrentRevision(data.currentRevision);
381
- setSelectedRevision(data.currentRevision);
382
- setCompareRevision(null);
383
- if (allRevisions.length > 0) {
384
- setRevisions(allRevisions);
385
- const latest = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision);
386
- if (latest?.canvasFiles) {
387
- const files = latest.canvasFiles.map((cf: CanvasFileInfo) => cf.filename).sort();
388
- setCanvasFiles(files);
389
- // Determine what changed and auto-navigate
390
- const previous = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision - 1);
391
- const changes = categorizeChanges(latest, previous);
392
- const affected = getAffectedFiles(changes);
393
- if (affected.length === 1) {
394
- setActiveViewRaw({ type: "canvas", filename: affected[0] });
395
- } else if (affected.length > 1 || files.length > 1) {
396
- setActiveViewRaw({ type: "overview" });
397
- } else {
398
- setActiveViewRaw({ type: "canvas", filename: files[0] || "" });
399
- }
400
- }
401
- }
402
- }
403
- if (data.type === "revision-updated") {
404
- if (Array.isArray(data.revisions)) {
405
- const nextRevisions: RevisionInfo[] = data.revisions;
406
- for (const revision of nextRevisions) {
407
- if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
408
- }
409
- setRevisions(nextRevisions);
410
- }
411
- }
412
- if (data.type === "watcher-status") {
413
- setAgentWatching(!!data.watching);
414
- }
415
- if (data.type === "remote-feedback") {
416
- const rev = data.revision as number;
417
- const entries = (data.entries || []) as RemoteFeedbackEntry[];
418
- const anns = remoteFeedbackToAnnotations(entries);
419
- setRemoteAnnotationsByRev((prev) => {
420
- const next = new Map(prev);
421
- const existing = next.get(rev) || [];
422
- const seenIds = new Set(existing.map((a) => a.id));
423
- const merged = [...existing, ...anns.filter((a) => !seenIds.has(a.id))];
424
- next.set(rev, merged);
425
- return next;
426
- });
427
- }
428
- } catch {}
429
- };
430
- };
431
- connect();
432
- return () => { if (reconnectTimer) clearTimeout(reconnectTimer); wsRef.current?.close(); };
433
- }, [sessionId]);
434
-
435
- const submitSharedFeedbackWithIdentity = useCallback(async (feedback: string, identity: { id: string; name: string }) => {
436
- try {
437
- const raw = localStorage.getItem(`canvas:${sessionId}:rev:${selectedRevision}`);
438
- let structuredAnnotations: any[] = [];
439
- if (raw) {
440
- try {
441
- const parsed = JSON.parse(raw);
442
- structuredAnnotations = (parsed.annotations || []).filter((a: any) => a.source !== "remote");
443
- } catch {}
444
- }
445
- await submitSharedFeedback({
446
- author: identity,
447
- revision: selectedRevision,
448
- annotations: structuredAnnotations,
449
- generalNote: feedback,
450
- });
451
- clearPersistedDraft(localStorage, sessionId, selectedRevision);
452
- setSharedFeedbackSubmitted(true);
453
- setToast({ kind: "success", message: "Feedback submitted. The canvas author will see it shortly." });
454
- } catch (e: any) {
455
- setToast({ kind: "error", message: `Failed to submit feedback: ${e.message || e}` });
456
- }
457
- }, [sessionId, selectedRevision]);
458
-
459
- const handleSubmit = useCallback(async (feedback: string) => {
460
- if (isSharedMode) {
461
- // Shared mode: POST feedback to the worker via the reviewer identity
462
- // modal flow. If identity already cached, skip the modal.
463
- const identity = getReviewerIdentity();
464
- if (!identity) {
465
- setPendingFeedback(feedback);
466
- setReviewerDialogOpen(true);
467
- return;
468
- }
469
- await submitSharedFeedbackWithIdentity(feedback, identity);
470
- return;
471
- }
472
-
473
- if (wsRef.current?.readyState === WebSocket.OPEN) {
474
- wsRef.current.send(JSON.stringify({ type: "submit", feedback }));
475
- // Server will broadcast revision-updated with hasFeedback: true
476
- // which triggers isReadOnly via revisions state
477
- }
478
- }, [isSharedMode, submitSharedFeedbackWithIdentity]);
479
-
480
- if (!sessionId) {
481
- return <div className="flex items-center justify-center h-screen text-text-tertiary font-body">No session selected.</div>;
482
- }
483
-
484
- return (
485
- <CanvasHostContext.Provider value={canvasHost}>
486
- <SessionContext.Provider value={sessionId}>
487
- <RenderErrorContext.Provider value={reportRenderError}>
488
- <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
489
- <AnnotationProvider
490
- key={`${sessionId}:${selectedRevision}:${draftPhase}`}
491
- sessionId={sessionId}
492
- revision={selectedRevision}
493
- isReadOnly={isReadOnly}
494
- draftPhase={draftPhase}
495
- remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
496
- canvasFiles={canvasFiles}
497
- >
498
- <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
499
- <ActiveViewCtx.Provider value={{ setActiveView }}>
500
- <div className="min-h-screen bg-bg-base">
501
- {/* Left panel — fixed to viewport */}
502
- <LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
503
-
504
- {/* Right panel — fixed to viewport, resizable */}
505
- <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
506
- <AnnotationSidebar onSubmit={handleSubmit} />
507
- </ResizableSidebar>
508
-
509
- {/* Center content — normal document flow, browser scroll */}
510
- {compareRevision !== null ? (
511
- <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`}>
512
- <CompareView
513
- initialLeft={compareRevision.left}
514
- initialRight={compareRevision.right}
515
- sessionId={sessionId}
516
- onExit={() => setCompareRevision(null)}
517
- />
518
- </div>
519
- ) : (
520
- <div className={`relative ${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`} id="plan-scroll-container">
521
- {/* Mobile top bar — only visible < lg */}
522
- <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">
523
- <div className="flex items-center gap-2">
524
- {!isSharedMode && <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />}
525
- {isSharedMode && <span className="text-[11px] text-text-tertiary font-body">Shared canvas</span>}
526
- {!isSharedMode && <span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-accent-green" : "bg-accent-red"}`} />}
527
- </div>
528
- <div className="flex items-center gap-1">
529
- <RevisionSelector />
530
- <button
531
- onClick={() => setMobileSidebar(!mobileSidebar)}
532
- className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
533
- title="Annotations"
534
- >
535
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
536
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
537
- </svg>
538
- </button>
539
- <ThemeSwitcher />
540
- </div>
541
- </div>
542
-
543
- <ContentTabs />
544
- {/* Overview — shows all affected canvases inline */}
545
- {activeView.type === "overview" && (
546
- <OverviewView
547
- revision={selectedRevision}
548
- responseBanner={selectedRevInfo?.response ? <ResponseBanner markdown={selectedRevInfo.response} /> : undefined}
549
- />
550
- )}
551
- {/* Individual canvas view — only mounted when active */}
552
- {activeView.type === "canvas" && canvasFiles.includes(activeView.filename) && (
553
- <div className="relative max-w-[720px] mx-auto px-6 pt-12 pb-32">
554
- {shareEnabled && (
555
- <div className="absolute top-3 right-12 z-10">
556
- <ShareButton
557
- onClick={() => setShareDialogOpen(true)}
558
- hasShare={shares.some((s) => s.revision === selectedRevision)}
559
- title={shares.some((s) => s.revision === selectedRevision) ? "Manage share link" : "Share this revision"}
560
- />
561
- </div>
562
- )}
563
- <button
564
- onClick={() => {
565
- const planContent = document.querySelector(`[data-canvas-file="${activeView.filename}"] .plan-content`);
566
- if (!planContent) return;
567
- const md = exportCanvasToMarkdown(planContent as HTMLElement);
568
- navigator.clipboard.writeText(md).then(() => {
569
- const btn = document.getElementById("export-md-btn");
570
- if (btn) {
571
- btn.setAttribute("data-copied", "true");
572
- setTimeout(() => { btn.removeAttribute("data-copied"); }, 1500);
573
- }
574
- });
575
- }}
576
- id="export-md-btn"
577
- className="group absolute top-3 right-6 p-1.5 text-text-tertiary hover:text-text-secondary transition-colors z-10"
578
- title="Copy as Markdown"
579
- >
580
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="group-[[data-copied=true]]:hidden">
581
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
582
- <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
583
- </svg>
584
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="hidden group-[[data-copied=true]]:block text-green-500">
585
- <polyline points="20 6 9 17 4 12"/>
586
- </svg>
587
- </button>
588
- {/* Show response banner on individual canvas only when there's no overview (single canvas) */}
589
- {canvasFiles.length <= 1 && selectedRevInfo?.response && (
590
- <ResponseBanner markdown={selectedRevInfo.response} />
591
- )}
592
- <div data-canvas-file={activeView.filename}>
593
- <PlanRenderer key={activeView.filename} revision={selectedRevision} filename={activeView.filename} />
594
- </div>
595
- </div>
596
- )}
597
- {openFiles.map((filePath) => (
598
- <div key={filePath} style={{ display: activeView.type === "file" && activeView.path === filePath ? undefined : "none" }}>
599
- <FileViewer path={filePath} />
600
- </div>
601
- ))}
602
- </div>
603
- )}
604
-
605
- {/* Mobile sidebar overlay */}
606
- {mobileSidebar && (
607
- <div className="lg:hidden fixed inset-0 z-40 flex">
608
- <div className="flex-1" onClick={() => setMobileSidebar(false)} />
609
- <div className="w-80 max-w-[85vw] bg-bg-surface border-l border-border-subtle flex flex-col shadow-lg">
610
- <div className="flex items-center justify-between px-4 py-2 border-b border-border-subtle flex-shrink-0">
611
- <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">Sidebar</span>
612
- <button onClick={() => setMobileSidebar(false)} className="text-text-tertiary hover:text-text-secondary w-7 h-7 flex items-center justify-center">&#x2715;</button>
613
- </div>
614
- <div className="flex-1 overflow-hidden">
615
- <AnnotationSidebar onSubmit={handleSubmit} />
616
- </div>
617
- </div>
618
- </div>
619
- )}
620
-
621
- <ShareDialog
622
- sessionId={sessionId}
623
- revision={selectedRevision}
624
- open={shareDialogOpen}
625
- shareEnabled={shareEnabled}
626
- existingShares={shares}
627
- onClose={() => setShareDialogOpen(false)}
628
- onShareCreated={(share) => {
629
- setShares((prev) => [...prev.filter((s) => s.shareId !== share.shareId), share]);
630
- setToast({ kind: "success", message: "Share link created. Copy and send it to reviewers." });
631
- }}
632
- onShareRevoked={(shareId) => {
633
- setShares((prev) => prev.filter((s) => s.shareId !== shareId));
634
- setToast({ kind: "success", message: "Share link revoked." });
635
- }}
636
- />
637
-
638
- <ReviewerIdentityDialog
639
- open={reviewerDialogOpen}
640
- onClose={() => { setReviewerDialogOpen(false); setPendingFeedback(null); }}
641
- onSubmit={async (name) => {
642
- const identity = setReviewerIdentity(name);
643
- setReviewerDialogOpen(false);
644
- if (pendingFeedback !== null) {
645
- await submitSharedFeedbackWithIdentity(pendingFeedback, identity);
646
- setPendingFeedback(null);
647
- }
648
- }}
649
- />
650
-
651
- {toast && (
652
- <div
653
- 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 ${
654
- toast.kind === "error"
655
- ? "bg-accent-red text-white"
656
- : "bg-accent-green text-white"
657
- }`}
658
- >
659
- {toast.message}
660
- <button
661
- onClick={() => setToast(null)}
662
- className="ml-2 opacity-70 hover:opacity-100"
663
- >
664
- &#x2715;
665
- </button>
666
- </div>
667
- )}
668
- </div>
669
- </ActiveViewCtx.Provider>
670
- </ActiveViewContext.Provider>
671
- </AnnotationProvider>
672
- </RevisionContext.Provider>
673
- </RenderErrorContext.Provider>
674
- </SessionContext.Provider>
675
- </CanvasHostContext.Provider>
676
- );
193
+ // In shared mode the "sessionId" as far as the annotation system cares is
194
+ // the shareId (stable identifier for localStorage scoping). In local mode
195
+ // it's the real daemon session id from the URL.
196
+ const sessionId = MODE.isShared ? MODE.shareId : MODE.sessionId
197
+ const isSharedMode = MODE.isShared
198
+ const [currentRevision, setCurrentRevision] = useState(1)
199
+ const [selectedRevision, setSelectedRevision] = useState(1)
200
+ const [revisions, setRevisions] = useState<RevisionInfo[]>([])
201
+ const [compareRevision, setCompareRevision] = useState<{ left: number; right: number } | null>(null)
202
+ const [connected, setConnected] = useState(false)
203
+ const [sharedFeedbackSubmitted, setSharedFeedbackSubmitted] = useState(false)
204
+ const [agentWatching, setAgentWatching] = useState(false)
205
+ const [canvasFiles, setCanvasFiles] = useState<string[]>([])
206
+ const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: 'overview' })
207
+ const [openFiles, setOpenFiles] = useState<string[]>([])
208
+ const wsRef = useRef<WebSocket | null>(null)
209
+ const pendingRenderErrorsRef = useRef<CanvasRenderError[]>([])
210
+ const reportedRenderErrorsRef = useRef(new Set<string>())
211
+
212
+ const [projectRoot, setProjectRoot] = useState<string | undefined>()
213
+ const [mobileSidebar, setMobileSidebar] = useState(false)
214
+ const [leftCollapsed, setLeftCollapsed] = useState(false)
215
+ const [rightCollapsed, setRightCollapsed] = useState(false)
216
+ const [shareDialogOpen, setShareDialogOpen] = useState(false)
217
+ const [shares, setShares] = useState<ShareEntry[]>([])
218
+ const [shareEnabled, setShareEnabled] = useState(false)
219
+ // Remote annotations keyed by revision — merged read-only into the
220
+ // annotation provider so reviewer feedback shows up as extra annotations.
221
+ const [remoteAnnotationsByRev, setRemoteAnnotationsByRev] = useState<Map<number, Annotation[]>>(new Map())
222
+ // Shared-mode state
223
+ const [reviewerDialogOpen, setReviewerDialogOpen] = useState(false)
224
+ const [pendingFeedback, setPendingFeedback] = useState<string | null>(null)
225
+ const [toast, setToast] = useState<{ kind: 'error' | 'success'; message: string } | null>(null)
226
+
227
+ const reportRenderError = useCallback((error: CanvasRenderError) => {
228
+ if (isSharedMode) return
229
+ const signature = `${error.revision}\0${error.filename}\0${error.message}`
230
+ if (reportedRenderErrorsRef.current.has(signature)) return
231
+ reportedRenderErrorsRef.current.add(signature)
232
+
233
+ const payload = JSON.stringify({ type: 'render-error', error })
234
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
235
+ wsRef.current.send(payload)
236
+ } else {
237
+ pendingRenderErrorsRef.current.push(error)
238
+ }
239
+ }, [isSharedMode])
240
+
241
+ useEffect(() => {
242
+ if (!toast) return
243
+ const t = setTimeout(() => setToast(null), 4000)
244
+ return () => clearTimeout(t)
245
+ }, [toast])
246
+
247
+ // Dynamic document title
248
+ useEffect(() => {
249
+ const parts = ['Agent Canvas']
250
+ if (projectRoot) {
251
+ const segments = projectRoot.replace(/\/+$/, '').split('/')
252
+ parts.push(segments[segments.length - 1] || sessionId)
253
+ } else if (sessionId) {
254
+ parts.push(sessionId)
255
+ }
256
+ if (activeView.type === 'file') {
257
+ parts.push(activeView.path.split('/').pop() || activeView.path)
258
+ } else if (activeView.type === 'overview') {
259
+ parts.push('Overview')
260
+ } else if (activeView.type === 'canvas' && canvasFiles.length > 1 && activeView.filename) {
261
+ parts.push(activeView.filename.replace(/\.jsx$/, ''))
262
+ } else if (compareRevision) {
263
+ parts.push('Compare')
264
+ }
265
+ document.title = parts.join(' — ')
266
+ }, [sessionId, projectRoot, activeView, compareRevision])
267
+
268
+ // Scroll position restore per view
269
+ const scrollPositions = useRef<Map<string, number>>(new Map())
270
+
271
+ const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision)
272
+ const isReadOnly = selectedRevision !== currentRevision || !!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted
273
+ // Keep feedback added after submission separate from the already-sent draft.
274
+ const draftPhase: AnnotationDraftPhase = selectedRevision === currentRevision && (!!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted)
275
+ ? 'next'
276
+ : 'current'
277
+
278
+ const setActiveView = useCallback((v: ActiveView) => {
279
+ // Save current scroll position before switching
280
+ const key = activeView.type === 'overview' ? 'overview' : activeView.type === 'canvas' ? `canvas:${activeView.filename}` : `file:${activeView.path}`
281
+ scrollPositions.current.set(key, window.scrollY)
282
+ setActiveViewRaw(v)
283
+ if (v.type === 'file') {
284
+ setOpenFiles((prev) => prev.includes(v.path) ? prev : [...prev, v.path])
285
+ }
286
+ }, [activeView])
287
+
288
+ const closeFile = useCallback((path: string) => {
289
+ scrollPositions.current.delete(`file:${path}`)
290
+ setOpenFiles((prev) => prev.filter((p) => p !== path))
291
+ setActiveViewRaw((prev) => {
292
+ if (prev.type === 'file' && prev.path === path) {
293
+ return canvasFiles.length > 1 ? { type: 'overview' } : { type: 'canvas', filename: canvasFiles[0] || '' }
294
+ }
295
+ return prev
296
+ })
297
+ }, [canvasFiles])
298
+
299
+ // Restore scroll synchronously after DOM update (before paint)
300
+ useLayoutEffect(() => {
301
+ const key = activeView.type === 'overview' ? 'overview' : activeView.type === 'canvas' ? `canvas:${activeView.filename}` : `file:${activeView.path}`
302
+ window.scrollTo(0, scrollPositions.current.get(key) || 0)
303
+ }, [activeView])
304
+
305
+ // Fetch initial meta
306
+ useEffect(() => {
307
+ if (!sessionId) return
308
+ fetchMeta()
309
+ .then((data) => {
310
+ if (data.currentRevision) {
311
+ const nextRevisions: RevisionInfo[] = data.revisions || []
312
+ for (const revision of nextRevisions) {
313
+ if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision)
314
+ }
315
+ carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions)
316
+ setCurrentRevision(data.currentRevision)
317
+ setSelectedRevision(data.currentRevision)
318
+ setRevisions(nextRevisions)
319
+ }
320
+ if (data.canvasFiles) {
321
+ const files = data.canvasFiles.sort()
322
+ setCanvasFiles(files)
323
+ setActiveViewRaw((prev) => {
324
+ if (prev.type === 'overview') {
325
+ // On initial load: overview if multiple files, direct if single
326
+ return files.length > 1 ? prev : { type: 'canvas', filename: files[0] || '' }
327
+ }
328
+ if (prev.type === 'canvas' && (!prev.filename || !files.includes(prev.filename))) {
329
+ return { type: 'canvas', filename: files[0] || '' }
330
+ }
331
+ return prev
332
+ })
333
+ }
334
+ if (data.projectRoot) setProjectRoot(data.projectRoot)
335
+ if (Array.isArray(data.shares)) setShares(data.shares)
336
+ setShareEnabled(!!data.shareEnabled)
337
+ })
338
+ .catch(() => {})
339
+ }, [sessionId])
340
+
341
+ // Load any already-persisted remote feedback for the currently selected
342
+ // revision on first render. New entries arrive via WebSocket broadcast.
343
+ // This only runs in local (daemon) mode — shared mode has no notion of
344
+ // "previously polled remote feedback": the reviewer IS the remote.
345
+ useEffect(() => {
346
+ if (!sessionId || !selectedRevision || isSharedMode) return
347
+ fetch(`/api/session/${sessionId}/revision/${selectedRevision}/remote-feedback`)
348
+ .then((r) => r.json())
349
+ .then((raw) => {
350
+ const data = remoteFeedbackResponseSchema.safeParse(raw)
351
+ if (!data.success) return
352
+ const anns = remoteFeedbackToAnnotations(data.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
+
362
+ useEffect(() => {
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
+ }
372
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
373
+ const connect = () => {
374
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
375
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`)
376
+ wsRef.current = ws
377
+ ws.onopen = () => {
378
+ setConnected(true)
379
+ for (const error of pendingRenderErrorsRef.current) {
380
+ ws.send(JSON.stringify({ type: 'render-error', error }))
381
+ }
382
+ pendingRenderErrorsRef.current = []
383
+ }
384
+ ws.onclose = () => {
385
+ setConnected(false)
386
+ reconnectTimer = setTimeout(connect, 2000)
387
+ }
388
+ ws.onmessage = (event) => {
389
+ try {
390
+ const data = JSON.parse(event.data)
391
+ if (data.type === 'plan-updated') {
392
+ const allRevisions: RevisionInfo[] = Array.isArray(data.revisions) ? data.revisions : []
393
+ carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, allRevisions)
394
+ setCurrentRevision(data.currentRevision)
395
+ setSelectedRevision(data.currentRevision)
396
+ setCompareRevision(null)
397
+ if (allRevisions.length > 0) {
398
+ setRevisions(allRevisions)
399
+ const latest = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision)
400
+ if (latest?.canvasFiles) {
401
+ const files = latest.canvasFiles.map((cf: CanvasFileInfo) => cf.filename).sort()
402
+ setCanvasFiles(files)
403
+ // Determine what changed and auto-navigate
404
+ const previous = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision - 1)
405
+ const changes = categorizeChanges(latest, previous)
406
+ const affected = getAffectedFiles(changes)
407
+ if (affected.length === 1) {
408
+ setActiveViewRaw({ type: 'canvas', filename: affected[0] })
409
+ } else if (affected.length > 1 || files.length > 1) {
410
+ setActiveViewRaw({ type: 'overview' })
411
+ } else {
412
+ setActiveViewRaw({ type: 'canvas', filename: files[0] || '' })
413
+ }
414
+ }
415
+ }
416
+ }
417
+ if (data.type === 'revision-updated') {
418
+ if (Array.isArray(data.revisions)) {
419
+ const nextRevisions: RevisionInfo[] = data.revisions
420
+ for (const revision of nextRevisions) {
421
+ if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision)
422
+ }
423
+ setRevisions(nextRevisions)
424
+ }
425
+ }
426
+ if (data.type === 'watcher-status') {
427
+ setAgentWatching(!!data.watching)
428
+ }
429
+ if (data.type === 'remote-feedback') {
430
+ const rev = data.revision as number
431
+ const entries = (data.entries || []) as RemoteFeedbackEntry[]
432
+ const anns = remoteFeedbackToAnnotations(entries)
433
+ setRemoteAnnotationsByRev((prev) => {
434
+ const next = new Map(prev)
435
+ const existing = next.get(rev) || []
436
+ const seenIds = new Set(existing.map((a) => a.id))
437
+ const merged = [...existing, ...anns.filter((a) => !seenIds.has(a.id))]
438
+ next.set(rev, merged)
439
+ return next
440
+ })
441
+ }
442
+ } catch {}
443
+ }
444
+ }
445
+ connect()
446
+ return () => {
447
+ if (reconnectTimer) clearTimeout(reconnectTimer)
448
+ wsRef.current?.close()
449
+ }
450
+ }, [sessionId])
451
+
452
+ const submitSharedFeedbackWithIdentity = useCallback(async (feedback: string, identity: { id: string; name: string }) => {
453
+ try {
454
+ const raw = localStorage.getItem(`canvas:${sessionId}:rev:${selectedRevision}`)
455
+ let structuredAnnotations: SharedFeedbackPayload['annotations'] = []
456
+ if (raw) {
457
+ try {
458
+ const parsed = parsePersistedState(JSON.parse(raw))
459
+ structuredAnnotations = (parsed?.annotations ?? []).filter((a) => a.source !== 'remote')
460
+ } catch {}
461
+ }
462
+ await submitSharedFeedback({
463
+ author: identity,
464
+ revision: selectedRevision,
465
+ annotations: structuredAnnotations,
466
+ generalNote: feedback,
467
+ })
468
+ clearPersistedDraft(localStorage, sessionId, selectedRevision)
469
+ setSharedFeedbackSubmitted(true)
470
+ setToast({ kind: 'success', message: 'Feedback submitted. The canvas author will see it shortly.' })
471
+ } catch (e) {
472
+ setToast({ kind: 'error', message: `Failed to submit feedback: ${errorMessage(e)}` })
473
+ }
474
+ }, [sessionId, selectedRevision])
475
+
476
+ const handleSubmit = useCallback(async (feedback: string) => {
477
+ if (isSharedMode) {
478
+ // Shared mode: POST feedback to the worker via the reviewer identity
479
+ // modal flow. If identity already cached, skip the modal.
480
+ const identity = getReviewerIdentity()
481
+ if (!identity) {
482
+ setPendingFeedback(feedback)
483
+ setReviewerDialogOpen(true)
484
+ return
485
+ }
486
+ await submitSharedFeedbackWithIdentity(feedback, identity)
487
+ return
488
+ }
489
+
490
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
491
+ wsRef.current.send(JSON.stringify({ type: 'submit', feedback }))
492
+ // Server will broadcast revision-updated with hasFeedback: true
493
+ // which triggers isReadOnly via revisions state
494
+ }
495
+ }, [isSharedMode, submitSharedFeedbackWithIdentity])
496
+
497
+ if (!sessionId) {
498
+ return <div className="flex items-center justify-center h-screen text-text-tertiary font-body">No session selected.</div>
499
+ }
500
+
501
+ return (
502
+ <CanvasHostContext.Provider value={canvasHost}>
503
+ <SessionContext.Provider value={sessionId}>
504
+ <RenderErrorContext.Provider value={reportRenderError}>
505
+ <RevisionContext.Provider
506
+ value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}
507
+ >
508
+ <AnnotationProvider
509
+ key={`${sessionId}:${selectedRevision}:${draftPhase}`}
510
+ sessionId={sessionId}
511
+ revision={selectedRevision}
512
+ isReadOnly={isReadOnly}
513
+ draftPhase={draftPhase}
514
+ remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
515
+ canvasFiles={canvasFiles}
516
+ >
517
+ <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
518
+ <ActiveViewCtx.Provider value={{ setActiveView }}>
519
+ <div className="min-h-screen bg-bg-base">
520
+ {/* Left panel — fixed to viewport */}
521
+ <LeftPanel
522
+ sessionId={sessionId}
523
+ projectRoot={projectRoot}
524
+ connected={connected}
525
+ onMobileSidebar={() => setMobileSidebar(!mobileSidebar)}
526
+ collapsed={leftCollapsed}
527
+ onToggle={() => setLeftCollapsed((c) => !c)}
528
+ />
529
+
530
+ {/* Right panel — fixed to viewport, resizable */}
531
+ <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
532
+ <AnnotationSidebar onSubmit={handleSubmit} />
533
+ </ResizableSidebar>
534
+
535
+ {/* Center content — normal document flow, browser scroll */}
536
+ {compareRevision !== null
537
+ ? (
538
+ <div
539
+ className={`${leftCollapsed ? 'lg:ml-0' : 'lg:ml-60'} ${
540
+ rightCollapsed ? 'lg:mr-0' : 'lg:mr-[var(--sidebar-width,320px)]'
541
+ } transition-[margin] duration-200`}
542
+ >
543
+ <CompareView
544
+ initialLeft={compareRevision.left}
545
+ initialRight={compareRevision.right}
546
+ sessionId={sessionId}
547
+ onExit={() => setCompareRevision(null)}
548
+ />
549
+ </div>
550
+ )
551
+ : (
552
+ <div
553
+ className={`relative ${leftCollapsed ? 'lg:ml-0' : 'lg:ml-60'} ${
554
+ rightCollapsed ? 'lg:mr-0' : 'lg:mr-[var(--sidebar-width,320px)]'
555
+ } transition-[margin] duration-200`}
556
+ id="plan-scroll-container"
557
+ >
558
+ {/* Mobile top bar — only visible < lg */}
559
+ <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">
560
+ <div className="flex items-center gap-2">
561
+ {!isSharedMode && <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />}
562
+ {isSharedMode && <span className="text-[11px] text-text-tertiary font-body">Shared canvas</span>}
563
+ {!isSharedMode && <span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-accent-green' : 'bg-accent-red'}`} />}
564
+ </div>
565
+ <div className="flex items-center gap-1">
566
+ <RevisionSelector />
567
+ <button
568
+ onClick={() => setMobileSidebar(!mobileSidebar)}
569
+ className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
570
+ title="Annotations"
571
+ >
572
+ <svg
573
+ width="14"
574
+ height="14"
575
+ viewBox="0 0 24 24"
576
+ fill="none"
577
+ stroke="currentColor"
578
+ strokeWidth="2"
579
+ strokeLinecap="round"
580
+ strokeLinejoin="round"
581
+ >
582
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
583
+ </svg>
584
+ </button>
585
+ <ThemeSwitcher />
586
+ </div>
587
+ </div>
588
+
589
+ <ContentTabs />
590
+ {/* Overview — shows all affected canvases inline */}
591
+ {activeView.type === 'overview' && (
592
+ <OverviewView
593
+ revision={selectedRevision}
594
+ responseBanner={selectedRevInfo?.response ? <ResponseBanner markdown={selectedRevInfo.response} /> : undefined}
595
+ />
596
+ )}
597
+ {/* Individual canvas view — only mounted when active */}
598
+ {activeView.type === 'canvas' && canvasFiles.includes(activeView.filename) && (
599
+ <div className="relative max-w-[720px] mx-auto px-6 pt-12 pb-32">
600
+ {shareEnabled && (
601
+ <div className="absolute top-3 right-12 z-10">
602
+ <ShareButton
603
+ onClick={() => setShareDialogOpen(true)}
604
+ hasShare={shares.some((s) => s.revision === selectedRevision)}
605
+ title={shares.some((s) => s.revision === selectedRevision) ? 'Manage share link' : 'Share this revision'}
606
+ />
607
+ </div>
608
+ )}
609
+ <button
610
+ onClick={() => {
611
+ const planContent = document.querySelector(`[data-canvas-file="${activeView.filename}"] .plan-content`)
612
+ if (!planContent) return
613
+ const md = exportCanvasToMarkdown(planContent as HTMLElement)
614
+ navigator.clipboard.writeText(md).then(() => {
615
+ const btn = document.getElementById('export-md-btn')
616
+ if (btn) {
617
+ btn.setAttribute('data-copied', 'true')
618
+ setTimeout(() => {
619
+ btn.removeAttribute('data-copied')
620
+ }, 1500)
621
+ }
622
+ })
623
+ }}
624
+ id="export-md-btn"
625
+ className="group absolute top-3 right-6 p-1.5 text-text-tertiary hover:text-text-secondary transition-colors z-10"
626
+ title="Copy as Markdown"
627
+ >
628
+ <svg
629
+ width="14"
630
+ height="14"
631
+ viewBox="0 0 24 24"
632
+ fill="none"
633
+ stroke="currentColor"
634
+ strokeWidth="2"
635
+ strokeLinecap="round"
636
+ strokeLinejoin="round"
637
+ className="group-[[data-copied=true]]:hidden"
638
+ >
639
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
640
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
641
+ </svg>
642
+ <svg
643
+ width="14"
644
+ height="14"
645
+ viewBox="0 0 24 24"
646
+ fill="none"
647
+ stroke="currentColor"
648
+ strokeWidth="2"
649
+ strokeLinecap="round"
650
+ strokeLinejoin="round"
651
+ className="hidden group-[[data-copied=true]]:block text-green-500"
652
+ >
653
+ <polyline points="20 6 9 17 4 12" />
654
+ </svg>
655
+ </button>
656
+ {/* Show response banner on individual canvas only when there's no overview (single canvas) */}
657
+ {canvasFiles.length <= 1 && selectedRevInfo?.response && <ResponseBanner markdown={selectedRevInfo.response} />}
658
+ <div data-canvas-file={activeView.filename}>
659
+ <PlanRenderer key={activeView.filename} revision={selectedRevision} filename={activeView.filename} />
660
+ </div>
661
+ </div>
662
+ )}
663
+ {openFiles.map((filePath) => (
664
+ <div key={filePath} style={{ display: activeView.type === 'file' && activeView.path === filePath ? undefined : 'none' }}>
665
+ <FileViewer path={filePath} />
666
+ </div>
667
+ ))}
668
+ </div>
669
+ )}
670
+
671
+ {/* Mobile sidebar overlay */}
672
+ {mobileSidebar && (
673
+ <div className="lg:hidden fixed inset-0 z-40 flex">
674
+ <div className="flex-1" onClick={() => setMobileSidebar(false)} />
675
+ <div className="w-80 max-w-[85vw] bg-bg-surface border-l border-border-subtle flex flex-col shadow-lg">
676
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border-subtle flex-shrink-0">
677
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">Sidebar</span>
678
+ <button
679
+ onClick={() => setMobileSidebar(false)}
680
+ className="text-text-tertiary hover:text-text-secondary w-7 h-7 flex items-center justify-center"
681
+ >
682
+ &#x2715;
683
+ </button>
684
+ </div>
685
+ <div className="flex-1 overflow-hidden">
686
+ <AnnotationSidebar onSubmit={handleSubmit} />
687
+ </div>
688
+ </div>
689
+ </div>
690
+ )}
691
+
692
+ <ShareDialog
693
+ sessionId={sessionId}
694
+ revision={selectedRevision}
695
+ open={shareDialogOpen}
696
+ shareEnabled={shareEnabled}
697
+ existingShares={shares}
698
+ onClose={() => setShareDialogOpen(false)}
699
+ onShareCreated={(share) => {
700
+ setShares((prev) => [...prev.filter((s) => s.shareId !== share.shareId), share])
701
+ setToast({ kind: 'success', message: 'Share link created. Copy and send it to reviewers.' })
702
+ }}
703
+ onShareRevoked={(shareId) => {
704
+ setShares((prev) => prev.filter((s) => s.shareId !== shareId))
705
+ setToast({ kind: 'success', message: 'Share link revoked.' })
706
+ }}
707
+ />
708
+
709
+ <ReviewerIdentityDialog
710
+ open={reviewerDialogOpen}
711
+ onClose={() => {
712
+ setReviewerDialogOpen(false)
713
+ setPendingFeedback(null)
714
+ }}
715
+ onSubmit={async (name) => {
716
+ const identity = setReviewerIdentity(name)
717
+ setReviewerDialogOpen(false)
718
+ if (pendingFeedback !== null) {
719
+ await submitSharedFeedbackWithIdentity(pendingFeedback, identity)
720
+ setPendingFeedback(null)
721
+ }
722
+ }}
723
+ />
724
+
725
+ {toast && (
726
+ <div
727
+ 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 ${
728
+ toast.kind === 'error'
729
+ ? 'bg-accent-red text-white'
730
+ : 'bg-accent-green text-white'
731
+ }`}
732
+ >
733
+ {toast.message}
734
+ <button
735
+ onClick={() => setToast(null)}
736
+ className="ml-2 opacity-70 hover:opacity-100"
737
+ >
738
+ &#x2715;
739
+ </button>
740
+ </div>
741
+ )}
742
+ </div>
743
+ </ActiveViewCtx.Provider>
744
+ </ActiveViewContext.Provider>
745
+ </AnnotationProvider>
746
+ </RevisionContext.Provider>
747
+ </RenderErrorContext.Provider>
748
+ </SessionContext.Provider>
749
+ </CanvasHostContext.Provider>
750
+ )
677
751
  }
678
752
 
679
- function LeftPanel({ sessionId, projectRoot, connected, onMobileSidebar, collapsed, onToggle }: { sessionId: string; projectRoot?: string; connected: boolean; onMobileSidebar: () => void; collapsed: boolean; onToggle: () => void }) {
680
- const isSharedMode = MODE.isShared;
681
- return (
682
- <>
683
- {/* Collapsed toggle button */}
684
- {collapsed && (
685
- <button
686
- onClick={onToggle}
687
- className="hidden lg:flex fixed top-3 left-3 z-20 w-7 h-7 items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors bg-bg-surface border border-border-subtle"
688
- title="Show sidebar"
689
- >
690
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
691
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
692
- <line x1="9" y1="3" x2="9" y2="21"/>
693
- </svg>
694
- </button>
695
- )}
696
- <div className={`hidden lg:flex w-60 flex-col fixed top-0 left-0 bottom-0 bg-bg-surface border-r border-border-subtle z-10 transition-transform duration-200 ${collapsed ? "-translate-x-full" : ""}`}>
697
- {/* Session + round info */}
698
- <div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-border-subtle">
699
- <div className="flex items-center gap-2 mb-2">
700
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-tertiary flex-shrink-0">
701
- <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"/>
702
- </svg>
703
- {isSharedMode ? (
704
- <span className="text-[12px] font-body text-text-secondary truncate flex-1">Shared canvas</span>
705
- ) : (
706
- <>
707
- <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
708
- <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
709
- </>
710
- )}
711
- <button
712
- onClick={onToggle}
713
- 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"
714
- title="Hide sidebar"
715
- >
716
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
717
- <polyline points="11 17 6 12 11 7"/>
718
- <polyline points="18 17 13 12 18 7"/>
719
- </svg>
720
- </button>
721
- </div>
722
- <RevisionSelector />
723
- </div>
724
-
725
- {/* File browser */}
726
- <div className="flex-1 flex flex-col overflow-hidden">
727
- <FileBrowser embedded />
728
- </div>
729
-
730
- {/* Bottom: theme switcher */}
731
- <div className="px-4 py-3 border-t border-border-subtle flex items-center justify-between flex-shrink-0">
732
- <span className="text-[10px] text-text-tertiary font-body uppercase tracking-widest">Theme</span>
733
- <ThemeSwitcher />
734
- </div>
735
- </div>
736
- </>
737
- );
753
+ function LeftPanel(
754
+ { sessionId, projectRoot, connected, onMobileSidebar, collapsed, onToggle }: {
755
+ sessionId: string
756
+ projectRoot?: string
757
+ connected: boolean
758
+ onMobileSidebar: () => void
759
+ collapsed: boolean
760
+ onToggle: () => void
761
+ },
762
+ ) {
763
+ const isSharedMode = MODE.isShared
764
+ return (
765
+ <>
766
+ {/* Collapsed toggle button */}
767
+ {collapsed && (
768
+ <button
769
+ onClick={onToggle}
770
+ className="hidden lg:flex fixed top-3 left-3 z-20 w-7 h-7 items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors bg-bg-surface border border-border-subtle"
771
+ title="Show sidebar"
772
+ >
773
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
774
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
775
+ <line x1="9" y1="3" x2="9" y2="21" />
776
+ </svg>
777
+ </button>
778
+ )}
779
+ <div
780
+ className={`hidden lg:flex w-60 flex-col fixed top-0 left-0 bottom-0 bg-bg-surface border-r border-border-subtle z-10 transition-transform duration-200 ${
781
+ collapsed ? '-translate-x-full' : ''
782
+ }`}
783
+ >
784
+ {/* Session + round info */}
785
+ <div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-border-subtle">
786
+ <div className="flex items-center gap-2 mb-2">
787
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-tertiary flex-shrink-0">
788
+ <path
789
+ d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
790
+ stroke="currentColor"
791
+ strokeWidth="2"
792
+ strokeLinecap="round"
793
+ strokeLinejoin="round"
794
+ />
795
+ </svg>
796
+ {isSharedMode ? <span className="text-[12px] font-body text-text-secondary truncate flex-1">Shared canvas</span> : (
797
+ <>
798
+ <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
799
+ <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${connected ? 'bg-accent-green' : 'bg-accent-red'}`} />
800
+ </>
801
+ )}
802
+ <button
803
+ onClick={onToggle}
804
+ 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"
805
+ title="Hide sidebar"
806
+ >
807
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
808
+ <polyline points="11 17 6 12 11 7" />
809
+ <polyline points="18 17 13 12 18 7" />
810
+ </svg>
811
+ </button>
812
+ </div>
813
+ <RevisionSelector />
814
+ </div>
815
+
816
+ {/* File browser */}
817
+ <div className="flex-1 flex flex-col overflow-hidden">
818
+ <FileBrowser embedded />
819
+ </div>
820
+
821
+ {/* Bottom: theme switcher */}
822
+ <div className="px-4 py-3 border-t border-border-subtle flex items-center justify-between flex-shrink-0">
823
+ <span className="text-[10px] text-text-tertiary font-body uppercase tracking-widest">Theme</span>
824
+ <ThemeSwitcher />
825
+ </div>
826
+ </div>
827
+ </>
828
+ )
738
829
  }
739
830
 
740
831
  function ResizableSidebar({ children, collapsed, onToggle }: { children: React.ReactNode; collapsed: boolean; onToggle: () => void }) {
741
- const [width, setWidth] = useState(() => {
742
- const saved = localStorage.getItem("canvas-sidebar-width");
743
- return saved ? parseInt(saved, 10) : 320;
744
- });
745
- const dragging = useRef(false);
746
- const startX = useRef(0);
747
- const startWidth = useRef(0);
748
-
749
- const widthRef = useRef(width);
750
- widthRef.current = width;
751
-
752
- const onMouseDown = useCallback((e: React.MouseEvent) => {
753
- e.preventDefault();
754
- dragging.current = true;
755
- startX.current = e.clientX;
756
- startWidth.current = widthRef.current;
757
-
758
- const onMouseMove = (e: MouseEvent) => {
759
- if (!dragging.current) return;
760
- const delta = startX.current - e.clientX;
761
- const newWidth = Math.max(240, Math.min(600, startWidth.current + delta));
762
- setWidth(newWidth);
763
- widthRef.current = newWidth;
764
- };
765
- const onMouseUp = () => {
766
- dragging.current = false;
767
- localStorage.setItem("canvas-sidebar-width", String(widthRef.current));
768
- document.removeEventListener("mousemove", onMouseMove);
769
- document.removeEventListener("mouseup", onMouseUp);
770
- document.body.style.cursor = "";
771
- document.body.style.userSelect = "";
772
- };
773
- document.addEventListener("mousemove", onMouseMove);
774
- document.addEventListener("mouseup", onMouseUp);
775
- document.body.style.cursor = "col-resize";
776
- document.body.style.userSelect = "none";
777
- }, []);
778
-
779
- // Set CSS variable so center content can use it for margin-right
780
- useEffect(() => {
781
- document.documentElement.style.setProperty("--sidebar-width", `${width}px`);
782
- return () => { document.documentElement.style.removeProperty("--sidebar-width"); };
783
- }, [width]);
784
-
785
- return (
786
- <>
787
- {/* Collapsed toggle button */}
788
- {collapsed && (
789
- <button
790
- onClick={onToggle}
791
- className="hidden lg:flex fixed top-3 right-3 z-20 w-7 h-7 items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors bg-bg-surface border border-border-subtle"
792
- title="Show annotations"
793
- >
794
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
795
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
796
- </svg>
797
- </button>
798
- )}
799
- <div className={`hidden lg:flex flex-col fixed top-0 right-0 bottom-0 bg-bg-surface border-l border-border-subtle z-10 transition-transform duration-200 ${collapsed ? "translate-x-full" : ""}`} style={{ width }}>
800
- {/* Drag handle */}
801
- <div
802
- onMouseDown={onMouseDown}
803
- className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent-blue/30 transition-colors z-20"
804
- style={{ marginLeft: "-2px" }}
805
- />
806
- {React.Children.map(children, (child) =>
807
- React.isValidElement(child)
808
- ? React.cloneElement(child as React.ReactElement<any>, { collapseButton: (
809
- <button
810
- onClick={onToggle}
811
- className="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
812
- title="Hide annotations"
813
- >
814
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
815
- <polyline points="13 17 18 12 13 7"/>
816
- <polyline points="6 17 11 12 6 7"/>
817
- </svg>
818
- </button>
819
- )})
820
- : child
821
- )}
822
- </div>
823
- </>
824
- );
832
+ const [width, setWidth] = useState(() => {
833
+ const saved = localStorage.getItem('canvas-sidebar-width')
834
+ return saved ? parseInt(saved, 10) : 320
835
+ })
836
+ const dragging = useRef(false)
837
+ const startX = useRef(0)
838
+ const startWidth = useRef(0)
839
+
840
+ const widthRef = useRef(width)
841
+ widthRef.current = width
842
+
843
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
844
+ e.preventDefault()
845
+ dragging.current = true
846
+ startX.current = e.clientX
847
+ startWidth.current = widthRef.current
848
+
849
+ const onMouseMove = (e: MouseEvent) => {
850
+ if (!dragging.current) return
851
+ const delta = startX.current - e.clientX
852
+ const newWidth = Math.max(240, Math.min(600, startWidth.current + delta))
853
+ setWidth(newWidth)
854
+ widthRef.current = newWidth
855
+ }
856
+ const onMouseUp = () => {
857
+ dragging.current = false
858
+ localStorage.setItem('canvas-sidebar-width', String(widthRef.current))
859
+ document.removeEventListener('mousemove', onMouseMove)
860
+ document.removeEventListener('mouseup', onMouseUp)
861
+ document.body.style.cursor = ''
862
+ document.body.style.userSelect = ''
863
+ }
864
+ document.addEventListener('mousemove', onMouseMove)
865
+ document.addEventListener('mouseup', onMouseUp)
866
+ document.body.style.cursor = 'col-resize'
867
+ document.body.style.userSelect = 'none'
868
+ }, [])
869
+
870
+ // Set CSS variable so center content can use it for margin-right
871
+ useEffect(() => {
872
+ document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
873
+ return () => {
874
+ document.documentElement.style.removeProperty('--sidebar-width')
875
+ }
876
+ }, [width])
877
+
878
+ return (
879
+ <>
880
+ {/* Collapsed toggle button */}
881
+ {collapsed && (
882
+ <button
883
+ onClick={onToggle}
884
+ className="hidden lg:flex fixed top-3 right-3 z-20 w-7 h-7 items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors bg-bg-surface border border-border-subtle"
885
+ title="Show annotations"
886
+ >
887
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
888
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
889
+ </svg>
890
+ </button>
891
+ )}
892
+ <div
893
+ className={`hidden lg:flex flex-col fixed top-0 right-0 bottom-0 bg-bg-surface border-l border-border-subtle z-10 transition-transform duration-200 ${
894
+ collapsed ? 'translate-x-full' : ''
895
+ }`}
896
+ style={{ width }}
897
+ >
898
+ {/* Drag handle */}
899
+ <div
900
+ onMouseDown={onMouseDown}
901
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent-blue/30 transition-colors z-20"
902
+ style={{ marginLeft: '-2px' }}
903
+ />
904
+ {React.Children.map(children, (child) =>
905
+ React.isValidElement<{ collapseButton?: React.ReactNode }>(child)
906
+ ? React.cloneElement(child, {
907
+ collapseButton: (
908
+ <button
909
+ onClick={onToggle}
910
+ className="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
911
+ title="Hide annotations"
912
+ >
913
+ <svg
914
+ width="12"
915
+ height="12"
916
+ viewBox="0 0 24 24"
917
+ fill="none"
918
+ stroke="currentColor"
919
+ strokeWidth="2"
920
+ strokeLinecap="round"
921
+ strokeLinejoin="round"
922
+ >
923
+ <polyline points="13 17 18 12 13 7" />
924
+ <polyline points="6 17 11 12 6 7" />
925
+ </svg>
926
+ </button>
927
+ ),
928
+ })
929
+ : child)}
930
+ </div>
931
+ </>
932
+ )
825
933
  }
826
934
 
827
935
  function ResponseBanner({ markdown }: { markdown: string }) {
828
- const [dismissed, setDismissed] = useState(false);
829
- const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
830
- const contentRef = useRef<HTMLDivElement>(null);
831
- const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
832
- const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
833
- const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
834
-
835
- const responseAnnotations = useMemo(() => annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH), [annotations]);
836
-
837
- // Reset dismissed state when markdown changes
838
- const [prevMarkdown, setPrevMarkdown] = useState(markdown);
839
- if (markdown !== prevMarkdown) {
840
- setPrevMarkdown(markdown);
841
- setDismissed(false);
842
- }
843
-
844
- // Restore marks after content renders
845
- useEffect(() => {
846
- if (!contentRef.current || responseAnnotations.length === 0) return;
847
- const timer = setTimeout(() => {
848
- if (contentRef.current) restoreMarks(contentRef.current, responseAnnotations);
849
- }, 50);
850
- return () => clearTimeout(timer);
851
- }, [html, responseAnnotations.length]);
852
-
853
- // Update mark active states
854
- const prevActiveRef = useRef<string | null>(null);
855
- useEffect(() => {
856
- updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
857
- prevActiveRef.current = activeAnnotationId;
858
- }, [activeAnnotationId]);
859
-
860
- // Click/hover handlers on marks
861
- useEffect(() => {
862
- const container = contentRef.current;
863
- if (!container) return;
864
- const handleClick = (e: MouseEvent) => {
865
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
866
- if (!mark) return;
867
- e.stopPropagation();
868
- const annId = mark.getAttribute("data-annotation-id")!;
869
- if (annId === activeAnnotationId) {
870
- setEditPopover({ anchorEl: mark, annId });
871
- } else {
872
- setActiveAnnotationId(annId);
873
- }
874
- };
875
- const handleMouseOver = (e: MouseEvent) => {
876
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
877
- if (mark) setActiveAnnotationId(mark.getAttribute("data-annotation-id")!);
878
- };
879
- const handleMouseOut = (e: MouseEvent) => {
880
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
881
- const relatedMark = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
882
- if (mark && !relatedMark) setActiveAnnotationId(null);
883
- };
884
- container.addEventListener("click", handleClick);
885
- container.addEventListener("mouseover", handleMouseOver);
886
- container.addEventListener("mouseout", handleMouseOut);
887
- return () => {
888
- container.removeEventListener("click", handleClick);
889
- container.removeEventListener("mouseover", handleMouseOver);
890
- container.removeEventListener("mouseout", handleMouseOut);
891
- };
892
- }, [html, activeAnnotationId]);
893
-
894
- // Text selection → annotation
895
- useEffect(() => {
896
- const handler = () => {
897
- const sel = window.getSelection();
898
- if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
899
- if (!contentRef.current) return;
900
- const range = sel.getRangeAt(0);
901
- if (!contentRef.current.contains(range.startContainer)) return;
902
- if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
903
- const snippet = sel.toString().trim();
904
- if (snippet.length < 2) return;
905
- const savedRange = range.cloneRange();
906
- const ctx = extractContext(range, contentRef.current);
907
- const tempId = `__pending_${Date.now()}`;
908
- try { wrapRangeWithMark(savedRange, tempId); } catch {}
909
- window.getSelection()?.removeAllRanges();
910
- const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
911
- const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
912
- if (!lastMark) return;
913
- setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
914
- };
915
- document.addEventListener("mouseup", handler);
916
- return () => document.removeEventListener("mouseup", handler);
917
- }, []);
918
-
919
- if (dismissed) return null;
920
-
921
- const scrollContainer = document.getElementById("plan-scroll-container");
922
-
923
- return (
924
- <div className="mb-6 rounded-lg border border-accent-blue/20 bg-accent-blue/5 relative">
925
- <button
926
- onClick={() => setDismissed(true)}
927
- className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
928
- >
929
- <span className="text-xs">&#x2715;</span>
930
- </button>
931
- <div className="px-4 py-3 pr-10">
932
- <div className="flex items-center gap-2 mb-1.5">
933
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-blue flex-shrink-0">
934
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
935
- </svg>
936
- <span className="text-[11px] font-medium uppercase tracking-widest text-accent-blue font-body">Agent Response</span>
937
- </div>
938
- <div ref={contentRef} className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
939
- </div>
940
-
941
- {createPopover && (
942
- <AnnotationCreatePopover
943
- anchorEl={createPopover.anchorEl}
944
- scrollContainer={scrollContainer}
945
- snippet={createPopover.snippet}
946
- truncateAt={80}
947
- onAdd={(note, images) => {
948
- const id = generateAnnotationId();
949
- renameMarkId(createPopover.tempId, id);
950
- addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx, images);
951
- setCreatePopover(null);
952
- }}
953
- onCancel={() => {
954
- unwrapMarks(createPopover.tempId);
955
- setCreatePopover(null);
956
- }}
957
- />
958
- )}
959
-
960
- {editPopover && (() => {
961
- const ann = annotations.find((a) => a.id === editPopover.annId);
962
- if (!ann) return null;
963
- return (
964
- <AnnotationEditPopover
965
- anchorEl={editPopover.anchorEl}
966
- scrollContainer={scrollContainer}
967
- initialNote={ann.note}
968
- initialImages={ann.images}
969
- onUpdate={(note, images) => {
970
- updateAnnotation(editPopover.annId, note);
971
- const current = ann.images || [];
972
- for (const img of images) {
973
- if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
974
- }
975
- for (const img of current) {
976
- if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
977
- }
978
- }}
979
- onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
980
- onClose={() => setEditPopover(null)}
981
- />
982
- );
983
- })()}
984
- </div>
985
- );
936
+ const [dismissed, setDismissed] = useState(false)
937
+ const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown])
938
+ const contentRef = useRef<HTMLDivElement>(null)
939
+ const {
940
+ annotations,
941
+ addAnnotationWithId,
942
+ removeAnnotation,
943
+ updateAnnotation,
944
+ addAnnotationImage,
945
+ removeAnnotationImage,
946
+ activeAnnotationId,
947
+ setActiveAnnotationId,
948
+ } = useAnnotations()
949
+ const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: AnnotationContext } | null>(null)
950
+ const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null)
951
+
952
+ const responseAnnotations = useMemo(() => annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH), [annotations])
953
+
954
+ // Reset dismissed state when markdown changes
955
+ const [prevMarkdown, setPrevMarkdown] = useState(markdown)
956
+ if (markdown !== prevMarkdown) {
957
+ setPrevMarkdown(markdown)
958
+ setDismissed(false)
959
+ }
960
+
961
+ // Restore marks after content renders
962
+ useEffect(() => {
963
+ if (!contentRef.current || responseAnnotations.length === 0) return
964
+ const timer = setTimeout(() => {
965
+ if (contentRef.current) restoreMarks(contentRef.current, responseAnnotations)
966
+ }, 50)
967
+ return () => clearTimeout(timer)
968
+ }, [html, responseAnnotations.length])
969
+
970
+ // Update mark active states
971
+ const prevActiveRef = useRef<string | null>(null)
972
+ useEffect(() => {
973
+ updateAllMarkStates(activeAnnotationId, prevActiveRef.current)
974
+ prevActiveRef.current = activeAnnotationId
975
+ }, [activeAnnotationId])
976
+
977
+ // Click/hover handlers on marks
978
+ useEffect(() => {
979
+ const container = contentRef.current
980
+ if (!container) return
981
+ const handleClick = (e: MouseEvent) => {
982
+ const mark = (e.target as HTMLElement).closest('[data-annotation-id]') as HTMLElement | null
983
+ if (!mark) return
984
+ e.stopPropagation()
985
+ const annId = mark.getAttribute('data-annotation-id')!
986
+ if (annId === activeAnnotationId) {
987
+ setEditPopover({ anchorEl: mark, annId })
988
+ } else {
989
+ setActiveAnnotationId(annId)
990
+ }
991
+ }
992
+ const handleMouseOver = (e: MouseEvent) => {
993
+ const mark = (e.target as HTMLElement).closest('[data-annotation-id]') as HTMLElement | null
994
+ if (mark) setActiveAnnotationId(mark.getAttribute('data-annotation-id')!)
995
+ }
996
+ const handleMouseOut = (e: MouseEvent) => {
997
+ const mark = (e.target as HTMLElement).closest('[data-annotation-id]')
998
+ const relatedMark = (e.relatedTarget as HTMLElement | null)?.closest?.('[data-annotation-id]')
999
+ if (mark && !relatedMark) setActiveAnnotationId(null)
1000
+ }
1001
+ container.addEventListener('click', handleClick)
1002
+ container.addEventListener('mouseover', handleMouseOver)
1003
+ container.addEventListener('mouseout', handleMouseOut)
1004
+ return () => {
1005
+ container.removeEventListener('click', handleClick)
1006
+ container.removeEventListener('mouseover', handleMouseOver)
1007
+ container.removeEventListener('mouseout', handleMouseOut)
1008
+ }
1009
+ }, [html, activeAnnotationId])
1010
+
1011
+ // Text selection → annotation
1012
+ useEffect(() => {
1013
+ const handler = () => {
1014
+ const sel = window.getSelection()
1015
+ if (!sel || sel.isCollapsed || !sel.toString().trim()) return
1016
+ if (!contentRef.current) return
1017
+ const range = sel.getRangeAt(0)
1018
+ if (!contentRef.current.contains(range.startContainer)) return
1019
+ if ((range.startContainer.parentElement as HTMLElement)?.closest?.('[data-annotation-id]')) return
1020
+ const snippet = sel.toString().trim()
1021
+ if (snippet.length < 2) return
1022
+ const savedRange = range.cloneRange()
1023
+ const ctx = extractContext(range, contentRef.current)
1024
+ const tempId = `__pending_${Date.now()}`
1025
+ try {
1026
+ wrapRangeWithMark(savedRange, tempId)
1027
+ } catch {}
1028
+ window.getSelection()?.removeAllRanges()
1029
+ const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`)
1030
+ const lastMark = marks[marks.length - 1] as HTMLElement | undefined
1031
+ if (!lastMark) return
1032
+ setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx })
1033
+ }
1034
+ document.addEventListener('mouseup', handler)
1035
+ return () => document.removeEventListener('mouseup', handler)
1036
+ }, [])
1037
+
1038
+ if (dismissed) return null
1039
+
1040
+ const scrollContainer = document.getElementById('plan-scroll-container')
1041
+
1042
+ return (
1043
+ <div className="mb-6 rounded-lg border border-accent-blue/20 bg-accent-blue/5 relative">
1044
+ <button
1045
+ onClick={() => setDismissed(true)}
1046
+ className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
1047
+ >
1048
+ <span className="text-xs">&#x2715;</span>
1049
+ </button>
1050
+ <div className="px-4 py-3 pr-10">
1051
+ <div className="flex items-center gap-2 mb-1.5">
1052
+ <svg
1053
+ width="14"
1054
+ height="14"
1055
+ viewBox="0 0 24 24"
1056
+ fill="none"
1057
+ stroke="currentColor"
1058
+ strokeWidth="2"
1059
+ strokeLinecap="round"
1060
+ strokeLinejoin="round"
1061
+ className="text-accent-blue flex-shrink-0"
1062
+ >
1063
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
1064
+ </svg>
1065
+ <span className="text-[11px] font-medium uppercase tracking-widest text-accent-blue font-body">Agent Response</span>
1066
+ </div>
1067
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: The response is the local agent's markdown, the same author whose canvas modules already execute as code here. */}
1068
+ <div ref={contentRef} className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
1069
+ </div>
1070
+
1071
+ {createPopover && (
1072
+ <AnnotationCreatePopover
1073
+ anchorEl={createPopover.anchorEl}
1074
+ scrollContainer={scrollContainer}
1075
+ snippet={createPopover.snippet}
1076
+ truncateAt={80}
1077
+ onAdd={(note, images) => {
1078
+ const id = generateAnnotationId()
1079
+ renameMarkId(createPopover.tempId, id)
1080
+ addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx, images)
1081
+ setCreatePopover(null)
1082
+ }}
1083
+ onCancel={() => {
1084
+ unwrapMarks(createPopover.tempId)
1085
+ setCreatePopover(null)
1086
+ }}
1087
+ />
1088
+ )}
1089
+
1090
+ {editPopover && (() => {
1091
+ const ann = annotations.find((a) => a.id === editPopover.annId)
1092
+ if (!ann) return null
1093
+ return (
1094
+ <AnnotationEditPopover
1095
+ anchorEl={editPopover.anchorEl}
1096
+ scrollContainer={scrollContainer}
1097
+ initialNote={ann.note}
1098
+ initialImages={ann.images}
1099
+ onUpdate={(note, images) => {
1100
+ updateAnnotation(editPopover.annId, note)
1101
+ const current = ann.images || []
1102
+ for (const img of images) {
1103
+ if (!current.includes(img)) addAnnotationImage(editPopover.annId, img)
1104
+ }
1105
+ for (const img of current) {
1106
+ if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img)
1107
+ }
1108
+ }}
1109
+ onDelete={() => {
1110
+ removeAnnotation(editPopover.annId)
1111
+ setActiveAnnotationId(null)
1112
+ }}
1113
+ onClose={() => setEditPopover(null)}
1114
+ />
1115
+ )
1116
+ })()}
1117
+ </div>
1118
+ )
986
1119
  }
987
1120
 
988
1121
  function ContentTabs() {
989
- const { activeView, setActiveView, openFiles, closeFile, canvasFiles } = React.useContext(ActiveViewContext);
990
- const sessionId = React.useContext(SessionContext);
991
- const { addAnnotationWithId, setActiveAnnotationId } = useAnnotations();
992
-
993
- const showTabs = canvasFiles.length > 1 || openFiles.length > 0;
994
- if (!showTabs) return null;
995
-
996
- return (
997
- <div className="flex items-center border-b border-border-medium bg-bg-surface overflow-x-auto sticky top-0 z-10">
998
- {/* Overview tab — only when multiple canvases */}
999
- {canvasFiles.length > 1 && (
1000
- <>
1001
- <button
1002
- onClick={() => setActiveView({ type: "overview" })}
1003
- className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
1004
- activeView.type === "overview"
1005
- ? "text-text-primary"
1006
- : "text-text-tertiary hover:text-text-secondary"
1007
- }`}
1008
- >
1009
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
1010
- <path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
1011
- </svg>
1012
- Overview
1013
- {activeView.type === "overview" && (
1014
- <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
1015
- )}
1016
- </button>
1017
- <span className="w-px h-4 bg-border-medium flex-shrink-0" />
1018
- </>
1019
- )}
1020
-
1021
- {/* Canvas tabs */}
1022
- {canvasFiles.map((filename, i) => {
1023
- const label = filename.replace(/\.jsx$/, "");
1024
- const isActive = activeView.type === "canvas" && activeView.filename === filename;
1025
- return (
1026
- <React.Fragment key={filename}>
1027
- <button
1028
- onClick={() => setActiveView({ type: "canvas", filename })}
1029
- className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
1030
- isActive
1031
- ? "text-text-primary"
1032
- : "text-text-tertiary hover:text-text-secondary"
1033
- }`}
1034
- >
1035
- {label}
1036
- {isActive && (
1037
- <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
1038
- )}
1039
- </button>
1040
- {i < canvasFiles.length - 1 && (
1041
- <span className="w-px h-4 bg-border-medium flex-shrink-0" />
1042
- )}
1043
- </React.Fragment>
1044
- );
1045
- })}
1046
-
1047
- {openFiles.length > 0 && <>
1048
- {/* Separator between canvas and file tabs */}
1049
- <span className="w-px h-4 bg-border-medium flex-shrink-0" />
1050
-
1051
- {/* File tabs */}
1052
- {openFiles.map((path, i) => {
1053
- const name = path.split("/").pop() || path;
1054
- const isActive = activeView.type === "file" && activeView.path === path;
1055
- return (
1056
- <React.Fragment key={path}>
1057
- <div className="flex items-center">
1058
- <button
1059
- onClick={() => setActiveView({ type: "file", path })}
1060
- className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
1061
- isActive
1062
- ? "text-text-primary"
1063
- : "text-text-tertiary hover:text-text-secondary"
1064
- }`}
1065
- >
1066
- <FileIcon name={name} type="file" />
1067
- {name}
1068
- {isActive && (
1069
- <span className="absolute bottom-0 left-3 right-1 h-[2px] bg-text-primary rounded-full" />
1070
- )}
1071
- </button>
1072
- <button
1073
- onClick={(e) => { e.stopPropagation(); closeFile(path); }}
1074
- className="text-text-tertiary hover:text-text-secondary px-1.5 py-1 text-[10px] transition-colors rounded hover:bg-bg-input"
1075
- >
1076
- &#x2715;
1077
- </button>
1078
- </div>
1079
- {i < openFiles.length - 1 && (
1080
- <span className="w-px h-4 bg-border-medium flex-shrink-0" />
1081
- )}
1082
- </React.Fragment>
1083
- );
1084
- })}
1085
- </>}
1086
-
1087
- {/* Right-aligned actions */}
1088
- <div className="ml-auto flex items-center gap-1 flex-shrink-0">
1089
- {activeView.type === "file" && FS_AVAILABLE && (
1090
- <button
1091
- onClick={async () => {
1092
- const path = activeView.path;
1093
- try {
1094
- const res = await fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`);
1095
- const data = await res.json() as any;
1096
- if (!data.error) {
1097
- const preview = data.content.split("\n").slice(0, 3).join("\n") + (data.content.split("\n").length > 3 ? "\n..." : "");
1098
- const id = generateAnnotationId();
1099
- addAnnotationWithId(id, preview, "", path);
1100
- setActiveAnnotationId(id);
1101
- }
1102
- } catch {}
1103
- }}
1104
- className="flex items-center gap-1 px-3 py-1.5 text-[11px] font-medium font-body text-text-tertiary hover:text-text-secondary whitespace-nowrap transition-colors"
1105
- >
1106
- <span className="text-[13px] leading-none">+</span>
1107
- Add to context
1108
- </button>
1109
- )}
1110
- </div>
1111
- </div>
1112
- );
1122
+ const { activeView, setActiveView, openFiles, closeFile, canvasFiles } = React.useContext(ActiveViewContext)
1123
+ const sessionId = React.useContext(SessionContext)
1124
+ const { addAnnotationWithId, setActiveAnnotationId } = useAnnotations()
1125
+
1126
+ const showTabs = canvasFiles.length > 1 || openFiles.length > 0
1127
+ if (!showTabs) return null
1128
+
1129
+ return (
1130
+ <div className="flex items-center border-b border-border-medium bg-bg-surface overflow-x-auto sticky top-0 z-10">
1131
+ {/* Overview tab — only when multiple canvases */}
1132
+ {canvasFiles.length > 1 && (
1133
+ <>
1134
+ <button
1135
+ onClick={() => setActiveView({ type: 'overview' })}
1136
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
1137
+ activeView.type === 'overview'
1138
+ ? 'text-text-primary'
1139
+ : 'text-text-tertiary hover:text-text-secondary'
1140
+ }`}
1141
+ >
1142
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
1143
+ <path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
1144
+ </svg>
1145
+ Overview
1146
+ {activeView.type === 'overview' && <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />}
1147
+ </button>
1148
+ <span className="w-px h-4 bg-border-medium flex-shrink-0" />
1149
+ </>
1150
+ )}
1151
+
1152
+ {/* Canvas tabs */}
1153
+ {canvasFiles.map((filename, i) => {
1154
+ const label = filename.replace(/\.jsx$/, '')
1155
+ const isActive = activeView.type === 'canvas' && activeView.filename === filename
1156
+ return (
1157
+ <React.Fragment key={filename}>
1158
+ <button
1159
+ onClick={() => setActiveView({ type: 'canvas', filename })}
1160
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
1161
+ isActive
1162
+ ? 'text-text-primary'
1163
+ : 'text-text-tertiary hover:text-text-secondary'
1164
+ }`}
1165
+ >
1166
+ {label}
1167
+ {isActive && <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />}
1168
+ </button>
1169
+ {i < canvasFiles.length - 1 && <span className="w-px h-4 bg-border-medium flex-shrink-0" />}
1170
+ </React.Fragment>
1171
+ )
1172
+ })}
1173
+
1174
+ {openFiles.length > 0 && (
1175
+ <>
1176
+ {/* Separator between canvas and file tabs */}
1177
+ <span className="w-px h-4 bg-border-medium flex-shrink-0" />
1178
+
1179
+ {/* File tabs */}
1180
+ {openFiles.map((path, i) => {
1181
+ const name = path.split('/').pop() || path
1182
+ const isActive = activeView.type === 'file' && activeView.path === path
1183
+ return (
1184
+ <React.Fragment key={path}>
1185
+ <div className="flex items-center">
1186
+ <button
1187
+ onClick={() => setActiveView({ type: 'file', path })}
1188
+ className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
1189
+ isActive
1190
+ ? 'text-text-primary'
1191
+ : 'text-text-tertiary hover:text-text-secondary'
1192
+ }`}
1193
+ >
1194
+ <FileIcon name={name} type="file" />
1195
+ {name}
1196
+ {isActive && <span className="absolute bottom-0 left-3 right-1 h-[2px] bg-text-primary rounded-full" />}
1197
+ </button>
1198
+ <button
1199
+ onClick={(e) => {
1200
+ e.stopPropagation()
1201
+ closeFile(path)
1202
+ }}
1203
+ className="text-text-tertiary hover:text-text-secondary px-1.5 py-1 text-[10px] transition-colors rounded hover:bg-bg-input"
1204
+ >
1205
+ &#x2715;
1206
+ </button>
1207
+ </div>
1208
+ {i < openFiles.length - 1 && <span className="w-px h-4 bg-border-medium flex-shrink-0" />}
1209
+ </React.Fragment>
1210
+ )
1211
+ })}
1212
+ </>
1213
+ )}
1214
+
1215
+ {/* Right-aligned actions */}
1216
+ <div className="ml-auto flex items-center gap-1 flex-shrink-0">
1217
+ {activeView.type === 'file' && FS_AVAILABLE && (
1218
+ <button
1219
+ onClick={async () => {
1220
+ const path = activeView.path
1221
+ try {
1222
+ const res = await fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`)
1223
+ const data = filePreviewResponseSchema.parse(await res.json())
1224
+ if ('content' in data) {
1225
+ const preview = data.content.split('\n').slice(0, 3).join('\n') + (data.content.split('\n').length > 3 ? '\n...' : '')
1226
+ const id = generateAnnotationId()
1227
+ addAnnotationWithId(id, preview, '', path)
1228
+ setActiveAnnotationId(id)
1229
+ }
1230
+ } catch {}
1231
+ }}
1232
+ className="flex items-center gap-1 px-3 py-1.5 text-[11px] font-medium font-body text-text-tertiary hover:text-text-secondary whitespace-nowrap transition-colors"
1233
+ >
1234
+ <span className="text-[13px] leading-none">+</span>
1235
+ Add to context
1236
+ </button>
1237
+ )}
1238
+ </div>
1239
+ </div>
1240
+ )
1113
1241
  }
1114
1242
 
1115
- const root = document.getElementById("root");
1243
+ const root = document.getElementById('root')
1116
1244
  if (root) {
1117
- createRoot(root).render(<App />);
1245
+ createRoot(root).render(<App />)
1118
1246
  }