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, | |
| Workspace agents are told where they are and who is in the conversation every turn, and hand work to a colleague with a hand_off tool instead of an @mention: the brief is posted where the colleague already is, or in a group message of the person who asked, the agent and the colleague with a card linking to it; an agent's messages no longer wake other agents, its mentions of anyone outside the conversation show as plain names, and the agents and chat guides and the workspace plan say how. | 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 | ||
| 50 | // A card g1t or an agent posts in chat (docs/WORKSPACE.md, "Cards"): what | |
| 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} />, | |
| Workspace agents are told where they are and who is in the conversation every turn, and hand work to a colleague with a hand_off tool instead of an @mention: the brief is posted where the colleague already is, or in a group message of the person who asked, the agent and the colleague with a card linking to it; an agent's messages no longer wake other agents, its mentions of anyone outside the conversation show as plain names, and the agents and chat guides and the workspace plan say how. | 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( | |
| 166 | "pointer-events-auto flex w-full animate-pop-in items-start gap-2.5 rounded-xl bg-[#17171b] 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", | |
| 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 | } |