g1t/apps/web/app/components/activity.tsx
| 1 | import { |
| 2 | CircleCheck, |
| 3 | CircleDot, |
| 4 | CircleSlash, |
| 5 | GitMerge, |
| 6 | GitPullRequest, |
| 7 | MessageCircleQuestion, |
| 8 | MessageSquare, |
| 9 | Play, |
| 10 | Terminal, |
| 11 | } from "lucide-react"; |
| 12 | import type { ReactNode } from "react"; |
| 13 | import { Link } from "react-router"; |
| 14 | |
| 15 | import type { AgentMessage, G1tEvent } from "@g1t/contracts"; |
| 16 | |
| 17 | import { Avatar, TimeAgo } from "./ui"; |
| 18 | |
| 19 | /** The accounts that are g1t's own agents and machinery. */ |
| 20 | const AGENTS = new Set(["g1t-agent", "g1t"]); |
| 21 | |
| 22 | type Line = { icon: ReactNode; tone: string; actor: string | null; text: ReactNode; coordination?: boolean }; |
| 23 | |
| 24 | /** One event as a sentence, or null for those not worth a line. */ |
| 25 | function line(event: G1tEvent, base: string): Line | null { |
| 26 | const ref = (number: number) => ( |
| 27 | <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline"> |
| 28 | #{number} |
| 29 | </Link> |
| 30 | ); |
| 31 | const actor = event.actor; |
| 32 | switch (event.type) { |
| 33 | case "issue.opened": |
| 34 | return { |
| 35 | icon: <CircleDot size={14} />, |
| 36 | tone: actor && AGENTS.has(actor) ? "text-merged" : "text-muted", |
| 37 | actor, |
| 38 | text: actor && AGENTS.has(actor) ? <>filed {ref(event.data.number)} for something it found</> : <>opened {ref(event.data.number)}</>, |
| 39 | coordination: actor != null && AGENTS.has(actor), |
| 40 | }; |
| 41 | case "pull.opened": |
| 42 | return { |
| 43 | icon: <Play size={14} />, |
| 44 | tone: "text-merged", |
| 45 | actor: event.data.agent, |
| 46 | text: ( |
| 47 | <> |
| 48 | started on {event.data.issue != null ? ref(event.data.issue) : "a change"} in {ref(event.data.number)} |
| 49 | </> |
| 50 | ), |
| 51 | }; |
| 52 | case "pull.ready": |
| 53 | return { icon: <GitPullRequest size={14} />, tone: "text-info", actor, text: <>marked {ref(event.data.number)} ready for review</> }; |
| 54 | case "checks.completed": |
| 55 | return { |
| 56 | icon: <Terminal size={14} />, |
| 57 | tone: event.data.status === "passed" ? "text-accent" : "text-danger", |
| 58 | actor: null, |
| 59 | text: ( |
| 60 | <> |
| 61 | checks {event.data.status} on {ref(event.data.number)} |
| 62 | </> |
| 63 | ), |
| 64 | }; |
| 65 | case "review.completed": |
| 66 | return { |
| 67 | icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />, |
| 68 | tone: event.data.verdict === "approve" ? "text-accent" : "text-warn", |
| 69 | actor: "g1t-agent", |
| 70 | text: ( |
| 71 | <> |
| 72 | {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)} |
| 73 | </> |
| 74 | ), |
| 75 | }; |
| 76 | case "comment.created": |
| 77 | if (event.data.verdict) { |
| 78 | return { |
| 79 | icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />, |
| 80 | tone: event.data.verdict === "approve" ? "text-accent" : "text-warn", |
| 81 | actor, |
| 82 | text: ( |
| 83 | <> |
| 84 | {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)} |
| 85 | </> |
| 86 | ), |
| 87 | }; |
| 88 | } |
| 89 | return { |
| 90 | icon: <MessageSquare size={14} />, |
| 91 | tone: actor && AGENTS.has(actor) ? "text-merged" : "text-muted", |
| 92 | actor, |
| 93 | text: <>commented on {ref(event.data.number)}</>, |
| 94 | coordination: actor != null && AGENTS.has(actor), |
| 95 | }; |
| 96 | case "pull.merged": |
| 97 | return { icon: <GitMerge size={14} />, tone: "text-accent", actor, text: <>landed {ref(event.data.number)} on main</> }; |
| 98 | case "agent.asked": |
| 99 | return { icon: <MessageCircleQuestion size={14} />, tone: "text-merged", actor, text: <>asked the agent on {ref(event.data.number)}, and g1t woke it to answer</> }; |
| 100 | case "pull.merge_requested": |
| 101 | return { icon: <GitMerge size={14} />, tone: "text-muted", actor, text: <>is bringing {ref(event.data.number)} up to date</> }; |
| 102 | default: |
| 103 | return null; |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * What happened across an outcome's issues and pull requests, newest |
| 109 | * first. Agents talking to each other through the forge stand out. |
| 110 | */ |
| 111 | export function Activity({ events, base }: { events: G1tEvent[]; base: string }) { |
| 112 | const lines = events.map((event) => ({ event, line: line(event, base) })).filter((item) => item.line != null); |
| 113 | if (lines.length === 0) return null; |
| 114 | return ( |
| 115 | <ol className="space-y-0.5"> |
| 116 | {lines.map(({ event, line }) => ( |
| 117 | <li |
| 118 | key={event.id} |
| 119 | className={`flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm ${ |
| 120 | line!.coordination ? "bg-merged/[0.07] ring-1 ring-merged/25" : "" |
| 121 | }`} |
| 122 | > |
| 123 | <span className={`shrink-0 ${line!.tone}`}>{line!.icon}</span> |
| 124 | {line!.actor && ( |
| 125 | <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-fg-soft"> |
| 126 | <Avatar name={line!.actor} size={14} /> |
| 127 | {line!.actor} |
| 128 | </span> |
| 129 | )} |
| 130 | <span className="min-w-0 grow truncate text-muted">{line!.text}</span> |
| 131 | <span className="shrink-0 text-xs text-faint"> |
| 132 | <TimeAgo at={event.time} /> |
| 133 | </span> |
| 134 | </li> |
| 135 | ))} |
| 136 | </ol> |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * Questions and handoffs between the agents on an outcome's pull requests, |
| 142 | * each with where it stands: asked, read, answered or declined. |
| 143 | */ |
| 144 | export function Exchanges({ exchanges, base }: { exchanges: AgentMessage[]; base: string }) { |
| 145 | if (exchanges.length === 0) return null; |
| 146 | const pull = (number: number) => ( |
| 147 | <Link to={`${base}/pull/${number}`} prefetch="intent" className="font-mono text-fg hover:underline"> |
| 148 | #{number} |
| 149 | </Link> |
| 150 | ); |
| 151 | return ( |
| 152 | <ul className="space-y-3"> |
| 153 | {exchanges.map((exchange) => { |
| 154 | const state = exchange.answer |
| 155 | ? exchange.declined |
| 156 | ? { label: "Declined", tone: "text-warn ring-warn/40" } |
| 157 | : { label: exchange.kind === "handoff" ? "Taken on" : "Answered", tone: "text-accent ring-accent/40" } |
| 158 | : exchange.deliveredAt |
| 159 | ? { label: "Read", tone: "text-info ring-info/40" } |
| 160 | : { label: "Waiting to be read", tone: "text-faint ring-line" }; |
| 161 | return ( |
| 162 | <li key={exchange.id} className="rounded-2xl bg-merged/[0.05] p-4 ring-1 ring-merged/25"> |
| 163 | <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted"> |
| 164 | <Avatar name="g1t-agent" size={14} /> |
| 165 | <span> |
| 166 | The agent on {exchange.fromNumber != null ? pull(exchange.fromNumber) : exchange.author}{" "} |
| 167 | {exchange.kind === "handoff" ? "handed work to" : "asked"} the agent on {pull(exchange.toNumber)} |
| 168 | </span> |
| 169 | <span className={`ml-auto rounded-full px-2 py-0.5 ring-1 ${state.tone}`}>{state.label}</span> |
| 170 | </p> |
| 171 | <p className="mt-2 text-sm">{exchange.body}</p> |
| 172 | {exchange.answer && ( |
| 173 | <p className="mt-2 border-l-2 border-merged/40 pl-3 text-sm text-fg-soft">{exchange.answer}</p> |
| 174 | )} |
| 175 | </li> |
| 176 | ); |
| 177 | })} |
| 178 | </ul> |
| 179 | ); |
| 180 | } |