flagon-io/g1t

public

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

g1t/apps/web/app/routes/repo/sessions.tsx

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