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.

378 linesCodeBlameRaw
1/**
2 * Sessions across a workspace, drawn: a live session's card (who, in what,
3 * where, on which work item and pull request, how lately), a row for one
4 * that ended, the "cooking now" strip over Work, and Sessions' filters as
5 * menus on a computer and a sheet on a phone. What each says is worked
6 * out in lib/sessions.ts.
7 */
8import { Bot, ChevronRight, Cpu, GitBranch, ScrollText, SlidersHorizontal, Terminal, X } from "lucide-react";
9import { type ReactNode, useState } from "react";
10import { Link } from "react-router";
11import { siClaude, siCursor, siGithubcopilot } from "simple-icons";
12
13import type { Harness, PullStatus, SessionKind, SessionState, WorkspaceSession } from "@g1t/contracts";
14
15import { FilterMenu } from "./labels";
16import { BottomSheet } from "./mobile";
17import { TimeAgo } from "./ui";
18import { Avatar } from "./ui/avatar";
19import { Badge } from "./ui/badge";
20import { Button } from "./ui/button";
21import { Card } from "./ui/card";
22import { Hint } from "./ui/hint";
23import { SheetChoices } from "./work-items";
24import { ItemIcon, PullIcon } from "./work-icons";
25import { cn } from "../lib/cn";
26import { HARNESS_NAMES, SESSION_STATES } from "../lib/coordination";
27import { KIND_NAMES, type SessionsFilters, narrowed, repoName, runsIn, sessionCost, sessionHref, sessionsQuery, workspaceSessionTitle } from "../lib/sessions";
28import { itemPath, sessionsPath } from "../lib/work-items";
29
30const BRANDS: Partial<Record<Harness, { path: string; title: string }>> = {
31 claude_code: siClaude,
32 claude_managed_agents: siClaude,
33 cursor: siCursor,
34 copilot: siGithubcopilot,
35};
36
37/** What a session runs in, as a mark: the harness's own, g1t's agent's, or a recorded one's. */
38export function HarnessMark({ session, size = 14 }: { session: Pick<WorkspaceSession, "kind" | "harness">; size?: number }) {
39 const brand = session.harness ? BRANDS[session.harness] : undefined;
40 if (brand) {
41 return (
42 <svg role="img" aria-label={brand.title} viewBox="0 0 24 24" width={size} height={size} className="shrink-0 fill-current text-fg-soft">
43 <path d={brand.path} />
44 </svg>
45 );
46 }
47 if (session.kind === "agent") return <Cpu size={size} className="shrink-0 text-accent" aria-label="g1t's agent" />;
48 if (session.kind === "recorded") return <ScrollText size={size} className="shrink-0 text-faint" aria-label="Recorded" />;
49 if (session.harness === "codex") return <Terminal size={size} className="shrink-0 text-fg-soft" aria-label="Codex" />;
50 return <Bot size={size} className="shrink-0 text-fg-soft" aria-label="Harness" />;
51}
52
53const DOT: Record<SessionState, string> = { active: "bg-success", idle: "bg-warn", ended: "bg-line-strong" };
54
55/** A session's state as a dot: green and breathing while it goes, amber when idle, grey once ended. */
56export function StateDot({ state }: { state: SessionState }) {
57 return (
58 <Hint label={SESSION_STATES[state].hint}>
59 <span className="relative flex size-2.5 shrink-0" aria-label={SESSION_STATES[state].label}>
60 {state === "active" && <span className="absolute inline-flex size-full animate-ping rounded-full bg-success opacity-50 motion-reduce:hidden" />}
61 <span className={cn("relative inline-flex size-2.5 rounded-full", DOT[state])} />
62 </span>
63 </Hint>
64 );
65}
66
67/** The work item a session is on, as a chip. */
68function ItemChip({ workspace, number, title }: { workspace: string; number: number; title: string | null }) {
69 return (
70 <Link to={itemPath(workspace, number)} prefetch="intent" className="min-w-0 max-w-full">
71 <Badge size="md" className="max-w-full hover:border-line-strong hover:text-fg">
72 <ItemIcon item={{ state: "open", reason: null }} size={12} />
73 <span className="truncate">
74 {title ?? "Work item"} <span className="text-faint">#{number}</span>
75 </span>
76 </Badge>
77 </Link>
78 );
79}
80
81/** The pull request a session is on, as a chip. */
82function PullChip({ repo, number, title, status }: { repo: string; number: number; title: string | null; status: PullStatus | null }) {
83 return (
84 <Link to={`/${repo}/pull/${number}`} prefetch="intent" className="min-w-0 max-w-full">
85 <Badge size="md" className="max-w-full hover:border-line-strong hover:text-fg">
86 <PullIcon status={status ?? "open"} size={12} />
87 <span className="truncate">
88 {title ?? "Pull request"} <span className="text-faint">#{number}</span>
89 </span>
90 </Badge>
91 </Link>
92 );
93}
94
95/** A session's title, linked where it opens. */
96function TitleLink({ session, workspace, className }: { session: WorkspaceSession; workspace: string; className?: string }) {
97 const to = sessionHref(workspace, session);
98 const title = workspaceSessionTitle(session);
99 if (!to) return <span className={className}>{title}</span>;
100 return (
101 <Link to={to} prefetch="intent" className={cn("hover:text-accent", className)}>
102 {title}
103 </Link>
104 );
105}
106
107/** Where it is: the repository, and its branch. */
108function Where({ session, workspace }: { session: WorkspaceSession; workspace: string }) {
109 return (
110 <span className="flex min-w-0 items-center gap-1 font-mono text-xs text-muted">
111 <Link to={`/${session.repo}`} className="shrink-0 hover:text-fg">
112 {repoName(session.repo, workspace)}
113 </Link>
114 {session.branch && (
115 <>
116 <GitBranch size={11} className="shrink-0 text-faint" />
117 <span className="truncate">{session.branch}</span>
118 </>
119 )}
120 </span>
121 );
122}
123
124/** What a harness session cost, quietly, when it reported usage. */
125function Cost({ session }: { session: WorkspaceSession }) {
126 const cost = sessionCost(session.session);
127 if (!cost) return null;
128 return (
129 <Hint label="Estimated from the tokens its transcript recorded, at Anthropic's list price. Its subagents count on their own sessions.">
130 <span className="text-xs text-faint tabular-nums">{cost}</span>
131 </Hint>
132 );
133}
134
135/**
136 * A live session: its state, what it is called, who runs it and in what,
137 * where, the work item and pull request it is on, its subagents and when
138 * it was last heard from. Reads at 375px: everything wraps, nothing scrolls.
139 */
140export function SessionCard({ session, workspace }: { session: WorkspaceSession; workspace: string }) {
141 return (
142 <li>
143 <Card className="flex h-full flex-col gap-2.5 p-4">
144 <div className="flex items-start gap-2.5">
145 <span className="mt-1.5">
146 <StateDot state={session.state} />
147 </span>
148 <TitleLink session={session} workspace={workspace} className="min-w-0 grow font-medium wrap-break-word" />
149 <span className="mt-0.5 shrink-0 text-xs text-faint">
150 <TimeAgo at={session.lastActivityAt} />
151 </span>
152 </div>
153 <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-muted">
154 {session.actor && (
155 <Link to={`/u/${session.actor}`} className="flex items-center gap-1.5 hover:text-fg">
156 <Avatar name={session.actor} size={18} agent={session.kind !== "recorded"} />
157 <span className="font-medium text-fg-soft">{session.actor}</span>
158 </Link>
159 )}
160 <span className="flex items-center gap-1.5">
161 <HarnessMark session={session} />
162 {runsIn(session)}
163 </span>
164 <Where session={session} workspace={workspace} />
165 </div>
166 {session.step && <p className="line-clamp-1 text-xs text-muted">{session.step}</p>}
167 <Cost session={session} />
168 {(session.item != null || session.pull != null || session.subagents > 0) && (
169 <div className="flex flex-wrap items-center gap-2">
170 {session.item != null && <ItemChip workspace={workspace} number={session.item} title={session.itemTitle} />}
171 {session.pull != null && <PullChip repo={session.repo} number={session.pull} title={session.pullTitle} status={session.pullStatus} />}
172 {session.subagents > 0 && (
173 <Badge size="md" tone="accent">
174 <Bot size={11} />
175 {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"}
176 </Badge>
177 )}
178 </div>
179 )}
180 </Card>
181 </li>
182 );
183}
184
185/** A session in a list of many: one line on a computer, two on a phone. */
186export function SessionRow({ session, workspace }: { session: WorkspaceSession; workspace: string }) {
187 return (
188 <li className="flex items-start gap-3 px-4 py-3 text-sm">
189 <span className="mt-1.5">
190 <StateDot state={session.state} />
191 </span>
192 <div className="min-w-0 grow">
193 <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
194 <TitleLink session={session} workspace={workspace} className="min-w-0 font-medium wrap-break-word" />
195 {session.item != null && (
196 <Link to={itemPath(workspace, session.item)} className="text-xs text-faint hover:text-fg">
197 #{session.item}
198 </Link>
199 )}
200 </div>
201 <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
202 {session.actor && <span className="font-medium text-fg-soft">{session.actor}</span>}
203 <span className="flex items-center gap-1">
204 <HarnessMark session={session} size={12} />
205 {runsIn(session)}
206 </span>
207 <Where session={session} workspace={workspace} />
208 {session.subagents > 0 && <span>{session.subagents} subagents</span>}
209 <Cost session={session} />
210 </div>
211 </div>
212 <span className="shrink-0 text-xs text-faint">
213 <TimeAgo at={session.lastActivityAt} />
214 </span>
215 </li>
216 );
217}
218
219/**
220 * What is cooking now, over Work's list: the live sessions on its work
221 * items, a few of them, and the way to all of Sessions.
222 */
223export function CookingStrip({ sessions, workspace, total, repo }: { sessions: WorkspaceSession[]; workspace: string; total: number; repo?: string | null }) {
224 const all = sessionsPath(workspace, { repo });
225 return (
226 <section aria-label="Cooking now" className="mb-5">
227 <div className="mb-2 flex items-baseline justify-between gap-3">
228 <h2 className="flex items-center gap-2 text-sm font-medium">
229 <StateDot state={total > 0 ? "active" : "ended"} />
230 Cooking now
231 {total > 0 && <span className="font-normal text-muted tabular-nums">{total}</span>}
232 </h2>
233 <Link to={all} className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg">
234 Sessions
235 <ChevronRight size={13} />
236 </Link>
237 </div>
238 {sessions.length === 0 ? (
239 <p className="rounded-lg border border-dashed border-line px-4 py-3 text-sm text-muted">
240 Nothing is running right now. Sessions people and agents start on these work items show here as they go.
241 </p>
242 ) : (
243 <ol className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
244 {sessions.map((session, index) => (
245 // A phone shows three; Sessions has the rest.
246 <li key={session.id} className={cn("min-w-0", index >= 3 && "max-sm:hidden")}>
247 <div className="flex h-full items-start gap-2.5 rounded-lg border border-line bg-surface px-3 py-2.5">
248 <span className="mt-1.5">
249 <StateDot state={session.state} />
250 </span>
251 <div className="min-w-0 grow">
252 <TitleLink session={session} workspace={workspace} className="line-clamp-1 text-sm font-medium" />
253 <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
254 <HarnessMark session={session} size={12} />
255 <span className="truncate">
256 {session.actor ?? runsIn(session)}
257 {session.item != null && ` · #${session.item}`} · {repoName(session.repo, workspace)}
258 </span>
259 </p>
260 </div>
261 <span className="shrink-0 text-[0.6875rem] text-faint">
262 <TimeAgo at={session.lastActivityAt} />
263 </span>
264 </div>
265 </li>
266 ))}
267 </ol>
268 )}
269 </section>
270 );
271}
272
273/** The two views of Sessions: what is live now, and every session. */
274export function SessionsViews({ workspace, filters }: { workspace: string; filters: SessionsFilters }) {
275 const tab = (state: SessionsFilters["state"], label: string) => (
276 <Link
277 to={`${sessionsPath(workspace)}${sessionsQuery({ ...filters, state })}`}
278 aria-current={filters.state === state ? "page" : undefined}
279 className={cn(
280 "inline-flex h-full items-center rounded-[5px] px-2.5 text-sm transition-colors",
281 filters.state === state ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg",
282 )}
283 >
284 {label}
285 </Link>
286 );
287 return (
288 <Card tone="plain" radius="lg" className="flex h-8 gap-0.5 rounded-md p-0.5 max-sm:h-10">
289 {tab("live", "Live now")}
290 {tab("recent", "Every session")}
291 </Card>
292 );
293}
294
295const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[];
296const KINDS = Object.keys(KIND_NAMES) as SessionKind[];
297
298/** Sessions' filters: a menu each on a computer, one sheet on a phone. Each choice is a link. */
299export function SessionsFiltersBar({ workspace, filters, repos, people, me }: { workspace: string; filters: SessionsFilters; repos: string[]; people: string[]; me: string | null }) {
300 const [open, setOpen] = useState(false);
301 const to = (change: Partial<SessionsFilters>) => `${sessionsPath(workspace)}${sessionsQuery({ ...filters, ...change })}`;
302 const count = [filters.repo, filters.person, filters.harness, filters.kind, filters.item].filter(Boolean).length;
303 const clear = to({ repo: null, person: null, harness: null, kind: null, item: null });
304 // A work item's own sessions (`?item=12`, from its page): said, with the way back to all of them.
305 const onItem = filters.item ? (
306 <Link to={to({ item: null })} className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2.5 text-xs text-fg-soft hover:text-fg" aria-label={`On #${filters.item}: show sessions on every work item`}>
307 On #{filters.item}
308 <X size={12} />
309 </Link>
310 ) : null;
311 const everyone = me ? [me, ...people.filter((name) => name !== me)] : people;
312 const menus: { title: string; active: string | null; any: string; field: keyof SessionsFilters; options: { key: string; label: ReactNode }[] }[] = [
313 { title: "Repository", active: filters.repo, any: "Any repository", field: "repo", options: repos.map((name) => ({ key: name, label: <span className="font-mono text-[0.8125rem]">{name}</span> })) },
314 { title: "Person", active: filters.person, any: "Anyone", field: "person", options: everyone.map((name) => ({ key: name, label: name === me ? `${name} (you)` : name })) },
315 { title: "Harness", active: filters.harness ? HARNESS_NAMES[filters.harness] : null, any: "Any harness", field: "harness", options: HARNESSES.map((key) => ({ key, label: HARNESS_NAMES[key] })) },
316 { title: "Kind", active: filters.kind ? KIND_NAMES[filters.kind] : null, any: "Every kind", field: "kind", options: KINDS.map((key) => ({ key, label: KIND_NAMES[key] })) },
317 ];
318 return (
319 <>
320 <div className="hidden flex-wrap items-center gap-2 sm:flex">
321 {onItem}
322 {menus.map((menu) => (
323 <FilterMenu
324 key={menu.title}
325 label={menu.title}
326 active={menu.active ?? undefined}
327 clearTo={menu.active ? to({ [menu.field]: null }) : undefined}
328 searchPlaceholder={`Filter ${menu.title.toLowerCase()}`}
329 emptyText="Nothing matches."
330 options={menu.options.map((option) => ({
331 key: option.key,
332 to: to({ [menu.field]: option.key }),
333 keywords: option.key,
334 selected: filters[menu.field] === option.key,
335 label: <span className="truncate">{option.label}</span>,
336 }))}
337 />
338 ))}
339 {narrowed(filters) && (
340 <Link to={clear} className="text-xs text-muted hover:text-fg">
341 Clear filters
342 </Link>
343 )}
344 </div>
345 <div className="flex min-w-0 items-center gap-2 sm:hidden">
346 <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)} aria-haspopup="dialog" className="h-10 gap-2 px-3.5">
347 <SlidersHorizontal size={15} />
348 Filters
349 {count > 0 && <span className="rounded-full bg-accent px-1.5 text-[0.6875rem] leading-4.5 font-bold text-bg tabular-nums">{count}</span>}
350 </Button>
351 {onItem}
352 {filters.repo && <span className="min-w-0 truncate font-mono text-xs text-muted">{filters.repo}</span>}
353 </div>
354 <BottomSheet open={open} onOpenChange={setOpen} title="Filters">
355 <div className="flex items-center justify-between px-3 pt-1 pb-2">
356 <h2 className="text-base font-semibold">Filters</h2>
357 {count > 0 && (
358 <Link to={clear} onClick={() => setOpen(false)} className="inline-flex h-10 items-center gap-1 px-2 text-sm text-muted active:text-fg">
359 <X size={14} />
360 Clear all
361 </Link>
362 )}
363 </div>
364 {menus.map((menu) => (
365 <SheetChoices
366 key={menu.title}
367 title={menu.title}
368 onPick={() => setOpen(false)}
369 choices={[
370 { key: "any", label: menu.any, to: to({ [menu.field]: null }), on: !filters[menu.field] },
371 ...menu.options.map((option) => ({ key: option.key, label: option.label, to: to({ [menu.field]: option.key }), on: filters[menu.field] === option.key })),
372 ]}
373 />
374 ))}
375 </BottomSheet>
376 </>
377 );
378}