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.

365 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, 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/**
125 * A live session: its state, what it is called, who runs it and in what,
126 * where, the work item and pull request it is on, its subagents and when
127 * it was last heard from. Reads at 375px: everything wraps, nothing scrolls.
128 */
129export function SessionCard({ session, workspace }: { session: WorkspaceSession; workspace: string }) {
130 return (
131 <li>
132 <Card className="flex h-full flex-col gap-2.5 p-4">
133 <div className="flex items-start gap-2.5">
134 <span className="mt-1.5">
135 <StateDot state={session.state} />
136 </span>
137 <TitleLink session={session} workspace={workspace} className="min-w-0 grow font-medium wrap-break-word" />
138 <span className="mt-0.5 shrink-0 text-xs text-faint">
139 <TimeAgo at={session.lastActivityAt} />
140 </span>
141 </div>
142 <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-muted">
143 {session.actor && (
144 <Link to={`/u/${session.actor}`} className="flex items-center gap-1.5 hover:text-fg">
145 <Avatar name={session.actor} size={18} agent={session.kind !== "recorded"} />
146 <span className="font-medium text-fg-soft">{session.actor}</span>
147 </Link>
148 )}
149 <span className="flex items-center gap-1.5">
150 <HarnessMark session={session} />
151 {runsIn(session)}
152 </span>
153 <Where session={session} workspace={workspace} />
154 </div>
155 {session.step && <p className="line-clamp-1 text-xs text-muted">{session.step}</p>}
156 {(session.item != null || session.pull != null || session.subagents > 0) && (
157 <div className="flex flex-wrap items-center gap-2">
158 {session.item != null && <ItemChip workspace={workspace} number={session.item} title={session.itemTitle} />}
159 {session.pull != null && <PullChip repo={session.repo} number={session.pull} title={session.pullTitle} status={session.pullStatus} />}
160 {session.subagents > 0 && (
161 <Badge size="md" tone="accent">
162 <Bot size={11} />
163 {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"}
164 </Badge>
165 )}
166 </div>
167 )}
168 </Card>
169 </li>
170 );
171}
172
173/** A session in a list of many: one line on a computer, two on a phone. */
174export function SessionRow({ session, workspace }: { session: WorkspaceSession; workspace: string }) {
175 return (
176 <li className="flex items-start gap-3 px-4 py-3 text-sm">
177 <span className="mt-1.5">
178 <StateDot state={session.state} />
179 </span>
180 <div className="min-w-0 grow">
181 <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
182 <TitleLink session={session} workspace={workspace} className="min-w-0 font-medium wrap-break-word" />
183 {session.item != null && (
184 <Link to={itemPath(workspace, session.item)} className="text-xs text-faint hover:text-fg">
185 #{session.item}
186 </Link>
187 )}
188 </div>
189 <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
190 {session.actor && <span className="font-medium text-fg-soft">{session.actor}</span>}
191 <span className="flex items-center gap-1">
192 <HarnessMark session={session} size={12} />
193 {runsIn(session)}
194 </span>
195 <Where session={session} workspace={workspace} />
196 {session.subagents > 0 && <span>{session.subagents} subagents</span>}
197 </div>
198 </div>
199 <span className="shrink-0 text-xs text-faint">
200 <TimeAgo at={session.lastActivityAt} />
201 </span>
202 </li>
203 );
204}
205
206/**
207 * What is cooking now, over Work's list: the live sessions on its work
208 * items, a few of them, and the way to all of Sessions.
209 */
210export function CookingStrip({ sessions, workspace, total, repo }: { sessions: WorkspaceSession[]; workspace: string; total: number; repo?: string | null }) {
211 const all = sessionsPath(workspace, { repo });
212 return (
213 <section aria-label="Cooking now" className="mb-5">
214 <div className="mb-2 flex items-baseline justify-between gap-3">
215 <h2 className="flex items-center gap-2 text-sm font-medium">
216 <StateDot state={total > 0 ? "active" : "ended"} />
217 Cooking now
218 {total > 0 && <span className="font-normal text-muted tabular-nums">{total}</span>}
219 </h2>
220 <Link to={all} className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg">
221 Sessions
222 <ChevronRight size={13} />
223 </Link>
224 </div>
225 {sessions.length === 0 ? (
226 <p className="rounded-lg border border-dashed border-line px-4 py-3 text-sm text-muted">
227 Nothing is running right now. Sessions people and agents start on these work items show here as they go.
228 </p>
229 ) : (
230 <ol className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
231 {sessions.map((session, index) => (
232 // A phone shows three; Sessions has the rest.
233 <li key={session.id} className={cn("min-w-0", index >= 3 && "max-sm:hidden")}>
234 <div className="flex h-full items-start gap-2.5 rounded-lg border border-line bg-surface px-3 py-2.5">
235 <span className="mt-1.5">
236 <StateDot state={session.state} />
237 </span>
238 <div className="min-w-0 grow">
239 <TitleLink session={session} workspace={workspace} className="line-clamp-1 text-sm font-medium" />
240 <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
241 <HarnessMark session={session} size={12} />
242 <span className="truncate">
243 {session.actor ?? runsIn(session)}
244 {session.item != null && ` · #${session.item}`} · {repoName(session.repo, workspace)}
245 </span>
246 </p>
247 </div>
248 <span className="shrink-0 text-[0.6875rem] text-faint">
249 <TimeAgo at={session.lastActivityAt} />
250 </span>
251 </div>
252 </li>
253 ))}
254 </ol>
255 )}
256 </section>
257 );
258}
259
260/** The two views of Sessions: what is live now, and every session. */
261export function SessionsViews({ workspace, filters }: { workspace: string; filters: SessionsFilters }) {
262 const tab = (state: SessionsFilters["state"], label: string) => (
263 <Link
264 to={`${sessionsPath(workspace)}${sessionsQuery({ ...filters, state })}`}
265 aria-current={filters.state === state ? "page" : undefined}
266 className={cn(
267 "inline-flex h-full items-center rounded-[5px] px-2.5 text-sm transition-colors",
268 filters.state === state ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg",
269 )}
270 >
271 {label}
272 </Link>
273 );
274 return (
275 <Card tone="plain" radius="lg" className="flex h-8 gap-0.5 rounded-md p-0.5 max-sm:h-10">
276 {tab("live", "Live now")}
277 {tab("recent", "Every session")}
278 </Card>
279 );
280}
281
282const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[];
283const KINDS = Object.keys(KIND_NAMES) as SessionKind[];
284
285/** Sessions' filters: a menu each on a computer, one sheet on a phone. Each choice is a link. */
286export function SessionsFiltersBar({ workspace, filters, repos, people, me }: { workspace: string; filters: SessionsFilters; repos: string[]; people: string[]; me: string | null }) {
287 const [open, setOpen] = useState(false);
288 const to = (change: Partial<SessionsFilters>) => `${sessionsPath(workspace)}${sessionsQuery({ ...filters, ...change })}`;
289 const count = [filters.repo, filters.person, filters.harness, filters.kind, filters.item].filter(Boolean).length;
290 const clear = to({ repo: null, person: null, harness: null, kind: null, item: null });
291 // A work item's own sessions (`?item=12`, from its page): said, with the way back to all of them.
292 const onItem = filters.item ? (
293 <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`}>
294 On #{filters.item}
295 <X size={12} />
296 </Link>
297 ) : null;
298 const everyone = me ? [me, ...people.filter((name) => name !== me)] : people;
299 const menus: { title: string; active: string | null; any: string; field: keyof SessionsFilters; options: { key: string; label: ReactNode }[] }[] = [
300 { 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> })) },
301 { title: "Person", active: filters.person, any: "Anyone", field: "person", options: everyone.map((name) => ({ key: name, label: name === me ? `${name} (you)` : name })) },
302 { title: "Harness", active: filters.harness ? HARNESS_NAMES[filters.harness] : null, any: "Any harness", field: "harness", options: HARNESSES.map((key) => ({ key, label: HARNESS_NAMES[key] })) },
303 { title: "Kind", active: filters.kind ? KIND_NAMES[filters.kind] : null, any: "Every kind", field: "kind", options: KINDS.map((key) => ({ key, label: KIND_NAMES[key] })) },
304 ];
305 return (
306 <>
307 <div className="hidden flex-wrap items-center gap-2 sm:flex">
308 {onItem}
309 {menus.map((menu) => (
310 <FilterMenu
311 key={menu.title}
312 label={menu.title}
313 active={menu.active ?? undefined}
314 clearTo={menu.active ? to({ [menu.field]: null }) : undefined}
315 searchPlaceholder={`Filter ${menu.title.toLowerCase()}`}
316 emptyText="Nothing matches."
317 options={menu.options.map((option) => ({
318 key: option.key,
319 to: to({ [menu.field]: option.key }),
320 keywords: option.key,
321 selected: filters[menu.field] === option.key,
322 label: <span className="truncate">{option.label}</span>,
323 }))}
324 />
325 ))}
326 {narrowed(filters) && (
327 <Link to={clear} className="text-xs text-muted hover:text-fg">
328 Clear filters
329 </Link>
330 )}
331 </div>
332 <div className="flex min-w-0 items-center gap-2 sm:hidden">
333 <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)} aria-haspopup="dialog" className="h-10 gap-2 px-3.5">
334 <SlidersHorizontal size={15} />
335 Filters
336 {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>}
337 </Button>
338 {onItem}
339 {filters.repo && <span className="min-w-0 truncate font-mono text-xs text-muted">{filters.repo}</span>}
340 </div>
341 <BottomSheet open={open} onOpenChange={setOpen} title="Filters">
342 <div className="flex items-center justify-between px-3 pt-1 pb-2">
343 <h2 className="text-base font-semibold">Filters</h2>
344 {count > 0 && (
345 <Link to={clear} onClick={() => setOpen(false)} className="inline-flex h-10 items-center gap-1 px-2 text-sm text-muted active:text-fg">
346 <X size={14} />
347 Clear all
348 </Link>
349 )}
350 </div>
351 {menus.map((menu) => (
352 <SheetChoices
353 key={menu.title}
354 title={menu.title}
355 onPick={() => setOpen(false)}
356 choices={[
357 { key: "any", label: menu.any, to: to({ [menu.field]: null }), on: !filters[menu.field] },
358 ...menu.options.map((option) => ({ key: option.key, label: option.label, to: to({ [menu.field]: option.key }), on: filters[menu.field] === option.key })),
359 ]}
360 />
361 ))}
362 </BottomSheet>
363 </>
364 );
365}