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.

423 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({
300 workspace,
301 filters,
302 repos,
303 people,
304 me,
305}: {
306 workspace: string;
307 filters: SessionsFilters;
308 repos: string[];
309 people: string[];
310 me: string | null;
311}) {
312 const [open, setOpen] = useState(false);
313 const to = (change: Partial<SessionsFilters>) => `${sessionsPath(workspace)}${sessionsQuery({ ...filters, ...change })}`;
314 const count = [filters.repo, filters.person, filters.harness, filters.kind, filters.item].filter(Boolean).length;
315 const clear = to({ repo: null, person: null, harness: null, kind: null, item: null });
316 // A work item's own sessions (`?item=12`, from its page): said, with the way back to all of them.
317 const onItem = filters.item ? (
318 <Link
319 to={to({ item: null })}
320 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"
321 aria-label={`On #${filters.item}: show sessions on every work item`}
322 >
323 On #{filters.item}
324 <X size={12} />
325 </Link>
326 ) : null;
327 const everyone = me ? [me, ...people.filter((name) => name !== me)] : people;
328 const menus: { title: string; active: string | null; any: string; field: keyof SessionsFilters; options: { key: string; label: ReactNode }[] }[] = [
329 {
330 title: "Repository",
331 active: filters.repo,
332 any: "Any repository",
333 field: "repo",
334 options: repos.map((name) => ({ key: name, label: <span className="font-mono text-[0.8125rem]">{name}</span> })),
335 },
336 {
337 title: "Person",
338 active: filters.person,
339 any: "Anyone",
340 field: "person",
341 options: everyone.map((name) => ({ key: name, label: name === me ? `${name} (you)` : name })),
342 },
343 {
344 title: "Harness",
345 active: filters.harness ? HARNESS_NAMES[filters.harness] : null,
346 any: "Any harness",
347 field: "harness",
348 options: HARNESSES.map((key) => ({ key, label: HARNESS_NAMES[key] })),
349 },
350 {
351 title: "Kind",
352 active: filters.kind ? KIND_NAMES[filters.kind] : null,
353 any: "Every kind",
354 field: "kind",
355 options: KINDS.map((key) => ({ key, label: KIND_NAMES[key] })),
356 },
357 ];
358 return (
359 <>
360 <div className="hidden flex-wrap items-center gap-2 sm:flex">
361 {onItem}
362 {menus.map((menu) => (
363 <FilterMenu
364 key={menu.title}
365 label={menu.title}
366 active={menu.active ?? undefined}
367 clearTo={menu.active ? to({ [menu.field]: null }) : undefined}
368 searchPlaceholder={`Filter ${menu.title.toLowerCase()}`}
369 emptyText="Nothing matches."
370 options={menu.options.map((option) => ({
371 key: option.key,
372 to: to({ [menu.field]: option.key }),
373 keywords: option.key,
374 selected: filters[menu.field] === option.key,
375 label: <span className="truncate">{option.label}</span>,
376 }))}
377 />
378 ))}
379 {narrowed(filters) && (
380 <Link to={clear} className="text-xs text-muted hover:text-fg">
381 Clear filters
382 </Link>
383 )}
384 </div>
385 <div className="flex min-w-0 items-center gap-2 sm:hidden">
386 <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)} aria-haspopup="dialog" className="h-10 gap-2 px-3.5">
387 <SlidersHorizontal size={15} />
388 Filters
389 {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>}
390 </Button>
391 {onItem}
392 {filters.repo && <span className="min-w-0 truncate font-mono text-xs text-muted">{filters.repo}</span>}
393 </div>
394 <BottomSheet open={open} onOpenChange={setOpen} title="Filters">
395 <div className="flex items-center justify-between px-3 pt-1 pb-2">
396 <h2 className="text-base font-semibold">Filters</h2>
397 {count > 0 && (
398 <Link to={clear} onClick={() => setOpen(false)} className="inline-flex h-10 items-center gap-1 px-2 text-sm text-muted active:text-fg">
399 <X size={14} />
400 Clear all
401 </Link>
402 )}
403 </div>
404 {menus.map((menu) => (
405 <SheetChoices
406 key={menu.title}
407 title={menu.title}
408 onPick={() => setOpen(false)}
409 choices={[
410 { key: "any", label: menu.any, to: to({ [menu.field]: null }), on: !filters[menu.field] },
411 ...menu.options.map((option) => ({
412 key: option.key,
413 label: option.label,
414 to: to({ [menu.field]: option.key }),
415 on: filters[menu.field] === option.key,
416 })),
417 ]}
418 />
419 ))}
420 </BottomSheet>
421 </>
422 );
423}