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.

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