| 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 | */ |
| 5 | import type { FolioSuggestion as DocSuggestion, MemberProfile } from "@g1t/contracts"; |
| 6 | import { Check, MessageSquare, Sparkles, X } from "lucide-react"; |
| 7 | import { type ReactNode, useState } from "react"; |
| 8 | |
| 9 | import { TimeAgo } from "../../ui"; |
| 10 | import { Button } from "../../ui/button"; |
| 11 | import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; |
| 12 | import { Face } from "../parts"; |
| 13 | |
| 14 | // ── Suggestions ───────────────────────────────────────────────────────────── |
| 15 | |
| 16 | function 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 | |
| 29 | function 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 | |
| 42 | export 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 | |
| 97 | const 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 | |
| 136 | export 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 | |
| 199 | export type PageThread = { id: string; resolved: boolean; comments: { id: string; author: string; text: string; created_at: number }[] }; |
| 200 | |
| 201 | export 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 | } |