| 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 { Button, TimeAgo } from "../../ui"; |
| 10 | import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; |
| 11 | import { Face } from "../parts"; |
| 12 | |
| 13 | // ── Suggestions ───────────────────────────────────────────────────────────── |
| 14 | |
| 15 | function 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 | |
| 28 | function 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 | |
| 41 | export 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 | |
| 86 | const EMOJI = ["📄", "📘", "📚", "📝", "📐", "🧭", "🗺️", "🚀", "🛠️", "🧰", "🧪", "🐛", "🔒", "🔑", "⚙️", "📦", "🧩", "🎯", "📈", "📊", "🗓️", "⏱️", "✅", "⚠️", "🔥", "🩹", "💡", "🧠", "🤖", "👋", "🤝", "💬", "📣", "🏁", "🌱", "⭐"]; |
| 87 | |
| 88 | export 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 | |
| 143 | export type PageThread = { id: string; resolved: boolean; comments: { id: string; author: string; text: string; created_at: number }[] }; |
| 144 | |
| 145 | export 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 | } |