wiki-viewer 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.next/standalone/README.md +328 -99
  2. package/.next/standalone/agents/bootstrap-prompt.md +1 -0
  3. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
  4. package/.next/standalone/bin/wiki-viewer.js +57 -4
  5. package/.next/standalone/docs/agent-collab-plan.md +1615 -0
  6. package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
  7. package/.next/standalone/package.json +19 -2
  8. package/.next/standalone/pnpm-lock.yaml +1368 -325
  9. package/.next/standalone/pnpm-workspace.yaml +7 -1
  10. package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
  11. package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
  12. package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
  13. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
  14. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
  15. package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
  16. package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
  17. package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
  18. package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
  19. package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
  20. package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
  21. package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
  22. package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
  23. package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
  24. package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
  25. package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
  26. package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
  27. package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
  28. package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
  29. package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
  30. package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
  31. package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
  32. package/.next/standalone/src/app/api/system/config/route.ts +5 -1
  33. package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
  34. package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
  35. package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
  36. package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
  37. package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
  38. package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
  39. package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
  40. package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
  41. package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
  42. package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
  43. package/.next/standalone/src/app/api/wiki/route.ts +10 -0
  44. package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
  45. package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
  46. package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
  47. package/.next/standalone/src/app/globals.css +70 -0
  48. package/.next/standalone/src/app/page.tsx +461 -217
  49. package/.next/standalone/src/app/signin/page.tsx +217 -0
  50. package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
  51. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
  52. package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
  53. package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
  54. package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
  55. package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
  56. package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
  57. package/.next/standalone/src/components/editor/editor.tsx +513 -10
  58. package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
  59. package/.next/standalone/src/components/editor/extensions.ts +2 -0
  60. package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
  61. package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
  62. package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
  63. package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
  64. package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
  65. package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
  66. package/.next/standalone/src/lib/auth/client.ts +4 -0
  67. package/.next/standalone/src/lib/auth/csrf.ts +68 -0
  68. package/.next/standalone/src/lib/auth/server.ts +164 -0
  69. package/.next/standalone/src/lib/config.ts +4 -0
  70. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
  71. package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
  72. package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
  73. package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
  74. package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
  75. package/.next/standalone/src/lib/proof/activity.ts +74 -0
  76. package/.next/standalone/src/lib/proof/auth.ts +132 -0
  77. package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
  78. package/.next/standalone/src/lib/proof/blocks.ts +73 -0
  79. package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
  80. package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
  81. package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
  82. package/.next/standalone/src/lib/proof/glob.ts +51 -0
  83. package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
  84. package/.next/standalone/src/lib/proof/mutex.ts +32 -0
  85. package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
  86. package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
  87. package/.next/standalone/src/lib/proof/pending.ts +97 -0
  88. package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
  89. package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
  90. package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
  91. package/.next/standalone/src/lib/proof/registry.ts +190 -0
  92. package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
  93. package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
  94. package/.next/standalone/src/lib/proof/types.ts +170 -0
  95. package/.next/standalone/src/lib/proof-config.ts +14 -0
  96. package/.next/standalone/src/middleware.ts +38 -0
  97. package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
  98. package/.next/standalone/src/stores/editor-store.ts +115 -9
  99. package/.next/standalone/src/stores/proof-store.ts +123 -0
  100. package/.next/standalone/src/stores/view-width-store.ts +62 -0
  101. package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
  102. package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
  103. package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
  104. package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
  105. package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
  106. package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
  107. package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
  108. package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
  109. package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
  110. package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
  111. package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
  112. package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
  113. package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
  114. package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
  115. package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
  116. package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
  117. package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
  118. package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
  119. package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
  120. package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
  121. package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
  122. package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
  123. package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
  124. package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
  125. package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
  126. package/README.md +328 -99
  127. package/bin/wiki-viewer.js +57 -4
  128. package/package.json +19 -2
@@ -0,0 +1,312 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { CheckCircle, XCircle, Clock, Trash2, RefreshCw } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { toast } from "sonner";
7
+
8
+ // ── Types ─────────────────────────────────────────────────────────────────────
9
+
10
+ interface PendingReg {
11
+ registrationId: string;
12
+ agentId: string;
13
+ displayName: string;
14
+ requestedScope: { paths: string[]; ops: string[] };
15
+ requestedAt: string;
16
+ }
17
+
18
+ interface RegisteredAgent {
19
+ id: string;
20
+ displayName: string;
21
+ scope: { paths: string[]; ops: string[] };
22
+ createdAt: string;
23
+ lastSeen: string;
24
+ }
25
+
26
+ function relativeTime(iso: string): string {
27
+ const diff = Date.now() - new Date(iso).getTime();
28
+ if (diff < 60_000) return "just now";
29
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
30
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
31
+ return `${Math.floor(diff / 86_400_000)}d ago`;
32
+ }
33
+
34
+ // ── Sub-components ────────────────────────────────────────────────────────────
35
+
36
+ function ScopeTag({ scope }: { scope: { paths: string[]; ops: string[] } }) {
37
+ return (
38
+ <span className="text-[10px] text-muted-foreground/70 font-mono">
39
+ {scope.paths.join(", ")} · {scope.ops.join(",")}
40
+ </span>
41
+ );
42
+ }
43
+
44
+ function PendingRow({
45
+ reg,
46
+ onApprove,
47
+ onDeny,
48
+ }: {
49
+ reg: PendingReg;
50
+ onApprove: (id: string) => Promise<void>;
51
+ onDeny: (id: string) => Promise<void>;
52
+ }) {
53
+ const [busy, setBusy] = useState(false);
54
+
55
+ async function handle(fn: (id: string) => Promise<void>) {
56
+ setBusy(true);
57
+ try {
58
+ await fn(reg.registrationId);
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ }
63
+
64
+ return (
65
+ <li className="py-1.5 border-b border-border/40 last:border-0">
66
+ <div className="flex items-start justify-between gap-1">
67
+ <div className="min-w-0">
68
+ <p className="text-xs font-mono truncate">{reg.displayName}</p>
69
+ <p className="text-[10px] text-muted-foreground/60 truncate">{reg.agentId}</p>
70
+ <ScopeTag scope={reg.requestedScope} />
71
+ </div>
72
+ <div className="flex items-center gap-1 shrink-0">
73
+ <Button
74
+ size="sm"
75
+ variant="default"
76
+ className="h-6 px-2 text-[10px]"
77
+ disabled={busy}
78
+ onClick={() => void handle(onApprove)}
79
+ title="Approve"
80
+ >
81
+ <CheckCircle className="h-3 w-3 mr-0.5" />
82
+ Approve
83
+ </Button>
84
+ <Button
85
+ size="sm"
86
+ variant="ghost"
87
+ className="h-6 px-2 text-[10px] text-destructive hover:text-destructive"
88
+ disabled={busy}
89
+ onClick={() => void handle(onDeny)}
90
+ title="Deny"
91
+ >
92
+ <XCircle className="h-3 w-3 mr-0.5" />
93
+ Deny
94
+ </Button>
95
+ </div>
96
+ </div>
97
+ <p className="text-[10px] text-muted-foreground/50 mt-0.5">
98
+ Requested {relativeTime(reg.requestedAt)}
99
+ </p>
100
+ </li>
101
+ );
102
+ }
103
+
104
+ function AgentRow({
105
+ agent,
106
+ onRevoke,
107
+ }: {
108
+ agent: RegisteredAgent;
109
+ onRevoke: (id: string) => Promise<void>;
110
+ }) {
111
+ const [busy, setBusy] = useState(false);
112
+
113
+ return (
114
+ <li className="py-1.5 border-b border-border/40 last:border-0">
115
+ <div className="flex items-start justify-between gap-1">
116
+ <div className="min-w-0">
117
+ <p className="text-xs font-mono truncate">{agent.displayName}</p>
118
+ <p className="text-[10px] text-muted-foreground/60 truncate">{agent.id}</p>
119
+ <ScopeTag scope={agent.scope} />
120
+ </div>
121
+ <Button
122
+ size="sm"
123
+ variant="ghost"
124
+ className="h-6 w-6 p-0 shrink-0 text-destructive hover:text-destructive"
125
+ disabled={busy}
126
+ onClick={async () => {
127
+ setBusy(true);
128
+ try {
129
+ await onRevoke(agent.id);
130
+ } finally {
131
+ setBusy(false);
132
+ }
133
+ }}
134
+ title="Revoke agent"
135
+ >
136
+ <Trash2 className="h-3 w-3" />
137
+ </Button>
138
+ </div>
139
+ <p className="text-[10px] text-muted-foreground/50 mt-0.5">
140
+ Last seen {relativeTime(agent.lastSeen)}
141
+ </p>
142
+ </li>
143
+ );
144
+ }
145
+
146
+ // ── Main component ────────────────────────────────────────────────────────────
147
+
148
+ export function TokenSection() {
149
+ const [pending, setPending] = useState<PendingReg[]>([]);
150
+ const [agents, setAgents] = useState<RegisteredAgent[]>([]);
151
+ const [ownerReady, setOwnerReady] = useState(false);
152
+
153
+ // Bootstrap owner cookie on first mount (localhost only)
154
+ useEffect(() => {
155
+ fetch("/api/owner/init", { credentials: "same-origin" })
156
+ .then((r) => {
157
+ if (r.ok || r.status === 204) setOwnerReady(true);
158
+ })
159
+ .catch(() => {
160
+ // Non-localhost: owner init will fail — that's expected for remote
161
+ setOwnerReady(true);
162
+ });
163
+ }, []);
164
+
165
+ const fetchPending = useCallback(async () => {
166
+ try {
167
+ const r = await fetch("/api/agent/admin/registrations", { credentials: "same-origin" });
168
+ if (!r.ok) return;
169
+ const data = (await r.json()) as { pending: PendingReg[] };
170
+ setPending(data.pending);
171
+ } catch {
172
+ // Ignore fetch errors (offline, etc.)
173
+ }
174
+ }, []);
175
+
176
+ const fetchAgents = useCallback(async () => {
177
+ try {
178
+ const r = await fetch("/api/agent/admin/agents", { credentials: "same-origin" });
179
+ if (!r.ok) return;
180
+ const data = (await r.json()) as { agents: RegisteredAgent[] };
181
+ setAgents(data.agents);
182
+ } catch {
183
+ // Ignore fetch errors
184
+ }
185
+ }, []);
186
+
187
+ // Poll pending every 5s, agents every 30s
188
+ useEffect(() => {
189
+ if (!ownerReady) return;
190
+ void fetchPending();
191
+ void fetchAgents();
192
+ const pendingId = setInterval(() => void fetchPending(), 5_000);
193
+ const agentsId = setInterval(() => void fetchAgents(), 30_000);
194
+ return () => {
195
+ clearInterval(pendingId);
196
+ clearInterval(agentsId);
197
+ };
198
+ }, [ownerReady, fetchPending, fetchAgents]);
199
+
200
+ const handleApprove = useCallback(
201
+ async (regId: string) => {
202
+ const r = await fetch(`/api/agent/admin/registrations/${regId}/approve`, {
203
+ method: "POST",
204
+ credentials: "same-origin",
205
+ headers: { "Content-Type": "application/json" },
206
+ body: "{}",
207
+ });
208
+ if (r.ok) {
209
+ toast.success("Agent approved");
210
+ await Promise.all([fetchPending(), fetchAgents()]);
211
+ } else {
212
+ toast.error("Failed to approve agent");
213
+ }
214
+ },
215
+ [fetchPending, fetchAgents],
216
+ );
217
+
218
+ const handleDeny = useCallback(
219
+ async (regId: string) => {
220
+ const r = await fetch(`/api/agent/admin/registrations/${regId}/deny`, {
221
+ method: "POST",
222
+ credentials: "same-origin",
223
+ });
224
+ if (r.ok) {
225
+ toast.success("Registration denied");
226
+ await fetchPending();
227
+ } else {
228
+ toast.error("Failed to deny registration");
229
+ }
230
+ },
231
+ [fetchPending],
232
+ );
233
+
234
+ const handleRevoke = useCallback(
235
+ async (agentId: string) => {
236
+ const r = await fetch(
237
+ `/api/agent/admin/agents/${encodeURIComponent(agentId)}/revoke`,
238
+ { method: "POST", credentials: "same-origin" },
239
+ );
240
+ if (r.ok) {
241
+ toast.success("Agent revoked");
242
+ await fetchAgents();
243
+ } else {
244
+ toast.error("Failed to revoke agent");
245
+ }
246
+ },
247
+ [fetchAgents],
248
+ );
249
+
250
+ return (
251
+ <section className="space-y-2">
252
+ <div className="flex items-center justify-between">
253
+ <h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
254
+ Agents
255
+ </h3>
256
+ <Button
257
+ size="sm"
258
+ variant="ghost"
259
+ className="h-5 w-5 p-0"
260
+ title="Refresh"
261
+ onClick={() => void Promise.all([fetchPending(), fetchAgents()])}
262
+ >
263
+ <RefreshCw className="h-3 w-3" />
264
+ </Button>
265
+ </div>
266
+
267
+ {/* Pending registrations */}
268
+ {pending.length > 0 && (
269
+ <div className="rounded-md border border-amber-500/40 bg-amber-50/10 p-2">
270
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-amber-600/80 mb-1 flex items-center gap-1">
271
+ <Clock className="h-3 w-3" />
272
+ Pending ({pending.length})
273
+ </p>
274
+ <ul>
275
+ {pending.map((reg) => (
276
+ <PendingRow
277
+ key={reg.registrationId}
278
+ reg={reg}
279
+ onApprove={handleApprove}
280
+ onDeny={handleDeny}
281
+ />
282
+ ))}
283
+ </ul>
284
+ </div>
285
+ )}
286
+
287
+ {/* Registered agents */}
288
+ <div className="rounded-md border border-border bg-muted/40 p-2">
289
+ {agents.length === 0 ? (
290
+ <p className="text-xs text-muted-foreground/60 py-1">
291
+ No registered agents.{" "}
292
+ <span className="text-muted-foreground/40">
293
+ Agents register via POST /api/agent/register.
294
+ </span>
295
+ </p>
296
+ ) : (
297
+ <ul>
298
+ {agents.map((agent) => (
299
+ <AgentRow key={agent.id} agent={agent} onRevoke={handleRevoke} />
300
+ ))}
301
+ </ul>
302
+ )}
303
+ </div>
304
+
305
+ <p className="text-[10px] text-muted-foreground/60 leading-relaxed">
306
+ Agents authenticate via bearer token + X-Agent-Id header. Use{" "}
307
+ <code className="bg-muted px-0.5 rounded">POST /api/agent/register</code> to start the
308
+ TOFU flow.
309
+ </p>
310
+ </section>
311
+ );
312
+ }
@@ -0,0 +1,209 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { AlertCircle, Loader2 } from "lucide-react";
5
+
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ Sheet,
9
+ SheetContent,
10
+ SheetDescription,
11
+ SheetFooter,
12
+ SheetHeader,
13
+ SheetTitle,
14
+ } from "@/components/ui/sheet";
15
+ import { showError } from "@/lib/toast";
16
+
17
+ interface AuthSettings {
18
+ allowedEmails: string[];
19
+ allowedDomains: string[];
20
+ source: "config" | "env";
21
+ envFallbackActive: boolean;
22
+ }
23
+
24
+ function listToText(list: string[]): string {
25
+ return list.join("\n");
26
+ }
27
+
28
+ function textToList(text: string): string[] {
29
+ return text
30
+ .split(/[\n,]/)
31
+ .map((s) => s.trim())
32
+ .filter(Boolean);
33
+ }
34
+
35
+ export function AuthSettingsSheet({
36
+ open,
37
+ onOpenChange,
38
+ }: {
39
+ open: boolean;
40
+ onOpenChange: (open: boolean) => void;
41
+ }) {
42
+ const [loading, setLoading] = useState(false);
43
+ const [saving, setSaving] = useState(false);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [source, setSource] = useState<"config" | "env">("env");
46
+ const [emailsText, setEmailsText] = useState("");
47
+ const [domainsText, setDomainsText] = useState("");
48
+
49
+ const load = useCallback(async () => {
50
+ setLoading(true);
51
+ setError(null);
52
+ try {
53
+ const res = await fetch("/api/system/auth-settings");
54
+ if (!res.ok) throw new Error("Failed to load settings");
55
+ const data: AuthSettings = await res.json();
56
+ setEmailsText(listToText(data.allowedEmails));
57
+ setDomainsText(listToText(data.allowedDomains));
58
+ setSource(data.source);
59
+ } catch (e) {
60
+ setError(e instanceof Error ? e.message : "Failed to load");
61
+ } finally {
62
+ setLoading(false);
63
+ }
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ if (open) void load();
68
+ }, [open, load]);
69
+
70
+ async function handleSave() {
71
+ setSaving(true);
72
+ setError(null);
73
+ try {
74
+ const res = await fetch("/api/system/auth-settings", {
75
+ method: "PUT",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({
78
+ allowedEmails: textToList(emailsText),
79
+ allowedDomains: textToList(domainsText),
80
+ }),
81
+ });
82
+ const data: { error?: string; source?: "config" | "env" } =
83
+ await res.json();
84
+ if (!res.ok) {
85
+ setError(data.error ?? "Save failed");
86
+ showError(data.error ?? "Save failed");
87
+ return;
88
+ }
89
+ if (data.source) setSource(data.source);
90
+ onOpenChange(false);
91
+ } catch {
92
+ setError("Save failed");
93
+ } finally {
94
+ setSaving(false);
95
+ }
96
+ }
97
+
98
+ const noRestriction =
99
+ textToList(emailsText).length === 0 &&
100
+ textToList(domainsText).length === 0;
101
+
102
+ return (
103
+ <Sheet open={open} onOpenChange={onOpenChange}>
104
+ <SheetContent side="right" className="w-full sm:max-w-md flex flex-col">
105
+ <SheetHeader>
106
+ <SheetTitle>Signup allowlist</SheetTitle>
107
+ <SheetDescription>
108
+ Control who can create an account. Leave both lists empty to allow
109
+ any email. Saving here overrides the AUTH_ALLOWED_EMAILS and
110
+ AUTH_ALLOWED_DOMAIN environment variables.
111
+ </SheetDescription>
112
+ </SheetHeader>
113
+
114
+ {loading ? (
115
+ <div className="flex flex-1 items-center justify-center">
116
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
117
+ </div>
118
+ ) : (
119
+ <div className="flex-1 space-y-5 overflow-auto py-4">
120
+ {source === "env" && (
121
+ <div className="flex items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
122
+ <AlertCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
123
+ <span>
124
+ Current values come from environment variables. Saving moves
125
+ the allowlist into config and takes precedence going forward.
126
+ </span>
127
+ </div>
128
+ )}
129
+
130
+ <div className="space-y-1.5">
131
+ <label
132
+ htmlFor="allowed-emails"
133
+ className="text-sm font-medium"
134
+ >
135
+ Allowed emails
136
+ </label>
137
+ <p className="text-xs text-muted-foreground">
138
+ One per line (or comma-separated). Exact match,
139
+ case-insensitive.
140
+ </p>
141
+ <textarea
142
+ id="allowed-emails"
143
+ value={emailsText}
144
+ onChange={(e) => setEmailsText(e.target.value)}
145
+ spellCheck={false}
146
+ rows={5}
147
+ placeholder="alice@example.com&#10;bob@example.com"
148
+ className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-sm outline-none focus:ring-2 focus:ring-primary"
149
+ />
150
+ </div>
151
+
152
+ <div className="space-y-1.5">
153
+ <label
154
+ htmlFor="allowed-domains"
155
+ className="text-sm font-medium"
156
+ >
157
+ Allowed domains
158
+ </label>
159
+ <p className="text-xs text-muted-foreground">
160
+ One per line (or comma-separated). Matches any email at that
161
+ domain.
162
+ </p>
163
+ <textarea
164
+ id="allowed-domains"
165
+ value={domainsText}
166
+ onChange={(e) => setDomainsText(e.target.value)}
167
+ spellCheck={false}
168
+ rows={4}
169
+ placeholder="example.com&#10;trusted.org"
170
+ className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-sm outline-none focus:ring-2 focus:ring-primary"
171
+ />
172
+ </div>
173
+
174
+ {noRestriction && (
175
+ <div className="flex items-start gap-2 rounded-md bg-warning-soft px-3 py-2 text-xs text-warning-ink">
176
+ <AlertCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
177
+ <span>
178
+ Both lists are empty. Anyone with the signup URL can create an
179
+ account.
180
+ </span>
181
+ </div>
182
+ )}
183
+
184
+ {error && (
185
+ <div className="flex items-center gap-1.5 text-xs text-destructive">
186
+ <AlertCircle className="h-3.5 w-3.5 shrink-0" />
187
+ {error}
188
+ </div>
189
+ )}
190
+ </div>
191
+ )}
192
+
193
+ <SheetFooter>
194
+ <Button
195
+ variant="ghost"
196
+ onClick={() => onOpenChange(false)}
197
+ disabled={saving}
198
+ >
199
+ Cancel
200
+ </Button>
201
+ <Button onClick={handleSave} disabled={saving || loading} className="gap-1.5">
202
+ {saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
203
+ Save
204
+ </Button>
205
+ </SheetFooter>
206
+ </SheetContent>
207
+ </Sheet>
208
+ );
209
+ }
@@ -11,6 +11,8 @@ import {
11
11
  Code,
12
12
  Italic,
13
13
  Link as LinkIcon,
14
+ MessageCircle,
15
+ MessageSquarePlus,
14
16
  Strikethrough,
15
17
  Subscript as SubIcon,
16
18
  Superscript as SuperIcon,
@@ -22,6 +24,10 @@ import { LinkPopover } from "./link-popover";
22
24
 
23
25
  interface Props {
24
26
  editor: Editor | null;
27
+ /** Open the human "suggest edit" flow for the current selection's block. */
28
+ onSuggestEdit?: () => void;
29
+ /** Open a comment thread on the current selection's block. */
30
+ onComment?: () => void;
25
31
  }
26
32
 
27
33
  type OpenPopover =
@@ -34,7 +40,7 @@ type OpenPopover =
34
40
  anchor: { top: number; left: number };
35
41
  };
36
42
 
37
- export function EditorBubbleMenu({ editor }: Props) {
43
+ export function EditorBubbleMenu({ editor, onSuggestEdit, onComment }: Props) {
38
44
  const [popover, setPopover] = useState<OpenPopover>(null);
39
45
 
40
46
  useEffect(() => {
@@ -58,7 +64,7 @@ export function EditorBubbleMenu({ editor }: Props) {
58
64
 
59
65
  const btn = (active: boolean) =>
60
66
  cn(
61
- "h-7 w-7 flex items-center justify-center rounded hover:bg-accent text-foreground/80 transition-colors",
67
+ "h-9 w-9 sm:h-7 sm:w-7 flex items-center justify-center rounded hover:bg-accent text-foreground/80 transition-colors",
62
68
  active && "bg-accent text-foreground",
63
69
  );
64
70
 
@@ -205,6 +211,39 @@ export function EditorBubbleMenu({ editor }: Props) {
205
211
  >
206
212
  <LinkIcon className="w-3.5 h-3.5" />
207
213
  </button>
214
+ {(onSuggestEdit || onComment) && (
215
+ <div className="w-px h-5 bg-border mx-1" />
216
+ )}
217
+ {onComment && (
218
+ <button
219
+ type="button"
220
+ className={btn(false)}
221
+ onMouseDown={(e) => e.preventDefault()}
222
+ onClick={(e) => {
223
+ e.preventDefault();
224
+ onComment();
225
+ }}
226
+ aria-label="Comment"
227
+ title="Comment"
228
+ >
229
+ <MessageCircle className="w-3.5 h-3.5" />
230
+ </button>
231
+ )}
232
+ {onSuggestEdit && (
233
+ <button
234
+ type="button"
235
+ className={btn(false)}
236
+ onMouseDown={(e) => e.preventDefault()}
237
+ onClick={(e) => {
238
+ e.preventDefault();
239
+ onSuggestEdit();
240
+ }}
241
+ aria-label="Suggest edit"
242
+ title="Suggest edit"
243
+ >
244
+ <MessageSquarePlus className="w-3.5 h-3.5" />
245
+ </button>
246
+ )}
208
247
  <div className="w-px h-5 bg-border mx-1" />
209
248
  <div className="relative">
210
249
  <button
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { CheckCircle2, MessageCircle } from "lucide-react";
4
+ import type { Comment } from "@/lib/proof/types";
5
+
6
+ interface Props {
7
+ blockRef: string;
8
+ comments: Comment[];
9
+ top: number;
10
+ left: number;
11
+ onClick: () => void;
12
+ }
13
+
14
+ /**
15
+ * Gutter pip rendered absolutely inside the editor scroll container.
16
+ * Positioned via `top`/`left` props (pixels relative to scroll container).
17
+ */
18
+ export function CommentPip({ blockRef, comments, top, left, onClick }: Props) {
19
+ if (comments.length === 0) return null;
20
+
21
+ const open = comments.filter((c) => !c.resolved);
22
+ const allResolved = open.length === 0;
23
+
24
+ let variant: "dot-ai" | "ring-human" | "check";
25
+ if (allResolved) {
26
+ variant = "check";
27
+ } else {
28
+ const lastTurn = open[0].turns.at(-1);
29
+ variant = lastTurn?.by.startsWith("ai:") ? "dot-ai" : "ring-human";
30
+ }
31
+
32
+ return (
33
+ <button
34
+ type="button"
35
+ onClick={onClick}
36
+ style={{
37
+ position: "absolute",
38
+ top,
39
+ left,
40
+ transform: "translateY(2px)",
41
+ }}
42
+ className="z-10 p-2 sm:p-0.5 -m-1.5 sm:m-0 rounded transition-colors hover:bg-accent focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
43
+ aria-label={`Comment thread for block ${blockRef}`}
44
+ >
45
+ {variant === "check" && (
46
+ <CheckCircle2 className="h-3.5 w-3.5 text-muted-foreground/30" />
47
+ )}
48
+ {variant === "dot-ai" && (
49
+ <MessageCircle className="h-3.5 w-3.5 fill-primary text-primary" />
50
+ )}
51
+ {variant === "ring-human" && (
52
+ <MessageCircle className="h-3.5 w-3.5 text-muted-foreground/70" />
53
+ )}
54
+ </button>
55
+ );
56
+ }