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.

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