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