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 | /** |
| 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 | */ | |
| 7 | import type { CardAction, MessageCard } from "@g1t/contracts"; | |
| 8 | ||
| 9 | import type { SessionChip } from "./session-card"; | |
| 10 | ||
| 11 | /** A card's state chip, as a session's is, outlined only when it is waiting quietly. */ | |
| 12 | export type CardChip = { tone: SessionChip["tone"] | "merged"; live: boolean; outline?: boolean }; | |
| 13 | ||
| 14 | const 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 | */ | |
| 25 | export 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. */ | |
| 32 | export type ActionMode = "link" | "confirm" | "input" | "run"; | |
| 33 | ||
| 34 | export 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 | */ | |
| 46 | export 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. */ | |
| 51 | export 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. */ | |
| 63 | export 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. */ | |
| 68 | export 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. */ | |
| 74 | export 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). */ | |
| 80 | export 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 | } |