| 1 | import { Activity, GitMerge, History, Layers, Radio } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { Link } from "react-router"; |
| 4 | |
| 5 | import { type EventType, MAX_LIST_REPOS, type RepoPull } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/list"; |
| 8 | import { Unavailable } from "../../../components/boundary"; |
| 9 | import { CoordinationFeed } from "../../../components/coordination"; |
| 10 | import { QueueTrain } from "../../../components/merge-queue"; |
| 11 | import { SessionCard, SessionRow, SessionsFiltersBar, SessionsViews } from "../../../components/sessions"; |
| 12 | import { EmptyState, TimeAgo } from "../../../components/ui"; |
| 13 | import { Avatar } from "../../../components/ui/avatar"; |
| 14 | import { Card } from "../../../components/ui/card"; |
| 15 | import { PullIcon } from "../../../components/work-icons"; |
| 16 | import { COORDINATION_TYPES, feedLine } from "../../../lib/coordination"; |
| 17 | import { page } from "../../../lib/meta"; |
| 18 | import { isFailed, part } from "../../../lib/partial"; |
| 19 | import { useRefreshWhile } from "../../../lib/refresh"; |
| 20 | import { repoPages, workPages } from "../../../lib/repo-live"; |
| 21 | import { events, work } from "../../../lib/services.server"; |
| 22 | import { getViewer, unwrap } from "../../../lib/session.server"; |
| 23 | import { narrowed, peopleIn, repoName, sessionsFilters, splitLive } from "../../../lib/sessions"; |
| 24 | import { useLive } from "../../../lib/use-repo-live"; |
| 25 | import { itemPath, sessionsPath } from "../../../lib/work-items"; |
| 26 | |
| 27 | /** How many coordination events What happened shows. */ |
| 28 | const FEED = 30; |
| 29 | /** How many ended sessions Recent shows under Live now. */ |
| 30 | const RECENT = 20; |
| 31 | |
| 32 | export function meta({ params, location, ...args }: Route.MetaArgs) { |
| 33 | const repo = new URLSearchParams(location.search).get("repo"); |
| 34 | return page({ location, ...args }, { title: `Sessions${repo ? ` · ${repo}` : ""} · ${params.owner} · g1t` }); |
| 35 | } |
| 36 | |
| 37 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 38 | const viewer = getViewer(context); |
| 39 | const workspace = params.owner.toLowerCase(); |
| 40 | const filters = sessionsFilters(new URL(request.url).searchParams); |
| 41 | const narrow = { repo: filters.repo ?? undefined, person: filters.person ?? undefined, harness: filters.harness ?? undefined, kind: filters.kind ?? undefined }; |
| 42 | const every = filters.state === "recent"; |
| 43 | // What is going now is what the page is about; what ended, a part of its own. |
| 44 | const [now, ended] = await Promise.all([ |
| 45 | work.listWorkspaceSessions(workspace, viewer, { ...narrow, shown: every ? "recent" : "live", limit: every ? 120 : 60 }), |
| 46 | every ? null : part(work.listWorkspaceSessions(workspace, viewer, { ...narrow, shown: "ended", sessionsOnly: true, limit: RECENT }), "Recent sessions"), |
| 47 | ]); |
| 48 | const found = unwrap(now); |
| 49 | const paths = new Map(found.repos.map((repo) => [repo.id, repo.path])); |
| 50 | // What happened, across the repositories looked in (the one asked for, or every one the viewer may read). |
| 51 | const recent = await part( |
| 52 | events.list({ repoIds: found.repos.slice(0, MAX_LIST_REPOS).map((repo) => repo.id), types: [...COORDINATION_TYPES] as EventType[], limit: FEED }), |
| 53 | "What happened", |
| 54 | ); |
| 55 | const feed = isFailed(recent) |
| 56 | ? recent |
| 57 | : recent.flatMap((event) => { |
| 58 | const line = feedLine(event); |
| 59 | if (!line) return []; |
| 60 | const repo = event.repoId ? paths.get(event.repoId) : undefined; |
| 61 | return [repo ? { ...line, repo } : line]; |
| 62 | }); |
| 63 | return { |
| 64 | workspace, |
| 65 | filters, |
| 66 | signedIn: viewer != null, |
| 67 | me: viewer?.username ?? null, |
| 68 | sessions: found.sessions, |
| 69 | queue: found.queue, |
| 70 | landed: found.landed, |
| 71 | repos: found.repos, |
| 72 | ended: ended == null ? null : isFailed(ended) ? ended : ended.ok ? ended.value.sessions : [], |
| 73 | feed, |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | function Section({ icon, title, aside, children, id }: { icon: ReactNode; title: string; aside?: ReactNode; children: ReactNode; id?: string }) { |
| 78 | return ( |
| 79 | <section id={id} className="mt-9 first:mt-6"> |
| 80 | <div className="flex flex-wrap items-baseline justify-between gap-2"> |
| 81 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 82 | {icon} |
| 83 | {title} |
| 84 | </h2> |
| 85 | {aside && <span className="text-xs text-muted">{aside}</span>} |
| 86 | </div> |
| 87 | <div className="mt-3">{children}</div> |
| 88 | </section> |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | function LandedRow({ pull, workspace }: { pull: RepoPull; workspace: string }) { |
| 93 | const base = `/${pull.repo}`; |
| 94 | return ( |
| 95 | <li className="flex items-start gap-3 px-4 py-3 text-sm"> |
| 96 | <span className="mt-0.5"> |
| 97 | <PullIcon status={pull.status} /> |
| 98 | </span> |
| 99 | <span className="flex min-w-0 grow flex-wrap items-center gap-x-3 gap-y-1"> |
| 100 | <Link to={`${base}/pull/${pull.number}`} prefetch="intent" className="min-w-0 grow font-medium wrap-break-word hover:text-accent"> |
| 101 | {pull.title} <span className="font-normal text-faint">#{pull.number}</span> |
| 102 | </Link> |
| 103 | <span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> |
| 104 | <Link to={base} className="font-mono hover:text-fg"> |
| 105 | {repoName(pull.repo, workspace)} |
| 106 | </Link> |
| 107 | {pull.item != null && ( |
| 108 | <Link to={itemPath(workspace, pull.item)} className="hover:text-fg"> |
| 109 | for #{pull.item} |
| 110 | </Link> |
| 111 | )} |
| 112 | <Link to={`${base}/pull/${pull.number}/session`} className="hover:text-fg"> |
| 113 | Why |
| 114 | </Link> |
| 115 | {pull.mergedBy && ( |
| 116 | <span className="flex items-center gap-1.5"> |
| 117 | <Avatar name={pull.mergedBy} size={14} /> |
| 118 | {pull.mergedBy} |
| 119 | </span> |
| 120 | )} |
| 121 | {pull.mergedAt && <TimeAgo at={pull.mergedAt} />} |
| 122 | </span> |
| 123 | </span> |
| 124 | </li> |
| 125 | ); |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Sessions: what is cooking across the workspace, in every repository of |
| 130 | * it the viewer may read. Live now first (people's harnesses and their |
| 131 | * subagents, sessions started on g1t, g1t's agent, what is being recorded |
| 132 | * on pull requests), then the merge queues, what just landed, what |
| 133 | * happened and what ended. |
| 134 | */ |
| 135 | export default function SessionsPage({ loaderData }: Route.ComponentProps) { |
| 136 | const { workspace, filters, sessions, queue, landed, repos, ended, feed, signedIn, me } = loaderData; |
| 137 | const every = filters.state === "recent"; |
| 138 | const { active, idle } = splitLive(sessions); |
| 139 | const going = active.length + idle.length; |
| 140 | const waiting = queue.reduce((sum, one) => sum + one.active.length, 0); |
| 141 | const repoNames = repos.map((repo) => repoName(repo.path, workspace)).sort(); |
| 142 | const people = peopleIn([...sessions, ...(ended && !isFailed(ended) ? ended : [])]); |
| 143 | // Sessions, claims, overlaps, integration results and merges in any of its repositories, and its work items. |
| 144 | useLive({ |
| 145 | match: repoPages.work(), |
| 146 | repos: repos.map((repo) => ({ id: repo.id, path: repo.path })), |
| 147 | signedIn, |
| 148 | workspace: { slug: workspace, match: workPages.list() }, |
| 149 | }); |
| 150 | // Sessions go idle and time out with no event to say so. |
| 151 | useRefreshWhile(going > 0 || waiting > 0, 15_000); |
| 152 | |
| 153 | return ( |
| 154 | <div className="max-w-5xl"> |
| 155 | <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"> |
| 156 | <Activity size={19} className="text-accent" /> |
| 157 | Sessions |
| 158 | </h1> |
| 159 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> |
| 160 | What is cooking across {workspace}: everyone's harness sessions and their subagents, sessions started on g1t, g1t's own agent, |
| 161 | the merge queues combining their changes, and what landed. |
| 162 | </p> |
| 163 | <div className="mt-4 flex flex-wrap items-center gap-2"> |
| 164 | <SessionsViews workspace={workspace} filters={filters} /> |
| 165 | <SessionsFiltersBar workspace={workspace} filters={filters} repos={repoNames} people={people} me={me} /> |
| 166 | </div> |
| 167 | |
| 168 | {every ? ( |
| 169 | <Section icon={<History size={15} className="text-accent" />} title="Every session" aside={`${sessions.length} most recently heard from`}> |
| 170 | {sessions.length === 0 ? ( |
| 171 | <EmptyState title={narrowed(filters) ? "No sessions match these filters" : "No sessions yet"}> |
| 172 | Sessions start when someone works with g1t's plugin in their harness, starts one on a work item, or an agent records its work on a pull request. |
| 173 | </EmptyState> |
| 174 | ) : ( |
| 175 | <Card asChild divided> |
| 176 | <ol> |
| 177 | {sessions.map((session) => ( |
| 178 | <SessionRow key={session.id} session={session} workspace={workspace} /> |
| 179 | ))} |
| 180 | </ol> |
| 181 | </Card> |
| 182 | )} |
| 183 | </Section> |
| 184 | ) : ( |
| 185 | <Section |
| 186 | icon={<Radio size={15} className={going > 0 ? "text-success" : "text-faint"} />} |
| 187 | title="Live now" |
| 188 | aside={going > 0 ? `${active.length} going${idle.length > 0 ? `, ${idle.length} idle` : ""}` : undefined} |
| 189 | > |
| 190 | {going === 0 ? ( |
| 191 | <EmptyState title={narrowed(filters) ? "Nothing matching is running right now" : "Nothing is running right now"}> |
| 192 | Sessions show here as they go: Claude Code, Codex or Cursor with g1t's plugin, sessions started on a work item, and g1t's own agent. |
| 193 | {narrowed(filters) && ( |
| 194 | <> |
| 195 | {" "} |
| 196 | <Link to={sessionsPath(workspace)} className="text-fg underline underline-offset-4"> |
| 197 | Clear the filters |
| 198 | </Link> |
| 199 | . |
| 200 | </> |
| 201 | )} |
| 202 | </EmptyState> |
| 203 | ) : ( |
| 204 | <ol className="grid gap-3 md:grid-cols-2"> |
| 205 | {[...active, ...idle].map((session) => ( |
| 206 | <SessionCard key={session.id} session={session} workspace={workspace} /> |
| 207 | ))} |
| 208 | </ol> |
| 209 | )} |
| 210 | </Section> |
| 211 | )} |
| 212 | |
| 213 | {queue.length > 0 && ( |
| 214 | <Section id="merge-queue" icon={<Layers size={15} className="text-accent" />} title="Merge queue" aside={`${waiting} waiting to land`}> |
| 215 | <div className="space-y-4"> |
| 216 | {queue.map((one) => ( |
| 217 | <div key={one.repo}> |
| 218 | <p className="mb-2 flex items-center justify-between gap-2 text-xs text-muted"> |
| 219 | <Link to={`/${one.repo}`} className="font-mono hover:text-fg"> |
| 220 | {repoName(one.repo, workspace)} |
| 221 | </Link> |
| 222 | <Link to={`/${one.repo}/queue`} className="hover:text-fg"> |
| 223 | The whole queue |
| 224 | </Link> |
| 225 | </p> |
| 226 | <QueueTrain active={one.active} base={`/${one.repo}`} branch={one.defaultBranch} empty="Nothing is waiting to land." /> |
| 227 | </div> |
| 228 | ))} |
| 229 | </div> |
| 230 | </Section> |
| 231 | )} |
| 232 | |
| 233 | {!every && ( |
| 234 | <Section icon={<GitMerge size={15} className="text-merged" />} title="Just landed"> |
| 235 | {landed.length === 0 ? ( |
| 236 | <EmptyState title="Nothing merged yet">Merged pull requests show here, each with the session behind it.</EmptyState> |
| 237 | ) : ( |
| 238 | <Card asChild divided> |
| 239 | <ol> |
| 240 | {landed.map((pull) => ( |
| 241 | <LandedRow key={pull.id} pull={pull} workspace={workspace} /> |
| 242 | ))} |
| 243 | </ol> |
| 244 | </Card> |
| 245 | )} |
| 246 | </Section> |
| 247 | )} |
| 248 | |
| 249 | {!every && ( |
| 250 | <Section icon={<History size={15} className="text-accent" />} title="What happened"> |
| 251 | {isFailed(feed) ? ( |
| 252 | <Unavailable what={feed.what} /> |
| 253 | ) : feed.length === 0 ? ( |
| 254 | <EmptyState title="Nothing yet"> |
| 255 | Claims, sessions, overlapping changes, dependencies and what the merge queue finds when it combines changes are listed here as they happen. |
| 256 | </EmptyState> |
| 257 | ) : ( |
| 258 | <CoordinationFeed lines={feed} base={`/${workspace}/-/work`} /> |
| 259 | )} |
| 260 | </Section> |
| 261 | )} |
| 262 | |
| 263 | {ended != null && ( |
| 264 | <Section |
| 265 | icon={<History size={15} className="text-faint" />} |
| 266 | title="Recent" |
| 267 | aside={ |
| 268 | <Link to={sessionsPath(workspace, { state: "recent", repo: filters.repo, person: filters.person, harness: filters.harness, kind: filters.kind })} className="hover:text-fg"> |
| 269 | Every session |
| 270 | </Link> |
| 271 | } |
| 272 | > |
| 273 | {isFailed(ended) ? ( |
| 274 | <Unavailable what={ended.what} /> |
| 275 | ) : ended.length === 0 ? ( |
| 276 | <EmptyState title="Nothing ended yet">Sessions that ended show here, newest first.</EmptyState> |
| 277 | ) : ( |
| 278 | <Card asChild divided> |
| 279 | <ol> |
| 280 | {ended.map((session) => ( |
| 281 | <SessionRow key={session.id} session={session} workspace={workspace} /> |
| 282 | ))} |
| 283 | </ol> |
| 284 | </Card> |
| 285 | )} |
| 286 | </Section> |
| 287 | )} |
| 288 | </div> |
| 289 | ); |
| 290 | } |