Skip to content
83 linesCodeBlameRaw

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 calendar1/**
2 * Cards people can act on in chat (docs/WORKSPACE.md, "Cards"): how a
3 * card's state reads, which of its actions are links and which run, and
4 * the money and text people type into them. Pure, so it is tested on its
5 * own; components/chat/card.tsx draws them.
6 */
7import type { CardAction, MessageCard } from "@g1t/contracts";
8
9import type { SessionChip } from "./session-card";
10
11/** A card's state chip, as a session's is, outlined only when it is waiting quietly. */
12export type CardChip = { tone: SessionChip["tone"] | "merged"; live: boolean; outline?: boolean };
13
14const DRAFT_CHIPS: Record<string, CardChip> = {
15 // Waiting on someone to file it: lavender, outlined only.
16 draft: { tone: "accent", live: false, outline: true },
17 filed: { tone: "success", live: false },
18 discarded: { tone: "neutral", live: false },
19};
20
21/**
22 * How a draft issue's state reads; null for other kinds (a session's chip
23 * is lib/session-card.ts's, others are guessed from their words).
24 */
25export function cardChip(card: Pick<MessageCard, "kind" | "state">): CardChip | null {
26 if (!card.state) return null;
27 if (card.kind === "draft_issue") return DRAFT_CHIPS[card.state.trim().toLowerCase()] ?? { tone: "neutral", live: false };
28 return null;
29}
30
31/** What pressing an action does: opens a place, asks first, asks for a value first, or just runs. */
32export type ActionMode = "link" | "confirm" | "input" | "run";
33
34export function actionMode(action: CardAction): ActionMode {
35 if (action.href) return "link";
36 if (action.input) return "input";
37 if (action.confirm) return "confirm";
38 return "run";
39}
40
41/**
42 * The actions a card shows: links always; the rest only when the card has
43 * an owner to answer them (chat drops them otherwise, this is the same rule
44 * for a card that never went through it).
45 */
46export function shownActions(card: Pick<MessageCard, "actions" | "owner">): CardAction[] {
47 return (card.actions ?? []).filter((a) => !!a.href || !!card.owner);
48}
49
50/** Dollars as people type them ("5", "$12.5", "1,200") as "12.50", or null when it is not an amount. */
51export function moneyValue(input: string | null | undefined): string | null {
52 const text = String(input ?? "")
53 .trim()
54 .replace(/^\$\s*/, "")
55 .replace(/,/g, "");
56 if (!/^\d+(\.\d{0,2})?$|^\.\d{1,2}$/.test(text)) return null;
57 const amount = Number(text);
58 if (!Number.isFinite(amount) || amount <= 0) return null;
59 return amount.toFixed(2);
60}
61
62/** What a money field starts with: the suggestion as an amount, or empty. */
63export function moneyInitial(initial: string | null | undefined): string {
64 return moneyValue(initial) ?? "";
65}
66
67/** What is typed into a text action, trimmed, or null when there is nothing. */
68export function textValue(input: string | null | undefined): string | null {
69 const text = String(input ?? "").trim();
70 return text ? text : null;
71}
72
73/** The value an input action sends, or null when it is not ready to send. */
74export function inputValue(action: CardAction, typed: string): string | null {
75 if (!action.input) return null;
76 return action.input.kind === "money" ? moneyValue(typed) : textValue(typed);
77}
78
79/** Whether a body is long enough that its preview starts folded (over about eight lines). */
80export function foldsBody(body: string, lines = 8): boolean {
81 const rows = body.split("\n").reduce((n, line) => n + Math.max(1, Math.ceil(line.length / 80)), 0);
82 return rows > lines;
83}