| 1 | import { |
| 2 | ArrowUpRight, |
| 3 | Bot, |
| 4 | CircleAlert, |
| 5 | CircleCheck, |
| 6 | CircleDot, |
| 7 | CircleDotDashed, |
| 8 | Forward, |
| 9 | GitPullRequest, |
| 10 | ListChecks, |
| 11 | Rocket, |
| 12 | ShieldCheck, |
| 13 | Sparkles, |
| 14 | X, |
| 15 | } from "lucide-react"; |
| 16 | import { type ReactNode, useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; |
| 17 | import { Link } from "react-router"; |
| 18 | |
| 19 | import type { CardAction, MessageCard } from "@g1t/contracts"; |
| 20 | |
| 21 | import { Markdown } from "../markdown"; |
| 22 | import { Badge, type BadgeTone } from "../ui/badge"; |
| 23 | import { ButtonLink } from "../ui"; |
| 24 | import { Button, type ButtonVariant } from "../ui/button"; |
| 25 | import { cn } from "../../lib/cn"; |
| 26 | import { type CardChip, cardChip, foldsBody, shownActions } from "../../lib/card-actions"; |
| 27 | import { repoAt } from "../../lib/markdown-plugins"; |
| 28 | import { sessionChip } from "../../lib/session-card"; |
| 29 | import { codeAccessPath } from "../../lib/workspace-nav"; |
| 30 | |
| 31 | // A card g1t posts in chat (or an agent did, on old messages): what it is |
| 32 | // about, its state, a preview, a few facts, and links to where it goes. |
| 33 | // The card changes in place when the service that posted it updates it |
| 34 | // (`message.updated`). Buttons that ran something on an old agent's card |
| 35 | // are not shown: nothing answers them any more. |
| 36 | |
| 37 | const CARD_ICONS: Record<string, ReactNode> = { |
| 38 | pull: <GitPullRequest size={16} />, |
| 39 | issue: <CircleDot size={16} />, |
| 40 | draft_issue: <CircleDotDashed size={16} />, |
| 41 | task: <ListChecks size={16} />, |
| 42 | deploy: <Rocket size={16} />, |
| 43 | approval: <ShieldCheck size={16} />, |
| 44 | session: <Bot size={16} />, |
| 45 | // Where an agent handed the work, on old messages: the group message it went to. |
| 46 | handoff: <Forward size={16} />, |
| 47 | }; |
| 48 | |
| 49 | /** Where a card's link goes for this viewer: a member without Code goes to the workspace's own view of it. */ |
| 50 | export function cardHref(href: string | null | undefined, slug: string, code: boolean): string | null { |
| 51 | if (!href) return null; |
| 52 | if (code || !/^\/[^/]+\/(?!-\/)[^/]+/.test(href)) return href; |
| 53 | return codeAccessPath(slug, href); |
| 54 | } |
| 55 | |
| 56 | /** How loud a card's state reads, from what it says, for kinds without chips of their own. */ |
| 57 | function stateTone(state: string): BadgeTone { |
| 58 | const s = state.toLowerCase(); |
| 59 | if (/merged/.test(s)) return "merged"; |
| 60 | if (/fail|error|blocked|over budget|rejected/.test(s)) return "danger"; |
| 61 | if (/approval|waiting|review|queued|needs/.test(s)) return "warn"; |
| 62 | if (/pass|done|deployed|ready|approved|live|success/.test(s)) return "success"; |
| 63 | if (/working|running|in progress|open/.test(s)) return "info"; |
| 64 | return "neutral"; |
| 65 | } |
| 66 | |
| 67 | function chipOf(card: MessageCard): CardChip | null { |
| 68 | if (!card.state) return null; |
| 69 | if (card.kind === "session") return sessionChip(card.state); |
| 70 | return cardChip(card) ?? { tone: stateTone(card.state), live: false }; |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * A card's state, as a chip: a session's Working pulses softly in lavender, |
| 75 | * Needs approval is amber, Done green; a draft issue is outlined lavender |
| 76 | * until it is filed (green) or discarded (quiet). |
| 77 | */ |
| 78 | function StateChip({ state, chip }: { state: string; chip: CardChip }) { |
| 79 | return ( |
| 80 | <Badge tone={chip.tone} className={cn("mt-0.5", chip.outline && "bg-transparent")}> |
| 81 | {chip.live && ( |
| 82 | <span aria-hidden="true" className="relative flex size-1.5"> |
| 83 | <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" /> |
| 84 | <span className="relative inline-flex size-1.5 rounded-full bg-current" /> |
| 85 | </span> |
| 86 | )} |
| 87 | {state} |
| 88 | </Badge> |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | // ── What pressing an action said, as a toast above the composer. |
| 93 | |
| 94 | type CardToast = { id: number; ok: boolean; message: string }; |
| 95 | |
| 96 | const TOAST_MS = 4_500; |
| 97 | const NO_TOASTS: CardToast[] = []; |
| 98 | let toasts: CardToast[] = NO_TOASTS; |
| 99 | let toastSeq = 0; |
| 100 | const toastListeners = new Set<() => void>(); |
| 101 | |
| 102 | function setToasts(next: CardToast[]) { |
| 103 | toasts = next; |
| 104 | for (const listener of toastListeners) listener(); |
| 105 | } |
| 106 | |
| 107 | function dismissToast(id: number) { |
| 108 | setToasts(toasts.filter((t) => t.id !== id)); |
| 109 | } |
| 110 | |
| 111 | /** Says something in the conversation's toasts: that a link was copied, say. */ |
| 112 | export function showToast(ok: boolean, message: string) { |
| 113 | const id = ++toastSeq; |
| 114 | // Three at most; a fourth pushes the oldest out. |
| 115 | setToasts([...toasts.slice(-2), { id, ok, message }]); |
| 116 | setTimeout(() => dismissToast(id), ok ? TOAST_MS : TOAST_MS * 1.5); |
| 117 | } |
| 118 | |
| 119 | function subscribeToasts(listener: () => void) { |
| 120 | toastListeners.add(listener); |
| 121 | return () => toastListeners.delete(listener); |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * The conversation's toasts (a link copied, or not). Put once in a conversation; |
| 126 | * along the top on a phone, above the composer on a computer, clear of the |
| 127 | * notification toasts in the corner. |
| 128 | */ |
| 129 | export function CardToasts() { |
| 130 | const list = useSyncExternalStore( |
| 131 | subscribeToasts, |
| 132 | () => toasts, |
| 133 | () => NO_TOASTS, |
| 134 | ); |
| 135 | return ( |
| 136 | <section |
| 137 | aria-label="Notices" |
| 138 | aria-live="polite" |
| 139 | className="pointer-events-none fixed z-[66] flex flex-col items-center gap-2 max-sm:inset-x-3 max-sm:top-[calc(env(safe-area-inset-top)+0.75rem)] sm:bottom-28 sm:left-1/2 sm:w-[min(26rem,calc(100vw-2rem))] sm:-translate-x-1/2" |
| 140 | > |
| 141 | {list.map((toast) => ( |
| 142 | <div |
| 143 | key={toast.id} |
| 144 | role={toast.ok ? "status" : "alert"} |
| 145 | className={cn( |
| 146 | "pointer-events-auto flex w-full animate-pop-in items-start gap-2.5 rounded-xl bg-surface py-2.5 pr-2 pl-3 text-[0.8125rem] leading-snug text-fg shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 motion-reduce:animate-none", |
| 147 | toast.ok ? "ring-success/35" : "ring-danger/45", |
| 148 | )} |
| 149 | > |
| 150 | <span className={cn("mt-px shrink-0", toast.ok ? "text-success" : "text-danger")} aria-hidden="true"> |
| 151 | {toast.ok ? <CircleCheck size={16} /> : <CircleAlert size={16} />} |
| 152 | </span> |
| 153 | <p className="min-w-0 grow">{toast.message}</p> |
| 154 | <Button |
| 155 | type="button" |
| 156 | aria-label="Dismiss" |
| 157 | onClick={() => dismissToast(toast.id)} |
| 158 | variant="ghost" size="icon" |
| 159 | className="size-6 text-faint" |
| 160 | > |
| 161 | <X size={14} /> |
| 162 | </Button> |
| 163 | </div> |
| 164 | ))} |
| 165 | </section> |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | // ── The card. |
| 170 | |
| 171 | /** A card's links are small buttons, a finger tall on a phone; the action's style names the variant. */ |
| 172 | const ACTION_VARIANT: Record<NonNullable<CardAction["style"]>, ButtonVariant> = { primary: "accent", danger: "destructive", default: "outline" }; |
| 173 | |
| 174 | function actionProps(action: Pick<CardAction, "style">) { |
| 175 | return { variant: ACTION_VARIANT[action.style ?? "default"], size: "sm" as const, className: "max-md:h-10 max-md:px-3.5" }; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * A card g1t (or, on old messages, an agent) posted: its title (a link |
| 180 | * when it goes somewhere), its state, a line of detail, a preview, its |
| 181 | * facts, and its links. |
| 182 | */ |
| 183 | export function CardBox({ |
| 184 | card, |
| 185 | slug, |
| 186 | code, |
| 187 | }: { |
| 188 | card: MessageCard; |
| 189 | slug: string; |
| 190 | /** Whether the viewer uses Code, for where links go. */ |
| 191 | code: boolean; |
| 192 | }) { |
| 193 | const href = cardHref(card.href, slug, code); |
| 194 | const chip = chipOf(card); |
| 195 | const live = card.kind === "session" && !!chip?.live; |
| 196 | const actions = shownActions(card); |
| 197 | const head = ( |
| 198 | <> |
| 199 | <span |
| 200 | className={cn( |
| 201 | "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors", |
| 202 | live ? "bg-accent/10 text-accent" : "bg-raised text-muted group-hover/card:text-fg", |
| 203 | )} |
| 204 | > |
| 205 | {CARD_ICONS[card.kind] ?? <Sparkles size={16} />} |
| 206 | </span> |
| 207 | <span className="min-w-0 grow"> |
| 208 | <span className={cn("line-clamp-2 text-sm font-medium text-fg", href && "group-hover/head:underline group-hover/head:decoration-line-strong group-hover/head:underline-offset-2")}> |
| 209 | {card.title} |
| 210 | </span> |
| 211 | {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted tabular-nums">{card.detail}</span>} |
| 212 | </span> |
| 213 | {card.state && chip && <StateChip state={card.state} chip={chip} />} |
| 214 | </> |
| 215 | ); |
| 216 | |
| 217 | const repository = card.fields?.find((f) => f.label.toLowerCase() === "repository")?.value; |
| 218 | |
| 219 | return ( |
| 220 | <div |
| 221 | className={cn( |
| 222 | "group/card mt-1.5 w-full max-w-xl rounded-xl border bg-surface px-3.5 py-3 transition-[transform,box-shadow,border-color] duration-150", |
| 223 | "hover:-translate-y-px hover:border-line-strong hover:shadow-lg hover:shadow-black/25 motion-reduce:transition-none motion-reduce:hover:translate-y-0", |
| 224 | live ? "border-accent/25" : "border-line", |
| 225 | )} |
| 226 | > |
| 227 | {href ? ( |
| 228 | <Link to={href} className="group/head -m-1 flex items-start gap-3 rounded-lg p-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/50"> |
| 229 | {head} |
| 230 | </Link> |
| 231 | ) : ( |
| 232 | <div className="flex items-start gap-3">{head}</div> |
| 233 | )} |
| 234 | |
| 235 | {card.body && <CardBody body={card.body} repo={repository} />} |
| 236 | |
| 237 | {card.fields && card.fields.length > 0 && ( |
| 238 | <dl className="mt-2.5 grid grid-cols-2 gap-x-4 gap-y-2 sm:pl-11"> |
| 239 | {card.fields.map((field) => ( |
| 240 | <div key={field.label} className="min-w-0"> |
| 241 | <dt className="text-[0.6875rem] font-medium text-faint">{field.label}</dt> |
| 242 | <dd className="line-clamp-2 text-xs break-words text-fg-soft"> |
| 243 | {field.value} |
| 244 | </dd> |
| 245 | </div> |
| 246 | ))} |
| 247 | </dl> |
| 248 | )} |
| 249 | |
| 250 | {actions.length > 0 && ( |
| 251 | <div |
| 252 | role="group" |
| 253 | aria-label={`${card.title}: links`} |
| 254 | className="mt-3 flex flex-wrap items-center gap-2 sm:pl-11" |
| 255 | // A press here is for the link, not the message's long-press sheet. |
| 256 | onTouchStart={(event) => event.stopPropagation()} |
| 257 | > |
| 258 | {actions.map((action) => { |
| 259 | const target = cardHref(action.href, slug, code); |
| 260 | return target ? ( |
| 261 | <ButtonLink key={action.id} to={target} {...actionProps(action)}> |
| 262 | {action.label} |
| 263 | <ArrowUpRight size={14} aria-hidden="true" className="-mr-0.5 text-faint" /> |
| 264 | </ButtonLink> |
| 265 | ) : null; |
| 266 | })} |
| 267 | </div> |
| 268 | )} |
| 269 | </div> |
| 270 | ); |
| 271 | } |
| 272 | |
| 273 | /** A card's Markdown preview: about eight lines, then "Show more". */ |
| 274 | function CardBody({ body, repo }: { body: string; repo?: string }) { |
| 275 | const [open, setOpen] = useState(false); |
| 276 | // Folded by its length at first (the same on the server), then by whether it really overflows. |
| 277 | const [folds, setFolds] = useState(() => foldsBody(body)); |
| 278 | const box = useRef<HTMLDivElement>(null); |
| 279 | const id = useId(); |
| 280 | useEffect(() => { |
| 281 | const element = box.current; |
| 282 | if (!element || open) return; |
| 283 | const measure = () => setFolds(element.scrollHeight > element.clientHeight + 2); |
| 284 | measure(); |
| 285 | const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); |
| 286 | observer?.observe(element); |
| 287 | return () => observer?.disconnect(); |
| 288 | }, [body, open]); |
| 289 | const folded = folds && !open; |
| 290 | return ( |
| 291 | <div className="mt-2.5 sm:pl-11"> |
| 292 | <div |
| 293 | ref={box} |
| 294 | id={id} |
| 295 | className={cn("card-md relative rounded-lg border border-line/70 bg-bg/40 px-3 py-2", !open && "max-h-[11.5rem] overflow-hidden")} |
| 296 | > |
| 297 | <Markdown source={body} repo={repoAt(repo)} /> |
| 298 | {folded && ( |
| 299 | <span aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-12 rounded-b-lg bg-gradient-to-t from-surface to-transparent" /> |
| 300 | )} |
| 301 | </div> |
| 302 | {(folded || open) && ( |
| 303 | <Button |
| 304 | type="button" |
| 305 | aria-expanded={open} |
| 306 | aria-controls={id} |
| 307 | onClick={() => setOpen((now) => !now)} |
| 308 | variant="ghost" |
| 309 | size="inline" |
| 310 | className="mt-1 rounded px-1 py-0.5 text-xs text-accent focus-visible:ring-accent/50 max-md:min-h-9" |
| 311 | > |
| 312 | {open ? "Show less" : "Show more"} |
| 313 | </Button> |
| 314 | )} |
| 315 | </div> |
| 316 | ); |
| 317 | } |