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

180 lines6,942 bytesCodeBlame
1import {
2 CircleCheck,
3 CircleDot,
4 CircleSlash,
5 GitMerge,
6 GitPullRequest,
7 MessageCircleQuestion,
8 MessageSquare,
9 Play,
10 Terminal,
11} from "lucide-react";
12import type { ReactNode } from "react";
13import { Link } from "react-router";
14
15import type { AgentMessage, G1tEvent } from "@g1t/contracts";
16
17import { Avatar, TimeAgo } from "./ui";
18
19/** The accounts that are g1t's own agents and machinery. */
20const AGENTS = new Set(["g1t-agent", "g1t"]);
21
22type 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. */
25function 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 */
111export 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 */
144export 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}