Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

173 linesCodeBlameRaw
1import { History, Loader2 } from "lucide-react";
2import { Form, Link, useSubmit } from "react-router";
3
4import { type PullStatus, RUN_KINDS, RUN_KIND_LABEL, type RunKind } from "@g1t/contracts";
5
6import type { Route } from "./+types/sessions";
7import { page } from "../../lib/meta";
8import { KindLabel, formatCost, useLiveRefresh } from "../../components/agents";
9import { EmptyState, TimeAgo } from "../../components/ui";
10import { Avatar } from "../../components/ui/avatar";
11import { Card } from "../../components/ui/card";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
13import { agents } from "../../lib/services.server";
14import { getViewer, unwrap } from "../../lib/session.server";
15
16const OUTCOMES: Record<PullStatus, string> = {
17 draft: "In progress",
18 open: "Ready for review",
19 merged: "Merged",
20 closed: "Closed",
21};
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `Sessions · ${params.owner}/${params.repo} · g1t` });
25}
26
27export async function loader({ params, context, request }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 const query = new URL(request.url).searchParams;
30 const kind = query.get("kind") as RunKind | null;
31 const outcome = query.get("outcome") as PullStatus | null;
32 const number = Number(String(query.get("pr") ?? "").replace("#", ""));
33 const filter = {
34 kind: kind && RUN_KINDS.includes(kind) ? kind : undefined,
35 outcome: outcome && outcome in OUTCOMES ? outcome : undefined,
36 number: number > 0 ? number : undefined,
37 };
38 const sessions = await agents.listSessions(viewer, { namespace: params.owner, name: params.repo }, filter);
39 return { sessions: unwrap(sessions), filter };
40}
41
42export default function Sessions({ loaderData, params }: Route.ComponentProps) {
43 const { sessions, filter } = loaderData;
44 const base = `/${params.owner}/${params.repo}`;
45 const submit = useSubmit();
46 useLiveRefresh(sessions.some((session) => session.active));
47 const filtered = filter.kind || filter.outcome || filter.number;
48 return (
49 <div className="max-w-4xl">
50 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
51 <History size={19} className="text-accent" />
52 Sessions
53 </h2>
54 <p className="mt-1.5 max-w-2xl text-sm text-muted">
55 Every recorded session on this project: the prompt an agent was given, what it said, the
56 tools it ran and what came back, for each pull request. g1t records its own as it
57 works; your own agents record theirs with record_session.
58 </p>
59
60 {/* Each choice applies at once; the number applies on Enter. */}
61 <Form method="get" className="mt-6 flex flex-wrap items-center gap-2">
62 <div className="w-44">
63 <Select
64 name="kind"
65 defaultValue={filter.kind ?? "any"}
66 onValueChange={(value) => submit({ ...current(filter), kind: value === "any" ? "" : value }, { method: "get" })}
67 >
68 <SelectTrigger size="sm">
69 <SelectValue />
70 </SelectTrigger>
71 <SelectContent>
72 <SelectItem value="any">Any kind of run</SelectItem>
73 {RUN_KINDS.map((kind) => (
74 <SelectItem key={kind} value={kind}>
75 {RUN_KIND_LABEL[kind]}
76 </SelectItem>
77 ))}
78 </SelectContent>
79 </Select>
80 </div>
81 <div className="w-44">
82 <Select
83 name="outcome"
84 defaultValue={filter.outcome ?? "any"}
85 onValueChange={(value) => submit({ ...current(filter), outcome: value === "any" ? "" : value }, { method: "get" })}
86 >
87 <SelectTrigger size="sm">
88 <SelectValue />
89 </SelectTrigger>
90 <SelectContent>
91 <SelectItem value="any">Any outcome</SelectItem>
92 {(Object.keys(OUTCOMES) as PullStatus[]).map((status) => (
93 <SelectItem key={status} value={status}>
94 {OUTCOMES[status]}
95 </SelectItem>
96 ))}
97 </SelectContent>
98 </Select>
99 </div>
100 <input
101 name="pr"
102 defaultValue={filter.number ?? ""}
103 placeholder="#"
104 inputMode="numeric"
105 autoComplete="off"
106 data-1p-ignore
107 aria-label="Pull request number"
108 className="h-8 w-24 rounded-md border border-line bg-bg px-2.5 text-[0.8125rem] outline-none placeholder:text-faint focus:border-accent-dim"
109 />
110 {filtered && (
111 <Link to={`${base}/sessions`} className="text-xs text-muted hover:text-fg">
112 Clear
113 </Link>
114 )}
115 </Form>
116
117 {sessions.length === 0 ? (
118 <div className="mt-6">
119 <EmptyState title={filtered ? "No session matches" : "No sessions yet"}>
120 {filtered
121 ? "Try another kind of run or outcome."
122 : "A session is recorded when an agent works on a pull request here."}
123 </EmptyState>
124 </div>
125 ) : (
126 <Card asChild divided className="mt-6">
127 <ul>
128 {sessions.map((session) => (
129 <li key={session.number} className="px-4 py-3.5">
130 <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
131 <Avatar name={session.agent} size={18} />
132 <Link to={`${base}/sessions/${session.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
133 {session.title} <span className="font-normal text-faint">#{session.number}</span>
134 </Link>
135 {session.active && (
136 <span className="inline-flex items-center gap-1 text-xs text-accent">
137 <Loader2 size={11} className="animate-spin" />
138 at work
139 </span>
140 )}
141 <span className="text-xs text-muted">{OUTCOMES[session.status]}</span>
142 </div>
143 {session.prompt && <p className="mt-1.5 line-clamp-1 text-sm text-muted">{session.prompt}</p>}
144 <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted">
145 {session.kinds.map((kind) => (
146 <KindLabel key={kind} kind={kind} />
147 ))}
148 <span>{session.entries} entries</span>
149 <span>{session.tools} tool calls</span>
150 {session.runs > 0 && <span>{session.runs} {session.runs === 1 ? "run" : "runs"}</span>}
151 {formatCost(session.costUsd) && <span>{formatCost(session.costUsd)}</span>}
152 <span className="grow" />
153 <span>
154 <TimeAgo at={session.lastAt} />
155 </span>
156 </div>
157 </li>
158 ))}
159 </ul>
160 </Card>
161 )}
162 </div>
163 );
164}
165
166/** The filters as they stand, for changing one of them. */
167function current(filter: { kind?: string; outcome?: string; number?: number }): Record<string, string> {
168 return {
169 kind: filter.kind ?? "",
170 outcome: filter.outcome ?? "",
171 pr: filter.number ? String(filter.number) : "",
172 };
173}