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 is a section of Work, and Explore is second in the rail

The rail is Home, Explore, Work, Chat, Code, Artifacts and Routines. Sessions has no rail entry or mode of its own: Work's sidebar has two sections, Work items and Sessions, and its pages moved to /<ws>/-/work/sessions and /<ws>/-/work/sessions/<id>, with no redirect from the old addresses. Every session link (Home, the cooking strip, the board, chat cards, the hosted session_url) names the new address. On a phone, More starts with Explore and has no Sessions tile; Work's menu holds both sections.

G1T-Session:
ses_01m56vafhge7ttrgs5288dpxvb
syntaqxcommitted Parentfc89c5eBrowse files
21 files+1047−10730/21 viewed
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+1−2
1−import { Activity, Bell, Building2, Code2, House, LayoutGrid, ListTodo, Lock, MessagesSquare, Pin, Plug, Repeat, Search, Shapes, Users } from "lucide-react";
1+import { 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} />,
1817 chat: <MessagesSquare size={size} />,
1918 notifications: <Bell size={size} />,
2019 code: <Code2 size={size} />,
+20−19
123123 * The bottom bar of a phone, flat along the bottom edge under a rule, as
124124 * the rail stands flat beside the page on a computer: Home, Work, Chat and
125125 * Notifications (lib/apps.ts `PHONE_TABS`), each with what is unread, and
126− * More, whose sheet starts with Code: a phone triages and talks about work
127− * far more than it browses code. The tab you are in has the rail's own
126+ * More, whose sheet starts with Explore, then Code: a phone triages and
127+ * talks about work far more than it browses code. The tab you are in has the rail's own
128128 * mark: its icon on the sidebar's accent. It steps aside while the keyboard is up
129129 * and inside a conversation. The tab you are in, tapped again, opens its
130− * mode's menu (its sidebar: Work's saved views, labels and milestones).
130+ * mode's menu (its sidebar: Work's views, board, labels, milestones and
131+ * sessions).
131132 */
132133 export function BottomBar({
133134 user,
258259
259260 /**
260261 * More, from the bottom bar: a flat panel standing on it, with g1t's
261− * mark at its top and a grid of big tiles: Code (for a member with Code
262− * access), all apps, your pinned apps, Artifacts, Routines, People,
263− * Workspace, Explore, and "You and
264− * help", which opens your account's sheet. Browsing beyond your
262+ * mark at its top and a grid of big tiles: Explore (second in the rail
263+ * on a computer), Code (for a member with Code access), all apps, your
264+ * pinned apps, Artifacts, Routines, People, Workspace, and "You and
265+ * help", which opens your account's sheet. Sessions are Work's: its menu
266+ * (the Work tab tapped again) has them. Browsing beyond your
265267 * workspaces, Explore opens Explore's menu (`onExplore`) rather than the
266268 * page.
267269 */
301303 <span className="ml-auto truncate font-mono text-xs text-faint">{slug}</span>
302304 </div>
303305 <div className="grid grid-cols-4 gap-1.5">
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" />
306− {apps.includes("code") && <MoreTile to={appOf("code").path(slug)} icon={appIcon("code", 20)} label="Code" />}
307− <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
308− {shown.map((app) => (
309− <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} />
310− ))}
311− {apps
312− .filter((key) => key !== "code" && key !== "sessions")
313− .map((key) => (
314− <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} />
315− ))}
306+ {/* Explore first, second in the rail on a computer; then Code for a member who has it, which left the bar for Work. */}
316307 {onExplore ? (
317308 <MoreTile
318309 icon={<Compass size={20} />}
325316 ) : (
326317 <MoreTile to="/explore" icon={<Compass size={20} />} label="Explore" />
327318 )}
319+ {apps.includes("code") && <MoreTile to={appOf("code").path(slug)} icon={appIcon("code", 20)} label="Code" />}
320+ <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
321+ {shown.map((app) => (
322+ <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} />
323+ ))}
324+ {apps
325+ .filter((key) => key !== "code")
326+ .map((key) => (
327+ <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} />
328+ ))}
328329 <MoreTile
329330 icon={<Avatar name={user.username} image={user.avatar} size={24} />}
330331 label="You and help"
+2−2
1414 * per step, chosen with the pills.
1515 *
1616 * It is an honest miniature of the app: the same rail (the workspace on
17− * the top bar's line, then Home, Explore, Chat, Code and Artifacts, with names),
17+ * the top bar's line, then Home, Explore, Work, Chat, Code and Artifacts, with names),
1818 * each mode's sidebar with its real sections and words, the same top bar
1919 * (where you are, Explore, Docs, the inbox and +), which Chat gives up to
2020 * the conversation's own header as the app does, and the app's own cards.
274274 /** The rail's modes, in the app's order (components/rail.tsx), with its icons. */
275275 const RAIL: { key: string; icon: ReactNode; label: string; target?: CursorTarget; scenes?: Scene[]; badge?: number }[] = [
276276 { key: "home", icon: <House size={15} />, label: "Home", scenes: ["home"] },
277+ { key: "explore", icon: <Compass size={15} />, label: "Explore" },
277278 { key: "work", icon: <ListTodo size={15} />, label: "Work" },
278− { key: "explore", icon: <Compass size={15} />, label: "Explore" },
279279 { key: "chat", icon: <MessagesSquare size={15} />, label: "Chat", target: "rail-chat", scenes: ["chat"], badge: 3 },
280280 { key: "code", icon: <Code2 size={15} />, label: "Code", target: "rail-code", scenes: ["code", "session"] },
281281 { key: "artifacts", icon: <Shapes size={15} />, label: "Artifacts" },
+25−36
11 import { useSidebarLocation } from "../lib/use-sidebar-location";
22 import { Check, ChevronDown, CircleUserRound, Compass, LayoutGrid, Plus, Settings } from "lucide-react";
3−import { Fragment, type ReactNode, useState } from "react";
3+import { type ReactNode, useState } from "react";
44 import { Link } from "react-router";
55
66 import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
2222 } from "./ui/dropdown-menu";
2323 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
2424 import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator } from "./ui/sidebar";
25−import { type BuiltinApp, type PinnableApp } from "../lib/apps";
25+import { type PinnableApp, appOf, railApps } from "../lib/apps";
2626 import { cn } from "../lib/cn";
2727 import { paletteKeyLabel } from "../lib/shortcut";
2828 import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav";
2929
30−type Item = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } | null };
30+type Item = { badge?: { count: number; loud: boolean } | null };
3131
3232 /** A count on an icon: loud (the accent) for what waits on you, quiet for the rest. */
3333 export function CountBadge({ count, loud, className }: { count: number; loud: boolean; className?: string }) {
199199
200200 /**
201201 * The rail down the window's left edge: a narrow column of icons, each
202− * named by the hint beside it. g1t's mark, which leads to Home; the
203− * built-in apps (Home, Work, Chat, Code, Artifacts and Routines) with what is
204− * unread on them (Notifications is the bell in the top bar, not a rail
205− * app); Explore, lit anywhere beyond your workspaces; the apps you
206− * pinned; the Apps launcher;
202+ * named by the hint beside it. g1t's mark, which leads to Home; Home;
203+ * Explore, lit anywhere beyond your workspaces; then the other built-in
204+ * apps (Work, with its sessions, Chat, Code, Artifacts and Routines) with
205+ * what is unread on them (Notifications is the bell in the top bar, not a
206+ * rail app); the apps you pinned; the Apps launcher;
207207 * and at its foot People, Workspace and your account. The one you are in
208208 * sits on a filled square, and which that is follows the address. A
209209 * member without Code access has no Code. From 768px; a phone has the
228228 const { pathname: path } = useSidebarLocation();
229229 const here = mode ?? modeOf(path, workspace.slug);
230230 const slug = workspace.slug;
231+ // A member without Code access has no Code; Work is every member's, its sessions in it.
231232 const code = hasCodeAccess(workspace);
232233 const { pins, toggle } = useAppPins(slug, saved);
233− const builtins: Item[] = [
234− { key: "home", label: "Home" },
235− // Every member's, Code access or not.
236− { key: "work", label: "Work" },
237− // What is cooking across the workspace, right after its work items.
238− { key: "sessions", label: "Sessions" },
239− {
240− key: "chat",
241− label: "Chat",
242− badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
243− },
244− ...(code ? [{ key: "code" as const, label: "Code" }] : []),
245− { key: "artifacts", label: "Artifacts" },
246− { key: "routines", label: "Routines" },
247− ];
234+ const badges: Partial<Record<ModeKey, Item["badge"]>> = {
235+ chat: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
236+ };
248237 const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
249238 // A pinned app is current on its own page and the pages under it.
250239 const at = (to: string) => path === to || path.startsWith(`${to}/`);
265254 </SidebarHeader>
266255 <SidebarContent className="items-center gap-0">
267256 <SidebarMenu className="w-auto shrink-0 gap-2">
268− {builtins.map((item) => (
269− <Fragment key={item.key}>
257+ {railApps(code).map((key) =>
258+ // g1t beyond your workspaces: Explore, search, profiles, other workspaces and their public projects.
259+ key === "explore" ? (
260+ <RailLink key={key} to="/explore" label="Explore" icon={<Compass size={20} />} current={here === "browse" || at("/explore")} />
261+ ) : (
270262 <RailLink
271− to={modeHome(item.key, slug)}
272− label={item.label}
273− icon={appIcon(item.key as BuiltinApp, 20)}
274− current={here === item.key && !shown.some((app) => at(app.to))}
275− badge={item.badge}
263+ key={key}
264+ to={modeHome(key, slug)}
265+ label={appOf(key).name}
266+ icon={appIcon(key, 20)}
267+ current={here === key && !shown.some((app) => at(app.to))}
268+ badge={badges[key]}
276269 />
277− {/* Right after Sessions: g1t beyond your workspaces (Explore, search, profiles, other workspaces and their public projects). */}
278− {item.key === "sessions" && (
279− <RailLink to="/explore" label="Explore" icon={<Compass size={20} />} current={here === "browse" || at("/explore")} />
280− )}
281− </Fragment>
282− ))}
270+ ),
271+ )}
283272 </SidebarMenu>
284273 {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */}
285274 {shown.length > 0 && (
+28−68
14171417 }
14181418
14191419 /**
1420− * Work's sidebar, and its menu on a phone (the Work tab tapped again):
1421− * the views people keep coming back to, saved in the address (open, yours,
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.
1420+ * Work's sidebar, and its menu on a phone (the Work tab tapped again), in
1421+ * two sections. Work items: the views people keep coming back to, saved
1422+ * in the address (open, yours, closed), the board of them as they move, a
1423+ * new one, and the workspace's labels and milestones. Sessions, the work
1424+ * being done on them: what is going now, what just ended, your own, and
1425+ * each kind, kept in the address as well.
14251426 */
14261427 function WorkSidebar({ slug, username }: { slug: string; username: string }) {
14271428 const { pathname: path, search } = useSidebarLocation();
14281429 const list = workPath(slug);
1430+ const sessions = sessionsPath(slug);
14291431 const query = new URLSearchParams(search);
1430− const onList = path === list;
1431− const view = (want: Record<string, string>) =>
1432− onList && [...query.keys()].every((key) => key in want) && Object.entries(want).every(([key, value]) => query.get(key) === value);
1432+ const viewOf = (page: string) => (want: Record<string, string>) =>
1433+ path === page && [...query.keys()].every((key) => key in want) && Object.entries(want).every(([key, value]) => query.get(key) === value);
1434+ const view = viewOf(list);
1435+ const sessionView = viewOf(sessions);
14331436 const at = (page: string) => path === workPath(slug, page) || path.startsWith(`${workPath(slug, page)}/`);
14341437 return (
14351438 <div className="flex h-full flex-col">
14361439 <ModeHeader title="Work" />
14371440 <nav aria-label="Work" className={PANEL}>
1438− <NavRows>
1441+ <NavGroup title="Work items">
14391442 <SidebarLink to={list} icon={<ListTodo size={15} />} current={view({})}>
14401443 Open
14411444 </SidebarLink>
14481451 <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />} current={at("board")}>
14491452 Board
14501453 </SidebarLink>
1451− <div className="md:hidden">
1452− <SidebarLink to={sessionsPath(slug)} icon={<Activity size={15} />}>
1453− Sessions
1454− </SidebarLink>
1455− </div>
14561454 <SidebarLink to={newItemPath(slug)} icon={<Plus size={15} />} current={at("new")}>
14571455 New work item
14581456 </SidebarLink>
1459− </NavRows>
1460− <Rule />
1461− <NavRows>
14621457 <SidebarLink to={workPath(slug, "labels")} icon={<Ticket size={15} />} current={at("labels")}>
14631458 Labels
14641459 </SidebarLink>
14651460 <SidebarLink to={workPath(slug, "milestones")} icon={<Flag size={15} />} current={at("milestones")}>
14661461 Milestones
14671462 </SidebarLink>
1468− </NavRows>
1469− </nav>
1470− </div>
1471− );
1472−}
1473−
1474−/**
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({})}>
1463+ </NavGroup>
1464+ <Rule />
1465+ <NavGroup title="Sessions">
1466+ <SidebarLink to={sessions} icon={<Radio size={15} />} current={sessionView({})}>
14911467 Live now
14921468 </SidebarLink>
1493− <SidebarLink to={sessionsPath(slug, { state: "recent" })} icon={<History size={15} />} current={view({ state: "recent" })}>
1469+ <SidebarLink to={sessionsPath(slug, { state: "recent" })} icon={<History size={15} />} current={sessionView({ state: "recent" })}>
14941470 Recent
14951471 </SidebarLink>
1496− <SidebarLink to={sessionsPath(slug, { person: username })} icon={<CircleUserRound size={15} />} current={view({ person: username })}>
1472+ <SidebarLink to={sessionsPath(slug, { person: username })} icon={<CircleUserRound size={15} />} current={sessionView({ person: username })}>
14971473 Yours
14981474 </SidebarLink>
1499− </NavRows>
1500− <Rule />
1501− <NavRows>
1502− <SidebarLink to={sessionsPath(slug, { kind: "harness" })} icon={<Bot size={15} />} current={view({ kind: "harness" })}>
1475+ <SidebarLink to={sessionsPath(slug, { kind: "harness" })} icon={<Bot size={15} />} current={sessionView({ kind: "harness" })}>
15031476 People's harnesses
15041477 </SidebarLink>
1505− <SidebarLink to={sessionsPath(slug, { kind: "hosted" })} icon={<Sparkles size={15} />} current={view({ kind: "hosted" })}>
1478+ <SidebarLink to={sessionsPath(slug, { kind: "hosted" })} icon={<Sparkles size={15} />} current={sessionView({ kind: "hosted" })}>
15061479 Started on g1t
15071480 </SidebarLink>
1508− <SidebarLink to={sessionsPath(slug, { kind: "agent" })} icon={<Cpu size={15} />} current={view({ kind: "agent" })}>
1481+ <SidebarLink to={sessionsPath(slug, { kind: "agent" })} icon={<Cpu size={15} />} current={sessionView({ kind: "agent" })}>
15091482 g1t's agent
15101483 </SidebarLink>
1511− <SidebarLink to={sessionsPath(slug, { kind: "recorded" })} icon={<ScrollText size={15} />} current={view({ kind: "recorded" })}>
1484+ <SidebarLink to={sessionsPath(slug, { kind: "recorded" })} icon={<ScrollText size={15} />} current={sessionView({ kind: "recorded" })}>
15121485 Recorded on pull requests
15131486 </SidebarLink>
1514− </NavRows>
1515− <Rule />
1516− <NavRows>
1517− <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />}>
1518− Board
1519− </SidebarLink>
1520− </NavRows>
1487+ </NavGroup>
15211488 </nav>
15221489 </div>
15231490 );
16581625 workflows: "Workflows",
16591626 observability: "Observability",
16601627 insights: "Insights",
1661− sessions: "Sessions",
16621628 work: "Work",
16631629 chat: "Chat",
16641630 artifacts: "Artifacts",
17271693 // Chat settings is a page of its own, not one of General's.
17281694 trail.push({ label: "Chat settings", to: `${page}/chat` });
17291695 } else if (third === "work") {
1730− // Work: Work / #12, Work / Labels, Work / Milestones / #3.
1696+ // Work: Work / #12, Work / Labels, Work / Milestones / #3, Work / Sessions / Session.
17311697 trail.push({ label: "Work", to: page });
1732− const words: Record<string, string> = { new: "New work item", board: "Board", labels: "Labels", milestones: "Milestones" };
1698+ const words: Record<string, string> = { new: "New work item", board: "Board", labels: "Labels", milestones: "Milestones", sessions: "Sessions" };
17331699 if (fourth && /^\d+$/.test(fourth)) trail.push({ label: `Work item #${fourth}`, short: `#${fourth}`, to: `${page}/${fourth}` });
17341700 else if (fourth && words[fourth]) trail.push({ label: words[fourth]!, to: `${page}/${fourth}` });
17351701 const fifth = parts[4];
17361702 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}` });
1703+ if (fourth === "sessions" && fifth) trail.push({ label: "Session", to: `${page}/sessions/${fifth}` });
17411704 } else if (third) {
17421705 // The workspace's settings pages each stand alone: General, Members, Permissions.
17431706 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
18731836 { label: "Work", hint: "The workspace's work items", to: workPath(ws.slug), icon: <ListTodo size={15} /> },
18741837 { label: "New work item", to: newItemPath(ws.slug), icon: <Plus size={15} /> },
18751838 { 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} /> },
1839+ { label: "Sessions", hint: "Work: what is cooking across the workspace", to: sessionsPath(ws.slug), icon: <Activity size={15} /> },
18771840 { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> },
18781841 { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> },
18791842 { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> },
20181981 }
20191982
20201983 /** The sidebars that sit beside the page, one per mode that has one. */
2021−type Panel = "work" | "sessions" | "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse";
1984+type Panel = "work" | "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse";
20221985
20231986 /** Each sidebar's name, for the button that opens it on a phone. */
20241987 const MODE_MENU: Record<Panel, string> = {
20251988 browse: "Explore",
20261989 work: "Work",
2027− sessions: "Sessions",
20281990 chat: "Chat",
20291991 artifacts: "Artifacts",
20301992 code: "Code",
21842146 return <CodeSidebar user={user} shell={shell} missing={missing} browsing={browsing} />;
21852147 case "work":
21862148 return <WorkSidebar slug={ws.slug} username={user.username} />;
2187− case "sessions":
2188− return <SessionsSidebar slug={ws.slug} username={user.username} />;
21892149 case "chat":
21902150 return <ChatSidebar slug={ws.slug} />;
21912151 case "artifacts":
+14−5
2222 PHONE_TABS,
2323 appOf,
2424 phoneMoreApps,
25+ railApps,
2526 } from "./apps.ts";
2627
27−test("Work is a built-in app right after Home, for every member", () => {
28− assert.deepEqual(BUILTINS.slice(0, 3).map((app) => app.key), ["home", "work", "sessions"]);
28+test("Work is a built-in app right after Home, for every member, and Sessions are in it", () => {
29+ assert.deepEqual(BUILTINS.slice(0, 3).map((app) => app.key), ["home", "work", "chat"]);
2930 assert.equal(appOf("work").path("acme"), "/acme/-/work");
3031 assert.equal(appOf("work").code, undefined, "not part of Code");
32+ assert.equal(BUILTINS.some((app) => (app.key as string) === "sessions"), false, "no Sessions app of its own");
3133 });
3234
33−test("a phone's bar is Home, Work, Chat and Notifications, with Sessions then Code first under More", () => {
35+test("the rail is Home, Explore, Work, Chat, Code, Artifacts and Routines", () => {
36+ assert.deepEqual(railApps(true), ["home", "explore", "work", "chat", "code", "artifacts", "routines"]);
37+ // A member without Code access has no Code, and the rest in the same order.
38+ assert.deepEqual(railApps(false), ["home", "explore", "work", "chat", "artifacts", "routines"]);
39+});
40+
41+test("a phone's bar is Home, Work, Chat and Notifications, with Code first of More's apps", () => {
3442 assert.deepEqual([...PHONE_TABS], ["home", "work", "chat", "notifications"]);
3543 // Four tabs and More: five slots.
3644 assert.equal(PHONE_TABS.length + 1, 5);
37− assert.deepEqual(phoneMoreApps(true), ["sessions", "code", "artifacts", "routines", "people", "workspace"]);
45+ // Explore heads the sheet before these; Sessions are Work's, not More's.
46+ assert.deepEqual(phoneMoreApps(true), ["code", "artifacts", "routines", "people", "workspace"]);
3847 // A member without Code access has the rest, Work still on the bar.
39− assert.deepEqual(phoneMoreApps(false), ["sessions", "artifacts", "routines", "people", "workspace"]);
48+ assert.deepEqual(phoneMoreApps(false), ["artifacts", "routines", "people", "workspace"]);
4049 });
4150
4251 test("only integrations are pinned; built-in apps, their pages, old plugins and connectors are not", () => {
+21−7
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" | "sessions" | "chat" | "notifications" | "code" | "artifacts" | "routines" | "people" | "workspace";
29+export type BuiltinApp = "home" | "work" | "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") },
4746 { key: "chat", name: "Chat", path: under("chat") },
4847 { key: "notifications", name: "Notifications", path: () => "/notifications" },
4948 { key: "code", name: "Code", path: under("overview"), code: true },
5352 { key: "workspace", name: "Workspace", path: under("workspace") },
5453 ];
5554
55+/**
56+ * The rail's apps from its top, in order: Home, Explore (g1t beyond your
57+ * workspaces, which is no built-in app), Work with its sessions, Chat,
58+ * Code for a member with Code access, Artifacts and Routines. People and
59+ * Workspace stand at its foot.
60+ */
61+export const RAIL_APPS = ["home", "explore", "work", "chat", "code", "artifacts", "routines"] as const;
62+
63+export type RailApp = (typeof RAIL_APPS)[number];
64+
65+/** The rail's apps for a member, with Code only when they have Code access. */
66+export function railApps(code: boolean): RailApp[] {
67+ return RAIL_APPS.filter((key) => code || key !== "code");
68+}
69+
5670 /** A phone's bottom bar, before More: Work for every member, Code under More. */
5771 export const PHONE_TABS = ["home", "work", "chat", "notifications"] as const satisfies readonly BuiltinApp[];
5872
5973 /**
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.
74+ * The built-in apps the phone's More sheet has, in its order: Code for a
75+ * member with Code access, then the rest of the rail's. Explore, which is
76+ * no built-in app, heads the sheet before them; Sessions are Work's.
6377 */
6478 export function phoneMoreApps(code: boolean): BuiltinApp[] {
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];
79+ const rest = BUILTINS.map((app) => app.key).filter((key) => !(PHONE_TABS as readonly string[]).includes(key) && key !== "code");
80+ return code ? ["code", ...rest] : rest;
6781 }
6882
6983 /** The built-in app `key`. */
+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/-/sessions/ses_1" });
747+ assert.deepEqual(marked!.agent, { harness: "claude_code", to: "/acme/-/work/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/-/sessions/ses_9"],
776+ ["Fix the bell's count", "web · pushed to main · by ana", "c3", "/acme/web/commit/c3", "/acme/-/work/sessions/ses_9"],
777777 ["2 commits pushed to main", "web · Tidy · by ana", "c2", "/acme/web/activity", null],
778778 ],
779779 );
+2−2
3535 ({ 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;
3636
3737 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");
38+ assert.equal(sessionHref("Acme", listed("ses_1")), "/acme/-/work/sessions/ses_1");
39+ assert.equal(sessionHref("acme", listed("ses_2", { kind: "hosted", harness: "claude_managed_agents" })), "/acme/-/work/sessions/ses_2");
4040 // g1t's agent and a recorded session are their pull request's Session tab.
4141 assert.equal(sessionHref("acme", listed("run_1", { kind: "agent", repo: "acme/api", pull: 8 })), "/acme/api/pull/8/session");
4242 assert.equal(sessionHref("acme", listed("pr_1", { kind: "recorded", pull: 7 })), "/acme/web/pull/7/session");
+3−3
6868
6969 test("a repository's Work tab, and a new work item filed from it", () => {
7070 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");
71+ assert.equal(sessionsPath("Acme"), "/acme/-/work/sessions");
72+ assert.equal(sessionsPath("acme", { repo: "web", person: null }), "/acme/-/work/sessions?repo=web");
73+ assert.equal(sessionPath("Acme", "ses_1"), "/acme/-/work/sessions/ses_1");
7474 assert.equal(newItemPath("Acme"), "/acme/-/work/new");
7575 assert.equal(newItemPath("acme", "web"), "/acme/-/work/new?repo=web");
7676 assert.deepEqual(repoFilter("acme", "web"), { namespace: "acme", name: "web" });
+3−3
6060 return `${workPath(workspace)}?repo=${encodeURIComponent(repo)}`;
6161 }
6262
63−/** Sessions, the workspace's: what is cooking in it, with filters (`?repo=web&person=ana`). */
63+/** Work → Sessions: what is cooking in the workspace, with filters (`?repo=web&person=ana`). */
6464 export function sessionsPath(workspace: string, filters: Record<string, string | null | undefined> = {}): string {
6565 const query = new URLSearchParams();
6666 for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
6767 const text = query.toString();
68− return `/${workspace.toLowerCase()}/-/sessions${text ? `?${text}` : ""}`;
68+ return `${workPath(workspace, "sessions")}${text ? `?${text}` : ""}`;
6969 }
7070
7171 /** One session's page, in whichever of the workspace's repositories it runs. */
7272 export function sessionPath(workspace: string, id: string): string {
73− return `/${workspace.toLowerCase()}/-/sessions/${encodeURIComponent(id)}`;
73+ return `${workPath(workspace, "sessions")}/${encodeURIComponent(id)}`;
7474 }
7575
7676 /** A new work item, started from a repository (its origin) when `repo` is given. */
+5−0
182182 assert.equal(modeOf("/acme/-/work/new", "acme"), "work");
183183 assert.equal(modeOf("/acme/-/work/labels", "acme"), "work");
184184 assert.equal(modeOf("/acme/-/work/milestones/3", "acme"), "work");
185+ // Sessions are Work's: its list and each session light Work.
186+ assert.equal(modeOf("/acme/-/work/sessions", "acme"), "work");
187+ assert.equal(modeOf("/acme/-/work/sessions/ses_1.data", "acme"), "work");
188+ // Their old address is no page of its own any more, so it falls to Code's not-found page.
189+ assert.equal(modeOf("/acme/-/sessions", "acme"), "code");
185190 assert.equal(modeHome("work", "acme"), "/acme/-/work");
186191 // A repository's pages, its Sessions among them, are Code's.
187192 assert.equal(modeOf("/acme/web/sessions", "acme"), "code");
+2−5
215215
216216 /**
217217 * The rail's modes. Home is the front page; Work is the workspace's work
218− * items, for every member;
218+ * items and the sessions working on them, for every member;
219219 * Chat, Code and Artifacts are where work happens; Notifications
220220 * spans them; People is who belongs; Workspace is the workspace itself,
221221 * its settings area: who has access, money, policies and how each part is
231231 export type ModeKey =
232232 | "home"
233233 | "work"
234− | "sessions"
235234 | "chat"
236235 | "notifications"
237236 | "code"
247246 /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
248247 const PAGE_MODES: Record<string, ModeKey> = {
249248 home: "home",
249+ // Work's sessions (`-/work/sessions`) are Work's too.
250250 work: "work",
251− sessions: "sessions",
252251 chat: "chat",
253252 artifacts: "artifacts",
254253 routines: "routines",
312311 return homePagePath(slug);
313312 case "work":
314313 return `/${slug}/-/work`;
315− case "sessions":
316− return `/${slug}/-/sessions`;
317314 case "notifications":
318315 return "/notifications";
319316 case "chat":
+6−6
229229 // Work: the workspace's work items, for every member. The list (its
230230 // filters in the address), the board of them as they move, a new one,
231231 // the labels and milestones they share with pull requests, `#12` read
232− // in the workspace, and each item.
232+ // in the workspace, and each item. Sessions are Work's too: what is
233+ // cooking across the workspace, in every repository of it the viewer
234+ // may read (live sessions of every kind, the merge queues, what landed
235+ // and what happened), and one session's page.
233236 route("-/work", "routes/workspace/work/list.tsx"),
234237 route("-/work/board", "routes/workspace/work/board.tsx"),
238+ route("-/work/sessions", "routes/workspace/work/sessions/list.tsx"),
239+ route("-/work/sessions/:id", "routes/workspace/work/sessions/session.tsx"),
235240 route("-/work/new", "routes/workspace/work/new.tsx"),
236241 route("-/work/labels", "routes/workspace/work/labels.tsx"),
237242 route("-/work/milestones", "routes/workspace/work/milestones.tsx"),
238243 route("-/work/milestones/:number", "routes/workspace/work/milestone.tsx"),
239244 route("-/work/ref/:number", "routes/workspace/work/ref.ts"),
240245 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"),
246246 route("-/today", "routes/workspace/moved.ts", { id: "routes/workspace/moved-today" }),
247247 route("-/apps", "routes/workspace/apps.tsx"),
248248 // Routines: standing work for @g1t, on a schedule or when something
+0−290
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−}
+0−622
1−import { ArrowLeft, Bot, ChevronRight, ExternalLink, GitBranch, GitCommitHorizontal, Pencil, Sparkles, Square } from "lucide-react";
2−import { type ReactNode, useState } from "react";
3−import { Form, Link } from "react-router";
4−
5−import type { Pull, SessionLink, SessionTouch, State, TouchKind, WorkSession } from "@g1t/contracts";
6−
7−import type { Route } from "./+types/session";
8−import { page } from "../../../lib/meta";
9−import { Actor, CoordinationFeed, EndSessionDialog, HarnessBadge, SessionStateBadge } from "../../../components/coordination";
10−import { SessionEntries } from "../../../components/session-entries";
11−import { EmptyState, Input, SubmitButton, TimeAgo } from "../../../components/ui";
12−import { Alert } from "../../../components/ui/alert";
13−import { Badge } from "../../../components/ui/badge";
14−import { Button } from "../../../components/ui/button";
15−import { Card } from "../../../components/ui/card";
16−import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../components/ui/collapsible";
17−import { Hint } from "../../../components/ui/hint";
18−import { PersonLink } from "../../../components/work";
19−import { ItemIcon, PullIcon } from "../../../components/work-icons";
20−import { itemPath } from "../../../lib/refs";
21−import { accessTo } from "../../../lib/access.server";
22−import {
23− type SessionNode,
24− TOUCH_KINDS,
25− duration,
26− endReasonText,
27− harnessName,
28− mayEndSession,
29− pendingLines,
30− sessionTitle,
31− sessionTree,
32− titleHint,
33− titleIsProvisional,
34− touchedByKind,
35− viaLabel,
36−} from "../../../lib/coordination";
37−import { notFound } from "../../../lib/not-found.server";
38−import { repoPages } from "../../../lib/repo-live";
39−import { agentSessions, work } from "../../../lib/services.server";
40−import { assertSameOrigin, getViewer, requireUser } from "../../../lib/session.server";
41−import { useLive } from "../../../lib/use-repo-live";
42−import { sessionPath, sessionsPath } from "../../../lib/work-items";
43−
44−const MANAGED = "claude_managed_agents";
45−
46−/**
47− * What a session is called: its title, a subagent's kind and task, else
48− * what its first prompt was for, its branch, or "Untitled session".
49− */
50−function sessionName(session: WorkSession, firstPrompt?: string | null): string {
51− if (!session.title?.trim() && session.parentId) return [session.agentType ?? "Subagent", session.task].filter(Boolean).join(": ");
52− return sessionTitle(session, firstPrompt ?? session.firstPrompt);
53−}
54−
55−/** The workspace of the repository at `base` (`/acme/web`): its work items are numbered there. */
56−function workspaceOf(base: string): string {
57− return base.split("/").filter(Boolean)[0]?.toLowerCase() ?? "";
58−}
59−
60−/** The repository `owner/name` as a path to look it up by. */
61−function repoPath(repo: string): { namespace: string; name: string } {
62− const [namespace = "", name = ""] = repo.split("/");
63− return { namespace, name };
64−}
65−
66−export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
67− const session = loaderData?.session;
68− const firstPrompt = loaderData?.entries.find((entry) => entry.kind === "prompt")?.text;
69− const name = session ? `${sessionName(session, firstPrompt)} · ` : "";
70− return page(args, { title: `${name}Session · ${loaderData?.repo ?? params.owner} · g1t` });
71−}
72−
73−export async function loader({ params, context }: Route.LoaderArgs) {
74− const viewer = getViewer(context);
75− const workspace = params.owner.toLowerCase();
76− const read = () => work.getWorkspaceSession(workspace, params.id, viewer);
77− // The session is what the page is about: a service that fails fails the
78− // page, rather than calling it not found.
79− let found = await read();
80− if (!found.ok) throw notFound("page");
81− const repo = found.value.repo;
82− const path = repoPath(repo);
83− const access = await accessTo(context, { owner: path.namespace, repo: path.name });
84− // A session on the workspace's Anthropic account is followed every
85− // minute; while it goes, it is read now, through the top of its tree.
86− const top = found.value.root ?? found.value.session;
87− if (top.harness === MANAGED && top.state !== "ended") {
88− // Reading it fresh is a nicety: without it, the page shows it as last recorded.
89− const synced = await agentSessions.sync(path, top.id, viewer).catch(() => null);
90− if (synced?.ok) {
91− const again = await read().catch(() => null);
92− if (again?.ok) found = again;
93− }
94− }
95− const { session } = found.value;
96− // Where it opens on the Claude Console, which shows everything it was
97− // asked and every tool's output: told to whoever started it, nobody else
98− // (the integrations service checks that too). Without it, the page does without the link.
99− const consoleUrl =
100− viewer && viewer.id === top.actor.id && top.harness === MANAGED
101− ? await agentSessions
102− .link(viewer, path, top.id)
103− .then((linked) => (linked.ok ? linked.value : null))
104− .catch(() => null)
105− : null;
106− return {
107− ...found.value,
108− workspace,
109− repoId: session.repoId,
110− signedIn: viewer != null,
111− // Only whoever started it stops it, and only the top of the tree.
112− canStop: viewer?.id === session.actor.id && session.harness === MANAGED && session.state !== "ended" && !session.parentId,
113− // One its harness left behind is ended by hand: by whoever runs it, or anyone with write access.
114− canEnd: mayEndSession(session, viewer?.id, access.can.push),
115− consoleUrl,
116− };
117−}
118−
119−export async function action({ request, params, context }: Route.ActionArgs) {
120− assertSameOrigin(request);
121− const user = requireUser(context, request);
122− const form = await request.formData();
123− // Where it runs: the repository its ending and stopping go to.
124− const found = await work.getWorkspaceSession(params.owner.toLowerCase(), params.id, user, 4294967295).catch(() => null);
125− if (!found?.ok) return { error: "The session could not be found. Try again in a moment." };
126− const path = repoPath(found.value.repo);
127− // Ending it by hand (from here, or a list's menu by fetcher); the work service checks who may.
128− if (form.get("action") === "end") {
129− const ended = await work.endWorkSession(user, path, params.id).catch(() => null);
130− if (!ended) return { error: "The session could not be ended. Try again in a moment." };
131− return ended.ok ? { ended: true } : { error: ended.error.message };
132− }
133− // Naming it pins its title; an empty one lets g1t name it again. The work service checks who may.
134− if (form.get("action") === "title") {
135− const title = String(form.get("title") ?? "").trim();
136− if (title.length > 200) return { error: "A title is at most 200 characters." };
137− const titled = await work.titleWorkSession(user, path, params.id, title || null).catch(() => null);
138− if (!titled) return { error: "The title could not be saved. Try again in a moment." };
139− return titled.ok ? { titled: true } : { error: titled.error.message };
140− }
141− if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop, end or name the session." };
142− const result = await agentSessions.stop(user, path, params.id).catch(() => null);
143− if (!result) return { error: "The session could not be stopped. Try again in a moment." };
144− return result.ok ? null : { error: result.error.message };
145−}
146−
147−/**
148− * The session's name, muted while it is provisional, and for whoever may,
149− * a way to name it (pinning it: g1t keeps a person's title) or to let g1t
150− * name it again.
151− */
152−function SessionHeading({ session, name, canTitle }: { session: WorkSession; name: string; canTitle: boolean }) {
153− const [editing, setEditing] = useState(false);
154− const provisional = titleIsProvisional(session);
155− if (editing) {
156− return (
157− <div className="flex min-w-0 grow flex-wrap items-center gap-2">
158− <Form method="post" className="flex min-w-0 grow items-center gap-2" onSubmit={() => setEditing(false)}>
159− <input type="hidden" name="action" value="title" />
160− <Input
161− name="title"
162− defaultValue={provisional ? "" : (session.title ?? "")}
163− placeholder={name}
164− maxLength={200}
165− required
166− autoFocus
167− aria-label="Session title"
168− />
169− <SubmitButton size="xs" match={{ action: "title" }} pending="Saving…">
170− Save
171− </SubmitButton>
172− <Button type="button" variant="ghost" size="xs" onClick={() => setEditing(false)}>
173− Cancel
174− </Button>
175− </Form>
176− {session.titleSource === "person" && (
177− <Form method="post" onSubmit={() => setEditing(false)}>
178− <input type="hidden" name="action" value="title" />
179− <input type="hidden" name="title" value="" />
180− <Button type="submit" variant="ghost" size="xs">
181− Let g1t name it
182− </Button>
183− </Form>
184− )}
185− </div>
186− );
187− }
188− const hint = titleHint(session);
189− const text = <span className="min-w-0">{name}</span>;
190− return (
191− <h2 className={`flex min-w-0 grow items-center gap-1.5 text-lg font-semibold tracking-tight wrap-break-word ${provisional ? "text-muted" : ""}`}>
192− {hint ? <Hint label={hint}>{text}</Hint> : text}
193− {canTitle && (
194− <Hint label="Rename">
195− <Button type="button" variant="ghost" size="icon-xs" aria-label="Rename this session" onClick={() => setEditing(true)}>
196− <Pencil size={13} />
197− </Button>
198− </Hint>
199− )}
200− </h2>
201− );
202−}
203−
204−/** A part of the page under a heading. */
205−function Section({ title, aside, children }: { title: ReactNode; aside?: ReactNode; children: ReactNode }) {
206− return (
207− <section className="mt-10">
208− <div className="flex flex-wrap items-baseline justify-between gap-2">
209− <h3 className="text-sm font-medium">{title}</h3>
210− {aside && <span className="text-xs text-muted">{aside}</span>}
211− </div>
212− <div className="mt-3">{children}</div>
213− </section>
214− );
215−}
216−
217−/** Text a session wrote, as it wrote it: cut short, with a way to see it all. */
218−function LongText({ text, limit = 600 }: { text: string; limit?: number }) {
219− const [all, setAll] = useState(false);
220− const long = text.length > limit;
221− return (
222− <div>
223− <p className="text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{long && !all ? `${text.slice(0, limit).trimEnd()}…` : text}</p>
224− {long && (
225− <Button variant="link" size="inline" className="mt-1 text-xs" onClick={() => setAll(!all)}>
226− {all ? "Show less" : "Show all"}
227− </Button>
228− )}
229− </div>
230− );
231−}
232−
233−/** A work item or pull request it worked on, with how it was tied there. */
234−function LinkChip({ link, base }: { link: SessionLink; base: string }) {
235− const icon =
236− link.kind === "pull" ? (
237− <PullIcon status={(["open", "draft", "merged", "closed"].includes(link.state) ? link.state : "open") as Pull["status"]} size={12} />
238− ) : (
239− <ItemIcon item={{ state: (link.state === "closed" ? "closed" : "open") as State, reason: null }} size={12} />
240− );
241− return (
242− <Hint label={`Tied by ${link.via.map(viaLabel).join(", ")}`}>
243− <Link to={link.kind === "pull" ? `${base}/pull/${link.number}` : itemPath(workspaceOf(base), link.number)} prefetch="intent">
244− <Badge size="md" className="max-w-72 hover:border-line-strong hover:text-fg">
245− {icon}
246− <span className="truncate">
247− {link.title} <span className="text-faint">#{link.number}</span>
248− </span>
249− </Badge>
250− </Link>
251− </Hint>
252− );
253−}
254−
255−/** One thing touched, linked where the site shows it. */
256−function TouchedValue({ kind, value, base }: { kind: TouchKind; value: string; base: string }) {
257− if (kind === "file") return <span className="font-mono text-xs break-all text-fg-soft">{value}</span>;
258− const [to, text] =
259− kind === "commit"
260− ? [`${base}/commit/${value}`, value.slice(0, 7)]
261− : kind === "branch"
262− ? [`${base}/tree/${value}`, value]
263− : [kind === "pull" ? `${base}/pull/${value}` : itemPath(workspaceOf(base), Number(value)), `#${value}`];
264− return (
265− <Link to={to} className="font-mono text-xs text-fg-soft hover:text-accent">
266− {text}
267− </Link>
268− );
269−}
270−
271−const SHOWN = 8;
272−
273−/** Values of one kind: the first few, and the rest folded. */
274−function TouchedValues({ kind, values, base }: { kind: TouchKind; values: string[]; base: string }) {
275− const [open, setOpen] = useState(false);
276− const list = (items: string[]) => (
277− <ul className={kind === "file" ? "space-y-0.5" : "flex flex-wrap gap-x-3 gap-y-0.5"}>
278− {items.map((value) => (
279− <li key={value}>
280− <TouchedValue kind={kind} value={value} base={base} />
281− </li>
282− ))}
283− </ul>
284− );
285− if (values.length <= SHOWN) return list(values);
286− return (
287− <Collapsible open={open} onOpenChange={setOpen}>
288− {list(values.slice(0, SHOWN))}
289− <CollapsibleContent>{list(values.slice(SHOWN))}</CollapsibleContent>
290− <CollapsibleTrigger className="mt-1 text-xs">
291− <ChevronRight size={12} />
292− {open ? "Show fewer" : `${values.length - SHOWN} more`}
293− </CollapsibleTrigger>
294− </Collapsible>
295− );
296−}
297−
298−/** What was touched, by kind. */
299−function Touched({ touched, base, kinds }: { touched: SessionTouch[]; base: string; kinds?: TouchKind[] }) {
300− const groups = touchedByKind(touched).filter((group) => !kinds || kinds.includes(group.kind));
301− if (groups.length === 0) return null;
302− return (
303− <dl className="space-y-3">
304− {groups.map((group) => (
305− <div key={group.kind}>
306− <dt className="text-xs font-medium text-muted">
307− {TOUCH_KINDS[group.kind]} <span className="font-normal text-faint">· {group.values.length}</span>
308− </dt>
309− <dd className="mt-1">
310− <TouchedValues kind={group.kind} values={group.values} base={base} />
311− </dd>
312− </div>
313− ))}
314− </dl>
315− );
316−}
317−
318−/** A subagent in the tree: a line, and folded under it what it touched, what it reported and its own subagents. */
319−function SubagentNode({ node, base, touched, now }: { node: SessionNode; base: string; touched: SessionTouch[]; now: number }) {
320− const [open, setOpen] = useState(false);
321− const { session } = node;
322− const mine = touched.filter((one) => one.sessionId === session.id);
323− return (
324− <li className="px-4 py-3">
325− <Collapsible open={open} onOpenChange={setOpen}>
326− <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
327− <CollapsibleTrigger aria-label={open ? "Fold" : "Unfold"}>
328− <ChevronRight size={14} />
329− </CollapsibleTrigger>
330− <Bot size={14} className="shrink-0 text-faint" />
331− <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="shrink-0 text-sm font-medium hover:text-accent">
332− {session.agentType ?? "Subagent"}
333− </Link>
334− {session.task && (
335− <Hint label={session.task}>
336− <span className="min-w-0 flex-1 truncate text-sm text-muted">{session.task}</span>
337− </Hint>
338− )}
339− <span className="ml-auto flex items-center gap-2 text-xs text-faint">
340− {node.children.length > 0 && <Badge>{node.children.length} under it</Badge>}
341− <SessionStateBadge state={session.state} />
342− <TimeAgo at={session.startedAt} />
343− <span suppressHydrationWarning>· {duration(session, now)}</span>
344− </span>
345− </div>
346− <CollapsibleContent className="mt-3 space-y-4 pl-6">
347− {mine.length > 0 && <Touched touched={mine} base={base} />}
348− {session.result ? (
349− <div>
350− <p className="text-xs font-medium text-muted">What it reported</p>
351− <div className="mt-1">
352− <LongText text={session.result} limit={400} />
353− </div>
354− </div>
355− ) : (
356− mine.length === 0 && <p className="text-xs text-faint">{session.state === "ended" ? "It reported nothing." : "Nothing yet."}</p>
357− )}
358− {node.children.length > 0 && <SubagentTree nodes={node.children} base={base} touched={touched} now={now} />}
359− </CollapsibleContent>
360− </Collapsible>
361− </li>
362− );
363−}
364−
365−function SubagentTree({ nodes, base, touched, now }: { nodes: SessionNode[]; base: string; touched: SessionTouch[]; now: number }) {
366− return (
367− <Card asChild divided tone="plain">
368− <ol>
369− {nodes.map((node) => (
370− <SubagentNode key={node.session.id} node={node} base={base} touched={touched} now={now} />
371− ))}
372− </ol>
373− </Card>
374− );
375−}
376−
377−/** One harness session: who ran it, where, what it worked on, its subagents, what it touched and what it recorded. */
378−export default function WorkSessionPage({ loaderData, actionData }: Route.ComponentProps) {
379− const { session, entries, updates = [], children = [], touched = [], links = [], skills = [], root, canStop, canEnd, consoleUrl, limited, workspace, repo } = loaderData;
380− const base = `/${repo}`;
381− const [ending, setEnding] = useState(false);
382− const now = Date.now();
383− // Any session, claim or overlap in its repository: a change names no session.
384− useLive({ match: repoPages.session(), repos: [{ id: loaderData.repoId, path: repo }], signedIn: loaderData.signedIn });
385−
386− const tools = entries.filter((entry) => entry.kind === "tool_call").length;
387− const firstPrompt = entries.find((entry) => entry.kind === "prompt")?.text;
388− const isSubagent = Boolean(session.parentId);
389− const parent = isSubagent && root?.id === session.parentId ? root : null;
390− // Its own subagents: those whose parent is in this tree (children lists every descendant).
391− const tree = sessionTree([session, ...children]).find((node) => node.session.id === session.id)?.children ?? [];
392− const managed = session.harness === MANAGED || root?.harness === MANAGED;
393−
394− // The skills, summed by name across the tree.
395− const skillUses = new Map<string, number>();
396− for (const skill of skills) skillUses.set(skill.name, (skillUses.get(skill.name) ?? 0) + skill.uses);
397−
398− const pulls = links.filter((link) => link.kind === "pull");
399− const groups = touchedByKind(touched);
400− const commits = groups.find((group) => group.kind === "commit")?.values ?? [];
401− const where: TouchKind[] = ["file", "branch", "item"];
402−
403− return (
404− <div className="max-w-4xl">
405− {isSubagent ? (
406− <p className="flex flex-wrap items-center gap-x-1.5 text-sm text-muted">
407− <Link to={sessionPath(workspace, session.parentId!)} className="inline-flex items-center gap-1.5 hover:text-fg">
408− <ArrowLeft size={14} />
409− Subagent of {parent ? sessionName(parent) : "another subagent"}
410− </Link>
411− {root && root.id !== session.parentId && (
412− <>
413− <span className="text-faint">in</span>
414− <Link to={sessionPath(workspace, root.id)} className="hover:text-fg">
415− {sessionName(root)}
416− </Link>
417− </>
418− )}
419− </p>
420− ) : (
421− <Link to={sessionsPath(workspace)} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
422− <ArrowLeft size={14} />
423− Sessions
424− </Link>
425− )}
426−
427− <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
428− <Actor username={session.actor.username} harness={session.harness} size={24} />
429− <SessionHeading session={session} name={sessionName(session, firstPrompt)} canTitle={canEnd && !isSubagent} />
430− {isSubagent && session.agentType && (
431− <Badge tone="accent">
432− <Bot size={11} />
433− {session.agentType}
434− </Badge>
435− )}
436− <HarnessBadge harness={session.harness} />
437− {session.model && <Badge shape="tag" className="font-mono">{session.model}</Badge>}
438− <SessionStateBadge state={session.state} />
439− {canStop && (
440− <Form method="post">
441− <input type="hidden" name="action" value="stop" />
442− <SubmitButton variant="destructive" size="xs" match={{ action: "stop" }} pending="Stopping…">
443− <Square size={12} />
444− Stop
445− </SubmitButton>
446− </Form>
447− )}
448− {canEnd && (
449− <>
450− <Button variant="outline" size="xs" onClick={() => setEnding(true)}>
451− <Square size={12} />
452− End session
453− </Button>
454− <EndSessionDialog session={session} base={base} open={ending} onOpenChange={setEnding} />
455− </>
456− )}
457− {consoleUrl && (
458− <Button asChild variant="outline" size="xs">
459− <a href={consoleUrl} target="_blank" rel="noreferrer">
460− <ExternalLink size={12} />
461− Open in Claude Console
462− </a>
463− </Button>
464− )}
465− </div>
466−
467− <p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted">
468− <PersonLink name={session.actor.username} className="font-medium text-fg hover:underline" />
469− <Link to={base} className="font-mono text-xs hover:text-fg">
470− in {repo}
471− </Link>
472− <span>
473− started <TimeAgo at={session.startedAt} />
474− </span>
475− <span suppressHydrationWarning>
476− · {session.state === "ended" ? "ran" : "running for"} {duration(session, now)}
477− </span>
478− <span>
479− · {session.state === "ended" ? "ended" : "last heard from"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
480− {session.endReason && ` (${endReasonText(session.endReason)})`}
481− </span>
482− {session.branch && (
483− <Link to={`${base}/tree/${session.branch}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
484− · <GitBranch size={12} />
485− {session.branch}
486− </Link>
487− )}
488− {session.headCommit && (
489− <Link to={`${base}/commit/${session.headCommit}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
490− · <GitCommitHorizontal size={12} />
491− {session.headCommit.slice(0, 7)}
492− </Link>
493− )}
494− </p>
495−
496− {links.length > 0 && (
497− <div className="mt-3 flex flex-wrap gap-2">
498− {links.map((link) => (
499− <LinkChip key={`${link.kind}-${link.number}`} link={link} base={base} />
500− ))}
501− </div>
502− )}
503−
504− {actionData?.error && (
505− <Alert tone="danger" className="mt-4">
506− {actionData.error}
507− </Alert>
508− )}
509− {managed && session.state !== "ended" && (
510− <Alert tone="info" className="mt-4">
511− It runs on the workspace's Anthropic account, with Claude Managed Agents. What it does shows here as it goes.
512− </Alert>
513− )}
514− <p className="mt-3 text-xs text-faint">
515− {managed
516− ? "Started by g1t on the workspace's Anthropic account, for whoever started it."
517− : `Run by ${session.actor.username}'s token in ${harnessName(session.harness)}, as the harness declared.`}{" "}
518− What it records is its own account; the commits, checks and merges are the record.
519− </p>
520−
521− {isSubagent && (session.prompt || session.result) && (
522− <Card className="mt-6 space-y-4 p-4">
523− {session.prompt && (
524− <div>
525− <p className="text-xs font-medium text-muted">What it was asked</p>
526− <div className="mt-1">
527− <LongText text={session.prompt} />
528− </div>
529− </div>
530− )}
531− {session.result && (
532− <div>
533− <p className="text-xs font-medium text-muted">What it reported</p>
534− <div className="mt-1">
535− <LongText text={session.result} />
536− </div>
537− </div>
538− )}
539− </Card>
540− )}
541− {!isSubagent && session.result && (
542− <Section title="What it reported">
543− <Card className="p-4">
544− <LongText text={session.result} />
545− </Card>
546− </Section>
547− )}
548−
549− {tree.length > 0 && (
550− <Section title="Subagents" aside={children.length > tree.length ? `${children.length} in all` : undefined}>
551− <SubagentTree nodes={tree} base={base} touched={touched} now={now} />
552− </Section>
553− )}
554−
555− {skillUses.size > 0 && (
556− <Section title="Skills used">
557− <div className="flex flex-wrap gap-2">
558− {[...skillUses].map(([name, uses]) => (
559− <Badge key={name} size="md" shape="tag">
560− <Sparkles size={11} className="text-accent" />
561− <span className="font-mono">{name}</span>
562− {uses > 1 && <span className="text-faint">×{uses}</span>}
563− </Badge>
564− ))}
565− </div>
566− </Section>
567− )}
568−
569− {groups.some((group) => where.includes(group.kind)) && (
570− <Section title="What it touched" aside={tree.length > 0 ? "With its subagents" : undefined}>
571− <Card className="p-4">
572− <Touched touched={touched} base={base} kinds={where} />
573− </Card>
574− </Section>
575− )}
576−
577− {(pulls.length > 0 || commits.length > 0) && (
578− <Section title="Commits and pull requests">
579− <Card asChild divided>
580− <ul>
581− {pulls.map((link) => (
582− <li key={link.number} className="flex items-center gap-2.5 px-4 py-2.5 text-sm">
583− <PullIcon status={(["open", "draft", "merged", "closed"].includes(link.state) ? link.state : "open") as Pull["status"]} size={14} />
584− <Link to={`${base}/pull/${link.number}`} prefetch="intent" className="min-w-0 grow truncate hover:text-accent">
585− {link.title} <span className="text-faint">#{link.number}</span>
586− </Link>
587− <span className="shrink-0 text-xs text-faint">{link.via.map(viaLabel).join(", ")}</span>
588− </li>
589− ))}
590− {commits.map((sha) => (
591− <li key={sha} className="flex items-center gap-2.5 px-4 py-2.5 text-sm">
592− <GitCommitHorizontal size={14} className="shrink-0 text-faint" />
593− <Link to={`${base}/commit/${sha}`} className="font-mono text-xs hover:text-accent">
594− {sha.slice(0, 7)}
595− </Link>
596− </li>
597− ))}
598− </ul>
599− </Card>
600− </Section>
601− )}
602−
603− {updates.length > 0 && (
604− <Section title="Awaiting agent acknowledgement" aside={`${updates.length}${updates.length === 100 ? "+" : ""} pending`}>
605− <p className="mb-3 text-sm text-muted">What others did that concerns this session, which its agent has not said it considered yet. Acknowledging records that it was considered, not that anything was resolved.</p>
606− <CoordinationFeed lines={pendingLines(updates)} base={base} />
607− </Section>
608− )}
609− <Section title="What it recorded" aside={limited ? undefined : `${entries.length} entries, ${tools} tool calls`}>
610− {limited ? (
611− <EmptyState title="Shown to the workspace's members">
612− What a session records is the work of whoever runs it, as it happens. Members of {workspace} see it; anyone else sees that it runs, who runs it and what it is tied to.
613− </EmptyState>
614− ) : entries.length === 0 ? (
615− <EmptyState title="Nothing recorded yet">Prompts, messages, tool calls, skills and notes show here as the session records them.</EmptyState>
616− ) : (
617− <SessionEntries entries={entries} base={base} />
618− )}
619− </Section>
620− </div>
621− );
622−}
+290−0
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+}
+622−0
Loading the diff…
+1−1
Loading the diff…

Comments

Sign in to comment.