Skip to content
238 linesCodeBlameRaw
1/**
2 * A doc's surroundings: agents' suggestions with Accept and Reject, the
3 * emoji and cover pickers, and comments on the whole doc.
4 */
5import type { FolioSuggestion as DocSuggestion, MemberProfile } from "@g1t/contracts";
6import { Check, MessageSquare, Sparkles, X } from "lucide-react";
7import { type ReactNode, useState } from "react";
8
9import { Button, TimeAgo } from "../../ui";
10import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
11import { Face } from "../parts";
12
13// ── Suggestions ─────────────────────────────────────────────────────────────
14
15function DiffBlock({ text, tone }: { text: string; tone: "del" | "add" }) {
16 if (!text.trim()) return <p className="text-xs text-faint italic">{tone === "del" ? "Nothing removed" : "Nothing added"}</p>;
17 return (
18 <pre
19 className={`max-h-56 overflow-auto rounded-md px-2.5 py-2 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap [scrollbar-width:thin] ${
20 tone === "del" ? "bg-danger/8 text-fg-soft line-through decoration-danger/60" : "bg-success/10 text-fg"
21 }`}
22 >
23 {text}
24 </pre>
25 );
26}
27
28function targetLabel(s: DocSuggestion): string {
29 switch (s.target.kind) {
30 case "append":
31 return "Adds to the end";
32 case "document":
33 return "Rewrites the doc";
34 case "section":
35 return `Changes “${s.target.heading}”`;
36 default:
37 return s.block_ids.length === 1 ? "Changes one block" : `Changes ${s.block_ids.length} blocks`;
38 }
39}
40
41export function SuggestionCard({ suggestion, canDecide, onDecide, compact = false }: { suggestion: DocSuggestion; canDecide: boolean; onDecide: (id: string, decision: "accept" | "reject") => void; compact?: boolean }) {
42 const [open, setOpen] = useState(!compact);
43 const s = suggestion;
44 return (
45 <article className="rounded-xl border border-accent/30 bg-surface shadow-lg shadow-black/20">
46 <header className="flex items-center gap-2 border-b border-line px-3 py-2">
47 <Face who={s.author} size={20} />
48 <span className="min-w-0 grow">
49 <span className="block truncate text-xs font-medium text-fg">{s.author.display_name} suggests</span>
50 <span className="block truncate text-[0.6875rem] text-faint">
51 {targetLabel(s)}
52 {s.asked_by ? ` · for ${s.asked_by.display_name}` : ""} · <TimeAgo at={s.created_at} />
53 </span>
54 </span>
55 <Sparkles size={14} className="shrink-0 text-accent" aria-hidden="true" />
56 </header>
57 <div className="space-y-2 px-3 py-2.5">
58 {s.note && <p className="text-xs leading-relaxed text-muted">{s.note}</p>}
59 {open ? (
60 <>
61 {s.target.kind !== "append" && <DiffBlock text={s.before_markdown} tone="del" />}
62 <DiffBlock text={s.after_markdown} tone="add" />
63 </>
64 ) : (
65 <button type="button" onClick={() => setOpen(true)} className="text-xs text-accent hover:underline">
66 Show the change
67 </button>
68 )}
69 </div>
70 {canDecide && (
71 <footer className="flex gap-2 border-t border-line px-3 py-2">
72 <button type="button" onClick={() => onDecide(s.id, "accept")} className="inline-flex h-7 items-center gap-1 rounded-md bg-success/15 px-2.5 text-xs font-medium text-success hover:bg-success/25">
73 <Check size={13} /> Accept
74 </button>
75 <button type="button" onClick={() => onDecide(s.id, "reject")} className="inline-flex h-7 items-center gap-1 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
76 <X size={13} /> Reject
77 </button>
78 </footer>
79 )}
80 </article>
81 );
82}
83
84// ── Icon ──────────────────────────────────────────────────────────────────
85
86const EMOJI = ["📄", "📘", "📚", "📝", "📐", "🧭", "🗺️", "🚀", "🛠️", "🧰", "🧪", "🐛", "🔒", "🔑", "⚙️", "📦", "🧩", "🎯", "📈", "📊", "🗓️", "⏱️", "✅", "⚠️", "🔥", "🩹", "💡", "🧠", "🤖", "👋", "🤝", "💬", "📣", "🏁", "🌱", "⭐"];
87
88export function IconPicker({ value, onChange, children }: { value: string | null; onChange: (icon: string | null) => void; children: ReactNode }) {
89 const [open, setOpen] = useState(false);
90 const [custom, setCustom] = useState("");
91 return (
92 <Popover open={open} onOpenChange={setOpen}>
93 <PopoverTrigger asChild>{children}</PopoverTrigger>
94 <PopoverContent align="start" className="w-72 p-3">
95 <div className="grid grid-cols-9 gap-0.5">
96 {EMOJI.map((e) => (
97 <button
98 key={e}
99 type="button"
100 onClick={() => {
101 onChange(e);
102 setOpen(false);
103 }}
104 className={`flex size-7 items-center justify-center rounded text-base hover:bg-raised ${value === e ? "bg-raised" : ""}`}
105 aria-label={`Use ${e}`}
106 >
107 {e}
108 </button>
109 ))}
110 </div>
111 <form
112 className="mt-2 flex gap-1.5"
113 onSubmit={(e) => {
114 e.preventDefault();
115 if (custom.trim()) onChange(custom.trim());
116 setOpen(false);
117 }}
118 >
119 <input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="Any emoji" aria-label="Any emoji" className="h-8 min-w-0 grow rounded-md border border-line bg-bg px-2 text-sm outline-none focus:border-accent/60" />
120 <button type="submit" className="h-8 rounded-md bg-raised px-2 text-xs hover:bg-line">
121 Use
122 </button>
123 </form>
124 {value && (
125 <button
126 type="button"
127 onClick={() => {
128 onChange(null);
129 setOpen(false);
130 }}
131 className="mt-2 text-xs text-faint hover:text-fg"
132 >
133 Remove icon
134 </button>
135 )}
136 </PopoverContent>
137 </Popover>
138 );
139}
140
141// ── Comments on the whole doc ──────────────────────────────────────────────
142
143export type PageThread = { id: string; resolved: boolean; comments: { id: string; author: string; text: string; created_at: number }[] };
144
145export function Discussion({
146 threads,
147 people,
148 canComment,
149 onPost,
150 onReply,
151 onResolve,
152}: {
153 threads: PageThread[];
154 people: (key: string) => Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed">;
155 canComment: boolean;
156 onPost: (text: string) => Promise<boolean>;
157 onReply: (thread: string, text: string) => Promise<boolean>;
158 onResolve: (thread: string, resolved: boolean) => void;
159}) {
160 const [draft, setDraft] = useState("");
161 const [replies, setReplies] = useState<Record<string, string>>({});
162 const open = threads.filter((t) => !t.resolved);
163 const resolved = threads.filter((t) => t.resolved);
164 const [showResolved, setShowResolved] = useState(false);
165 const thread = (t: PageThread) => (
166 <li key={t.id} className={`rounded-xl border border-line bg-surface ${t.resolved ? "opacity-70" : ""}`}>
167 <ul className="divide-y divide-line">
168 {t.comments.map((c) => {
169 const who = people(c.author);
170 return (
171 <li key={c.id} className="flex gap-2.5 px-3.5 py-3">
172 <Face who={who} size={22} />
173 <div className="min-w-0 grow">
174 <p className="text-xs">
175 <span className="font-medium text-fg">{who.display_name}</span> <span className="text-faint">{c.created_at ? <TimeAgo at={c.created_at} /> : null}</span>
176 </p>
177 <p className="mt-0.5 text-sm leading-relaxed whitespace-pre-wrap text-fg-soft">{c.text}</p>
178 </div>
179 </li>
180 );
181 })}
182 </ul>
183 {canComment && (
184 <form
185 className="flex items-center gap-2 border-t border-line px-3 py-2"
186 onSubmit={async (e) => {
187 e.preventDefault();
188 const text = (replies[t.id] ?? "").trim();
189 if (text && (await onReply(t.id, text))) setReplies((r) => ({ ...r, [t.id]: "" }));
190 }}
191 >
192 <input value={replies[t.id] ?? ""} onChange={(e) => setReplies((r) => ({ ...r, [t.id]: e.target.value }))} placeholder="Reply" aria-label="Reply" className="h-8 min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
193 <button type="button" onClick={() => onResolve(t.id, !t.resolved)} className="shrink-0 text-xs text-faint hover:text-fg">
194 {t.resolved ? "Reopen" : "Resolve"}
195 </button>
196 </form>
197 )}
198 </li>
199 );
200 return (
201 <section aria-labelledby="discussion" className="mt-14 border-t border-line pt-8">
202 <h2 id="discussion" className="flex items-center gap-2 text-sm font-semibold">
203 <MessageSquare size={15} /> Discussion
204 </h2>
205 <p className="mt-1 text-xs text-faint">Comments on the whole doc. To comment on a passage, select it and choose Comment.</p>
206 <ul className="mt-4 space-y-3">{open.map(thread)}</ul>
207 {resolved.length > 0 && (
208 <>
209 <button type="button" onClick={() => setShowResolved(!showResolved)} className="mt-3 text-xs text-faint hover:text-fg">
210 {showResolved ? "Hide" : "Show"} {resolved.length} resolved
211 </button>
212 {showResolved && <ul className="mt-3 space-y-3">{resolved.map(thread)}</ul>}
213 </>
214 )}
215 {canComment && (
216 <form
217 className="mt-4 flex items-start gap-2"
218 onSubmit={async (e) => {
219 e.preventDefault();
220 if (draft.trim() && (await onPost(draft.trim()))) setDraft("");
221 }}
222 >
223 <textarea
224 value={draft}
225 onChange={(e) => setDraft(e.target.value)}
226 rows={2}
227 placeholder="Add a comment. @mention someone to tell them."
228 aria-label="Add a comment"
229 className="min-h-16 min-w-0 grow resize-y rounded-lg border border-line bg-surface px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent/60"
230 />
231 <Button type="submit" variant="quiet" disabled={!draft.trim()}>
232 Comment
233 </Button>
234 </form>
235 )}
236 </section>
237 );
238}