Skip to content
195 linesCodeBlameRaw
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/** Whether an actor is g1t itself: its agent or its machinery. */
20const isG1t = (actor: string | null): actor is string => actor === "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: isG1t(actor) ? "text-accent" : "text-muted",
37 actor,
38 text: isG1t(actor) ? <>filed {ref(event.data.number)} for something it found</> : <>opened {ref(event.data.number)}</>,
39 coordination: isG1t(actor),
40 };
41 case "pull.opened":
42 return {
43 icon: <Play size={14} />,
44 tone: "text-accent",
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-success" : "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-success" : "text-warn",
69 actor: "g1t",
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 // One of the workspace's agents, as itself: named, never the person it acted for.
78 if (event.data.agent) {
79 const said = event.data.verdict === "approve" ? "approved" : event.data.verdict === "request_changes" ? "asked for changes on" : event.data.advisory ? "reviewed" : "commented on";
80 return {
81 icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : event.data.verdict ? <CircleSlash size={14} /> : <MessageSquare size={14} />,
82 tone: "text-muted",
83 actor: `${event.data.agent.displayName} (agent)`,
84 text: (
85 <>
86 {said} {ref(event.data.number)}
87 {event.data.advisory && " (advisory)"}
88 </>
89 ),
90 };
91 }
92 if (event.data.verdict) {
93 return {
94 icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />,
95 tone: event.data.verdict === "approve" ? "text-success" : "text-warn",
96 actor,
97 text: (
98 <>
99 {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)}
100 </>
101 ),
102 };
103 }
104 return {
105 icon: <MessageSquare size={14} />,
106 tone: isG1t(actor) ? "text-accent" : "text-muted",
107 actor,
108 text: <>commented on {ref(event.data.number)}</>,
109 coordination: isG1t(actor),
110 };
111 case "pull.merged":
112 return { icon: <GitMerge size={14} />, tone: "text-accent", actor, text: <>landed {ref(event.data.number)} on main</> };
113 case "agent.asked":
114 return { icon: <MessageCircleQuestion size={14} />, tone: "text-accent", actor, text: <>asked the agent on {ref(event.data.number)}, and g1t woke it to answer</> };
115 case "pull.merge_requested":
116 return { icon: <GitMerge size={14} />, tone: "text-muted", actor, text: <>is bringing {ref(event.data.number)} up to date</> };
117 default:
118 return null;
119 }
120}
121
122/**
123 * What happened across an outcome's issues and pull requests, newest
124 * first. Agents talking to each other through the forge stand out.
125 */
126export function Activity({ events, base }: { events: G1tEvent[]; base: string }) {
127 const lines = events.map((event) => ({ event, line: line(event, base) })).filter((item) => item.line != null);
128 if (lines.length === 0) return null;
129 return (
130 <ol className="space-y-0.5">
131 {lines.map(({ event, line }) => (
132 <li
133 key={event.id}
134 className={`flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm ${
135 line!.coordination ? "bg-accent/[0.07] ring-1 ring-accent/25" : ""
136 }`}
137 >
138 <span className={`shrink-0 ${line!.tone}`}>{line!.icon}</span>
139 {line!.actor && (
140 <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-fg-soft">
141 <Avatar name={line!.actor} size={14} />
142 {line!.actor}
143 </span>
144 )}
145 <span className="min-w-0 grow truncate text-muted">{line!.text}</span>
146 <span className="shrink-0 text-xs text-faint">
147 <TimeAgo at={event.time} />
148 </span>
149 </li>
150 ))}
151 </ol>
152 );
153}
154
155/**
156 * Questions and handoffs between the agents on an outcome's pull requests,
157 * each with where it stands: asked, read, answered or declined.
158 */
159export function Exchanges({ exchanges, base }: { exchanges: AgentMessage[]; base: string }) {
160 if (exchanges.length === 0) return null;
161 const pull = (number: number) => (
162 <Link to={`${base}/pull/${number}`} prefetch="intent" className="font-mono text-fg hover:underline">
163 #{number}
164 </Link>
165 );
166 return (
167 <ul className="space-y-3">
168 {exchanges.map((exchange) => {
169 const state = exchange.answer
170 ? exchange.declined
171 ? { label: "Declined", tone: "text-warn ring-warn/40" }
172 : { label: exchange.kind === "handoff" ? "Taken on" : "Answered", tone: "text-success ring-success/40" }
173 : exchange.deliveredAt
174 ? { label: "Read", tone: "text-info ring-info/40" }
175 : { label: "Waiting to be read", tone: "text-faint ring-line" };
176 return (
177 <li key={exchange.id} className="rounded-2xl bg-accent/[0.05] p-4 ring-1 ring-accent/25">
178 <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
179 <Avatar name="g1t" size={14} />
180 <span>
181 The agent on {exchange.fromNumber != null ? pull(exchange.fromNumber) : exchange.author}{" "}
182 {exchange.kind === "handoff" ? "handed work to" : "asked"} the agent on {pull(exchange.toNumber)}
183 </span>
184 <span className={`ml-auto rounded-full px-2 py-0.5 ring-1 ${state.tone}`}>{state.label}</span>
185 </p>
186 <p className="mt-2 text-sm">{exchange.body}</p>
187 {exchange.answer && (
188 <p className="mt-2 border-l-2 border-accent/40 pl-3 text-sm text-fg-soft">{exchange.answer}</p>
189 )}
190 </li>
191 );
192 })}
193 </ul>
194 );
195}