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.

145 linesCodeBlameRaw
1import { ArrowLeft, MessageSquare, StickyNote, Terminal, Wrench } from "lucide-react";
2import { Link, useSearchParams } from "react-router";
3
4import type { SessionEntry } from "@g1t/contracts";
5
6import type { Route } from "./+types/session";
7import { page } from "../../lib/meta";
8import { TimeAgo } from "../../components/ui";
9import { Avatar } from "../../components/ui/avatar";
10import { Button } from "../../components/ui/button";
11import { Card } from "../../components/ui/card";
12import { Hint } from "../../components/ui/hint";
13import { agents } from "../../lib/services.server";
14import { getViewer, unwrap } from "../../lib/session.server";
15
16export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
17 return page(args, {
18 title: `Session of #${params.number}${loaderData ? ` ${loaderData.session.pull.title}` : ""} · ${params.owner}/${params.repo} · g1t`,
19 });
20}
21
22export async function loader({ params, context }: Route.LoaderArgs) {
23 const viewer = getViewer(context);
24 const session = await agents.getSession(viewer, { namespace: params.owner, name: params.repo }, Number(params.number));
25 return { session: unwrap(session) };
26}
27
28function Entry({ entry }: { entry: SessionEntry }) {
29 const at = (
30 <span className="shrink-0 text-xs text-faint">
31 <TimeAgo at={entry.at} />
32 </span>
33 );
34 if (entry.kind === "prompt") {
35 return (
36 <li className="px-4 py-3">
37 <details>
38 <summary className="flex cursor-pointer items-center gap-2 text-sm font-medium">
39 <Terminal size={14} className="text-accent" />
40 <span className="grow">Prompt</span>
41 {at}
42 </summary>
43 <pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-bg p-3 text-xs whitespace-pre-wrap text-muted">{entry.text}</pre>
44 </details>
45 </li>
46 );
47 }
48 if (entry.kind === "message") {
49 return (
50 <li className="flex gap-2.5 px-4 py-3 text-sm">
51 <MessageSquare size={14} className="mt-0.5 shrink-0 text-faint" />
52 <p className="min-w-0 grow whitespace-pre-wrap wrap-break-word">{entry.text}</p>
53 {at}
54 </li>
55 );
56 }
57 if (entry.kind === "tool_call") {
58 return (
59 <li className="flex gap-2.5 px-4 py-2 text-xs">
60 <Wrench size={13} className="mt-0.5 shrink-0 text-faint" />
61 <span className="shrink-0 font-medium text-muted">{entry.tool}</span>
62 <Hint label={entry.text}>
63 <code className="min-w-0 grow truncate font-mono text-fg/85">{entry.text}</code>
64 </Hint>
65 </li>
66 );
67 }
68 if (entry.kind === "tool_result") {
69 return (
70 <li className="px-4 py-1.5 pl-10">
71 <details>
72 <summary className="cursor-pointer text-xs text-faint">Result</summary>
73 <pre className="mt-1.5 max-h-72 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted">{entry.text}</pre>
74 </details>
75 </li>
76 );
77 }
78 return (
79 <li className="flex gap-2.5 px-4 py-2.5 text-xs text-muted">
80 <StickyNote size={13} className="mt-0.5 shrink-0 text-faint" />
81 <span className="min-w-0 grow">{entry.text}</span>
82 {at}
83 </li>
84 );
85}
86
87export default function SessionPage({ loaderData, params }: Route.ComponentProps) {
88 const { session } = loaderData;
89 const { pull, entries } = session;
90 const base = `/${params.owner}/${params.repo}`;
91 const [search, setSearch] = useSearchParams();
92 const quiet = search.get("results") !== "1";
93 const shown = quiet ? entries.filter((entry) => entry.kind !== "tool_result") : entries;
94 const tools = entries.filter((entry) => entry.kind === "tool_call").length;
95 return (
96 <div className="max-w-4xl">
97 <Link to={`${base}/sessions`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
98 <ArrowLeft size={14} />
99 Sessions
100 </Link>
101 <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
102 <Avatar name={pull.agent} size={22} />
103 <h2 className="min-w-0 grow text-lg font-semibold tracking-tight">
104 <Link to={`${base}/pull/${pull.number}`} className="hover:text-accent">
105 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
106 </Link>
107 </h2>
108 </div>
109 <p className="mt-1.5 text-sm text-muted">
110 {pull.agent} · {pull.status} · {entries.length} entries · {tools} tool calls
111 </p>
112
113 <section className="mt-8">
114 <div className="flex items-baseline justify-between">
115 <h3 className="text-sm font-medium">What happened</h3>
116 <Button
117 type="button"
118 onClick={() => setSearch(quiet ? { results: "1" } : {}, { replace: true, preventScrollReset: true })}
119 variant="link"
120 size="inline"
121 className="text-xs text-muted hover:text-fg font-normal"
122 >
123 {quiet ? "Show tool results" : "Hide tool results"}
124 </Button>
125 </div>
126 {shown.length === 0 ? (
127 <p className="mt-3 text-sm text-muted">Nothing recorded yet.</p>
128 ) : (
129 <Card asChild divided className="mt-3">
130 <ol>
131 {shown.map((entry) => (
132 <Entry key={entry.seq} entry={entry} />
133 ))}
134 </ol>
135 </Card>
136 )}
137 {entries.length >= 2000 && (
138 <p className="mt-3 text-xs text-muted">
139 The first 2000 entries. The rest are in the API's read_session, page by page.
140 </p>
141 )}
142 </section>
143 </div>
144 );
145}