Skip to content

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

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