| 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 | */ |
| 8 | import { Bot, ChevronRight, Cpu, GitBranch, ScrollText, SlidersHorizontal, Terminal, X } from "lucide-react"; |
| 9 | import { type ReactNode, useState } from "react"; |
| 10 | import { Link } from "react-router"; |
| 11 | import { siClaude, siCursor, siGithubcopilot } from "simple-icons"; |
| 12 | |
| 13 | import type { Harness, PullStatus, SessionKind, SessionState, WorkspaceSession } from "@g1t/contracts"; |
| 14 | |
| 15 | import { FilterMenu } from "./labels"; |
| 16 | import { BottomSheet } from "./mobile"; |
| 17 | import { TimeAgo } from "./ui"; |
| 18 | import { Avatar } from "./ui/avatar"; |
| 19 | import { Badge } from "./ui/badge"; |
| 20 | import { Button } from "./ui/button"; |
| 21 | import { Card } from "./ui/card"; |
| 22 | import { Hint } from "./ui/hint"; |
| 23 | import { SheetChoices } from "./work-items"; |
| 24 | import { ItemIcon, PullIcon } from "./work-icons"; |
| 25 | import { cn } from "../lib/cn"; |
| 26 | import { HARNESS_NAMES, SESSION_STATES } from "../lib/coordination"; |
| 27 | import { KIND_NAMES, type SessionsFilters, narrowed, repoName, runsIn, sessionCost, sessionHref, sessionsQuery, workspaceSessionTitle } from "../lib/sessions"; |
| 28 | import { itemPath, sessionsPath } from "../lib/work-items"; |
| 29 | |
| 30 | const 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. */ |
| 38 | export 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 | |
| 53 | const 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. */ |
| 56 | export 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. */ |
| 68 | function 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. */ |
| 82 | function 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. */ |
| 96 | function 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. */ |
| 108 | function 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. */ |
| 125 | function 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 | */ |
| 140 | export 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. */ |
| 186 | export 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 | */ |
| 223 | export 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. */ |
| 274 | export 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 | |
| 295 | const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[]; |
| 296 | const 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. */ |
| 299 | export 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 | } |