agent-canvas 0.1.27 → 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/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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal shown in shared mode when a reviewer submits feedback without a
|
|
5
|
+
* cached identity. Asks for a display name that will appear on the
|
|
6
|
+
* reviewer's annotations when they land in the canvas author's local view.
|
|
7
|
+
*
|
|
8
|
+
* Identity is persisted in localStorage so the prompt only appears once
|
|
9
|
+
* per browser per share worker origin. The id is a random UUID — never
|
|
10
|
+
* tied to any authenticated account, just enough to group annotations by
|
|
11
|
+
* the same reviewer across revisions.
|
|
12
|
+
*/
|
|
13
|
+
interface ReviewerIdentityDialogProps {
|
|
14
|
+
open: boolean;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onSubmit: (name: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ReviewerIdentityDialog({ open, onClose, onSubmit }: ReviewerIdentityDialogProps) {
|
|
20
|
+
const [name, setName] = useState("");
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (open) {
|
|
25
|
+
setName("");
|
|
26
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
27
|
+
}
|
|
28
|
+
}, [open]);
|
|
29
|
+
|
|
30
|
+
if (!open) return null;
|
|
31
|
+
|
|
32
|
+
const trimmed = name.trim();
|
|
33
|
+
const valid = trimmed.length >= 1 && trimmed.length <= 80;
|
|
34
|
+
|
|
35
|
+
const handleSubmit = () => {
|
|
36
|
+
if (!valid) return;
|
|
37
|
+
onSubmit(trimmed);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
43
|
+
onClick={onClose}
|
|
44
|
+
>
|
|
45
|
+
<div
|
|
46
|
+
className="bg-bg-surface border border-border-medium rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
|
|
47
|
+
onClick={(e) => e.stopPropagation()}
|
|
48
|
+
>
|
|
49
|
+
<div className="flex items-center justify-between mb-3">
|
|
50
|
+
<h2 className="text-sm font-medium text-text-primary font-body">
|
|
51
|
+
Your name
|
|
52
|
+
</h2>
|
|
53
|
+
<button
|
|
54
|
+
onClick={onClose}
|
|
55
|
+
className="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
|
|
56
|
+
>
|
|
57
|
+
<span className="text-xs">✕</span>
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<p className="text-[13px] text-text-secondary font-body mb-3 leading-relaxed">
|
|
62
|
+
Before you leave feedback, please enter a name so the canvas author
|
|
63
|
+
knows who reviewed it. This is stored locally in your browser.
|
|
64
|
+
</p>
|
|
65
|
+
|
|
66
|
+
<input
|
|
67
|
+
ref={inputRef}
|
|
68
|
+
type="text"
|
|
69
|
+
value={name}
|
|
70
|
+
onChange={(e) => setName(e.target.value)}
|
|
71
|
+
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
|
|
72
|
+
maxLength={80}
|
|
73
|
+
placeholder="e.g. Alice"
|
|
74
|
+
className="w-full px-3 py-2 text-[13px] font-body bg-bg-input border border-border-medium rounded text-text-primary placeholder:text-text-disabled focus:outline-none focus:border-accent-blue"
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<div className="flex justify-end gap-2 mt-4">
|
|
78
|
+
<button
|
|
79
|
+
onClick={onClose}
|
|
80
|
+
className="px-3 py-1.5 text-[12px] font-body font-medium rounded text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors"
|
|
81
|
+
>
|
|
82
|
+
Cancel
|
|
83
|
+
</button>
|
|
84
|
+
<button
|
|
85
|
+
onClick={handleSubmit}
|
|
86
|
+
disabled={!valid}
|
|
87
|
+
className="px-3 py-1.5 text-[12px] font-body font-medium rounded bg-accent-blue text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
88
|
+
>
|
|
89
|
+
Submit feedback
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useState, useRef } from "react";
|
|
2
|
+
import { FS_AVAILABLE } from "./clientApi";
|
|
2
3
|
|
|
3
4
|
interface SessionInfo {
|
|
4
5
|
id: string;
|
|
@@ -26,6 +27,10 @@ export function SessionSwitcher({ currentSessionId, projectRoot }: SessionSwitch
|
|
|
26
27
|
const ref = useRef<HTMLDivElement>(null);
|
|
27
28
|
|
|
28
29
|
useEffect(() => {
|
|
30
|
+
// /api/sessions only exists on the local daemon. In shared mode we skip
|
|
31
|
+
// the switcher entirely (it's hidden by the parent), but guard the poll
|
|
32
|
+
// just in case this component is mounted — nothing to switch to.
|
|
33
|
+
if (!FS_AVAILABLE) return;
|
|
29
34
|
const fetchSessions = async () => {
|
|
30
35
|
try {
|
|
31
36
|
const res = await fetch("/api/sessions");
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ShareEntry {
|
|
4
|
+
shareId: string;
|
|
5
|
+
url: string;
|
|
6
|
+
revision: number;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
ownerToken?: string;
|
|
9
|
+
expiresAt?: string;
|
|
10
|
+
lastFeedbackAt?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ShareDialogProps {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
revision: number;
|
|
16
|
+
open: boolean;
|
|
17
|
+
shareEnabled: boolean;
|
|
18
|
+
existingShares: ShareEntry[];
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
onShareCreated: (share: ShareEntry) => void;
|
|
21
|
+
onShareRevoked: (shareId: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Modal dialog for sharing a canvas revision to the cloud.
|
|
26
|
+
*
|
|
27
|
+
* Flow: user clicks Share → this dialog opens → user clicks "Create link"
|
|
28
|
+
* → POST to daemon → daemon uploads snapshot to CF Worker → daemon returns
|
|
29
|
+
* ShareEntry → dialog shows the URL with copy-to-clipboard.
|
|
30
|
+
*
|
|
31
|
+
* If a share already exists for this revision, the existing URL is shown
|
|
32
|
+
* immediately. Owner can revoke (delete on the worker side) via the
|
|
33
|
+
* stored ownerToken.
|
|
34
|
+
*/
|
|
35
|
+
export function ShareDialog({
|
|
36
|
+
sessionId,
|
|
37
|
+
revision,
|
|
38
|
+
open,
|
|
39
|
+
shareEnabled,
|
|
40
|
+
existingShares,
|
|
41
|
+
onClose,
|
|
42
|
+
onShareCreated,
|
|
43
|
+
onShareRevoked,
|
|
44
|
+
}: ShareDialogProps) {
|
|
45
|
+
const [loading, setLoading] = useState(false);
|
|
46
|
+
const [revoking, setRevoking] = useState(false);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const [copied, setCopied] = useState(false);
|
|
49
|
+
|
|
50
|
+
const shareForRev = existingShares.find((s) => s.revision === revision);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
setRevoking(false);
|
|
56
|
+
setError(null);
|
|
57
|
+
setCopied(false);
|
|
58
|
+
}
|
|
59
|
+
}, [open]);
|
|
60
|
+
|
|
61
|
+
const createShare = useCallback(async () => {
|
|
62
|
+
setLoading(true);
|
|
63
|
+
setError(null);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`/api/session/${sessionId}/revision/${revision}/share`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
});
|
|
68
|
+
const data = await res.json() as any;
|
|
69
|
+
if (!res.ok || !data.ok) {
|
|
70
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
71
|
+
}
|
|
72
|
+
onShareCreated(data.share as ShareEntry);
|
|
73
|
+
} catch (e: any) {
|
|
74
|
+
setError(e.message || String(e));
|
|
75
|
+
} finally {
|
|
76
|
+
setLoading(false);
|
|
77
|
+
}
|
|
78
|
+
}, [sessionId, revision, onShareCreated]);
|
|
79
|
+
|
|
80
|
+
const revoke = useCallback(async (shareId: string) => {
|
|
81
|
+
if (!window.confirm("Revoke this share link? Reviewers will no longer be able to view or comment.")) return;
|
|
82
|
+
setRevoking(true);
|
|
83
|
+
setError(null);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`/api/session/${sessionId}/shares/${shareId}/revoke`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
});
|
|
88
|
+
const data = await res.json() as any;
|
|
89
|
+
if (!res.ok || !data.ok) {
|
|
90
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
91
|
+
}
|
|
92
|
+
onShareRevoked(shareId);
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
setError(e.message || String(e));
|
|
95
|
+
} finally {
|
|
96
|
+
setRevoking(false);
|
|
97
|
+
}
|
|
98
|
+
}, [sessionId, onShareRevoked]);
|
|
99
|
+
|
|
100
|
+
const copyUrl = useCallback((url: string) => {
|
|
101
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
102
|
+
setCopied(true);
|
|
103
|
+
setTimeout(() => setCopied(false), 1500);
|
|
104
|
+
});
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
if (!open) return null;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
112
|
+
onClick={onClose}
|
|
113
|
+
>
|
|
114
|
+
<div
|
|
115
|
+
className="bg-bg-surface border border-border-medium rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
|
|
116
|
+
onClick={(e) => e.stopPropagation()}
|
|
117
|
+
>
|
|
118
|
+
<div className="flex items-center justify-between mb-3">
|
|
119
|
+
<h2 className="text-sm font-medium text-text-primary font-body">
|
|
120
|
+
Share this revision
|
|
121
|
+
</h2>
|
|
122
|
+
<button
|
|
123
|
+
onClick={onClose}
|
|
124
|
+
className="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
|
|
125
|
+
>
|
|
126
|
+
<span className="text-xs">✕</span>
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{!shareEnabled ? (
|
|
131
|
+
<div className="text-[13px] text-text-secondary font-body space-y-2">
|
|
132
|
+
<p>Canvas sharing is not configured.</p>
|
|
133
|
+
<p className="text-text-tertiary">
|
|
134
|
+
Set <code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">CANVAS_SHARE_ENDPOINT</code> to the URL of a deployed{" "}
|
|
135
|
+
<code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">canvas-share</code> worker and restart the daemon.
|
|
136
|
+
</p>
|
|
137
|
+
</div>
|
|
138
|
+
) : shareForRev ? (
|
|
139
|
+
<div className="space-y-3">
|
|
140
|
+
<p className="text-[13px] text-text-secondary font-body">
|
|
141
|
+
This revision is shared:
|
|
142
|
+
</p>
|
|
143
|
+
<div className="flex gap-2">
|
|
144
|
+
<input
|
|
145
|
+
type="text"
|
|
146
|
+
readOnly
|
|
147
|
+
value={shareForRev.url}
|
|
148
|
+
className="flex-1 px-2 py-1.5 text-[12px] font-mono bg-bg-input border border-border-medium rounded text-text-primary"
|
|
149
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
150
|
+
/>
|
|
151
|
+
<button
|
|
152
|
+
onClick={() => copyUrl(shareForRev.url)}
|
|
153
|
+
className="px-3 py-1.5 text-[12px] font-body font-medium rounded bg-accent-blue text-white hover:opacity-90 transition-opacity whitespace-nowrap"
|
|
154
|
+
>
|
|
155
|
+
{copied ? "Copied!" : "Copy"}
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
{shareForRev.expiresAt && (
|
|
159
|
+
<p className="text-[11px] text-text-tertiary font-body">
|
|
160
|
+
Expires {new Date(shareForRev.expiresAt).toLocaleDateString()}.
|
|
161
|
+
</p>
|
|
162
|
+
)}
|
|
163
|
+
<p className="text-[11px] text-text-tertiary font-body">
|
|
164
|
+
Anyone with this link can view and leave feedback. Reviewer
|
|
165
|
+
feedback will appear in your annotation sidebar within seconds.
|
|
166
|
+
</p>
|
|
167
|
+
<div className="flex items-center justify-between pt-1">
|
|
168
|
+
<button
|
|
169
|
+
onClick={createShare}
|
|
170
|
+
disabled={loading || revoking}
|
|
171
|
+
className="text-[11px] text-text-tertiary hover:text-text-secondary font-body underline disabled:opacity-50"
|
|
172
|
+
>
|
|
173
|
+
{loading ? "Creating..." : "Create a new link"}
|
|
174
|
+
</button>
|
|
175
|
+
{shareForRev.ownerToken && (
|
|
176
|
+
<button
|
|
177
|
+
onClick={() => revoke(shareForRev.shareId)}
|
|
178
|
+
disabled={loading || revoking}
|
|
179
|
+
className="text-[11px] text-accent-red hover:opacity-80 font-body underline disabled:opacity-50"
|
|
180
|
+
>
|
|
181
|
+
{revoking ? "Revoking..." : "Revoke link"}
|
|
182
|
+
</button>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
<div className="space-y-3">
|
|
188
|
+
<p className="text-[13px] text-text-secondary font-body">
|
|
189
|
+
Create a public link to this revision. Anyone with the link can
|
|
190
|
+
view the canvas and leave feedback. Reviewer feedback will sync
|
|
191
|
+
back here automatically.
|
|
192
|
+
</p>
|
|
193
|
+
<button
|
|
194
|
+
onClick={createShare}
|
|
195
|
+
disabled={loading}
|
|
196
|
+
className="w-full px-3 py-2 text-[13px] font-body font-medium rounded bg-accent-blue text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
197
|
+
>
|
|
198
|
+
{loading ? "Creating share link..." : "Create share link"}
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{error && (
|
|
204
|
+
<div className="mt-3 px-3 py-2 rounded bg-accent-red/10 border border-accent-red/20 text-[12px] text-accent-red font-body">
|
|
205
|
+
{error}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Small share icon button for toolbar use. */
|
|
214
|
+
export function ShareButton({ onClick, title = "Share this revision", hasShare }: { onClick: () => void; title?: string; hasShare?: boolean }) {
|
|
215
|
+
return (
|
|
216
|
+
<button
|
|
217
|
+
onClick={onClick}
|
|
218
|
+
className={`group p-1.5 transition-colors ${hasShare ? "text-accent-blue hover:opacity-80" : "text-text-tertiary hover:text-text-secondary"}`}
|
|
219
|
+
title={title}
|
|
220
|
+
>
|
|
221
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
222
|
+
<circle cx="18" cy="5" r="3"/>
|
|
223
|
+
<circle cx="6" cy="12" r="3"/>
|
|
224
|
+
<circle cx="18" cy="19" r="3"/>
|
|
225
|
+
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
|
226
|
+
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
|
227
|
+
</svg>
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client API layer — abstracts over daemon-local vs. shared (CF Worker)
|
|
3
|
+
* modes. Every fetch-adjacent function in the client should go through
|
|
4
|
+
* this module instead of hardcoding URLs, so shared mode works without
|
|
5
|
+
* scattering branches across components.
|
|
6
|
+
*
|
|
7
|
+
* Mode is determined once at module load by checking
|
|
8
|
+
* `window.__CANVAS_SHARE__`, which the CF Worker injects into the HTML
|
|
9
|
+
* shell it serves. Local daemon never sets this, so the app defaults to
|
|
10
|
+
* local mode.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
interface Window {
|
|
15
|
+
__CANVAS_SHARE__?: {
|
|
16
|
+
shareId: string;
|
|
17
|
+
mode: "shared";
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SharedModeInfo {
|
|
23
|
+
isShared: true;
|
|
24
|
+
shareId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LocalModeInfo {
|
|
28
|
+
isShared: false;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ModeInfo = SharedModeInfo | LocalModeInfo;
|
|
33
|
+
|
|
34
|
+
/** One-time detection. In shared mode the daemon-provided sessionId from
|
|
35
|
+
* the URL path is ignored in favor of the worker-injected shareId. */
|
|
36
|
+
export function detectMode(): ModeInfo {
|
|
37
|
+
if (typeof window !== "undefined" && window.__CANVAS_SHARE__?.shareId) {
|
|
38
|
+
return { isShared: true, shareId: window.__CANVAS_SHARE__.shareId };
|
|
39
|
+
}
|
|
40
|
+
// Local: session id is the path suffix /s/:sessionId
|
|
41
|
+
const sessionId = typeof window !== "undefined"
|
|
42
|
+
? window.location.pathname.replace("/s/", "")
|
|
43
|
+
: "";
|
|
44
|
+
return { isShared: false, sessionId };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const MODE: ModeInfo = detectMode();
|
|
48
|
+
|
|
49
|
+
/** Build a canonical id-ish string used as React keys / identifiers. */
|
|
50
|
+
export function getIdentifier(): string {
|
|
51
|
+
return MODE.isShared ? MODE.shareId : MODE.sessionId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- URL builders -----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Metadata endpoint (revisions, canvas files, project root). */
|
|
57
|
+
export function metaUrl(): string {
|
|
58
|
+
if (MODE.isShared) return `/s/${MODE.shareId}/meta`;
|
|
59
|
+
return `/api/session/${MODE.sessionId}/meta`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Compiled canvas JS module for a given filename (+ optional revision). */
|
|
63
|
+
export function canvasJsUrl(filename: string, revision?: number): string {
|
|
64
|
+
const jsName = filename.replace(/\.jsx$/, ".js");
|
|
65
|
+
if (MODE.isShared) {
|
|
66
|
+
return `/s/${MODE.shareId}/canvas/${encodeURIComponent(jsName)}`;
|
|
67
|
+
}
|
|
68
|
+
const rev = revision ? `?rev=${revision}&t=${Date.now()}` : `?t=${Date.now()}`;
|
|
69
|
+
return `/api/session/${MODE.sessionId}/canvas/${encodeURIComponent(jsName)}${rev}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Upload endpoint for annotation images. */
|
|
73
|
+
export function uploadUrl(): string {
|
|
74
|
+
if (MODE.isShared) return `/s/${MODE.shareId}/upload`;
|
|
75
|
+
return `/api/session/${MODE.sessionId}/upload`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Feedback submission ----------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Local-mode author info used when submitting feedback in shared mode.
|
|
82
|
+
* Prompted on first submit and cached in localStorage.
|
|
83
|
+
*/
|
|
84
|
+
const REVIEWER_KEY = "canvas-reviewer-identity";
|
|
85
|
+
|
|
86
|
+
export function getReviewerIdentity(): { id: string; name: string } | null {
|
|
87
|
+
try {
|
|
88
|
+
const raw = localStorage.getItem(REVIEWER_KEY);
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
return JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setReviewerIdentity(name: string): { id: string; name: string } {
|
|
97
|
+
let existing = getReviewerIdentity();
|
|
98
|
+
if (!existing) {
|
|
99
|
+
existing = { id: crypto.randomUUID?.() || String(Date.now()), name };
|
|
100
|
+
} else {
|
|
101
|
+
existing.name = name;
|
|
102
|
+
}
|
|
103
|
+
localStorage.setItem(REVIEWER_KEY, JSON.stringify(existing));
|
|
104
|
+
return existing;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Shape submitted by the shared-mode client when a reviewer submits
|
|
109
|
+
* feedback. Matches the worker's `FeedbackPostBody`.
|
|
110
|
+
*/
|
|
111
|
+
export interface SharedFeedbackPayload {
|
|
112
|
+
author: { id: string; name: string };
|
|
113
|
+
revision: number;
|
|
114
|
+
annotations: Array<Record<string, unknown>>;
|
|
115
|
+
generalNote?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Submit feedback in shared mode. Throws on HTTP error. Caller is
|
|
120
|
+
* responsible for collecting + prompting for the reviewer's name before
|
|
121
|
+
* invoking this.
|
|
122
|
+
*/
|
|
123
|
+
export async function submitSharedFeedback(payload: SharedFeedbackPayload): Promise<void> {
|
|
124
|
+
if (!MODE.isShared) throw new Error("submitSharedFeedback called outside shared mode");
|
|
125
|
+
const res = await fetch(`/s/${MODE.shareId}/feedback`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify(payload),
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const text = await res.text().catch(() => "");
|
|
132
|
+
throw new Error(`Feedback submission failed: ${res.status} ${text}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- Feature flags ----------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** Are daemon-only filesystem endpoints (file tree, file viewer) available? */
|
|
139
|
+
export const FS_AVAILABLE: boolean = !MODE.isShared;
|
|
140
|
+
|
|
141
|
+
/** Is live WebSocket session updates available? */
|
|
142
|
+
export const WS_AVAILABLE: boolean = !MODE.isShared;
|
package/daemon/client/runtime.ts
CHANGED
|
@@ -12,6 +12,22 @@ export interface AnnotationContext {
|
|
|
12
12
|
lineEnd?: number;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Identifies the person who created an annotation. For local annotations
|
|
16
|
+
* this is the daemon owner (unset). For remote annotations it carries the
|
|
17
|
+
* reviewer's self-reported name + stable anonymous id assigned by the worker. */
|
|
18
|
+
export interface AnnotationAuthor {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An image or other file attached to an annotation. Replaces the legacy
|
|
24
|
+
* `images: string[]` pattern with a richer shape that can point to either
|
|
25
|
+
* a local upload (daemon) or a remote blob (CF Worker R2). */
|
|
26
|
+
export interface AnnotationAttachment {
|
|
27
|
+
url: string;
|
|
28
|
+
mime?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
export interface Annotation {
|
|
16
32
|
id: string;
|
|
17
33
|
snippet: string;
|
|
@@ -20,7 +36,13 @@ export interface Annotation {
|
|
|
20
36
|
filePath?: string;
|
|
21
37
|
canvasFile?: string;
|
|
22
38
|
context?: AnnotationContext;
|
|
39
|
+
/** @deprecated use `attachments` — kept for localStorage backward compat */
|
|
23
40
|
images?: string[];
|
|
41
|
+
attachments?: AnnotationAttachment[];
|
|
42
|
+
/** "remote" annotations come from a shared view and are read-only locally. */
|
|
43
|
+
source?: "local" | "remote";
|
|
44
|
+
/** Only set for source === "remote". */
|
|
45
|
+
author?: AnnotationAuthor;
|
|
24
46
|
}
|
|
25
47
|
|
|
26
48
|
export interface PlanResponse {
|