pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/components/activity.tsx

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