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