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
21 files+1047−10730/21 viewed
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
| 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"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useFetchers } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 14 | 14 | const icons: Record<BuiltinApp, ReactNode> = { | |
| 15 | 15 | home: <House size={size} />, | |
| 16 | 16 | work: <ListTodo size={size} />, | |
| 17 | − | sessions: <Activity size={size} />, | |
| 18 | 17 | chat: <MessagesSquare size={size} />, | |
| 19 | 18 | notifications: <Bell size={size} />, | |
| 20 | 19 | code: <Code2 size={size} />, | |
| 123 | 123 | * The bottom bar of a phone, flat along the bottom edge under a rule, as | |
| 124 | 124 | * the rail stands flat beside the page on a computer: Home, Work, Chat and | |
| 125 | 125 | * 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 | |
| 128 | 128 | * mark: its icon on the sidebar's accent. It steps aside while the keyboard is up | |
| 129 | 129 | * 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). | |
| 131 | 132 | */ | |
| 132 | 133 | export function BottomBar({ | |
| 133 | 134 | user, | |
| ⋯ | |||
| 258 | 259 | ||
| 259 | 260 | /** | |
| 260 | 261 | * 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 | |
| 265 | 267 | * workspaces, Explore opens Explore's menu (`onExplore`) rather than the | |
| 266 | 268 | * page. | |
| 267 | 269 | */ | |
| ⋯ | |||
| 301 | 303 | <span className="ml-auto truncate font-mono text-xs text-faint">{slug}</span> | |
| 302 | 304 | </div> | |
| 303 | 305 | <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. */} | |
| 316 | 307 | {onExplore ? ( | |
| 317 | 308 | <MoreTile | |
| 318 | 309 | icon={<Compass size={20} />} | |
| ⋯ | |||
| 325 | 316 | ) : ( | |
| 326 | 317 | <MoreTile to="/explore" icon={<Compass size={20} />} label="Explore" /> | |
| 327 | 318 | )} | |
| 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 | + | ))} | |
| 328 | 329 | <MoreTile | |
| 329 | 330 | icon={<Avatar name={user.username} image={user.avatar} size={24} />} | |
| 330 | 331 | label="You and help" | |
| 14 | 14 | * per step, chosen with the pills. | |
| 15 | 15 | * | |
| 16 | 16 | * 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), | |
| 18 | 18 | * each mode's sidebar with its real sections and words, the same top bar | |
| 19 | 19 | * (where you are, Explore, Docs, the inbox and +), which Chat gives up to | |
| 20 | 20 | * the conversation's own header as the app does, and the app's own cards. | |
| ⋯ | |||
| 274 | 274 | /** The rail's modes, in the app's order (components/rail.tsx), with its icons. */ | |
| 275 | 275 | const RAIL: { key: string; icon: ReactNode; label: string; target?: CursorTarget; scenes?: Scene[]; badge?: number }[] = [ | |
| 276 | 276 | { key: "home", icon: <House size={15} />, label: "Home", scenes: ["home"] }, | |
| 277 | + | { key: "explore", icon: <Compass size={15} />, label: "Explore" }, | |
| 277 | 278 | { key: "work", icon: <ListTodo size={15} />, label: "Work" }, | |
| 278 | − | { key: "explore", icon: <Compass size={15} />, label: "Explore" }, | |
| 279 | 279 | { key: "chat", icon: <MessagesSquare size={15} />, label: "Chat", target: "rail-chat", scenes: ["chat"], badge: 3 }, | |
| 280 | 280 | { key: "code", icon: <Code2 size={15} />, label: "Code", target: "rail-code", scenes: ["code", "session"] }, | |
| 281 | 281 | { key: "artifacts", icon: <Shapes size={15} />, label: "Artifacts" }, | |
| 1 | 1 | import { useSidebarLocation } from "../lib/use-sidebar-location"; | |
| 2 | 2 | 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"; | |
| 4 | 4 | import { Link } from "react-router"; | |
| 5 | 5 | ||
| 6 | 6 | import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| ⋯ | |||
| 22 | 22 | } from "./ui/dropdown-menu"; | |
| 23 | 23 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 24 | 24 | 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"; | |
| 26 | 26 | import { cn } from "../lib/cn"; | |
| 27 | 27 | import { paletteKeyLabel } from "../lib/shortcut"; | |
| 28 | 28 | import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav"; | |
| 29 | 29 | ||
| 30 | − | type Item = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } | null }; | |
| 30 | + | type Item = { badge?: { count: number; loud: boolean } | null }; | |
| 31 | 31 | ||
| 32 | 32 | /** A count on an icon: loud (the accent) for what waits on you, quiet for the rest. */ | |
| 33 | 33 | export function CountBadge({ count, loud, className }: { count: number; loud: boolean; className?: string }) { | |
| ⋯ | |||
| 199 | 199 | ||
| 200 | 200 | /** | |
| 201 | 201 | * 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; | |
| 207 | 207 | * and at its foot People, Workspace and your account. The one you are in | |
| 208 | 208 | * sits on a filled square, and which that is follows the address. A | |
| 209 | 209 | * member without Code access has no Code. From 768px; a phone has the | |
| ⋯ | |||
| 228 | 228 | const { pathname: path } = useSidebarLocation(); | |
| 229 | 229 | const here = mode ?? modeOf(path, workspace.slug); | |
| 230 | 230 | const slug = workspace.slug; | |
| 231 | + | // A member without Code access has no Code; Work is every member's, its sessions in it. | |
| 231 | 232 | const code = hasCodeAccess(workspace); | |
| 232 | 233 | 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 | + | }; | |
| 248 | 237 | const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null); | |
| 249 | 238 | // A pinned app is current on its own page and the pages under it. | |
| 250 | 239 | const at = (to: string) => path === to || path.startsWith(`${to}/`); | |
| ⋯ | |||
| 265 | 254 | </SidebarHeader> | |
| 266 | 255 | <SidebarContent className="items-center gap-0"> | |
| 267 | 256 | <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 | + | ) : ( | |
| 270 | 262 | <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]} | |
| 276 | 269 | /> | |
| 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 | + | )} | |
| 283 | 272 | </SidebarMenu> | |
| 284 | 273 | {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */} | |
| 285 | 274 | {shown.length > 0 && ( | |
| 1417 | 1417 | } | |
| 1418 | 1418 | ||
| 1419 | 1419 | /** | |
| 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. | |
| 1425 | 1426 | */ | |
| 1426 | 1427 | function WorkSidebar({ slug, username }: { slug: string; username: string }) { | |
| 1427 | 1428 | const { pathname: path, search } = useSidebarLocation(); | |
| 1428 | 1429 | const list = workPath(slug); | |
| 1430 | + | const sessions = sessionsPath(slug); | |
| 1429 | 1431 | 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); | |
| 1433 | 1436 | const at = (page: string) => path === workPath(slug, page) || path.startsWith(`${workPath(slug, page)}/`); | |
| 1434 | 1437 | return ( | |
| 1435 | 1438 | <div className="flex h-full flex-col"> | |
| 1436 | 1439 | <ModeHeader title="Work" /> | |
| 1437 | 1440 | <nav aria-label="Work" className={PANEL}> | |
| 1438 | − | <NavRows> | |
| 1441 | + | <NavGroup title="Work items"> | |
| 1439 | 1442 | <SidebarLink to={list} icon={<ListTodo size={15} />} current={view({})}> | |
| 1440 | 1443 | Open | |
| 1441 | 1444 | </SidebarLink> | |
| ⋯ | |||
| 1448 | 1451 | <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />} current={at("board")}> | |
| 1449 | 1452 | Board | |
| 1450 | 1453 | </SidebarLink> | |
| 1451 | − | <div className="md:hidden"> | |
| 1452 | − | <SidebarLink to={sessionsPath(slug)} icon={<Activity size={15} />}> | |
| 1453 | − | Sessions | |
| 1454 | − | </SidebarLink> | |
| 1455 | − | </div> | |
| 1456 | 1454 | <SidebarLink to={newItemPath(slug)} icon={<Plus size={15} />} current={at("new")}> | |
| 1457 | 1455 | New work item | |
| 1458 | 1456 | </SidebarLink> | |
| 1459 | − | </NavRows> | |
| 1460 | − | <Rule /> | |
| 1461 | − | <NavRows> | |
| 1462 | 1457 | <SidebarLink to={workPath(slug, "labels")} icon={<Ticket size={15} />} current={at("labels")}> | |
| 1463 | 1458 | Labels | |
| 1464 | 1459 | </SidebarLink> | |
| 1465 | 1460 | <SidebarLink to={workPath(slug, "milestones")} icon={<Flag size={15} />} current={at("milestones")}> | |
| 1466 | 1461 | Milestones | |
| 1467 | 1462 | </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({})}> | |
| 1491 | 1467 | Live now | |
| 1492 | 1468 | </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" })}> | |
| 1494 | 1470 | Recent | |
| 1495 | 1471 | </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 })}> | |
| 1497 | 1473 | Yours | |
| 1498 | 1474 | </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" })}> | |
| 1503 | 1476 | People's harnesses | |
| 1504 | 1477 | </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" })}> | |
| 1506 | 1479 | Started on g1t | |
| 1507 | 1480 | </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" })}> | |
| 1509 | 1482 | g1t's agent | |
| 1510 | 1483 | </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" })}> | |
| 1512 | 1485 | Recorded on pull requests | |
| 1513 | 1486 | </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> | |
| 1521 | 1488 | </nav> | |
| 1522 | 1489 | </div> | |
| 1523 | 1490 | ); | |
| ⋯ | |||
| 1658 | 1625 | workflows: "Workflows", | |
| 1659 | 1626 | observability: "Observability", | |
| 1660 | 1627 | insights: "Insights", | |
| 1661 | − | sessions: "Sessions", | |
| 1662 | 1628 | work: "Work", | |
| 1663 | 1629 | chat: "Chat", | |
| 1664 | 1630 | artifacts: "Artifacts", | |
| ⋯ | |||
| 1727 | 1693 | // Chat settings is a page of its own, not one of General's. | |
| 1728 | 1694 | trail.push({ label: "Chat settings", to: `${page}/chat` }); | |
| 1729 | 1695 | } 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. | |
| 1731 | 1697 | 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" }; | |
| 1733 | 1699 | if (fourth && /^\d+$/.test(fourth)) trail.push({ label: `Work item #${fourth}`, short: `#${fourth}`, to: `${page}/${fourth}` }); | |
| 1734 | 1700 | else if (fourth && words[fourth]) trail.push({ label: words[fourth]!, to: `${page}/${fourth}` }); | |
| 1735 | 1701 | const fifth = parts[4]; | |
| 1736 | 1702 | 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}` }); | |
| 1741 | 1704 | } else if (third) { | |
| 1742 | 1705 | // The workspace's settings pages each stand alone: General, Members, Permissions. | |
| 1743 | 1706 | trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page }); | |
| ⋯ | |||
| 1873 | 1836 | { label: "Work", hint: "The workspace's work items", to: workPath(ws.slug), icon: <ListTodo size={15} /> }, | |
| 1874 | 1837 | { label: "New work item", to: newItemPath(ws.slug), icon: <Plus size={15} /> }, | |
| 1875 | 1838 | { 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} /> }, | |
| 1877 | 1840 | { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> }, | |
| 1878 | 1841 | { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> }, | |
| 1879 | 1842 | { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> }, | |
| ⋯ | |||
| 2018 | 1981 | } | |
| 2019 | 1982 | ||
| 2020 | 1983 | /** 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"; | |
| 2022 | 1985 | ||
| 2023 | 1986 | /** Each sidebar's name, for the button that opens it on a phone. */ | |
| 2024 | 1987 | const MODE_MENU: Record<Panel, string> = { | |
| 2025 | 1988 | browse: "Explore", | |
| 2026 | 1989 | work: "Work", | |
| 2027 | − | sessions: "Sessions", | |
| 2028 | 1990 | chat: "Chat", | |
| 2029 | 1991 | artifacts: "Artifacts", | |
| 2030 | 1992 | code: "Code", | |
| ⋯ | |||
| 2184 | 2146 | return <CodeSidebar user={user} shell={shell} missing={missing} browsing={browsing} />; | |
| 2185 | 2147 | case "work": | |
| 2186 | 2148 | return <WorkSidebar slug={ws.slug} username={user.username} />; | |
| 2187 | − | case "sessions": | |
| 2188 | − | return <SessionsSidebar slug={ws.slug} username={user.username} />; | |
| 2189 | 2149 | case "chat": | |
| 2190 | 2150 | return <ChatSidebar slug={ws.slug} />; | |
| 2191 | 2151 | case "artifacts": | |
| 22 | 22 | PHONE_TABS, | |
| 23 | 23 | appOf, | |
| 24 | 24 | phoneMoreApps, | |
| 25 | + | railApps, | |
| 25 | 26 | } from "./apps.ts"; | |
| 26 | 27 | ||
| 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"]); | |
| 29 | 30 | assert.equal(appOf("work").path("acme"), "/acme/-/work"); | |
| 30 | 31 | 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"); | |
| 31 | 33 | }); | |
| 32 | 34 | ||
| 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", () => { | |
| 34 | 42 | assert.deepEqual([...PHONE_TABS], ["home", "work", "chat", "notifications"]); | |
| 35 | 43 | // Four tabs and More: five slots. | |
| 36 | 44 | 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"]); | |
| 38 | 47 | // 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"]); | |
| 40 | 49 | }); | |
| 41 | 50 | ||
| 42 | 51 | test("only integrations are pinned; built-in apps, their pages, old plugins and connectors are not", () => { |
| 26 | 26 | import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors"; | |
| 27 | 27 | ||
| 28 | 28 | /** 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"; | |
| 30 | 30 | ||
| 31 | 31 | export type BuiltinInfo = { | |
| 32 | 32 | key: BuiltinApp; | |
| ⋯ | |||
| 43 | 43 | export const BUILTINS: BuiltinInfo[] = [ | |
| 44 | 44 | { key: "home", name: "Home", path: under("home") }, | |
| 45 | 45 | { key: "work", name: "Work", path: under("work") }, | |
| 46 | − | { key: "sessions", name: "Sessions", path: under("sessions") }, | |
| 47 | 46 | { key: "chat", name: "Chat", path: under("chat") }, | |
| 48 | 47 | { key: "notifications", name: "Notifications", path: () => "/notifications" }, | |
| 49 | 48 | { key: "code", name: "Code", path: under("overview"), code: true }, | |
| ⋯ | |||
| 53 | 52 | { key: "workspace", name: "Workspace", path: under("workspace") }, | |
| 54 | 53 | ]; | |
| 55 | 54 | ||
| 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 | + | ||
| 56 | 70 | /** A phone's bottom bar, before More: Work for every member, Code under More. */ | |
| 57 | 71 | export const PHONE_TABS = ["home", "work", "chat", "notifications"] as const satisfies readonly BuiltinApp[]; | |
| 58 | 72 | ||
| 59 | 73 | /** | |
| 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. | |
| 63 | 77 | */ | |
| 64 | 78 | 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; | |
| 67 | 81 | } | |
| 68 | 82 | ||
| 69 | 83 | /** The built-in app `key`. */ | |
| 744 | 744 | const [marked, plain] = withSessions([merge(), merge({ key: "landed:acme/web#13", sha: "m13" })], [ | |
| 745 | 745 | { repo: { namespace: "acme", name: "web" }, pulls: [{ number: 12, sessions: [{ id: "ses_1", harness: "claude_code" }, { id: "ses_2", harness: "codex" }] }] }, | |
| 746 | 746 | ]); | |
| 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" }); | |
| 748 | 748 | assert.equal(agentLabel(marked!.agent!), "Made in a Claude Code session"); | |
| 749 | 749 | assert.equal(plain!.agent, undefined); | |
| 750 | 750 | assert.equal(agentLabel({ harness: null, to: null }), "Made in a session"); | |
| ⋯ | |||
| 773 | 773 | assert.deepEqual( | |
| 774 | 774 | rows.map((row) => [row.title, row.detail, row.sha, row.to, row.agent?.to ?? null]), | |
| 775 | 775 | [ | |
| 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"], | |
| 777 | 777 | ["2 commits pushed to main", "web · Tidy · by ana", "c2", "/acme/web/activity", null], | |
| 778 | 778 | ], | |
| 779 | 779 | ); | |
| 35 | 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 | 36 | ||
| 37 | 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"); | |
| 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"); | |
| 40 | 40 | // g1t's agent and a recorded session are their pull request's Session tab. | |
| 41 | 41 | assert.equal(sessionHref("acme", listed("run_1", { kind: "agent", repo: "acme/api", pull: 8 })), "/acme/api/pull/8/session"); | |
| 42 | 42 | assert.equal(sessionHref("acme", listed("pr_1", { kind: "recorded", pull: 7 })), "/acme/web/pull/7/session"); |
| 68 | 68 | ||
| 69 | 69 | test("a repository's Work tab, and a new work item filed from it", () => { | |
| 70 | 70 | 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"); | |
| 74 | 74 | assert.equal(newItemPath("Acme"), "/acme/-/work/new"); | |
| 75 | 75 | assert.equal(newItemPath("acme", "web"), "/acme/-/work/new?repo=web"); | |
| 76 | 76 | assert.deepEqual(repoFilter("acme", "web"), { namespace: "acme", name: "web" }); |
| 60 | 60 | return `${workPath(workspace)}?repo=${encodeURIComponent(repo)}`; | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 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`). */ | |
| 64 | 64 | export function sessionsPath(workspace: string, filters: Record<string, string | null | undefined> = {}): string { | |
| 65 | 65 | const query = new URLSearchParams(); | |
| 66 | 66 | for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value); | |
| 67 | 67 | const text = query.toString(); | |
| 68 | − | return `/${workspace.toLowerCase()}/-/sessions${text ? `?${text}` : ""}`; | |
| 68 | + | return `${workPath(workspace, "sessions")}${text ? `?${text}` : ""}`; | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | 71 | /** One session's page, in whichever of the workspace's repositories it runs. */ | |
| 72 | 72 | export function sessionPath(workspace: string, id: string): string { | |
| 73 | − | return `/${workspace.toLowerCase()}/-/sessions/${encodeURIComponent(id)}`; | |
| 73 | + | return `${workPath(workspace, "sessions")}/${encodeURIComponent(id)}`; | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | /** A new work item, started from a repository (its origin) when `repo` is given. */ |
| 182 | 182 | assert.equal(modeOf("/acme/-/work/new", "acme"), "work"); | |
| 183 | 183 | assert.equal(modeOf("/acme/-/work/labels", "acme"), "work"); | |
| 184 | 184 | 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"); | |
| 185 | 190 | assert.equal(modeHome("work", "acme"), "/acme/-/work"); | |
| 186 | 191 | // A repository's pages, its Sessions among them, are Code's. | |
| 187 | 192 | assert.equal(modeOf("/acme/web/sessions", "acme"), "code"); |
| 215 | 215 | ||
| 216 | 216 | /** | |
| 217 | 217 | * 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; | |
| 219 | 219 | * Chat, Code and Artifacts are where work happens; Notifications | |
| 220 | 220 | * spans them; People is who belongs; Workspace is the workspace itself, | |
| 221 | 221 | * its settings area: who has access, money, policies and how each part is | |
| ⋯ | |||
| 231 | 231 | export type ModeKey = | |
| 232 | 232 | | "home" | |
| 233 | 233 | | "work" | |
| 234 | − | | "sessions" | |
| 235 | 234 | | "chat" | |
| 236 | 235 | | "notifications" | |
| 237 | 236 | | "code" | |
| ⋯ | |||
| 247 | 246 | /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */ | |
| 248 | 247 | const PAGE_MODES: Record<string, ModeKey> = { | |
| 249 | 248 | home: "home", | |
| 249 | + | // Work's sessions (`-/work/sessions`) are Work's too. | |
| 250 | 250 | work: "work", | |
| 251 | − | sessions: "sessions", | |
| 252 | 251 | chat: "chat", | |
| 253 | 252 | artifacts: "artifacts", | |
| 254 | 253 | routines: "routines", | |
| ⋯ | |||
| 312 | 311 | return homePagePath(slug); | |
| 313 | 312 | case "work": | |
| 314 | 313 | return `/${slug}/-/work`; | |
| 315 | − | case "sessions": | |
| 316 | − | return `/${slug}/-/sessions`; | |
| 317 | 314 | case "notifications": | |
| 318 | 315 | return "/notifications"; | |
| 319 | 316 | case "chat": | |
| 229 | 229 | // Work: the workspace's work items, for every member. The list (its | |
| 230 | 230 | // filters in the address), the board of them as they move, a new one, | |
| 231 | 231 | // 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. | |
| 233 | 236 | route("-/work", "routes/workspace/work/list.tsx"), | |
| 234 | 237 | 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"), | |
| 235 | 240 | route("-/work/new", "routes/workspace/work/new.tsx"), | |
| 236 | 241 | route("-/work/labels", "routes/workspace/work/labels.tsx"), | |
| 237 | 242 | route("-/work/milestones", "routes/workspace/work/milestones.tsx"), | |
| 238 | 243 | route("-/work/milestones/:number", "routes/workspace/work/milestone.tsx"), | |
| 239 | 244 | route("-/work/ref/:number", "routes/workspace/work/ref.ts"), | |
| 240 | 245 | 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"), | |
| 246 | 246 | route("-/today", "routes/workspace/moved.ts", { id: "routes/workspace/moved-today" }), | |
| 247 | 247 | route("-/apps", "routes/workspace/apps.tsx"), | |
| 248 | 248 | // Routines: standing work for @g1t, on a schedule or when something |
| 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 | − | } |
| 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 | − | } |
| 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 | + | } |
Loading the diff…
Loading the diff…
Comments
Sign in to comment.