Skip to content
610 linesCodeBlameRaw
1import {
2 ArrowUpRight,
3 Bot,
4 CircleAlert,
5 CircleCheck,
6 CircleDot,
7 CircleDotDashed,
8 Forward,
9 GitPullRequest,
10 ListChecks,
11 LoaderCircle,
12 Rocket,
13 ShieldCheck,
14 Sparkles,
15 X,
16} from "lucide-react";
17import {
18 type FormEvent,
19 type KeyboardEvent,
20 type ReactNode,
21 useEffect,
22 useId,
23 useRef,
24 useState,
25 useSyncExternalStore,
26} from "react";
27import { Link } from "react-router";
28
29import type { CardAction, CardActionResult, MessageCard, Result } from "@g1t/contracts";
30
31import { Markdown } from "../markdown";
32import {
33 AlertDialog,
34 AlertDialogAction,
35 AlertDialogCancel,
36 AlertDialogContent,
37 AlertDialogDescription,
38 AlertDialogFooter,
39 AlertDialogHeader,
40 AlertDialogTitle,
41} from "../ui/alert-dialog";
42import { Badge, type BadgeTone } from "../ui/badge";
43import { InputAddon, InputGroup } from "../ui/input";
44import { cn } from "../../lib/cn";
45import { type CardChip, actionMode, cardChip, foldsBody, inputValue, moneyInitial, shownActions } from "../../lib/card-actions";
46import { repoAt } from "../../lib/markdown-plugins";
47import { sessionChip } from "../../lib/session-card";
48import { 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
57const 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} />,
65 // Where an agent handed the work: the group message it went to.
66 handoff: <Forward size={16} />,
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. */
70export 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. */
77function 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
87function 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 */
98function 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
114type CardToast = { id: number; ok: boolean; message: string };
115
116const TOAST_MS = 4_500;
117const NO_TOASTS: CardToast[] = [];
118let toasts: CardToast[] = NO_TOASTS;
119let toastSeq = 0;
120const toastListeners = new Set<() => void>();
121
122function setToasts(next: CardToast[]) {
123 toasts = next;
124 for (const listener of toastListeners) listener();
125}
126
127function dismissToast(id: number) {
128 setToasts(toasts.filter((t) => t.id !== id));
129}
130
131/** Says something in the conversation's toasts: what a card's action did, or that a link was copied. */
132export function showToast(ok: boolean, message: string) {
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
139function 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 */
149export 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
190const 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
193const 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
199function 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. */
204async 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 */
228export 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". */
427function 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 */
475function 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}