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.

Commit

Web: Sessions in the rail after Work, and Work's board

Sessions, right after Work, is everything going on in the workspace: Live now, the merge queues, what just landed and what happened, every kind of session, filtered by repository, person and harness, and each session's own page. Work gains a board (Backlog, In progress, In review, Landing, Done) and a strip of what is cooking now. A repository's Sessions and Work pages are gone, with no redirects. On a phone, Sessions is first in More.

G1T-Session:
ses_01m560bz6eeqc8v1hrrha03pap
syntaqxcommitted Parentd63eb0eBrowse files
35 files+2117−9700/35 viewed
+2−1
1−import { Bell, Building2, Code2, House, LayoutGrid, ListTodo, Lock, MessagesSquare, Pin, Plug, Repeat, Search, Shapes, Users } from "lucide-react";
1+import { Activity, Bell, Building2, Code2, House, LayoutGrid, ListTodo, Lock, MessagesSquare, Pin, Plug, Repeat, Search, Shapes, Users } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useState } from "react";
33 import { Link, useFetcher, useFetchers } from "react-router";
44
1414 const icons: Record<BuiltinApp, ReactNode> = {
1515 home: <House size={size} />,
1616 work: <ListTodo size={size} />,
17+ sessions: <Activity size={size} />,
1718 chat: <MessagesSquare size={size} />,
1819 notifications: <Bell size={size} />,
1920 code: <Code2 size={size} />,
+10−8
7070 titleHint,
7171 titleIsProvisional,
7272 } from "../lib/coordination";
73+import { sessionPath } from "../lib/work-items";
7374 import { AgentSetup } from "./agent-setup";
7475 import { Input, SubmitButton, TimeAgo } from "./ui";
7576 import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
236237 return (
237238 <AlertDialog open={open} onOpenChange={onOpenChange}>
238239 <AlertDialogContent>
239− <fetcher.Form method="post" action={`${base}/sessions/${session.id}`} className="grid gap-5">
240+ <fetcher.Form method="post" action={sessionPath(workspaceOf(base), session.id)} className="grid gap-5">
240241 <input type="hidden" name="action" value="end" />
241242 <AlertDialogHeader>
242243 <AlertDialogTitle>End {sessionTitle(session)}?</AlertDialogTitle>
314315 {titleIsProvisional(session) ? (
315316 // Muted while it is what the first prompt asked: g1t names it once the work is clear.
316317 <Hint label={titleHint(session)}>
317− <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium text-muted hover:text-accent">
318+ <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="font-medium text-muted hover:text-accent">
318319 {sessionTitle(session)}
319320 </Link>
320321 </Hint>
321322 ) : (
322− <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
323+ <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="font-medium hover:text-accent">
323324 {sessionTitle(session)}
324325 </Link>
325326 )}
391392 }
392393 aside={
393394 <Link
394− to={`${base}/sessions/${session.id}`}
395+ to={sessionPath(workspaceOf(base), session.id)}
395396 prefetch="intent"
396397 className="inline-flex items-center gap-1 text-xs text-faint hover:text-fg"
397398 >
400401 </Link>
401402 }
402403 >
403− <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
404+ <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="font-medium hover:text-accent">
404405 {sessionTitle(session)}
405406 </Link>
406407 <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
447448 return (
448449 <div className="flex min-w-0 items-center gap-1.5 text-xs">
449450 <Bot size={12} className="shrink-0 text-faint" />
450− <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="shrink-0 font-medium text-fg hover:text-accent">
451+ <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="shrink-0 font-medium text-fg hover:text-accent">
451452 {session.agentType ?? "Subagent"}
452453 </Link>
453454 {session.task && (
780781 <p className="min-w-0 grow wrap-break-word text-muted">
781782 <PersonLink name={line.who ?? "g1t"} className="font-medium text-fg hover:underline" />
782783 {line.harness && <span className="text-faint"> via {harnessName(line.harness)}</span>}{" "}
783− <NumberedText text={line.text} base={base} />
784+ <NumberedText text={line.text} base={line.repo ? `/${line.repo}` : base} />
785+ {line.repo && line.repo !== base.slice(1) && <span className="text-faint"> in {line.repo}</span>}
784786 {line.session && (
785787 <>
786788 {" · "}
787− <Link to={`${base}/sessions/${line.session}`} className="text-fg hover:text-accent">
789+ <Link to={sessionPath(workspaceOf(base), line.session)} className="text-fg hover:text-accent">
788790 the session
789791 </Link>
790792 </>
+5−6
3636 import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav";
3737 import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome";
3838 import { ROADMAP, type RoadmapItem } from "../lib/roadmap";
39−import { repoWorkPath } from "../lib/work-items";
4039
4140 /**
4241 * The phone's layout (below 768px): a bottom bar of apps with More for
302301 <span className="ml-auto truncate font-mono text-xs text-faint">{slug}</span>
303302 </div>
304303 <div className="grid grid-cols-4 gap-1.5">
305− {/* Code first, for a member who has it: it left the bar for Work. */}
304+ {/* Sessions first, what is cooking, then Code for a member who has it: both left the bar for Work. */}
305+ <MoreTile to={appOf("sessions").path(slug)} icon={appIcon("sessions", 20)} label="Sessions" />
306306 {apps.includes("code") && <MoreTile to={appOf("code").path(slug)} icon={appIcon("code", 20)} label="Code" />}
307307 <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
308308 {shown.map((app) => (
309309 <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} />
310310 ))}
311311 {apps
312− .filter((key) => key !== "code")
312+ .filter((key) => key !== "code" && key !== "sessions")
313313 .map((key) => (
314314 <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} />
315315 ))}
594594 <NavTabs label="Project" className={always ? undefined : "md:hidden"}>
595595 {pages.map((page) => {
596596 const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page];
597− const [namespace = "", name = ""] = base.split("/").filter(Boolean);
598− const to = page === "work" ? repoWorkPath(namespace, name) : link.path ? `${base}/${link.path}` : base;
597+ const to = link.path ? `${base}/${link.path}` : base;
599598 return (
600599 <NavTab
601600 key={page}
602601 to={to}
603602 current={at === page}
604603 soon={page !== "overview" && PROJECT_PAGE_LINKS[page].soon}
605− count={page === "work" ? counts?.items : page === "pulls" ? counts?.pulls : undefined}
604+ count={page === "pulls" ? counts?.pulls : undefined}
606605 >
607606 {link.label}
608607 </NavTab>
+4−2
234234 { key: "home", label: "Home" },
235235 // Every member's, Code access or not.
236236 { key: "work", label: "Work" },
237+ // What is cooking across the workspace, right after its work items.
238+ { key: "sessions", label: "Sessions" },
237239 {
238240 key: "chat",
239241 label: "Chat",
272274 current={here === item.key && !shown.some((app) => at(app.to))}
273275 badge={item.badge}
274276 />
275− {/* Right after Work: g1t beyond your workspaces (Explore, search, profiles, other workspaces and their public projects). */}
276− {item.key === "work" && (
277+ {/* Right after Sessions: g1t beyond your workspaces (Explore, search, profiles, other workspaces and their public projects). */}
278+ {item.key === "sessions" && (
277279 <RailLink to="/explore" label="Explore" icon={<Compass size={20} />} current={here === "browse" || at("/explore")} />
278280 )}
279281 </Fragment>
+356−0
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, 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+/**
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+ */
129+export 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. */
174+export 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+ */
210+export 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. */
261+export 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+
282+const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[];
283+const 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. */
286+export 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].filter(Boolean).length;
290+ const clear = to({ repo: null, person: null, harness: null, kind: null });
291+ const everyone = me ? [me, ...people.filter((name) => name !== me)] : people;
292+ const menus: { title: string; active: string | null; any: string; field: keyof SessionsFilters; options: { key: string; label: ReactNode }[] }[] = [
293+ { 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> })) },
294+ { title: "Person", active: filters.person, any: "Anyone", field: "person", options: everyone.map((name) => ({ key: name, label: name === me ? `${name} (you)` : name })) },
295+ { title: "Harness", active: filters.harness ? HARNESS_NAMES[filters.harness] : null, any: "Any harness", field: "harness", options: HARNESSES.map((key) => ({ key, label: HARNESS_NAMES[key] })) },
296+ { title: "Kind", active: filters.kind ? KIND_NAMES[filters.kind] : null, any: "Every kind", field: "kind", options: KINDS.map((key) => ({ key, label: KIND_NAMES[key] })) },
297+ ];
298+ return (
299+ <>
300+ <div className="hidden flex-wrap items-center gap-2 sm:flex">
301+ {menus.map((menu) => (
302+ <FilterMenu
303+ key={menu.title}
304+ label={menu.title}
305+ active={menu.active ?? undefined}
306+ clearTo={menu.active ? to({ [menu.field]: null }) : undefined}
307+ searchPlaceholder={`Filter ${menu.title.toLowerCase()}`}
308+ emptyText="Nothing matches."
309+ options={menu.options.map((option) => ({
310+ key: option.key,
311+ to: to({ [menu.field]: option.key }),
312+ keywords: option.key,
313+ selected: filters[menu.field] === option.key,
314+ label: <span className="truncate">{option.label}</span>,
315+ }))}
316+ />
317+ ))}
318+ {narrowed(filters) && (
319+ <Link to={clear} className="text-xs text-muted hover:text-fg">
320+ Clear filters
321+ </Link>
322+ )}
323+ </div>
324+ <div className="flex min-w-0 items-center gap-2 sm:hidden">
325+ <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)} aria-haspopup="dialog" className="h-10 gap-2 px-3.5">
326+ <SlidersHorizontal size={15} />
327+ Filters
328+ {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>}
329+ </Button>
330+ {filters.repo && <span className="min-w-0 truncate font-mono text-xs text-muted">{filters.repo}</span>}
331+ </div>
332+ <BottomSheet open={open} onOpenChange={setOpen} title="Filters">
333+ <div className="flex items-center justify-between px-3 pt-1 pb-2">
334+ <h2 className="text-base font-semibold">Filters</h2>
335+ {count > 0 && (
336+ <Link to={clear} onClick={() => setOpen(false)} className="inline-flex h-10 items-center gap-1 px-2 text-sm text-muted active:text-fg">
337+ <X size={14} />
338+ Clear all
339+ </Link>
340+ )}
341+ </div>
342+ {menus.map((menu) => (
343+ <SheetChoices
344+ key={menu.title}
345+ title={menu.title}
346+ onPick={() => setOpen(false)}
347+ choices={[
348+ { key: "any", label: menu.any, to: to({ [menu.field]: null }), on: !filters[menu.field] },
349+ ...menu.options.map((option) => ({ key: option.key, label: option.label, to: to({ [menu.field]: option.key }), on: filters[menu.field] === option.key })),
350+ ]}
351+ />
352+ ))}
353+ </BottomSheet>
354+ </>
355+ );
356+}
+78−12
1−import { Database, Activity, BarChart3, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Database, Activity, BarChart3, Bot, Cpu, Columns3, Radio, ScrollText, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
8181 import { useLiveBadges } from "../lib/notify-client";
8282 import { OwnPresenceDot, OwnPresenceItems, StatusDialog } from "./presence";
8383 import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch";
84−import { newItemPath, repoWorkPath, workPath } from "../lib/work-items";
84+import { newItemPath, repoWorkPath, sessionsPath, workPath } from "../lib/work-items";
8585
8686 /**
8787 * What the sidebar needs, worked out by the root loader. For a visitor who
832832 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, `${base}/compare`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
833833 Code
834834 </SidebarLink>
835− {/* Its work items: the workspace's Work list, filtered to it. */}
836− {shows.has("work") && (
837− <SidebarLink to={repoWorkPath(repo.namespace, repo.name)} icon={<ListTodo size={15} />} count={repo.items}>
838− Work
839− </SidebarLink>
840− )}
841835 {shows.has("pulls") && (
842− <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`, `${base}/sessions`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
836+ <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
843837 Pull requests
844838 </SidebarLink>
845839 )}
14251419 /**
14261420 * Work's sidebar, and its menu on a phone (the Work tab tapped again):
14271421 * the views people keep coming back to, saved in the address (open, yours,
1428− * closed), then the workspace's labels and milestones, which Work holds.
1422+ * closed), the board of them as they move, then the workspace's labels and
1423+ * milestones, which Work holds. On a phone the menu also leads to
1424+ * Sessions, which has no tab of its own there.
14291425 */
14301426 function WorkSidebar({ slug, username }: { slug: string; username: string }) {
14311427 const { pathname: path, search } = useSidebarLocation();
14491445 <SidebarLink to={`${list}?state=closed`} icon={<Check size={15} />} current={view({ state: "closed" })}>
14501446 Closed
14511447 </SidebarLink>
1448+ <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />} current={at("board")}>
1449+ Board
1450+ </SidebarLink>
1451+ <div className="md:hidden">
1452+ <SidebarLink to={sessionsPath(slug)} icon={<Activity size={15} />}>
1453+ Sessions
1454+ </SidebarLink>
1455+ </div>
14521456 <SidebarLink to={newItemPath(slug)} icon={<Plus size={15} />} current={at("new")}>
14531457 New work item
14541458 </SidebarLink>
14681472 }
14691473
14701474 /**
1475+ * Sessions' sidebar, and its menu on a phone: what is going now, every
1476+ * session, your own, and each kind, kept in the address.
1477+ */
1478+function SessionsSidebar({ slug, username }: { slug: string; username: string }) {
1479+ const { pathname: path, search } = useSidebarLocation();
1480+ const list = sessionsPath(slug);
1481+ const query = new URLSearchParams(search);
1482+ const onList = path === list;
1483+ const view = (want: Record<string, string>) =>
1484+ onList && [...query.keys()].every((key) => key in want) && Object.entries(want).every(([key, value]) => query.get(key) === value);
1485+ return (
1486+ <div className="flex h-full flex-col">
1487+ <ModeHeader title="Sessions" />
1488+ <nav aria-label="Sessions" className={PANEL}>
1489+ <NavRows>
1490+ <SidebarLink to={list} icon={<Radio size={15} />} current={view({})}>
1491+ Live now
1492+ </SidebarLink>
1493+ <SidebarLink to={sessionsPath(slug, { state: "recent" })} icon={<History size={15} />} current={view({ state: "recent" })}>
1494+ Recent
1495+ </SidebarLink>
1496+ <SidebarLink to={sessionsPath(slug, { person: username })} icon={<CircleUserRound size={15} />} current={view({ person: username })}>
1497+ Yours
1498+ </SidebarLink>
1499+ </NavRows>
1500+ <Rule />
1501+ <NavRows>
1502+ <SidebarLink to={sessionsPath(slug, { kind: "harness" })} icon={<Bot size={15} />} current={view({ kind: "harness" })}>
1503+ People's harnesses
1504+ </SidebarLink>
1505+ <SidebarLink to={sessionsPath(slug, { kind: "hosted" })} icon={<Sparkles size={15} />} current={view({ kind: "hosted" })}>
1506+ Started on g1t
1507+ </SidebarLink>
1508+ <SidebarLink to={sessionsPath(slug, { kind: "agent" })} icon={<Cpu size={15} />} current={view({ kind: "agent" })}>
1509+ g1t's agent
1510+ </SidebarLink>
1511+ <SidebarLink to={sessionsPath(slug, { kind: "recorded" })} icon={<ScrollText size={15} />} current={view({ kind: "recorded" })}>
1512+ Recorded on pull requests
1513+ </SidebarLink>
1514+ </NavRows>
1515+ <Rule />
1516+ <NavRows>
1517+ <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />}>
1518+ Board
1519+ </SidebarLink>
1520+ </NavRows>
1521+ </nav>
1522+ </div>
1523+ );
1524+}
1525+
1526+/**
14711527 * People's sidebar: everyone in the workspace and its teams. Who belongs, and
14721528 * inviting people, is the workspace's: Workspace mode's Access group.
14731529 */
16731729 } else if (third === "work") {
16741730 // Work: Work / #12, Work / Labels, Work / Milestones / #3.
16751731 trail.push({ label: "Work", to: page });
1676− const words: Record<string, string> = { new: "New work item", labels: "Labels", milestones: "Milestones" };
1732+ const words: Record<string, string> = { new: "New work item", board: "Board", labels: "Labels", milestones: "Milestones" };
16771733 if (fourth && /^\d+$/.test(fourth)) trail.push({ label: `Work item #${fourth}`, short: `#${fourth}`, to: `${page}/${fourth}` });
16781734 else if (fourth && words[fourth]) trail.push({ label: words[fourth]!, to: `${page}/${fourth}` });
16791735 const fifth = parts[4];
16801736 if (fourth === "milestones" && fifth) trail.push({ label: `#${fifth}`, to: `${page}/milestones/${fifth}` });
1737+ } else if (third === "sessions") {
1738+ // Sessions: Sessions / one session.
1739+ trail.push({ label: "Sessions", to: page });
1740+ if (fourth) trail.push({ label: "Session", to: `${page}/${fourth}` });
16811741 } else if (third) {
16821742 // The workspace's settings pages each stand alone: General, Members, Permissions.
16831743 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
18121872 { label: "Home", to: homePagePath(ws.slug), icon: <House size={15} /> },
18131873 { label: "Work", hint: "The workspace's work items", to: workPath(ws.slug), icon: <ListTodo size={15} /> },
18141874 { label: "New work item", to: newItemPath(ws.slug), icon: <Plus size={15} /> },
1875+ { label: "Board", hint: "Work items as they move", to: workPath(ws.slug, "board"), icon: <Columns3 size={15} /> },
1876+ { label: "Sessions", hint: "What is cooking across the workspace", to: sessionsPath(ws.slug), icon: <Activity size={15} /> },
18151877 { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> },
18161878 { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> },
18171879 { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> },
18401902 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
18411903 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
18421904 { label: "Work", hint: name, to: repoWorkPath(repo.namespace, repo.name), icon: <ListTodo size={15} /> },
1905+ { label: "Sessions", hint: name, to: sessionsPath(repo.namespace, { repo: repo.name }), icon: <Activity size={15} /> },
18431906 { label: "New work item", hint: name, to: newItemPath(repo.namespace, repo.name), icon: <Plus size={15} /> },
18441907 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
18451908 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
19552018 }
19562019
19572020 /** The sidebars that sit beside the page, one per mode that has one. */
1958−type Panel = "work" | "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse";
2021+type Panel = "work" | "sessions" | "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse";
19592022
19602023 /** Each sidebar's name, for the button that opens it on a phone. */
19612024 const MODE_MENU: Record<Panel, string> = {
19622025 browse: "Explore",
19632026 work: "Work",
2027+ sessions: "Sessions",
19642028 chat: "Chat",
19652029 artifacts: "Artifacts",
19662030 code: "Code",
21202184 return <CodeSidebar user={user} shell={shell} missing={missing} browsing={browsing} />;
21212185 case "work":
21222186 return <WorkSidebar slug={ws.slug} username={user.username} />;
2187+ case "sessions":
2188+ return <SessionsSidebar slug={ws.slug} username={user.username} />;
21232189 case "chat":
21242190 return <ChatSidebar slug={ws.slug} />;
21252191 case "artifacts":
+1−1
357357 }
358358
359359 /** One filter in the phone's sheet: a heading and its choices, 48px rows, the one on ticked. */
360−function SheetChoices({
360+export function SheetChoices({
361361 title,
362362 choices,
363363 failed = false,
+2−1
1414 import { Markdown } from "./markdown";
1515 import { Reactions } from "./reactions";
1616 import { ItemIcon, PullIcon } from "./work-icons";
17+import { sessionPath } from "../lib/work-items";
1718 import { MentionTextarea } from "./mention-textarea";
1819 import { ErrorText, SubmitButton, TimeAgo } from "./ui";
1920 import { Badge } from "./ui/badge";
423424 )
424425 ) : part.kind === "session" ? (
425426 // The session it was closed in.
426− base ? <SessionChip key={at} href={`${base}/sessions/${part.id}`} id={part.id} /> : <code key={at} className="font-mono text-xs text-fg">{part.text}</code>
427+ base ? <SessionChip key={at} href={sessionPath(base.split("/").filter(Boolean)[0] ?? "", part.id)} id={part.id} /> : <code key={at} className="font-mono text-xs text-fg">{part.text}</code>
427428 ) : (
428429 part.text
429430 ),
+4−4
2525 } from "./apps.ts";
2626
2727 test("Work is a built-in app right after Home, for every member", () => {
28− assert.deepEqual(BUILTINS.slice(0, 2).map((app) => app.key), ["home", "work"]);
28+ assert.deepEqual(BUILTINS.slice(0, 3).map((app) => app.key), ["home", "work", "sessions"]);
2929 assert.equal(appOf("work").path("acme"), "/acme/-/work");
3030 assert.equal(appOf("work").code, undefined, "not part of Code");
3131 });
3232
33−test("a phone's bar is Home, Work, Chat and Notifications, with Code first under More", () => {
33+test("a phone's bar is Home, Work, Chat and Notifications, with Sessions then Code first under More", () => {
3434 assert.deepEqual([...PHONE_TABS], ["home", "work", "chat", "notifications"]);
3535 // Four tabs and More: five slots.
3636 assert.equal(PHONE_TABS.length + 1, 5);
37− assert.deepEqual(phoneMoreApps(true), ["code", "artifacts", "routines", "people", "workspace"]);
37+ assert.deepEqual(phoneMoreApps(true), ["sessions", "code", "artifacts", "routines", "people", "workspace"]);
3838 // A member without Code access has the rest, Work still on the bar.
39− assert.deepEqual(phoneMoreApps(false), ["artifacts", "routines", "people", "workspace"]);
39+ assert.deepEqual(phoneMoreApps(false), ["sessions", "artifacts", "routines", "people", "workspace"]);
4040 });
4141
4242 test("only integrations are pinned; built-in apps, their pages, old plugins and connectors are not", () => {
+7−5
2626 import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors";
2727
2828 /** The apps that are always in the rail. */
29−export type BuiltinApp = "home" | "work" | "chat" | "notifications" | "code" | "artifacts" | "routines" | "people" | "workspace";
29+export type BuiltinApp = "home" | "work" | "sessions" | "chat" | "notifications" | "code" | "artifacts" | "routines" | "people" | "workspace";
3030
3131 export type BuiltinInfo = {
3232 key: BuiltinApp;
4343 export const BUILTINS: BuiltinInfo[] = [
4444 { key: "home", name: "Home", path: under("home") },
4545 { key: "work", name: "Work", path: under("work") },
46+ { key: "sessions", name: "Sessions", path: under("sessions") },
4647 { key: "chat", name: "Chat", path: under("chat") },
4748 { key: "notifications", name: "Notifications", path: () => "/notifications" },
4849 { key: "code", name: "Code", path: under("overview"), code: true },
5657 export const PHONE_TABS = ["home", "work", "chat", "notifications"] as const satisfies readonly BuiltinApp[];
5758
5859 /**
59− * The built-in apps the phone's More sheet has, in its order: Code first,
60− * for a member with Code access, then the rest of the rail's.
60+ * The built-in apps the phone's More sheet has, in its order: Sessions
61+ * first, then Code for a member with Code access, then the rest of the
62+ * rail's.
6163 */
6264 export function phoneMoreApps(code: boolean): BuiltinApp[] {
63− const rest = BUILTINS.map((app) => app.key).filter((key) => !(PHONE_TABS as readonly string[]).includes(key) && key !== "code");
64− return code ? ["code", ...rest] : rest;
65+ const rest = BUILTINS.map((app) => app.key).filter((key) => !(PHONE_TABS as readonly string[]).includes(key) && key !== "code" && key !== "sessions");
66+ return code ? ["sessions", "code", ...rest] : ["sessions", ...rest];
6567 }
6668
6769 /** The built-in app `key`. */
+117−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { Claim, ItemRepo, WorkItem, WorkspaceSession } from "@g1t/contracts";
5+
6+import { boardOf, pullsByItem, shownStage, stageOf } from "./board.ts";
7+
8+const NOW = Date.parse("2026-10-17T12:00:00Z");
9+const DAY = 86_400_000;
10+
11+function repo(path: string, role: ItemRepo["role"], landed = false): ItemRepo {
12+ return { repoId: `rep_${path}`, path, role, legacyNumber: null, landed };
13+}
14+
15+function item(number: number, more: Partial<WorkItem> = {}): WorkItem {
16+ return {
17+ id: `wki_${number}`,
18+ workspace: "acme",
19+ number,
20+ title: `Item ${number}`,
21+ body: "",
22+ labels: [],
23+ state: "open",
24+ reason: null,
25+ resolutions: [],
26+ author: { id: "usr_1", username: "ana" } as WorkItem["author"],
27+ requestedBy: null,
28+ createdAt: new Date(NOW - 5 * DAY).toISOString(),
29+ updatedAt: new Date(NOW - number * 1000).toISOString(),
30+ closedAt: null,
31+ pullCount: 0,
32+ commentCount: 0,
33+ assignees: [],
34+ blockedBy: [],
35+ agent: null,
36+ repos: [repo("acme/web", "origin")],
37+ ...more,
38+ };
39+}
40+
41+function live(item: number, more: Partial<WorkspaceSession> = {}): WorkspaceSession {
42+ return {
43+ kind: "harness",
44+ id: `ses_${item}`,
45+ repo: "acme/web",
46+ repoId: "rep_acme/web",
47+ title: null,
48+ actor: "bo",
49+ harness: "claude_code",
50+ agent: null,
51+ branch: "main",
52+ item,
53+ itemTitle: null,
54+ pull: null,
55+ pullTitle: null,
56+ pullStatus: null,
57+ state: "active",
58+ startedAt: new Date(NOW).toISOString(),
59+ lastActivityAt: new Date(NOW).toISOString(),
60+ subagents: 0,
61+ step: null,
62+ session: null,
63+ ...more,
64+ };
65+}
66+
67+const claim = (number: number, who: string): Claim =>
68+ ({ id: `clm_${number}`, item: number, workspace: "acme", state: "active", actor: { id: "usr_9", username: who } }) as Claim;
69+
70+test("each work item's stage is the first that holds: done, landing, review, progress, backlog", () => {
71+ assert.equal(stageOf(item(1), {}, NOW), "backlog");
72+ assert.equal(stageOf(item(2, { assignees: ["ana"] }), {}, NOW), "progress");
73+ assert.equal(stageOf(item(3), { claimed: true }, NOW), "progress");
74+ assert.equal(stageOf(item(4), { live: true }, NOW), "progress");
75+ assert.equal(stageOf(item(5), { pulls: ["draft"] }, NOW), "progress");
76+ assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: ["draft", "open"] }, NOW), "review");
77+ // Landed in one target, not yet the other: Landing, even with a pull request open.
78+ const landing = item(7, { repos: [repo("acme/web", "target", true), repo("acme/api", "target")] });
79+ assert.equal(stageOf(landing, { pulls: ["open"] }, NOW), "landing");
80+ // A touched repository's landing is not a target's.
81+ assert.equal(stageOf(item(8, { repos: [repo("acme/web", "touched", true), repo("acme/api", "target")] }), {}, NOW), "backlog");
82+ assert.equal(stageOf(item(9, { state: "closed", closedAt: new Date(NOW - 3 * DAY).toISOString() }), {}, NOW), "done");
83+ // Closed long ago: off the board.
84+ assert.equal(stageOf(item(10, { state: "closed", closedAt: new Date(NOW - 40 * DAY).toISOString() }), {}, NOW), null);
85+});
86+
87+test("the board puts each item in its column with who and which session is on it", () => {
88+ const items = [
89+ item(1),
90+ item(2),
91+ item(3),
92+ item(4),
93+ item(5, { state: "closed", closedAt: new Date(NOW - DAY).toISOString() }),
94+ item(6, { state: "closed", closedAt: new Date(NOW - 2 * DAY).toISOString() }),
95+ ];
96+ const board = boardOf(
97+ items,
98+ { claims: [claim(2, "cy")], sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], pulls: pullsByItem([{ item: 4, status: "draft" }, { item: null, status: "open" }]) },
99+ NOW,
100+ );
101+ assert.deepEqual(board.backlog.map((card) => card.item.number), [1]);
102+ assert.deepEqual(board.progress.map((card) => card.item.number), [2, 4]);
103+ assert.deepEqual(board.progress[0]!.on.people, ["cy"]);
104+ // A live session's open pull request puts its item in review, the session on the card.
105+ assert.deepEqual(board.review.map((card) => card.item.number), [3]);
106+ assert.deepEqual(board.review[0]!.on.sessions.map((session) => session.id), ["ses_3"]);
107+ assert.deepEqual(board.review[0]!.on.people, ["bo"]);
108+ // Done: the most recently closed first.
109+ assert.deepEqual(board.done.map((card) => card.item.number), [5, 6]);
110+});
111+
112+test("a phone shows the stage asked for, else the first with something in it", () => {
113+ const board = boardOf([item(1, { assignees: ["ana"] })], {}, NOW);
114+ assert.equal(shownStage("review", board), "review");
115+ assert.equal(shownStage("nonsense", board), "progress");
116+ assert.equal(shownStage(null, boardOf([], {}, NOW)), "backlog");
117+});
+104−0
1+/**
2+ * Work's Board: the workspace's work items as they move, in columns read
3+ * from what is so (nothing is dragged): Backlog, In progress, In review,
4+ * Landing and Done. Pure, so it is tested under Node.
5+ */
6+import type { Claim, PullStatus, WorkItem, WorkspaceSession } from "@g1t/contracts";
7+
8+export type Stage = "backlog" | "progress" | "review" | "landing" | "done";
9+
10+/** The columns, left to right. */
11+export const STAGES: Stage[] = ["backlog", "progress", "review", "landing", "done"];
12+
13+export const STAGE_INFO: Record<Stage, { label: string; hint: string }> = {
14+ backlog: { label: "Backlog", hint: "Open, and nobody is on it" },
15+ progress: { label: "In progress", hint: "Claimed or assigned, a live session on it, or a draft pull request" },
16+ review: { label: "In review", hint: "A pull request for it is open" },
17+ landing: { label: "Landing", hint: "Landed in some of its repositories, still missing in others" },
18+ done: { label: "Done", hint: "Closed in the last 14 days" },
19+};
20+
21+/** How long a closed work item stays in Done. */
22+export const DONE_DAYS = 14;
23+
24+/** What is known of a work item's pull requests: their statuses. */
25+export type ItemPulls = Record<number, PullStatus[]>;
26+
27+/** Who or what is on a work item, for its card. */
28+export type OnIt = {
29+ /** People: assignees, claimants and whoever runs a live session on it, each once. */
30+ people: string[];
31+ /** Live sessions on it. */
32+ sessions: WorkspaceSession[];
33+};
34+
35+/**
36+ * The stage a work item is in, the first that holds: closed recently is
37+ * Done; landed in some target repositories and not others is Landing; an
38+ * open pull request is In review; claimed, assigned, a live session or a
39+ * draft is In progress; anything else open is Backlog. Null for one closed
40+ * longer ago than [`DONE_DAYS`], which the board leaves out.
41+ */
42+export function stageOf(item: WorkItem, facts: { pulls?: PullStatus[]; claimed?: boolean; live?: boolean }, now: number): Stage | null {
43+ if (item.state === "closed") {
44+ const closed = item.closedAt ? Date.parse(item.closedAt) : 0;
45+ return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
46+ }
47+ const targets = item.repos.filter((repo) => repo.role === "target");
48+ if (targets.some((repo) => repo.landed) && targets.some((repo) => !repo.landed)) return "landing";
49+ const pulls = facts.pulls ?? [];
50+ if (pulls.includes("open")) return "review";
51+ if (facts.claimed || facts.live || item.assignees.length > 0 || pulls.includes("draft")) return "progress";
52+ return "backlog";
53+}
54+
55+export type BoardCard = { item: WorkItem; stage: Stage; on: OnIt };
56+
57+export type Board = Record<Stage, BoardCard[]>;
58+
59+/**
60+ * The board of `items`, given the workspace's active claims, its live
61+ * sessions and what is known of each item's pull requests. Each column
62+ * keeps the most recently changed first; Done the most recently closed.
63+ */
64+export function boardOf(
65+ items: WorkItem[],
66+ { claims = [], sessions = [], pulls = {} }: { claims?: Claim[]; sessions?: WorkspaceSession[]; pulls?: ItemPulls },
67+ now: number,
68+): Board {
69+ const board: Board = { backlog: [], progress: [], review: [], landing: [], done: [] };
70+ const seen = new Set<number>();
71+ for (const item of items) {
72+ if (seen.has(item.number)) continue;
73+ seen.add(item.number);
74+ const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active");
75+ const live = sessions.filter((session) => session.item === item.number && session.state !== "ended");
76+ // A live session's pull request counts as one of the item's.
77+ const known = [...(pulls[item.number] ?? []), ...live.flatMap((session) => (session.pullStatus ? [session.pullStatus] : []))];
78+ const stage = stageOf(item, { pulls: known, claimed: claimed.length > 0, live: live.length > 0 }, now);
79+ if (!stage) continue;
80+ const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))];
81+ board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live } });
82+ }
83+ for (const stage of STAGES) {
84+ const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt);
85+ board[stage].sort((a, b) => at(b) - at(a));
86+ }
87+ return board;
88+}
89+
90+/** Each work item's pull requests' statuses, from pull requests that name their item. */
91+export function pullsByItem(pulls: { item: number | null; status: PullStatus }[]): ItemPulls {
92+ const by: ItemPulls = {};
93+ for (const pull of pulls) {
94+ if (pull.item == null) continue;
95+ (by[pull.item] ??= []).push(pull.status);
96+ }
97+ return by;
98+}
99+
100+/** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */
101+export function shownStage(asked: string | null, board: Board): Stage {
102+ if (asked && (STAGES as string[]).includes(asked)) return asked as Stage;
103+ return STAGES.find((stage) => board[stage].length > 0) ?? "backlog";
104+}
+5−3
5151
5252 test("a project's menu hides member-only pages from everyone else", () => {
5353 const visitor = projectPages(false);
54− assert.deepEqual(visitor.slice(0, 4), ["code", "work", "pulls", "actions"]);
54+ // Work items and sessions are the workspace's: no tab of the project's.
55+ assert.deepEqual(visitor.slice(0, 3), ["code", "pulls", "actions"]);
5556 for (const page of ["security", "settings"] as const) {
5657 assert.ok(!visitor.includes(page), page);
5758 assert.ok(projectPages(true).includes(page), page);
6970 assert.equal(projectPageAt("commit/abc123"), "code");
7071 // Work is the workspace's list, filtered to the project: no page of the project lights for an old issue's address.
7172 assert.equal(projectPageAt("issues/12"), null);
72− assert.equal(projectPageAt("sessions"), "pulls");
73+ // A repository has no Sessions page: Sessions is the workspace's.
74+ assert.equal(projectPageAt("sessions"), null);
7375 assert.equal(projectPageAt("pull/3/files"), "pulls");
7476 assert.equal(projectPageAt("queue"), "pulls");
75− assert.equal(projectPageAt("sessions/12"), "pulls");
77+ assert.equal(projectPageAt("sessions/12"), null);
7678 assert.equal(projectPageAt("actions/runs/9"), "actions");
7779 assert.equal(projectPageAt("security/secret-scanning"), "security");
7880 assert.equal(projectPageAt("stargazers"), "insights");
+1−5
6363 /** The project pages the sidebar lists, by key, for a member or not. */
6464 export type ProjectPage =
6565 | "code"
66− | "work"
6766 | "pulls"
6867 | "actions"
6968 | "deployments"
10099 export function projectPages(member: boolean, can?: Partial<Abilities>): ProjectPage[] {
101100 const pages: (ProjectPage | false)[] = [
102101 "code",
103− "work",
104102 "pulls",
105103 "actions",
106104 // A public repository's deployments and build logs are for anyone, as its code is.
127125 */
128126 export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean; outside?: boolean }> = {
129127 code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare"] },
130− // The workspace's Work list filtered to the project (`outside`): work items belong to the workspace.
131− work: { label: "Work", path: "work", also: [], outside: true },
132− pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue", "sessions"] },
128+ pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue"] },
133129 actions: { label: "Workflows", path: "actions", also: [] },
134130 deployments: { label: "Deployments", path: "deployments", also: ["settings/deployments"] },
135131 observability: { label: "Observability", path: "observability", also: [] },
+2−0
405405 number: number | null;
406406 /** The harness session it is about, by id, for a link to its page. */
407407 session?: string;
408+ /** The repository it happened in, `owner/name`, in a feed across a workspace's. */
409+ repo?: string;
408410 tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info";
409411 };
410412
+2−2
744744 const [marked, plain] = withSessions([merge(), merge({ key: "landed:acme/web#13", sha: "m13" })], [
745745 { repo: { namespace: "acme", name: "web" }, pulls: [{ number: 12, sessions: [{ id: "ses_1", harness: "claude_code" }, { id: "ses_2", harness: "codex" }] }] },
746746 ]);
747− assert.deepEqual(marked!.agent, { harness: "claude_code", to: "/acme/web/work/sessions/ses_1" });
747+ assert.deepEqual(marked!.agent, { harness: "claude_code", to: "/acme/-/sessions/ses_1" });
748748 assert.equal(agentLabel(marked!.agent!), "Made in a Claude Code session");
749749 assert.equal(plain!.agent, undefined);
750750 assert.equal(agentLabel({ harness: null, to: null }), "Made in a session");
773773 assert.deepEqual(
774774 rows.map((row) => [row.title, row.detail, row.sha, row.to, row.agent?.to ?? null]),
775775 [
776− ["Fix the bell's count", "web · pushed to main · by ana", "c3", "/acme/web/commit/c3", "/acme/web/work/sessions/ses_9"],
776+ ["Fix the bell's count", "web · pushed to main · by ana", "c3", "/acme/web/commit/c3", "/acme/-/sessions/ses_9"],
777777 ["2 commits pushed to main", "web · Tidy · by ana", "c2", "/acme/web/activity", null],
778778 ],
779779 );
+3−2
2929 import { HARNESS_NAMES } from "./coordination.ts";
3030 import { type PushRecord, pushedByPerson, pushedCommits } from "./mission-control.ts";
3131 import { settledLine } from "./inbox.ts";
32+import { sessionPath } from "./work-items.ts";
3233
3334 const MINUTE = 60_000;
3435 const HOUR = 60 * MINUTE;
748749 at: Date.parse(push.time),
749750 sha: push.data.after,
750751 repoId,
751− agent: session ? { harness: null, to: `/${repo.namespace}/${repo.name}/work/sessions/${session}` } : null,
752+ agent: session ? { harness: null, to: sessionPath(repo.namespace, session) } : null,
752753 ...(brought.length === 1 && subject(newest.message) ? { refsIn: `${repo.namespace}/${repo.name}` } : {}),
753754 });
754755 }
821822 for (const { repo, pulls } of sessions) {
822823 for (const pull of pulls) {
823824 const first = pull.sessions[0];
824− if (first) found.set(`landed:${pullKey(repo, pull.number)}`, { harness: first.harness, to: `/${repo.namespace}/${repo.name}/work/sessions/${first.id}` });
825+ if (first) found.set(`landed:${pullKey(repo, pull.number)}`, { harness: first.harness, to: sessionPath(repo.namespace, first.id) });
825826 }
826827 }
827828 return rows.map((row) => (row.agent || !found.has(row.key) ? row : { ...row, agent: found.get(row.key)! }));
+1−2
9797 ]);
9898 }
9999
100−/** Pull requests, the merge queue and the sessions working on them share a page's tabs too. */
100+/** Pull requests and the merge queue share a page's tabs too. */
101101 export const PULLS: Section = {
102102 key: "Code",
103103 tabs: [
104104 { label: "Pull requests", path: "pulls", also: ["pull"] },
105105 { label: "Merge queue", path: "queue" },
106− { label: "Sessions", path: "sessions" },
107106 ],
108107 };
109108
+1−1
161161 change.kind === "queue.changed";
162162 },
163163 /**
164− * The Sessions page: claims, sessions starting and ending, dependencies,
164+ * Sessions (the workspace's, over its repositories): claims, sessions starting and ending, dependencies,
165165 * overlaps, what the merge queue finds and decisions, the queue moving,
166166 * and merges; and sessions going on (heard from, so still active).
167167 */
+80−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { WorkSession, WorkspaceSession } from "@g1t/contracts";
5+
6+import { narrowed, peopleIn, repoName, runsIn, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts";
7+
8+function listed(id: string, more: Partial<WorkspaceSession> = {}): WorkspaceSession {
9+ return {
10+ kind: "harness",
11+ id,
12+ repo: "acme/web",
13+ repoId: "rep_1",
14+ title: null,
15+ actor: "ana",
16+ harness: "claude_code",
17+ agent: null,
18+ branch: "main",
19+ item: null,
20+ itemTitle: null,
21+ pull: null,
22+ pullTitle: null,
23+ pullStatus: null,
24+ state: "active",
25+ startedAt: "2026-10-17T10:00:00Z",
26+ lastActivityAt: "2026-10-17T11:00:00Z",
27+ subagents: 0,
28+ step: null,
29+ session: null,
30+ ...more,
31+ };
32+}
33+
34+const own = (more: Partial<WorkSession> = {}): WorkSession =>
35+ ({ id: "ses_1", repoId: "rep_1", item: null, pull: null, branch: "fix-it", actor: { id: "u", username: "ana" }, harness: "codex", harnessSessionId: null, title: null, state: "active", headCommit: null, startedAt: "", lastSeenAt: "", endedAt: null, endReason: null, ...more }) as WorkSession;
36+
37+test("every kind of session opens where it is shown", () => {
38+ assert.equal(sessionHref("Acme", listed("ses_1")), "/acme/-/sessions/ses_1");
39+ assert.equal(sessionHref("acme", listed("ses_2", { kind: "hosted", harness: "claude_managed_agents" })), "/acme/-/sessions/ses_2");
40+ // g1t's agent and a recorded session are their pull request's Session tab.
41+ assert.equal(sessionHref("acme", listed("run_1", { kind: "agent", repo: "acme/api", pull: 8 })), "/acme/api/pull/8/session");
42+ assert.equal(sessionHref("acme", listed("pr_1", { kind: "recorded", pull: 7 })), "/acme/web/pull/7/session");
43+ assert.equal(sessionHref("acme", listed("run_2", { kind: "agent", pull: null })), null);
44+});
45+
46+test("a session is named by its title, its subagent task, its pull request, never nothing", () => {
47+ assert.equal(workspaceSessionTitle(listed("a", { session: own({ title: "Fix login" }) })), "Fix login");
48+ assert.equal(workspaceSessionTitle(listed("b", { session: own() })), "Session on fix-it");
49+ assert.equal(workspaceSessionTitle(listed("c", { session: own({ parentId: "ses_0", agentType: "Explore", task: "Find endpoints" }) })), "Explore: Find endpoints");
50+ assert.equal(workspaceSessionTitle(listed("d", { kind: "recorded", pull: 7, pullTitle: "Retry hooks" })), "Retry hooks");
51+ assert.equal(workspaceSessionTitle(listed("e", { kind: "agent", pull: 7 })), "Pull request #7");
52+ assert.equal(runsIn(listed("f")), "Claude Code");
53+ assert.equal(runsIn(listed("g", { kind: "agent", harness: null, agent: "g1t" })), "g1t's agent");
54+ assert.equal(runsIn(listed("h", { kind: "recorded", harness: null, agent: "cursor" })), "cursor, recorded");
55+});
56+
57+test("Sessions keeps its filters in the address and drops what it does not know", () => {
58+ const filters = sessionsFilters(new URLSearchParams("repo=web&person=@bo&harness=codex&kind=bogus&state=recent"));
59+ assert.deepEqual(filters, { state: "recent", repo: "web", person: "bo", harness: "codex", kind: null });
60+ assert.equal(sessionsQuery(filters), "?state=recent&repo=web&person=bo&harness=codex");
61+ assert.equal(sessionsQuery({ state: "live" }), "");
62+ assert.equal(narrowed(filters), true);
63+ assert.equal(narrowed(sessionsFilters(new URLSearchParams("state=recent"))), false);
64+});
65+
66+test("live sessions come apart from those that ended, most recently heard from first", () => {
67+ const sessions = [
68+ listed("old", { state: "ended" }),
69+ listed("idle", { state: "idle" }),
70+ listed("a", { lastActivityAt: "2026-10-17T09:00:00Z", actor: "bo" }),
71+ listed("b", { lastActivityAt: "2026-10-17T11:30:00Z", actor: "bo" }),
72+ ];
73+ const { active, idle, ended } = splitLive(sessions);
74+ assert.deepEqual(active.map((session) => session.id), ["b", "a"]);
75+ assert.deepEqual(idle.map((session) => session.id), ["idle"]);
76+ assert.deepEqual(ended.map((session) => session.id), ["old"]);
77+ assert.deepEqual(peopleIn(sessions), ["ana", "bo"]);
78+ assert.equal(repoName("acme/web", "Acme"), "web");
79+ assert.equal(repoName("other/web", "acme"), "other/web");
80+});
+110−0
1+/**
2+ * Sessions as the site shows them across a workspace: what each is called
3+ * and where it opens, the filters Sessions keeps in its address, and live
4+ * ones apart from the rest. Pure, so it is tested under Node.
5+ */
6+import type { Harness, SessionKind, WorkspaceSession } from "@g1t/contracts";
7+
8+import { HARNESS_NAMES, sessionTitle } from "./coordination.ts";
9+import { sessionPath } from "./work-items.ts";
10+
11+/** How each kind is named in a filter. */
12+export const KIND_NAMES: Record<SessionKind, string> = {
13+ harness: "People's harnesses",
14+ hosted: "Started on g1t",
15+ agent: "g1t's agent",
16+ recorded: "Recorded on pull requests",
17+};
18+
19+/** What a session is called, never nothing. */
20+export function workspaceSessionTitle(session: WorkspaceSession): string {
21+ if (session.session) {
22+ const own = session.session;
23+ if (!own.title?.trim() && own.parentId) return [own.agentType ?? "Subagent", own.task].filter(Boolean).join(": ");
24+ return sessionTitle(own);
25+ }
26+ return session.title?.trim() || session.pullTitle?.trim() || (session.pull != null ? `Pull request #${session.pull}` : "Untitled session");
27+}
28+
29+/** What runs it, in a few words: the harness, g1t's agent, or the agent that recorded it. */
30+export function runsIn(session: Pick<WorkspaceSession, "kind" | "harness" | "agent">): string {
31+ if (session.kind === "agent") return "g1t's agent";
32+ if (session.harness) return HARNESS_NAMES[session.harness] ?? session.harness;
33+ return session.agent ? `${session.agent}, recorded` : "Recorded";
34+}
35+
36+/**
37+ * Where a session opens: a harness or hosted session's own page; g1t's
38+ * agent and a recorded session are their pull request's Session tab; one
39+ * with neither opens nowhere.
40+ */
41+export function sessionHref(workspace: string, session: Pick<WorkspaceSession, "kind" | "id" | "repo" | "pull">): string | null {
42+ if (session.kind === "harness" || session.kind === "hosted") return sessionPath(workspace, session.id);
43+ return session.pull != null ? `/${session.repo}/pull/${session.pull}/session` : null;
44+}
45+
46+/** The filters Sessions keeps in its address. */
47+export type SessionsFilters = {
48+ /** `recent`: every session in one list; otherwise Live now, then what ended. */
49+ state: "live" | "recent";
50+ repo: string | null;
51+ person: string | null;
52+ harness: Harness | null;
53+ kind: SessionKind | null;
54+};
55+
56+const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[];
57+const KINDS = Object.keys(KIND_NAMES) as SessionKind[];
58+
59+export function sessionsFilters(query: URLSearchParams): SessionsFilters {
60+ const harness = query.get("harness");
61+ const kind = query.get("kind");
62+ const text = (key: string) => query.get(key)?.trim().replace(/^@/, "") || null;
63+ return {
64+ state: query.get("state") === "recent" ? "recent" : "live",
65+ repo: text("repo"),
66+ person: text("person"),
67+ harness: harness && (HARNESSES as string[]).includes(harness) ? (harness as Harness) : null,
68+ kind: kind && (KINDS as string[]).includes(kind) ? (kind as SessionKind) : null,
69+ };
70+}
71+
72+/** The address of Sessions with `filters`, leaving out what is as it is by default. */
73+export function sessionsQuery(filters: Partial<SessionsFilters>): string {
74+ const query = new URLSearchParams();
75+ if (filters.state === "recent") query.set("state", "recent");
76+ for (const key of ["repo", "person", "harness", "kind"] as const) {
77+ const value = filters[key];
78+ if (value) query.set(key, value);
79+ }
80+ const text = query.toString();
81+ return text ? `?${text}` : "";
82+}
83+
84+/** Whether any filter narrows the list. */
85+export function narrowed(filters: SessionsFilters): boolean {
86+ return Boolean(filters.repo || filters.person || filters.harness || filters.kind);
87+}
88+
89+/**
90+ * Live sessions apart: those going now (active first, then idle, each the
91+ * most recently heard from first) and those that ended.
92+ */
93+export function splitLive(sessions: WorkspaceSession[]): { active: WorkspaceSession[]; idle: WorkspaceSession[]; ended: WorkspaceSession[] } {
94+ const by = (state: WorkspaceSession["state"]) =>
95+ sessions.filter((session) => session.state === state).sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt));
96+ return { active: by("active"), idle: by("idle"), ended: by("ended") };
97+}
98+
99+/** The people running sessions in a list, each once, most active first, for its filter. */
100+export function peopleIn(sessions: WorkspaceSession[]): string[] {
101+ const counts = new Map<string, number>();
102+ for (const session of sessions) if (session.actor) counts.set(session.actor, (counts.get(session.actor) ?? 0) + 1);
103+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
104+}
105+
106+/** `acme/web` as the workspace `acme` names it: `web`. */
107+export function repoName(path: string, workspace: string): string {
108+ const [owner, name] = path.split("/");
109+ return owner?.toLowerCase() === workspace.toLowerCase() && name ? name : path;
110+}
+8−2
1111 repoChips,
1212 repoFilter,
1313 repoWorkPath,
14+ sessionPath,
15+ sessionsPath,
1416 shortPath,
1517 workFilters,
1618 workListPath,
6668
6769 test("a repository's Work tab, and a new work item filed from it", () => {
6870 assert.equal(repoWorkPath("acme", "web"), "/acme/-/work?repo=web");
71+ assert.equal(sessionsPath("Acme"), "/acme/-/sessions");
72+ assert.equal(sessionsPath("acme", { repo: "web", person: null }), "/acme/-/sessions?repo=web");
73+ assert.equal(sessionPath("Acme", "ses_1"), "/acme/-/sessions/ses_1");
6974 assert.equal(newItemPath("Acme"), "/acme/-/work/new");
7075 assert.equal(newItemPath("acme", "web"), "/acme/-/work/new?repo=web");
7176 assert.deepEqual(repoFilter("acme", "web"), { namespace: "acme", name: "web" });
98103 // Milestones were numbered again in the workspace: an old one leads to the list.
99104 assert.equal(movedWorkPath("/acme/web/milestones"), "/acme/-/work/milestones");
100105 assert.equal(movedWorkPath("/acme/web/milestones/3"), "/acme/-/work/milestones");
101− assert.equal(movedWorkPath("/acme/web/work"), "/acme/web/sessions");
102− assert.equal(movedWorkPath("/acme/web/work/sessions/ses_123"), "/acme/web/sessions/ses_123");
106+ // A repository's old Work and Sessions pages are gone: Sessions is the workspace's.
107+ assert.equal(movedWorkPath("/acme/web/work"), null);
108+ assert.equal(movedWorkPath("/acme/web/work/sessions/ses_123"), null);
103109 assert.equal(movedWorkPath("/acme/web/pulls"), null);
104110 assert.equal(movedWorkPath("/acme/-/issues"), null);
105111 });
+16−9
1212
1313 /**
1414 * Where an old address under a repository is now, when it named something
15− * that moved to Work or to Sessions; null when it did not:
15+ * that moved to Work; null when it did not:
1616 *
1717 * - `/<owner>/<repo>/issues` is the Work list filtered to the repository,
1818 * keeping its filters (`?state=closed&label=bug`);
2121 * the work service can say: `{ resolve: n }`;
2222 * - `/<owner>/<repo>/labels` and `/milestones[/<n>]` the workspace's
2323 * (milestones were numbered again in the workspace, so an old one's
24− * number leads to the list);
25− * - `/<owner>/<repo>/work` and `/work/sessions/<id>` its Sessions page and
26− * a session's.
24+ * number leads to the list).
2725 */
2826 export function movedWorkPath(pathname: string, search = ""): string | { resolve: number } | null {
2927 const parts = pathname.replace(/\.data$/, "").split("/").filter(Boolean);
4846 return rest === undefined ? workPath(workspace, "labels") : null;
4947 case "milestones":
5048 return more === undefined ? workPath(workspace, "milestones") : null;
51− case "work":
52− if (rest === undefined) return `/${owner}/${repo}/sessions`;
53− if (rest === "sessions" && more) return `/${owner}/${repo}/sessions/${more}`;
54− return null;
5549 }
5650 return null;
5751 }
6155 return `/${workspace.toLowerCase()}/-/work${page ? `/${page}` : ""}`;
6256 }
6357
64−/** The Work list filtered to one repository of the workspace: a repository's Work tab. */
58+/** The Work list filtered to one repository of the workspace. */
6559 export function repoWorkPath(workspace: string, repo: string): string {
6660 return `${workPath(workspace)}?repo=${encodeURIComponent(repo)}`;
6761 }
6862
63+/** Sessions, the workspace's: what is cooking in it, with filters (`?repo=web&person=ana`). */
64+export function sessionsPath(workspace: string, filters: Record<string, string | null | undefined> = {}): string {
65+ const query = new URLSearchParams();
66+ for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
67+ const text = query.toString();
68+ return `/${workspace.toLowerCase()}/-/sessions${text ? `?${text}` : ""}`;
69+}
70+
71+/** One session's page, in whichever of the workspace's repositories it runs. */
72+export function sessionPath(workspace: string, id: string): string {
73+ return `/${workspace.toLowerCase()}/-/sessions/${encodeURIComponent(id)}`;
74+}
75+
6976 /** A new work item, started from a repository (its origin) when `repo` is given. */
7077 export function newItemPath(workspace: string, repo?: string | null): string {
7178 return `${workPath(workspace, "new")}${repo ? `?repo=${encodeURIComponent(repo)}` : ""}`;
+4−0
231231 export type ModeKey =
232232 | "home"
233233 | "work"
234+ | "sessions"
234235 | "chat"
235236 | "notifications"
236237 | "code"
247248 const PAGE_MODES: Record<string, ModeKey> = {
248249 home: "home",
249250 work: "work",
251+ sessions: "sessions",
250252 chat: "chat",
251253 artifacts: "artifacts",
252254 routines: "routines",
310312 return homePagePath(slug);
311313 case "work":
312314 return `/${slug}/-/work`;
315+ case "sessions":
316+ return `/${slug}/-/sessions`;
313317 case "notifications":
314318 return "/notifications";
315319 case "chat":
+11−12
227227 route("-/home", "routes/workspace/home.tsx"),
228228 route("-/home/seen", "routes/workspace/home-seen.ts"),
229229 // Work: the workspace's work items, for every member. The list (its
230− // filters in the address), a new one, the labels and milestones they
231− // share with pull requests, `#12` read in the workspace, and each item.
230+ // filters in the address), the board of them as they move, a new one,
231+ // the labels and milestones they share with pull requests, `#12` read
232+ // in the workspace, and each item.
232233 route("-/work", "routes/workspace/work/list.tsx"),
234+ route("-/work/board", "routes/workspace/work/board.tsx"),
233235 route("-/work/new", "routes/workspace/work/new.tsx"),
234236 route("-/work/labels", "routes/workspace/work/labels.tsx"),
235237 route("-/work/milestones", "routes/workspace/work/milestones.tsx"),
236238 route("-/work/milestones/:number", "routes/workspace/work/milestone.tsx"),
237239 route("-/work/ref/:number", "routes/workspace/work/ref.ts"),
238240 route("-/work/:number", "routes/workspace/work/item.tsx"),
241+ // Sessions: what is cooking across the workspace, in every repository
242+ // of it the viewer may read (live sessions of every kind, the merge
243+ // queues, what landed and what happened), and one session's page.
244+ route("-/sessions", "routes/workspace/sessions/list.tsx"),
245+ route("-/sessions/:id", "routes/workspace/sessions/session.tsx"),
239246 route("-/today", "routes/workspace/moved.ts", { id: "routes/workspace/moved-today" }),
240247 route("-/apps", "routes/workspace/apps.tsx"),
241248 // Routines: standing work for @g1t, on a schedule or when something
261268 // the card over a `#12` in a commit message or on a timeline.
262269 route(":owner/:repo/ref/:number", "routes/repo/ref-card.ts"),
263270 // A repository's issues, labels and milestones moved to its workspace's
264− // Work, and its Work page became Sessions: old links are sent on
265− // (routes/repo/moved-work.ts), before the repository's own pages.
271+ // Work: old links are sent on (routes/repo/moved-work.ts), before the
272+ // repository's own pages.
266273 route(":owner/:repo/issues", "routes/repo/moved-work.ts", { id: "routes/repo/moved-issues" }),
267274 route(":owner/:repo/issues/new", "routes/repo/moved-work.ts", { id: "routes/repo/moved-issue-new" }),
268275 route(":owner/:repo/issues/:number", "routes/repo/moved-work.ts", { id: "routes/repo/moved-issue" }),
269276 route(":owner/:repo/labels", "routes/repo/moved-work.ts", { id: "routes/repo/moved-labels" }),
270277 route(":owner/:repo/milestones", "routes/repo/moved-work.ts", { id: "routes/repo/moved-milestones" }),
271278 route(":owner/:repo/milestones/:number", "routes/repo/moved-work.ts", { id: "routes/repo/moved-milestone" }),
272− route(":owner/:repo/work", "routes/repo/moved-work.ts", { id: "routes/repo/moved-work" }),
273− route(":owner/:repo/work/sessions/:id", "routes/repo/moved-work.ts", { id: "routes/repo/moved-work-session" }),
274279 // Why a line is the way it is, fetched by the blame view.
275280 route(":owner/:repo/why/:hash", "routes/repo/why.ts"),
276281 // Files of a large change with their lines, fetched as the diff view reaches them.
332337 route("pull/:number/:tab?", "routes/repo/pull.tsx"),
333338 route("queue", "routes/repo/queue.tsx"),
334339 route("usage", "routes/repo/usage.tsx"),
335− // Sessions: who is working on what, live (claims, harness sessions,
336− // the queue, coordination events), and the sessions recorded on its
337− // pull requests. One session's page by its id (`ses_…`), or a pull
338− // request's recorded session by its number.
339− route("sessions", "routes/repo/sessions.tsx"),
340− route("sessions/:id", "routes/repo/session.tsx"),
341340 route("actions", "routes/repo/actions.tsx"),
342341 route("actions/runs/:id", "routes/repo/actions-run.tsx"),
343342 route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"),
+1−1
11 /**
22 * A repository's issues, labels and milestones moved to its workspace's
3− * Work, and its Work page became its Sessions page: old links are sent on
3+ * Work: old links are sent on
44 * (lib/work-items.ts `movedWorkPath` says where). An old issue's address
55 * is read as `#n` in the repository (lib/refs.ts): the work item its issue
66 * became, or a pull request. A work item belongs to the workspace, so these
+3−3
3636 import { type LivePull, LiveNowList, StartHere } from "../../components/coordination";
3737 import { awakeAndIdle, liveByItem } from "../../lib/coordination";
3838 import { type Need, ageBuckets, rankNeeds, runHealth } from "../../lib/mission";
39−import { repoWorkPath } from "../../lib/work-items";
39+import { repoWorkPath, sessionsPath } from "../../lib/work-items";
4040 import { count, duration, percent } from "../../lib/observability";
4141 import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
4242 import {
861861 </span>
862862 ) : null
863863 }
864− all={{ to: `${base}/sessions`, label: "Sessions" }}
864+ all={{ to: sessionsPath(base.split("/")[1]!, { repo: base.split("/")[2] }), label: "Sessions" }}
865865 >
866866 {!loaderData.liveLoaded ? (
867867 <Unavailable what="Who is working" />
879879 ender={loaderData.ender}
880880 />
881881 {moreItems > 0 && (
882− <Link to={`${base}/sessions`} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg">
882+ <Link to={sessionsPath(base.split("/")[1]!, { repo: base.split("/")[2] })} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg">
883883 {plural(moreItems, "more work item")} being worked on
884884 </Link>
885885 )}
+0−606
Loading the diff…
+0−277
Loading the diff…
+2−1
Loading the diff…
+290−0
Loading the diff…
+622−0
Loading the diff…
+241−0
Loading the diff…
+2−2
Loading the diff…
+22−2
Loading the diff…

Comments

Sign in to comment.