wiki-viewer 1.0.0 → 1.1.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/.next/standalone/README.md +365 -99
- package/.next/standalone/agents/bootstrap-prompt.md +1 -0
- package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
- package/.next/standalone/bin/wiki-viewer.js +431 -33
- package/.next/standalone/docs/agent-collab-plan.md +1615 -0
- package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
- package/.next/standalone/package.json +19 -2
- package/.next/standalone/pnpm-lock.yaml +1368 -325
- package/.next/standalone/pnpm-workspace.yaml +7 -1
- package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
- package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
- package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
- package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
- package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
- package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
- package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
- package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
- package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
- package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
- package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
- package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
- package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
- package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
- package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
- package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
- package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
- package/.next/standalone/src/app/api/system/config/route.ts +5 -1
- package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
- package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
- package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/route.ts +10 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
- package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
- package/.next/standalone/src/app/globals.css +70 -0
- package/.next/standalone/src/app/page.tsx +461 -217
- package/.next/standalone/src/app/signin/page.tsx +217 -0
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
- package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
- package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
- package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
- package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
- package/.next/standalone/src/components/editor/editor.tsx +513 -10
- package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
- package/.next/standalone/src/components/editor/extensions.ts +2 -0
- package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
- package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
- package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
- package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
- package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
- package/.next/standalone/src/lib/auth/client.ts +4 -0
- package/.next/standalone/src/lib/auth/csrf.ts +68 -0
- package/.next/standalone/src/lib/auth/server.ts +164 -0
- package/.next/standalone/src/lib/config.ts +4 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
- package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
- package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
- package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
- package/.next/standalone/src/lib/proof/activity.ts +74 -0
- package/.next/standalone/src/lib/proof/auth.ts +132 -0
- package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
- package/.next/standalone/src/lib/proof/blocks.ts +73 -0
- package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
- package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
- package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
- package/.next/standalone/src/lib/proof/glob.ts +51 -0
- package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
- package/.next/standalone/src/lib/proof/mutex.ts +32 -0
- package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
- package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
- package/.next/standalone/src/lib/proof/pending.ts +97 -0
- package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
- package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/registry.ts +190 -0
- package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
- package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
- package/.next/standalone/src/lib/proof/types.ts +170 -0
- package/.next/standalone/src/lib/proof-config.ts +14 -0
- package/.next/standalone/src/middleware.ts +38 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
- package/.next/standalone/src/stores/editor-store.ts +115 -9
- package/.next/standalone/src/stores/proof-store.ts +123 -0
- package/.next/standalone/src/stores/view-width-store.ts +62 -0
- package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
- package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
- package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
- package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
- package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
- package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
- package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
- package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
- package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
- package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
- package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
- package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
- package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
- package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
- package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
- package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
- package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
- package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
- package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
- package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
- package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
- package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
- package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
- package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
- package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
- package/README.md +365 -99
- package/bin/wiki-viewer.js +431 -33
- package/package.json +19 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { authHeaders } from "@/lib/proof/client-auth";
|
|
6
|
+
|
|
7
|
+
interface SpanMeta {
|
|
8
|
+
spanId: string;
|
|
9
|
+
origin: string | null;
|
|
10
|
+
basis: string | null;
|
|
11
|
+
basisDetail: string | null;
|
|
12
|
+
by: string | null;
|
|
13
|
+
at: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readMeta(el: HTMLElement): SpanMeta {
|
|
17
|
+
return {
|
|
18
|
+
spanId: el.getAttribute("id") ?? "",
|
|
19
|
+
origin: el.getAttribute("origin"),
|
|
20
|
+
basis: el.getAttribute("basis"),
|
|
21
|
+
basisDetail: el.getAttribute("basis-detail"),
|
|
22
|
+
by: el.getAttribute("by"),
|
|
23
|
+
at: el.getAttribute("at"),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function timeAgo(iso: string | null): string {
|
|
28
|
+
if (!iso) return "";
|
|
29
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
30
|
+
const secs = Math.floor(diff / 1000);
|
|
31
|
+
if (secs < 60) return `${secs}s ago`;
|
|
32
|
+
const mins = Math.floor(secs / 60);
|
|
33
|
+
if (mins < 60) return `${mins}m ago`;
|
|
34
|
+
const hrs = Math.floor(mins / 60);
|
|
35
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
36
|
+
return `${Math.floor(hrs / 24)}d ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
targetEl: HTMLElement | null;
|
|
41
|
+
path: string;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
onComment?: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ProofSpanPopover({ targetEl, path, onClose, onComment }: Props) {
|
|
47
|
+
const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
|
|
48
|
+
const [meta, setMeta] = useState<SpanMeta | null>(null);
|
|
49
|
+
const [busy, setBusy] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!targetEl) {
|
|
53
|
+
setAnchor(null);
|
|
54
|
+
setMeta(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const rect = targetEl.getBoundingClientRect();
|
|
58
|
+
setAnchor({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX });
|
|
59
|
+
setMeta(readMeta(targetEl));
|
|
60
|
+
}, [targetEl]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!targetEl) return;
|
|
64
|
+
const onKey = (e: KeyboardEvent) => {
|
|
65
|
+
if (e.key === "Escape") onClose();
|
|
66
|
+
};
|
|
67
|
+
window.addEventListener("keydown", onKey);
|
|
68
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
69
|
+
}, [targetEl, onClose]);
|
|
70
|
+
|
|
71
|
+
if (!targetEl || !anchor || !meta) return null;
|
|
72
|
+
|
|
73
|
+
async function sendAction(action: "accept" | "revert") {
|
|
74
|
+
if (busy || !meta) return;
|
|
75
|
+
setBusy(true);
|
|
76
|
+
try {
|
|
77
|
+
await fetch("/api/agent/internal/span", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
path,
|
|
82
|
+
spanId: meta.spanId,
|
|
83
|
+
action,
|
|
84
|
+
idempotencyKey: crypto.randomUUID(),
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
} finally {
|
|
88
|
+
setBusy(false);
|
|
89
|
+
onClose();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Popover.Root open>
|
|
95
|
+
<Popover.Anchor asChild>
|
|
96
|
+
<span
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
style={{
|
|
99
|
+
position: "fixed",
|
|
100
|
+
top: anchor.top,
|
|
101
|
+
left: anchor.left,
|
|
102
|
+
width: 0,
|
|
103
|
+
height: 0,
|
|
104
|
+
pointerEvents: "none",
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
</Popover.Anchor>
|
|
108
|
+
<Popover.Portal>
|
|
109
|
+
<Popover.Content
|
|
110
|
+
side="bottom"
|
|
111
|
+
align="start"
|
|
112
|
+
sideOffset={4}
|
|
113
|
+
collisionPadding={8}
|
|
114
|
+
onInteractOutside={onClose}
|
|
115
|
+
className="z-50 w-[min(16rem,calc(100vw-1rem))] bg-popover border border-border rounded-lg shadow-xl p-3 space-y-2 text-[12px] focus:outline-none"
|
|
116
|
+
>
|
|
117
|
+
<div className="space-y-0.5">
|
|
118
|
+
<p className="font-medium text-foreground truncate">
|
|
119
|
+
{meta.by ?? "unknown"} · {meta.basis ?? "—"} · {timeAgo(meta.at)}
|
|
120
|
+
</p>
|
|
121
|
+
{meta.basisDetail && (
|
|
122
|
+
<p className="text-muted-foreground italic truncate">
|
|
123
|
+
“{meta.basisDetail}”
|
|
124
|
+
</p>
|
|
125
|
+
)}
|
|
126
|
+
{meta.origin && (
|
|
127
|
+
<p className="text-muted-foreground/60">origin: {meta.origin}</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex items-center gap-1.5 pt-1">
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
disabled={busy}
|
|
134
|
+
onClick={() => void sendAction("accept")}
|
|
135
|
+
className="flex-1 py-1 rounded-md bg-primary text-primary-foreground text-[11px] font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
136
|
+
>
|
|
137
|
+
Accept
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
disabled={busy}
|
|
142
|
+
onClick={() => void sendAction("revert")}
|
|
143
|
+
className="flex-1 py-1 rounded-md border border-border text-[11px] font-medium hover:bg-accent disabled:opacity-50 transition-colors"
|
|
144
|
+
>
|
|
145
|
+
Revert
|
|
146
|
+
</button>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
disabled={busy}
|
|
150
|
+
onClick={() => { onComment?.(); onClose(); }}
|
|
151
|
+
className="flex-1 py-1 rounded-md border border-border text-[11px] font-medium hover:bg-accent disabled:opacity-50 transition-colors"
|
|
152
|
+
>
|
|
153
|
+
Comment
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
<Popover.Arrow className="fill-border" />
|
|
157
|
+
</Popover.Content>
|
|
158
|
+
</Popover.Portal>
|
|
159
|
+
</Popover.Root>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { authHeaders } from "@/lib/proof/client-auth";
|
|
6
|
+
import { useProofStore } from "@/stores/proof-store";
|
|
7
|
+
import type { SuggestionKind } from "@/lib/proof/types";
|
|
8
|
+
|
|
9
|
+
async function postOp(
|
|
10
|
+
path: string,
|
|
11
|
+
baseRevision: number,
|
|
12
|
+
ops: object[],
|
|
13
|
+
): Promise<{ ok: boolean; stale: boolean; newRevision?: number }> {
|
|
14
|
+
const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
|
|
15
|
+
const res = await fetch(`/api/agent/files/${encoded}`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
"Idempotency-Key": crypto.randomUUID(),
|
|
20
|
+
...authHeaders(),
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({ baseRevision, by: "human", ops }),
|
|
23
|
+
});
|
|
24
|
+
if (res.status === 409) {
|
|
25
|
+
const data = (await res.json()) as { code?: string; snapshot?: { revision?: number } };
|
|
26
|
+
if (data.code === "STALE_REVISION" && data.snapshot?.revision !== undefined) {
|
|
27
|
+
return { ok: false, stale: true, newRevision: data.snapshot.revision };
|
|
28
|
+
}
|
|
29
|
+
return { ok: false, stale: false };
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok) return { ok: false, stale: false };
|
|
32
|
+
return { ok: true, stale: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
path: string;
|
|
37
|
+
blockRef: string;
|
|
38
|
+
/** Current markdown of the block being suggested against. */
|
|
39
|
+
currentMarkdown: string;
|
|
40
|
+
anchor: { top: number; left: number };
|
|
41
|
+
onClose: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const KIND_LABELS: Record<SuggestionKind, string> = {
|
|
45
|
+
replace: "Replace block",
|
|
46
|
+
insertAfter: "Insert after",
|
|
47
|
+
insertBefore: "Insert before",
|
|
48
|
+
delete: "Delete block",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function SuggestEditPopover({ path, blockRef, currentMarkdown, anchor, onClose }: Props) {
|
|
52
|
+
const [kind, setKind] = useState<SuggestionKind>("replace");
|
|
53
|
+
const [markdown, setMarkdown] = useState(currentMarkdown);
|
|
54
|
+
const [reason, setReason] = useState("");
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
57
|
+
|
|
58
|
+
// Prefill markdown with current block on replace; clear it for inserts.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (kind === "replace") setMarkdown(currentMarkdown);
|
|
61
|
+
else if (kind === "delete") setMarkdown("");
|
|
62
|
+
else setMarkdown("");
|
|
63
|
+
}, [kind, currentMarkdown]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const onKey = (e: KeyboardEvent) => {
|
|
67
|
+
if (e.key === "Escape") onClose();
|
|
68
|
+
};
|
|
69
|
+
window.addEventListener("keydown", onKey);
|
|
70
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
71
|
+
}, [onClose]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setTimeout(() => textareaRef.current?.focus(), 50);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
function getRevision(): number {
|
|
78
|
+
return useProofStore.getState().byPath[path]?.snapshotRevision ?? 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const needsMarkdown = kind !== "delete";
|
|
82
|
+
const canSubmit = !busy && (!needsMarkdown || markdown.trim().length > 0);
|
|
83
|
+
|
|
84
|
+
async function handleSubmit() {
|
|
85
|
+
if (!canSubmit) return;
|
|
86
|
+
setBusy(true);
|
|
87
|
+
try {
|
|
88
|
+
const op: Record<string, unknown> = {
|
|
89
|
+
type: "suggestion.add",
|
|
90
|
+
ref: blockRef,
|
|
91
|
+
kind,
|
|
92
|
+
basis: "suggested",
|
|
93
|
+
};
|
|
94
|
+
if (needsMarkdown) op.markdown = markdown;
|
|
95
|
+
if (reason.trim()) op.basisDetail = reason.trim();
|
|
96
|
+
|
|
97
|
+
let rev = getRevision();
|
|
98
|
+
let result = await postOp(path, rev, [op]);
|
|
99
|
+
if (!result.ok && result.stale && result.newRevision !== undefined) {
|
|
100
|
+
await useProofStore.getState().loadSidecar(path);
|
|
101
|
+
rev = result.newRevision;
|
|
102
|
+
result = await postOp(path, rev, [op]);
|
|
103
|
+
}
|
|
104
|
+
if (result.ok) {
|
|
105
|
+
await useProofStore.getState().loadSidecar(path);
|
|
106
|
+
await useProofStore.getState().loadSnapshot(path);
|
|
107
|
+
onClose();
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
setBusy(false);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Popover.Root open>
|
|
116
|
+
<Popover.Anchor asChild>
|
|
117
|
+
<span
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
style={{
|
|
120
|
+
position: "fixed",
|
|
121
|
+
top: anchor.top,
|
|
122
|
+
left: anchor.left,
|
|
123
|
+
width: 0,
|
|
124
|
+
height: 0,
|
|
125
|
+
pointerEvents: "none",
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</Popover.Anchor>
|
|
129
|
+
<Popover.Portal>
|
|
130
|
+
<Popover.Content
|
|
131
|
+
side="bottom"
|
|
132
|
+
align="start"
|
|
133
|
+
sideOffset={4}
|
|
134
|
+
collisionPadding={8}
|
|
135
|
+
onInteractOutside={onClose}
|
|
136
|
+
className="z-50 w-[min(20rem,calc(100vw-1rem))] bg-popover border border-border rounded-lg shadow-xl p-3 space-y-2.5 text-[12px] focus:outline-none"
|
|
137
|
+
>
|
|
138
|
+
{/* Header */}
|
|
139
|
+
<div className="flex items-center justify-between">
|
|
140
|
+
<span className="font-medium text-foreground">Suggest an edit</span>
|
|
141
|
+
<span className="text-[11px] font-mono text-muted-foreground/60 truncate ml-2">
|
|
142
|
+
{blockRef}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Kind selector */}
|
|
147
|
+
<div className="flex flex-wrap gap-1">
|
|
148
|
+
{(Object.keys(KIND_LABELS) as SuggestionKind[]).map((k) => (
|
|
149
|
+
<button
|
|
150
|
+
key={k}
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => setKind(k)}
|
|
153
|
+
className={`px-2 py-0.5 rounded-md text-[10.5px] border transition-colors ${
|
|
154
|
+
kind === k
|
|
155
|
+
? "bg-primary text-primary-foreground border-primary"
|
|
156
|
+
: "border-border text-muted-foreground hover:bg-accent"
|
|
157
|
+
}`}
|
|
158
|
+
>
|
|
159
|
+
{KIND_LABELS[k]}
|
|
160
|
+
</button>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* Delete preview */}
|
|
165
|
+
{kind === "delete" && (
|
|
166
|
+
<pre className="whitespace-pre-wrap font-mono text-[11px] text-muted-foreground line-through bg-destructive/5 rounded px-2 py-1 max-h-32 overflow-y-auto">
|
|
167
|
+
{currentMarkdown || "(empty)"}
|
|
168
|
+
</pre>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Markdown editor */}
|
|
172
|
+
{needsMarkdown && (
|
|
173
|
+
<div className="space-y-1">
|
|
174
|
+
<p className="text-[10px] uppercase tracking-wider text-muted-foreground/60">
|
|
175
|
+
{kind === "replace" ? "proposed content" : "content to insert"}
|
|
176
|
+
</p>
|
|
177
|
+
<textarea
|
|
178
|
+
ref={textareaRef}
|
|
179
|
+
value={markdown}
|
|
180
|
+
onChange={(e) => setMarkdown(e.target.value)}
|
|
181
|
+
rows={4}
|
|
182
|
+
placeholder="Markdown…"
|
|
183
|
+
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 font-mono text-[11px] focus:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/40"
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Reason */}
|
|
189
|
+
<input
|
|
190
|
+
type="text"
|
|
191
|
+
value={reason}
|
|
192
|
+
onChange={(e) => setReason(e.target.value)}
|
|
193
|
+
onKeyDown={(e) => {
|
|
194
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void handleSubmit();
|
|
195
|
+
}}
|
|
196
|
+
placeholder="Reason (optional)"
|
|
197
|
+
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-[12px] focus:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/40"
|
|
198
|
+
/>
|
|
199
|
+
|
|
200
|
+
{/* Actions */}
|
|
201
|
+
<div className="flex items-center justify-between pt-0.5">
|
|
202
|
+
<span className="text-[10px] text-muted-foreground/40">⌘↵ submit</span>
|
|
203
|
+
<div className="flex items-center gap-2">
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
disabled={busy}
|
|
207
|
+
onClick={onClose}
|
|
208
|
+
className="px-2.5 py-1 rounded-md border border-border text-[11px] font-medium hover:bg-accent disabled:opacity-50 transition-colors"
|
|
209
|
+
>
|
|
210
|
+
Cancel
|
|
211
|
+
</button>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
disabled={!canSubmit}
|
|
215
|
+
onClick={() => void handleSubmit()}
|
|
216
|
+
className="px-2.5 py-1 rounded-md bg-primary text-primary-foreground text-[11px] font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
217
|
+
>
|
|
218
|
+
Suggest
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<Popover.Arrow className="fill-border" />
|
|
224
|
+
</Popover.Content>
|
|
225
|
+
</Popover.Portal>
|
|
226
|
+
</Popover.Root>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { authHeaders } from "@/lib/proof/client-auth";
|
|
5
|
+
import type { Suggestion } from "@/lib/proof/types";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
suggestion: Suggestion;
|
|
9
|
+
currentMarkdown: string; // current block content for replace diff
|
|
10
|
+
path: string;
|
|
11
|
+
baseRevision: number;
|
|
12
|
+
/** Return the freshest known revision (for 409 retry). */
|
|
13
|
+
getLatestRevision: () => number;
|
|
14
|
+
top: number;
|
|
15
|
+
left: number;
|
|
16
|
+
width: number;
|
|
17
|
+
onSettled: () => void; // called after accept or reject so parent can refresh
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function kindVerb(kind: Suggestion["kind"]): string {
|
|
21
|
+
switch (kind) {
|
|
22
|
+
case "replace": return "replacing this block";
|
|
23
|
+
case "insertAfter": return "inserting content after this block";
|
|
24
|
+
case "insertBefore": return "inserting content before this block";
|
|
25
|
+
case "delete": return "deleting this block";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function postOp(
|
|
30
|
+
path: string,
|
|
31
|
+
baseRevision: number,
|
|
32
|
+
opType: "suggestion.accept" | "suggestion.reject",
|
|
33
|
+
suggestionId: string,
|
|
34
|
+
): Promise<{ ok: boolean; status: number }> {
|
|
35
|
+
const encodedPath = encodeURIComponent(path).replace(/%2F/g, "/");
|
|
36
|
+
const res = await fetch(`/api/agent/files/${encodedPath}`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"Idempotency-Key": crypto.randomUUID(),
|
|
41
|
+
...authHeaders(),
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
baseRevision,
|
|
45
|
+
by: "human",
|
|
46
|
+
ops: [{ type: opType, suggestionId }],
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
return { ok: res.ok, status: res.status };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Inline suggestion card rendered as an absolutely-positioned sibling to
|
|
54
|
+
* the ProseMirror editor. NOT mounted inside the editor DOM tree.
|
|
55
|
+
*
|
|
56
|
+
* Phase D coordination: this component uses `top`/`left`/`width` computed by
|
|
57
|
+
* the editor's block-ref position tracker. Phase D's comment-pip uses the
|
|
58
|
+
* same tracker (see editor.tsx: useBlockRefPositions).
|
|
59
|
+
*/
|
|
60
|
+
export function SuggestionCard({
|
|
61
|
+
suggestion,
|
|
62
|
+
currentMarkdown,
|
|
63
|
+
path,
|
|
64
|
+
baseRevision,
|
|
65
|
+
getLatestRevision,
|
|
66
|
+
top,
|
|
67
|
+
left,
|
|
68
|
+
width,
|
|
69
|
+
onSettled,
|
|
70
|
+
}: Props) {
|
|
71
|
+
const [busy, setBusy] = useState(false);
|
|
72
|
+
|
|
73
|
+
async function handleAccept() {
|
|
74
|
+
if (busy) return;
|
|
75
|
+
setBusy(true);
|
|
76
|
+
try {
|
|
77
|
+
let result = await postOp(path, baseRevision, "suggestion.accept", suggestion.id);
|
|
78
|
+
// On 409 retry once with latest known revision
|
|
79
|
+
if (!result.ok && result.status === 409) {
|
|
80
|
+
result = await postOp(path, getLatestRevision(), "suggestion.accept", suggestion.id);
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
setBusy(false);
|
|
84
|
+
onSettled();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleReject() {
|
|
89
|
+
if (busy) return;
|
|
90
|
+
setBusy(true);
|
|
91
|
+
try {
|
|
92
|
+
await postOp(path, baseRevision, "suggestion.reject", suggestion.id);
|
|
93
|
+
} finally {
|
|
94
|
+
setBusy(false);
|
|
95
|
+
onSettled();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isDelete = suggestion.kind === "delete";
|
|
100
|
+
const isInsert = suggestion.kind === "insertAfter" || suggestion.kind === "insertBefore";
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
style={{
|
|
105
|
+
position: "absolute",
|
|
106
|
+
top,
|
|
107
|
+
left,
|
|
108
|
+
width: width > 0 ? width : undefined,
|
|
109
|
+
zIndex: 30,
|
|
110
|
+
}}
|
|
111
|
+
className="my-1 rounded-lg border border-border bg-card shadow-md text-[12px] overflow-hidden"
|
|
112
|
+
role="region"
|
|
113
|
+
aria-label={`Suggestion by ${suggestion.by}`}
|
|
114
|
+
>
|
|
115
|
+
{/* Header */}
|
|
116
|
+
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
|
117
|
+
<span className="font-medium text-foreground">
|
|
118
|
+
<span className="text-primary">{suggestion.by}</span>
|
|
119
|
+
{" suggests "}
|
|
120
|
+
<span className="text-muted-foreground">{kindVerb(suggestion.kind)}</span>
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Body */}
|
|
125
|
+
<div className="p-3 space-y-2">
|
|
126
|
+
{/* replace: two-pane diff */}
|
|
127
|
+
{suggestion.kind === "replace" && (
|
|
128
|
+
<>
|
|
129
|
+
<div>
|
|
130
|
+
<p className="text-[10px] uppercase tracking-wider text-muted-foreground/60 mb-1">
|
|
131
|
+
─ current ─
|
|
132
|
+
</p>
|
|
133
|
+
<pre className="whitespace-pre-wrap font-mono text-[11px] text-muted-foreground bg-destructive/5 rounded px-2 py-1">
|
|
134
|
+
{currentMarkdown || "(empty)"}
|
|
135
|
+
</pre>
|
|
136
|
+
</div>
|
|
137
|
+
<div>
|
|
138
|
+
<p className="text-[10px] uppercase tracking-wider text-muted-foreground/60 mb-1">
|
|
139
|
+
─ proposed ─
|
|
140
|
+
</p>
|
|
141
|
+
<pre className="whitespace-pre-wrap font-mono text-[11px] text-foreground bg-primary/5 rounded px-2 py-1">
|
|
142
|
+
{suggestion.markdown ?? "(empty)"}
|
|
143
|
+
</pre>
|
|
144
|
+
</div>
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{/* insert: proposed content with label */}
|
|
149
|
+
{isInsert && (
|
|
150
|
+
<div>
|
|
151
|
+
<p className="text-[10px] uppercase tracking-wider text-muted-foreground/60 mb-1">
|
|
152
|
+
─ will be inserted ─
|
|
153
|
+
</p>
|
|
154
|
+
<pre className="whitespace-pre-wrap font-mono text-[11px] text-foreground bg-primary/5 rounded px-2 py-1">
|
|
155
|
+
{suggestion.markdown ?? "(empty)"}
|
|
156
|
+
</pre>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* delete: current content struck through */}
|
|
161
|
+
{isDelete && (
|
|
162
|
+
<div>
|
|
163
|
+
<p className="text-[10px] uppercase tracking-wider text-muted-foreground/60 mb-1">
|
|
164
|
+
─ will be deleted ─
|
|
165
|
+
</p>
|
|
166
|
+
<pre className="whitespace-pre-wrap font-mono text-[11px] text-muted-foreground line-through bg-destructive/5 rounded px-2 py-1">
|
|
167
|
+
{currentMarkdown || "(empty)"}
|
|
168
|
+
</pre>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{/* Reason */}
|
|
173
|
+
{suggestion.basisDetail && (
|
|
174
|
+
<p className="italic text-muted-foreground/70 text-[11px]">
|
|
175
|
+
Reason: {suggestion.basisDetail}
|
|
176
|
+
</p>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Actions */}
|
|
181
|
+
<div className="flex items-center gap-2 px-3 py-2 border-t border-border bg-muted/30">
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
disabled={busy}
|
|
185
|
+
onClick={() => void handleAccept()}
|
|
186
|
+
className="flex-1 py-1 rounded-md bg-primary text-primary-foreground text-[11px] font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
187
|
+
>
|
|
188
|
+
Accept
|
|
189
|
+
</button>
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
disabled={busy}
|
|
193
|
+
onClick={() => void handleReject()}
|
|
194
|
+
className="flex-1 py-1 rounded-md border border-border text-[11px] font-medium hover:bg-accent disabled:opacity-50 transition-colors"
|
|
195
|
+
>
|
|
196
|
+
Reject
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, AlignJustify } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "@/components/ui/dropdown-menu";
|
|
11
|
+
import {
|
|
12
|
+
useViewWidthStore,
|
|
13
|
+
VIEW_WIDTH_LABEL,
|
|
14
|
+
VIEW_WIDTH_ORDER,
|
|
15
|
+
} from "@/stores/view-width-store";
|
|
16
|
+
|
|
17
|
+
export function ViewWidthToggle({ className }: { className?: string }) {
|
|
18
|
+
const width = useViewWidthStore((s) => s.width);
|
|
19
|
+
const setWidth = useViewWidthStore((s) => s.setWidth);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<DropdownMenu>
|
|
23
|
+
<DropdownMenuTrigger asChild>
|
|
24
|
+
<Button
|
|
25
|
+
size="sm"
|
|
26
|
+
variant="ghost"
|
|
27
|
+
className={`h-7 w-7 p-0 ${className ?? ""}`}
|
|
28
|
+
title={`Content width: ${VIEW_WIDTH_LABEL[width]}`}
|
|
29
|
+
>
|
|
30
|
+
<AlignJustify className="h-3.5 w-3.5" />
|
|
31
|
+
</Button>
|
|
32
|
+
</DropdownMenuTrigger>
|
|
33
|
+
<DropdownMenuContent align="end" className="w-36">
|
|
34
|
+
{VIEW_WIDTH_ORDER.map((w) => (
|
|
35
|
+
<DropdownMenuItem
|
|
36
|
+
key={w}
|
|
37
|
+
onClick={() => setWidth(w)}
|
|
38
|
+
className="flex items-center justify-between text-xs"
|
|
39
|
+
>
|
|
40
|
+
{VIEW_WIDTH_LABEL[w]}
|
|
41
|
+
{w === width && <Check className="h-3.5 w-3.5" />}
|
|
42
|
+
</DropdownMenuItem>
|
|
43
|
+
))}
|
|
44
|
+
</DropdownMenuContent>
|
|
45
|
+
</DropdownMenu>
|
|
46
|
+
);
|
|
47
|
+
}
|