Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 1 | import { |
| 2 | ArrowUpRight, | |
| 3 | Bot, | |
| 4 | CircleAlert, | |
| 5 | CircleCheck, | |
| 6 | CircleDot, | |
| 7 | CircleDotDashed, | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 8 | Forward, |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 9 | GitPullRequest, |
| 10 | ListChecks, | |
| 11 | LoaderCircle, | |
| 12 | Rocket, | |
| 13 | ShieldCheck, | |
| 14 | Sparkles, | |
| 15 | X, | |
| 16 | } from "lucide-react"; | |
| 17 | import { | |
| 18 | type FormEvent, | |
| 19 | type KeyboardEvent, | |
| 20 | type ReactNode, | |
| 21 | useEffect, | |
| 22 | useId, | |
| 23 | useRef, | |
| 24 | useState, | |
| 25 | useSyncExternalStore, | |
| 26 | } from "react"; | |
| 27 | import { Link } from "react-router"; | |
| 28 | ||
| 29 | import type { CardAction, CardActionResult, MessageCard, Result } from "@g1t/contracts"; | |
| 30 | ||
| 31 | import { Markdown } from "../markdown"; | |
| 32 | import { | |
| 33 | AlertDialog, | |
| 34 | AlertDialogAction, | |
| 35 | AlertDialogCancel, | |
| 36 | AlertDialogContent, | |
| 37 | AlertDialogDescription, | |
| 38 | AlertDialogFooter, | |
| 39 | AlertDialogHeader, | |
| 40 | AlertDialogTitle, | |
| 41 | } from "../ui/alert-dialog"; | |
| 42 | import { Badge, type BadgeTone } from "../ui/badge"; | |
| 43 | import { InputAddon, InputGroup } from "../ui/input"; | |
| 44 | import { cn } from "../../lib/cn"; | |
| 45 | import { type CardChip, actionMode, cardChip, foldsBody, inputValue, moneyInitial, shownActions } from "../../lib/card-actions"; | |
| 46 | import { repoAt } from "../../lib/markdown-plugins"; | |
| 47 | import { sessionChip } from "../../lib/session-card"; | |
| 48 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| 49 | ||
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 50 | // A card g1t or an agent posts in chat: what |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 51 | // it is about, its state, a preview, a few facts, and what people can do |
| 52 | // right here. Pressing an action goes to the site (routes/workspace/chat/ | |
| 53 | // api.ts, `card_action`), then to chat, then to the service that owns the | |
| 54 | // card; the card changes in place when that service updates it | |
| 55 | // (`message.updated`), so nothing here changes it ahead of time. | |
| 56 | ||
| 57 | const CARD_ICONS: Record<string, ReactNode> = { | |
| 58 | pull: <GitPullRequest size={16} />, | |
| 59 | issue: <CircleDot size={16} />, | |
| 60 | draft_issue: <CircleDotDashed size={16} />, | |
| 61 | task: <ListChecks size={16} />, | |
| 62 | deploy: <Rocket size={16} />, | |
| 63 | approval: <ShieldCheck size={16} />, | |
| 64 | session: <Bot size={16} />, | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 65 | // Where an agent handed the work: the group message it went to. |
| 66 | handoff: <Forward size={16} />, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 67 | }; |
| 68 | ||
| 69 | /** Where a card's link goes for this viewer: a member without Code goes to the workspace's own view of it. */ | |
| 70 | export function cardHref(href: string | null | undefined, slug: string, code: boolean): string | null { | |
| 71 | if (!href) return null; | |
| 72 | if (code || !/^\/[^/]+\/(?!-\/)[^/]+/.test(href)) return href; | |
| 73 | return codeAccessPath(slug, href); | |
| 74 | } | |
| 75 | ||
| 76 | /** How loud a card's state reads, from what it says, for kinds without chips of their own. */ | |
| 77 | function stateTone(state: string): BadgeTone { | |
| 78 | const s = state.toLowerCase(); | |
| 79 | if (/merged/.test(s)) return "merged"; | |
| 80 | if (/fail|error|blocked|over budget|rejected/.test(s)) return "danger"; | |
| 81 | if (/approval|waiting|review|queued|needs/.test(s)) return "warn"; | |
| 82 | if (/pass|done|deployed|ready|approved|live|success/.test(s)) return "success"; | |
| 83 | if (/working|running|in progress|open/.test(s)) return "info"; | |
| 84 | return "neutral"; | |
| 85 | } | |
| 86 | ||
| 87 | function chipOf(card: MessageCard): CardChip | null { | |
| 88 | if (!card.state) return null; | |
| 89 | if (card.kind === "session") return sessionChip(card.state); | |
| 90 | return cardChip(card) ?? { tone: stateTone(card.state), live: false }; | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * A card's state, as a chip: a session's Working pulses softly in lavender, | |
| 95 | * Needs approval is amber, Done green; a draft issue is outlined lavender | |
| 96 | * until it is filed (green) or discarded (quiet). | |
| 97 | */ | |
| 98 | function StateChip({ state, chip }: { state: string; chip: CardChip }) { | |
| 99 | return ( | |
| 100 | <Badge tone={chip.tone} className={cn("mt-0.5", chip.outline && "bg-transparent")}> | |
| 101 | {chip.live && ( | |
| 102 | <span aria-hidden="true" className="relative flex size-1.5"> | |
| 103 | <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" /> | |
| 104 | <span className="relative inline-flex size-1.5 rounded-full bg-current" /> | |
| 105 | </span> | |
| 106 | )} | |
| 107 | {state} | |
| 108 | </Badge> | |
| 109 | ); | |
| 110 | } | |
| 111 | ||
| 112 | // ── What pressing an action said, as a toast above the composer. | |
| 113 | ||
| 114 | type CardToast = { id: number; ok: boolean; message: string }; | |
| 115 | ||
| 116 | const TOAST_MS = 4_500; | |
| 117 | const NO_TOASTS: CardToast[] = []; | |
| 118 | let toasts: CardToast[] = NO_TOASTS; | |
| 119 | let toastSeq = 0; | |
| 120 | const toastListeners = new Set<() => void>(); | |
| 121 | ||
| 122 | function setToasts(next: CardToast[]) { | |
| 123 | toasts = next; | |
| 124 | for (const listener of toastListeners) listener(); | |
| 125 | } | |
| 126 | ||
| 127 | function dismissToast(id: number) { | |
| 128 | setToasts(toasts.filter((t) => t.id !== id)); | |
| 129 | } | |
| 130 | ||
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 131 | /** Says something in the conversation's toasts: what a card's action did, or that a link was copied. */ |
| 132 | export function showToast(ok: boolean, message: string) { | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 133 | const id = ++toastSeq; |
| 134 | // Three at most; a fourth pushes the oldest out. | |
| 135 | setToasts([...toasts.slice(-2), { id, ok, message }]); | |
| 136 | setTimeout(() => dismissToast(id), ok ? TOAST_MS : TOAST_MS * 1.5); | |
| 137 | } | |
| 138 | ||
| 139 | function subscribeToasts(listener: () => void) { | |
| 140 | toastListeners.add(listener); | |
| 141 | return () => toastListeners.delete(listener); | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * The toasts that say what a card's action did. Put once in a conversation; | |
| 146 | * along the top on a phone, above the composer on a computer, clear of the | |
| 147 | * notification toasts in the corner. | |
| 148 | */ | |
| 149 | export function CardToasts() { | |
| 150 | const list = useSyncExternalStore( | |
| 151 | subscribeToasts, | |
| 152 | () => toasts, | |
| 153 | () => NO_TOASTS, | |
| 154 | ); | |
| 155 | return ( | |
| 156 | <section | |
| 157 | aria-label="Card actions" | |
| 158 | aria-live="polite" | |
| 159 | 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" | |
| 160 | > | |
| 161 | {list.map((toast) => ( | |
| 162 | <div | |
| 163 | key={toast.id} | |
| 164 | role={toast.ok ? "status" : "alert"} | |
| 165 | className={cn( | |
| You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how. | 166 | "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", |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 167 | toast.ok ? "ring-success/35" : "ring-danger/45", |
| 168 | )} | |
| 169 | > | |
| 170 | <span className={cn("mt-px shrink-0", toast.ok ? "text-success" : "text-danger")} aria-hidden="true"> | |
| 171 | {toast.ok ? <CircleCheck size={16} /> : <CircleAlert size={16} />} | |
| 172 | </span> | |
| 173 | <p className="min-w-0 grow">{toast.message}</p> | |
| 174 | <button | |
| 175 | type="button" | |
| 176 | aria-label="Dismiss" | |
| 177 | onClick={() => dismissToast(toast.id)} | |
| 178 | className="flex size-6 shrink-0 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 179 | > | |
| 180 | <X size={14} /> | |
| 181 | </button> | |
| 182 | </div> | |
| 183 | ))} | |
| 184 | </section> | |
| 185 | ); | |
| 186 | } | |
| 187 | ||
| 188 | // ── The card. | |
| 189 | ||
| 190 | const ACTION_BASE = | |
| 191 | "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:pointer-events-none disabled:opacity-50 max-md:h-10 max-md:px-3.5"; | |
| 192 | ||
| 193 | const ACTION_STYLES: Record<NonNullable<CardAction["style"]>, string> = { | |
| 194 | primary: "bg-accent text-bg hover:bg-accent-hover", | |
| 195 | danger: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10", | |
| 196 | default: "border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg", | |
| 197 | }; | |
| 198 | ||
| 199 | function actionClass(action: CardAction, active = false) { | |
| 200 | return cn(ACTION_BASE, ACTION_STYLES[action.style ?? "default"], active && "border-accent/60 bg-raised text-fg"); | |
| 201 | } | |
| 202 | ||
| 203 | /** Sends a card's action; says what happened in a toast; true when it was done. */ | |
| 204 | async function pressAction(slug: string, channelId: string, messageId: string, action: CardAction, input: string | null): Promise<boolean> { | |
| 205 | try { | |
| 206 | const response = await fetch(`/${slug}/-/chat/api`, { | |
| 207 | method: "POST", | |
| 208 | headers: { "content-type": "application/json" }, | |
| 209 | body: JSON.stringify({ intent: "card_action", channel_id: channelId, message_id: messageId, action_id: action.id, input }), | |
| 210 | }); | |
| 211 | const result = (await response.json()) as Result<CardActionResult>; | |
| 212 | if (!result.ok) { | |
| 213 | showToast(false, result.error.message); | |
| 214 | return false; | |
| 215 | } | |
| 216 | showToast(result.value.ok, result.value.message ?? (result.value.ok ? `${action.label}: done.` : `${action.label} didn't work.`)); | |
| 217 | return result.value.ok; | |
| 218 | } catch { | |
| 219 | showToast(false, `${action.label} didn't go through. Try again in a moment.`); | |
| 220 | return false; | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | /** | |
| 225 | * A card g1t or an agent posted: its title (a link when it goes somewhere), | |
| 226 | * its state, a line of detail, a preview, its facts, and its actions. | |
| 227 | */ | |
| 228 | export function CardBox({ | |
| 229 | card, | |
| 230 | slug, | |
| 231 | code, | |
| 232 | channelId, | |
| 233 | messageId, | |
| 234 | inert = false, | |
| 235 | }: { | |
| 236 | card: MessageCard; | |
| 237 | slug: string; | |
| 238 | /** Whether the viewer uses Code, for where links go. */ | |
| 239 | code: boolean; | |
| 240 | channelId: string; | |
| 241 | messageId: string; | |
| 242 | /** Not yet sent, or gone: drawn, but nothing on it can be pressed. */ | |
| 243 | inert?: boolean; | |
| 244 | }) { | |
| 245 | const href = cardHref(card.href, slug, code); | |
| 246 | const chip = chipOf(card); | |
| 247 | const live = card.kind === "session" && !!chip?.live; | |
| 248 | const actions = shownActions(card); | |
| 249 | const [busy, setBusy] = useState<string | null>(null); | |
| 250 | const [asking, setAsking] = useState<string | null>(null); | |
| 251 | const [confirming, setConfirming] = useState<CardAction | null>(null); | |
| 252 | const triggers = useRef(new Map<string, HTMLButtonElement>()); | |
| 253 | const panelId = useId(); | |
| 254 | const askingAction = actions.find((a) => a.id === asking && a.input) ?? null; | |
| 255 | ||
| 256 | // The card changed under an open field (the session finished, say): the | |
| 257 | // action it was for is gone, so the field goes too. | |
| 258 | useEffect(() => { | |
| 259 | if (asking && !askingAction) setAsking(null); | |
| 260 | }, [asking, askingAction]); | |
| 261 | ||
| 262 | const run = async (action: CardAction, input: string | null = null) => { | |
| 263 | if (busy || inert) return false; | |
| 264 | setBusy(action.id); | |
| 265 | const done = await pressAction(slug, channelId, messageId, action, input); | |
| 266 | setBusy(null); | |
| 267 | return done; | |
| 268 | }; | |
| 269 | ||
| 270 | const press = (action: CardAction) => { | |
| 271 | switch (actionMode(action)) { | |
| 272 | case "input": | |
| 273 | setAsking((now) => (now === action.id ? null : action.id)); | |
| 274 | return; | |
| 275 | case "confirm": | |
| 276 | setConfirming(action); | |
| 277 | return; | |
| 278 | case "run": | |
| 279 | void run(action); | |
| 280 | return; | |
| 281 | } | |
| 282 | }; | |
| 283 | ||
| 284 | const closeField = (refocus: string | null) => { | |
| 285 | setAsking(null); | |
| 286 | if (refocus) requestAnimationFrame(() => triggers.current.get(refocus)?.focus()); | |
| 287 | }; | |
| 288 | ||
| 289 | const head = ( | |
| 290 | <> | |
| 291 | <span | |
| 292 | className={cn( | |
| 293 | "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors", | |
| 294 | live ? "bg-accent/10 text-accent" : "bg-raised text-muted group-hover/card:text-fg", | |
| 295 | )} | |
| 296 | > | |
| 297 | {CARD_ICONS[card.kind] ?? <Sparkles size={16} />} | |
| 298 | </span> | |
| 299 | <span className="min-w-0 grow"> | |
| 300 | <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")}> | |
| 301 | {card.title} | |
| 302 | </span> | |
| 303 | {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted tabular-nums">{card.detail}</span>} | |
| 304 | </span> | |
| 305 | {card.state && chip && <StateChip state={card.state} chip={chip} />} | |
| 306 | </> | |
| 307 | ); | |
| 308 | ||
| 309 | const repository = card.fields?.find((f) => f.label.toLowerCase() === "repository")?.value; | |
| 310 | ||
| 311 | return ( | |
| 312 | <div | |
| 313 | className={cn( | |
| 314 | "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", | |
| 315 | "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", | |
| 316 | live ? "border-accent/25" : "border-line", | |
| 317 | )} | |
| 318 | > | |
| 319 | {href ? ( | |
| 320 | <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"> | |
| 321 | {head} | |
| 322 | </Link> | |
| 323 | ) : ( | |
| 324 | <div className="flex items-start gap-3">{head}</div> | |
| 325 | )} | |
| 326 | ||
| 327 | {card.body && <CardBody body={card.body} repo={repository} />} | |
| 328 | ||
| 329 | {card.fields && card.fields.length > 0 && ( | |
| 330 | <dl className="mt-2.5 grid grid-cols-2 gap-x-4 gap-y-2 sm:pl-11"> | |
| 331 | {card.fields.map((field) => ( | |
| 332 | <div key={field.label} className="min-w-0"> | |
| 333 | <dt className="text-[0.6875rem] font-medium text-faint">{field.label}</dt> | |
| 334 | <dd className="line-clamp-2 text-xs break-words text-fg-soft"> | |
| 335 | {field.value} | |
| 336 | </dd> | |
| 337 | </div> | |
| 338 | ))} | |
| 339 | </dl> | |
| 340 | )} | |
| 341 | ||
| 342 | {actions.length > 0 && ( | |
| 343 | <div | |
| 344 | role="group" | |
| 345 | aria-label={`${card.title}: actions`} | |
| 346 | className="mt-3 flex flex-wrap items-center gap-2 sm:pl-11" | |
| 347 | aria-busy={busy ? true : undefined} | |
| 348 | // A press here is for the button, not the message's long-press sheet. | |
| 349 | onTouchStart={(event) => event.stopPropagation()} | |
| 350 | > | |
| 351 | {actions.map((action) => { | |
| 352 | const target = actionMode(action) === "link" ? cardHref(action.href, slug, code) : null; | |
| 353 | if (target) { | |
| 354 | return ( | |
| 355 | <Link key={action.id} to={target} className={actionClass(action)}> | |
| 356 | {action.label} | |
| 357 | <ArrowUpRight size={14} aria-hidden="true" className="-mr-0.5 text-faint" /> | |
| 358 | </Link> | |
| 359 | ); | |
| 360 | } | |
| 361 | const working = busy === action.id; | |
| 362 | const opens = !!action.input; | |
| 363 | return ( | |
| 364 | <button | |
| 365 | key={action.id} | |
| 366 | ref={(element) => { | |
| 367 | if (element) triggers.current.set(action.id, element); | |
| 368 | else triggers.current.delete(action.id); | |
| 369 | }} | |
| 370 | type="button" | |
| 371 | disabled={inert || !!busy} | |
| 372 | aria-busy={working || undefined} | |
| 373 | aria-expanded={opens ? asking === action.id : undefined} | |
| 374 | aria-controls={opens && asking === action.id ? panelId : undefined} | |
| 375 | aria-haspopup={action.confirm && !opens ? "dialog" : undefined} | |
| 376 | onClick={() => press(action)} | |
| 377 | className={actionClass(action, opens && asking === action.id)} | |
| 378 | > | |
| 379 | {working && <LoaderCircle size={14} aria-hidden="true" className="-ml-0.5 animate-spin" />} | |
| 380 | {action.label} | |
| 381 | </button> | |
| 382 | ); | |
| 383 | })} | |
| 384 | </div> | |
| 385 | )} | |
| 386 | ||
| 387 | {askingAction && ( | |
| 388 | <ActionField | |
| 389 | key={askingAction.id} | |
| 390 | id={panelId} | |
| 391 | action={askingAction} | |
| 392 | busy={busy === askingAction.id} | |
| 393 | disabled={inert || !!busy} | |
| 394 | onCancel={() => closeField(askingAction.id)} | |
| 395 | onSubmit={async (value) => { | |
| 396 | if (await run(askingAction, value)) closeField(null); | |
| 397 | }} | |
| 398 | /> | |
| 399 | )} | |
| 400 | ||
| 401 | <AlertDialog open={!!confirming} onOpenChange={(open) => !open && setConfirming(null)}> | |
| 402 | <AlertDialogContent> | |
| 403 | <AlertDialogHeader> | |
| 404 | <AlertDialogTitle>{confirming?.label}</AlertDialogTitle> | |
| 405 | <AlertDialogDescription>{confirming?.confirm}</AlertDialogDescription> | |
| 406 | </AlertDialogHeader> | |
| 407 | <AlertDialogFooter> | |
| 408 | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 409 | <AlertDialogAction | |
| 410 | className={confirming?.style === "danger" ? undefined : "bg-accent text-bg hover:bg-accent-hover"} | |
| 411 | onClick={() => { | |
| 412 | const action = confirming; | |
| 413 | setConfirming(null); | |
| 414 | if (action) void run(action); | |
| 415 | }} | |
| 416 | > | |
| 417 | {confirming?.label} | |
| 418 | </AlertDialogAction> | |
| 419 | </AlertDialogFooter> | |
| 420 | </AlertDialogContent> | |
| 421 | </AlertDialog> | |
| 422 | </div> | |
| 423 | ); | |
| 424 | } | |
| 425 | ||
| 426 | /** A card's Markdown preview: about eight lines, then "Show more". */ | |
| 427 | function CardBody({ body, repo }: { body: string; repo?: string }) { | |
| 428 | const [open, setOpen] = useState(false); | |
| 429 | // Folded by its length at first (the same on the server), then by whether it really overflows. | |
| 430 | const [folds, setFolds] = useState(() => foldsBody(body)); | |
| 431 | const box = useRef<HTMLDivElement>(null); | |
| 432 | const id = useId(); | |
| 433 | useEffect(() => { | |
| 434 | const element = box.current; | |
| 435 | if (!element || open) return; | |
| 436 | const measure = () => setFolds(element.scrollHeight > element.clientHeight + 2); | |
| 437 | measure(); | |
| 438 | const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); | |
| 439 | observer?.observe(element); | |
| 440 | return () => observer?.disconnect(); | |
| 441 | }, [body, open]); | |
| 442 | const folded = folds && !open; | |
| 443 | return ( | |
| 444 | <div className="mt-2.5 sm:pl-11"> | |
| 445 | <div | |
| 446 | ref={box} | |
| 447 | id={id} | |
| 448 | 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")} | |
| 449 | > | |
| 450 | <Markdown source={body} repo={repoAt(repo)} /> | |
| 451 | {folded && ( | |
| 452 | <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" /> | |
| 453 | )} | |
| 454 | </div> | |
| 455 | {(folded || open) && ( | |
| 456 | <button | |
| 457 | type="button" | |
| 458 | aria-expanded={open} | |
| 459 | aria-controls={id} | |
| 460 | onClick={() => setOpen((now) => !now)} | |
| 461 | className="mt-1 rounded px-1 py-0.5 text-xs font-medium text-accent transition-colors outline-none hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent/50 max-md:min-h-9" | |
| 462 | > | |
| 463 | {open ? "Show less" : "Show more"} | |
| 464 | </button> | |
| 465 | )} | |
| 466 | </div> | |
| 467 | ); | |
| 468 | } | |
| 469 | ||
| 470 | /** | |
| 471 | * The value an action needs, asked right under the card: an amount with a | |
| 472 | * "$" before it and the suggestion filled in, or a line of text that grows | |
| 473 | * (Enter sends, Shift+Enter is a new line). Escape puts it away. | |
| 474 | */ | |
| 475 | function ActionField({ | |
| 476 | id, | |
| 477 | action, | |
| 478 | busy, | |
| 479 | disabled, | |
| 480 | onSubmit, | |
| 481 | onCancel, | |
| 482 | }: { | |
| 483 | id: string; | |
| 484 | action: CardAction; | |
| 485 | busy: boolean; | |
| 486 | disabled: boolean; | |
| 487 | onSubmit: (value: string) => void; | |
| 488 | onCancel: () => void; | |
| 489 | }) { | |
| 490 | const input = action.input!; | |
| 491 | const money = input.kind === "money"; | |
| 492 | const [value, setValue] = useState(() => (money ? moneyInitial(input.initial) : (input.initial ?? ""))); | |
| 493 | const [tried, setTried] = useState(false); | |
| 494 | const field = useRef<HTMLInputElement & HTMLTextAreaElement>(null); | |
| 495 | const labelId = useId(); | |
| 496 | const errorId = useId(); | |
| 497 | const ready = inputValue(action, value); | |
| 498 | const invalid = tried && !ready; | |
| 499 | ||
| 500 | useEffect(() => { | |
| 501 | const element = field.current; | |
| 502 | if (!element) return; | |
| 503 | element.focus(); | |
| 504 | if (money) element.select(); | |
| 505 | }, [money]); | |
| 506 | ||
| 507 | // A line of text grows with what is written, up to about six lines. | |
| 508 | useEffect(() => { | |
| 509 | const element = field.current; | |
| 510 | if (money || !element) return; | |
| 511 | element.style.height = "auto"; | |
| 512 | element.style.height = `${Math.min(element.scrollHeight, 160)}px`; | |
| 513 | }, [value, money]); | |
| 514 | ||
| 515 | const submit = (event?: FormEvent) => { | |
| 516 | event?.preventDefault(); | |
| 517 | if (disabled) return; | |
| 518 | if (!ready) { | |
| 519 | setTried(true); | |
| 520 | field.current?.focus(); | |
| 521 | return; | |
| 522 | } | |
| 523 | onSubmit(ready); | |
| 524 | }; | |
| 525 | ||
| 526 | const onKeyDown = (event: KeyboardEvent) => { | |
| 527 | if (event.key === "Escape") { | |
| 528 | event.preventDefault(); | |
| 529 | event.stopPropagation(); | |
| 530 | onCancel(); | |
| 531 | return; | |
| 532 | } | |
| 533 | if (!money && event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { | |
| 534 | event.preventDefault(); | |
| 535 | submit(); | |
| 536 | } | |
| 537 | }; | |
| 538 | ||
| 539 | return ( | |
| 540 | <form | |
| 541 | id={id} | |
| 542 | onSubmit={submit} | |
| 543 | onTouchStart={(event) => event.stopPropagation()} | |
| 544 | className="mt-3 animate-pop-in sm:pl-11 motion-reduce:animate-none" | |
| 545 | aria-labelledby={labelId} | |
| 546 | > | |
| 547 | <label id={labelId} htmlFor={`${id}-field`} className="mb-1.5 block text-xs font-medium text-muted"> | |
| 548 | {input.label || action.label} | |
| 549 | </label> | |
| 550 | <div className="flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 551 | {money ? ( | |
| 552 | <InputGroup className={cn("sm:max-w-44 max-md:h-10", invalid && "border-danger/70")}> | |
| 553 | <InputAddon className="pr-0 font-mono text-muted">$</InputAddon> | |
| 554 | <input | |
| 555 | ref={field} | |
| 556 | id={`${id}-field`} | |
| 557 | value={value} | |
| 558 | onChange={(event) => { | |
| 559 | setValue(event.target.value); | |
| 560 | setTried(false); | |
| 561 | }} | |
| 562 | onKeyDown={onKeyDown} | |
| 563 | inputMode="decimal" | |
| 564 | autoComplete="off" | |
| 565 | data-1p-ignore | |
| 566 | placeholder={input.placeholder ?? "0.00"} | |
| 567 | aria-invalid={invalid || undefined} | |
| 568 | aria-describedby={invalid ? errorId : undefined} | |
| 569 | className="w-full min-w-0 bg-transparent pr-3 pl-1 font-mono text-sm text-fg tabular-nums outline-none placeholder:text-faint" | |
| 570 | /> | |
| 571 | </InputGroup> | |
| 572 | ) : ( | |
| 573 | <textarea | |
| 574 | ref={field} | |
| 575 | id={`${id}-field`} | |
| 576 | rows={1} | |
| 577 | value={value} | |
| 578 | onChange={(event) => { | |
| 579 | setValue(event.target.value); | |
| 580 | setTried(false); | |
| 581 | }} | |
| 582 | onKeyDown={onKeyDown} | |
| 583 | autoComplete="off" | |
| 584 | data-1p-ignore | |
| 585 | placeholder={input.placeholder ?? undefined} | |
| 586 | aria-invalid={invalid || undefined} | |
| 587 | aria-describedby={invalid ? errorId : undefined} | |
| 588 | className="min-h-9 w-full min-w-0 grow resize-none rounded-md border border-line bg-bg px-3 py-[0.4375rem] text-sm leading-5 text-fg outline-none transition-[border-color,box-shadow] placeholder:text-faint hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 aria-invalid:border-danger/70 max-md:min-h-10 max-md:text-base" | |
| 589 | /> | |
| 590 | )} | |
| 591 | <div className="flex shrink-0 gap-2 max-sm:[&>*]:grow"> | |
| 592 | <button type="button" onClick={onCancel} className={actionClass({ id: "cancel", label: "Cancel" })}> | |
| 593 | Cancel | |
| 594 | </button> | |
| 595 | <button type="submit" disabled={disabled} aria-busy={busy || undefined} className={actionClass({ ...action, style: action.style === "danger" ? "danger" : "primary" })}> | |
| 596 | {busy && <LoaderCircle size={14} aria-hidden="true" className="-ml-0.5 animate-spin" />} | |
| 597 | {action.label} | |
| 598 | </button> | |
| 599 | </div> | |
| 600 | </div> | |
| 601 | {invalid ? ( | |
| 602 | <p id={errorId} className="mt-1.5 text-xs text-danger"> | |
| 603 | {money ? "Enter an amount in dollars, like 5.00." : "Write something first."} | |
| 604 | </p> | |
| 605 | ) : ( | |
| 606 | !money && <p className="mt-1.5 text-[0.6875rem] text-faint max-md:hidden">Enter to send, Shift+Enter for a new line, Esc to close</p> | |
| 607 | )} | |
| 608 | </form> | |
| 609 | ); | |
| 610 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.