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.
- package/README.md +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -105
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +9 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
package/daemon/client/App.tsx
CHANGED
|
@@ -1,56 +1,62 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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):
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
89
|
+
document.documentElement.dataset.theme = resolveTheme(pref)
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
function ThemeSwitcher() {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
+
✕
|
|
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
|
+
✕
|
|
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(
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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">✕</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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
+
✕
|
|
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(
|
|
1243
|
+
const root = document.getElementById('root')
|
|
1116
1244
|
if (root) {
|
|
1117
|
-
|
|
1245
|
+
createRoot(root).render(<App />)
|
|
1118
1246
|
}
|