Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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