Web: Sessions in the rail after Work, and Work's board
Sessions, right after Work, is everything going on in the workspace: Live now, the merge queues, what just landed and what happened, every kind of session, filtered by repository, person and harness, and each session's own page. Work gains a board (Backlog, In progress, In review, Landing, Done) and a strip of what is cooking now. A repository's Sessions and Work pages are gone, with no redirects. On a phone, Sessions is first in More.
- G1T-Session:
- ses_01m560bz6eeqc8v1hrrha03pap
35 files+2117−9700/35 viewed
| 1 | − | import { Bell, Building2, Code2, House, LayoutGrid, ListTodo, Lock, MessagesSquare, Pin, Plug, Repeat, Search, Shapes, Users } from "lucide-react"; | |
| 1 | + | import { Activity, Bell, Building2, Code2, House, LayoutGrid, ListTodo, Lock, MessagesSquare, Pin, Plug, Repeat, Search, Shapes, Users } from "lucide-react"; | |
| 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} />, | |
| 17 | 18 | chat: <MessagesSquare size={size} />, | |
| 18 | 19 | notifications: <Bell size={size} />, | |
| 19 | 20 | code: <Code2 size={size} />, | |
| 70 | 70 | titleHint, | |
| 71 | 71 | titleIsProvisional, | |
| 72 | 72 | } from "../lib/coordination"; | |
| 73 | + | import { sessionPath } from "../lib/work-items"; | |
| 73 | 74 | import { AgentSetup } from "./agent-setup"; | |
| 74 | 75 | import { Input, SubmitButton, TimeAgo } from "./ui"; | |
| 75 | 76 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| ⋯ | |||
| 236 | 237 | return ( | |
| 237 | 238 | <AlertDialog open={open} onOpenChange={onOpenChange}> | |
| 238 | 239 | <AlertDialogContent> | |
| 239 | − | <fetcher.Form method="post" action={`${base}/sessions/${session.id}`} className="grid gap-5"> | |
| 240 | + | <fetcher.Form method="post" action={sessionPath(workspaceOf(base), session.id)} className="grid gap-5"> | |
| 240 | 241 | <input type="hidden" name="action" value="end" /> | |
| 241 | 242 | <AlertDialogHeader> | |
| 242 | 243 | <AlertDialogTitle>End {sessionTitle(session)}?</AlertDialogTitle> | |
| ⋯ | |||
| 314 | 315 | {titleIsProvisional(session) ? ( | |
| 315 | 316 | // Muted while it is what the first prompt asked: g1t names it once the work is clear. | |
| 316 | 317 | <Hint label={titleHint(session)}> | |
| 317 | − | <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium text-muted hover:text-accent"> | |
| 318 | + | <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="font-medium text-muted hover:text-accent"> | |
| 318 | 319 | {sessionTitle(session)} | |
| 319 | 320 | </Link> | |
| 320 | 321 | </Hint> | |
| 321 | 322 | ) : ( | |
| 322 | − | <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent"> | |
| 323 | + | <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="font-medium hover:text-accent"> | |
| 323 | 324 | {sessionTitle(session)} | |
| 324 | 325 | </Link> | |
| 325 | 326 | )} | |
| ⋯ | |||
| 391 | 392 | } | |
| 392 | 393 | aside={ | |
| 393 | 394 | <Link | |
| 394 | − | to={`${base}/sessions/${session.id}`} | |
| 395 | + | to={sessionPath(workspaceOf(base), session.id)} | |
| 395 | 396 | prefetch="intent" | |
| 396 | 397 | className="inline-flex items-center gap-1 text-xs text-faint hover:text-fg" | |
| 397 | 398 | > | |
| ⋯ | |||
| 400 | 401 | </Link> | |
| 401 | 402 | } | |
| 402 | 403 | > | |
| 403 | − | <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent"> | |
| 404 | + | <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="font-medium hover:text-accent"> | |
| 404 | 405 | {sessionTitle(session)} | |
| 405 | 406 | </Link> | |
| 406 | 407 | <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted"> | |
| ⋯ | |||
| 447 | 448 | return ( | |
| 448 | 449 | <div className="flex min-w-0 items-center gap-1.5 text-xs"> | |
| 449 | 450 | <Bot size={12} className="shrink-0 text-faint" /> | |
| 450 | − | <Link to={`${base}/sessions/${session.id}`} prefetch="intent" className="shrink-0 font-medium text-fg hover:text-accent"> | |
| 451 | + | <Link to={sessionPath(workspaceOf(base), session.id)} prefetch="intent" className="shrink-0 font-medium text-fg hover:text-accent"> | |
| 451 | 452 | {session.agentType ?? "Subagent"} | |
| 452 | 453 | </Link> | |
| 453 | 454 | {session.task && ( | |
| ⋯ | |||
| 780 | 781 | <p className="min-w-0 grow wrap-break-word text-muted"> | |
| 781 | 782 | <PersonLink name={line.who ?? "g1t"} className="font-medium text-fg hover:underline" /> | |
| 782 | 783 | {line.harness && <span className="text-faint"> via {harnessName(line.harness)}</span>}{" "} | |
| 783 | − | <NumberedText text={line.text} base={base} /> | |
| 784 | + | <NumberedText text={line.text} base={line.repo ? `/${line.repo}` : base} /> | |
| 785 | + | {line.repo && line.repo !== base.slice(1) && <span className="text-faint"> in {line.repo}</span>} | |
| 784 | 786 | {line.session && ( | |
| 785 | 787 | <> | |
| 786 | 788 | {" · "} | |
| 787 | − | <Link to={`${base}/sessions/${line.session}`} className="text-fg hover:text-accent"> | |
| 789 | + | <Link to={sessionPath(workspaceOf(base), line.session)} className="text-fg hover:text-accent"> | |
| 788 | 790 | the session | |
| 789 | 791 | </Link> | |
| 790 | 792 | </> | |
| 36 | 36 | import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav"; | |
| 37 | 37 | import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome"; | |
| 38 | 38 | import { ROADMAP, type RoadmapItem } from "../lib/roadmap"; | |
| 39 | − | import { repoWorkPath } from "../lib/work-items"; | |
| 40 | 39 | ||
| 41 | 40 | /** | |
| 42 | 41 | * The phone's layout (below 768px): a bottom bar of apps with More for | |
| ⋯ | |||
| 302 | 301 | <span className="ml-auto truncate font-mono text-xs text-faint">{slug}</span> | |
| 303 | 302 | </div> | |
| 304 | 303 | <div className="grid grid-cols-4 gap-1.5"> | |
| 305 | − | {/* Code first, for a member who has it: it left the bar for Work. */} | |
| 304 | + | {/* Sessions first, what is cooking, then Code for a member who has it: both left the bar for Work. */} | |
| 305 | + | <MoreTile to={appOf("sessions").path(slug)} icon={appIcon("sessions", 20)} label="Sessions" /> | |
| 306 | 306 | {apps.includes("code") && <MoreTile to={appOf("code").path(slug)} icon={appIcon("code", 20)} label="Code" />} | |
| 307 | 307 | <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" /> | |
| 308 | 308 | {shown.map((app) => ( | |
| 309 | 309 | <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} /> | |
| 310 | 310 | ))} | |
| 311 | 311 | {apps | |
| 312 | − | .filter((key) => key !== "code") | |
| 312 | + | .filter((key) => key !== "code" && key !== "sessions") | |
| 313 | 313 | .map((key) => ( | |
| 314 | 314 | <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} /> | |
| 315 | 315 | ))} | |
| ⋯ | |||
| 594 | 594 | <NavTabs label="Project" className={always ? undefined : "md:hidden"}> | |
| 595 | 595 | {pages.map((page) => { | |
| 596 | 596 | const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page]; | |
| 597 | − | const [namespace = "", name = ""] = base.split("/").filter(Boolean); | |
| 598 | − | const to = page === "work" ? repoWorkPath(namespace, name) : link.path ? `${base}/${link.path}` : base; | |
| 597 | + | const to = link.path ? `${base}/${link.path}` : base; | |
| 599 | 598 | return ( | |
| 600 | 599 | <NavTab | |
| 601 | 600 | key={page} | |
| 602 | 601 | to={to} | |
| 603 | 602 | current={at === page} | |
| 604 | 603 | soon={page !== "overview" && PROJECT_PAGE_LINKS[page].soon} | |
| 605 | − | count={page === "work" ? counts?.items : page === "pulls" ? counts?.pulls : undefined} | |
| 604 | + | count={page === "pulls" ? counts?.pulls : undefined} | |
| 606 | 605 | > | |
| 607 | 606 | {link.label} | |
| 608 | 607 | </NavTab> | |
| 234 | 234 | { key: "home", label: "Home" }, | |
| 235 | 235 | // Every member's, Code access or not. | |
| 236 | 236 | { key: "work", label: "Work" }, | |
| 237 | + | // What is cooking across the workspace, right after its work items. | |
| 238 | + | { key: "sessions", label: "Sessions" }, | |
| 237 | 239 | { | |
| 238 | 240 | key: "chat", | |
| 239 | 241 | label: "Chat", | |
| ⋯ | |||
| 272 | 274 | current={here === item.key && !shown.some((app) => at(app.to))} | |
| 273 | 275 | badge={item.badge} | |
| 274 | 276 | /> | |
| 275 | − | {/* Right after Work: g1t beyond your workspaces (Explore, search, profiles, other workspaces and their public projects). */} | |
| 276 | − | {item.key === "work" && ( | |
| 277 | + | {/* Right after Sessions: g1t beyond your workspaces (Explore, search, profiles, other workspaces and their public projects). */} | |
| 278 | + | {item.key === "sessions" && ( | |
| 277 | 279 | <RailLink to="/explore" label="Explore" icon={<Compass size={20} />} current={here === "browse" || at("/explore")} /> | |
| 278 | 280 | )} | |
| 279 | 281 | </Fragment> | |
| 1 | + | /** | |
| 2 | + | * Sessions across a workspace, drawn: a live session's card (who, in what, | |
| 3 | + | * where, on which work item and pull request, how lately), a row for one | |
| 4 | + | * that ended, the "cooking now" strip over Work, and Sessions' filters as | |
| 5 | + | * menus on a computer and a sheet on a phone. What each says is worked | |
| 6 | + | * out in lib/sessions.ts. | |
| 7 | + | */ | |
| 8 | + | import { Bot, ChevronRight, Cpu, GitBranch, ScrollText, SlidersHorizontal, Terminal, X } from "lucide-react"; | |
| 9 | + | import { type ReactNode, useState } from "react"; | |
| 10 | + | import { Link } from "react-router"; | |
| 11 | + | import { siClaude, siCursor, siGithubcopilot } from "simple-icons"; | |
| 12 | + | ||
| 13 | + | import type { Harness, PullStatus, SessionKind, SessionState, WorkspaceSession } from "@g1t/contracts"; | |
| 14 | + | ||
| 15 | + | import { FilterMenu } from "./labels"; | |
| 16 | + | import { BottomSheet } from "./mobile"; | |
| 17 | + | import { TimeAgo } from "./ui"; | |
| 18 | + | import { Avatar } from "./ui/avatar"; | |
| 19 | + | import { Badge } from "./ui/badge"; | |
| 20 | + | import { Button } from "./ui/button"; | |
| 21 | + | import { Card } from "./ui/card"; | |
| 22 | + | import { Hint } from "./ui/hint"; | |
| 23 | + | import { SheetChoices } from "./work-items"; | |
| 24 | + | import { ItemIcon, PullIcon } from "./work-icons"; | |
| 25 | + | import { cn } from "../lib/cn"; | |
| 26 | + | import { HARNESS_NAMES, SESSION_STATES } from "../lib/coordination"; | |
| 27 | + | import { KIND_NAMES, type SessionsFilters, narrowed, repoName, runsIn, sessionHref, sessionsQuery, workspaceSessionTitle } from "../lib/sessions"; | |
| 28 | + | import { itemPath, sessionsPath } from "../lib/work-items"; | |
| 29 | + | ||
| 30 | + | const BRANDS: Partial<Record<Harness, { path: string; title: string }>> = { | |
| 31 | + | claude_code: siClaude, | |
| 32 | + | claude_managed_agents: siClaude, | |
| 33 | + | cursor: siCursor, | |
| 34 | + | copilot: siGithubcopilot, | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | /** What a session runs in, as a mark: the harness's own, g1t's agent's, or a recorded one's. */ | |
| 38 | + | export function HarnessMark({ session, size = 14 }: { session: Pick<WorkspaceSession, "kind" | "harness">; size?: number }) { | |
| 39 | + | const brand = session.harness ? BRANDS[session.harness] : undefined; | |
| 40 | + | if (brand) { | |
| 41 | + | return ( | |
| 42 | + | <svg role="img" aria-label={brand.title} viewBox="0 0 24 24" width={size} height={size} className="shrink-0 fill-current text-fg-soft"> | |
| 43 | + | <path d={brand.path} /> | |
| 44 | + | </svg> | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | if (session.kind === "agent") return <Cpu size={size} className="shrink-0 text-accent" aria-label="g1t's agent" />; | |
| 48 | + | if (session.kind === "recorded") return <ScrollText size={size} className="shrink-0 text-faint" aria-label="Recorded" />; | |
| 49 | + | if (session.harness === "codex") return <Terminal size={size} className="shrink-0 text-fg-soft" aria-label="Codex" />; | |
| 50 | + | return <Bot size={size} className="shrink-0 text-fg-soft" aria-label="Harness" />; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | const DOT: Record<SessionState, string> = { active: "bg-success", idle: "bg-warn", ended: "bg-line-strong" }; | |
| 54 | + | ||
| 55 | + | /** A session's state as a dot: green and breathing while it goes, amber when idle, grey once ended. */ | |
| 56 | + | export function StateDot({ state }: { state: SessionState }) { | |
| 57 | + | return ( | |
| 58 | + | <Hint label={SESSION_STATES[state].hint}> | |
| 59 | + | <span className="relative flex size-2.5 shrink-0" aria-label={SESSION_STATES[state].label}> | |
| 60 | + | {state === "active" && <span className="absolute inline-flex size-full animate-ping rounded-full bg-success opacity-50 motion-reduce:hidden" />} | |
| 61 | + | <span className={cn("relative inline-flex size-2.5 rounded-full", DOT[state])} /> | |
| 62 | + | </span> | |
| 63 | + | </Hint> | |
| 64 | + | ); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** The work item a session is on, as a chip. */ | |
| 68 | + | function ItemChip({ workspace, number, title }: { workspace: string; number: number; title: string | null }) { | |
| 69 | + | return ( | |
| 70 | + | <Link to={itemPath(workspace, number)} prefetch="intent" className="min-w-0 max-w-full"> | |
| 71 | + | <Badge size="md" className="max-w-full hover:border-line-strong hover:text-fg"> | |
| 72 | + | <ItemIcon item={{ state: "open", reason: null }} size={12} /> | |
| 73 | + | <span className="truncate"> | |
| 74 | + | {title ?? "Work item"} <span className="text-faint">#{number}</span> | |
| 75 | + | </span> | |
| 76 | + | </Badge> | |
| 77 | + | </Link> | |
| 78 | + | ); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /** The pull request a session is on, as a chip. */ | |
| 82 | + | function PullChip({ repo, number, title, status }: { repo: string; number: number; title: string | null; status: PullStatus | null }) { | |
| 83 | + | return ( | |
| 84 | + | <Link to={`/${repo}/pull/${number}`} prefetch="intent" className="min-w-0 max-w-full"> | |
| 85 | + | <Badge size="md" className="max-w-full hover:border-line-strong hover:text-fg"> | |
| 86 | + | <PullIcon status={status ?? "open"} size={12} /> | |
| 87 | + | <span className="truncate"> | |
| 88 | + | {title ?? "Pull request"} <span className="text-faint">#{number}</span> | |
| 89 | + | </span> | |
| 90 | + | </Badge> | |
| 91 | + | </Link> | |
| 92 | + | ); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** A session's title, linked where it opens. */ | |
| 96 | + | function TitleLink({ session, workspace, className }: { session: WorkspaceSession; workspace: string; className?: string }) { | |
| 97 | + | const to = sessionHref(workspace, session); | |
| 98 | + | const title = workspaceSessionTitle(session); | |
| 99 | + | if (!to) return <span className={className}>{title}</span>; | |
| 100 | + | return ( | |
| 101 | + | <Link to={to} prefetch="intent" className={cn("hover:text-accent", className)}> | |
| 102 | + | {title} | |
| 103 | + | </Link> | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** Where it is: the repository, and its branch. */ | |
| 108 | + | function Where({ session, workspace }: { session: WorkspaceSession; workspace: string }) { | |
| 109 | + | return ( | |
| 110 | + | <span className="flex min-w-0 items-center gap-1 font-mono text-xs text-muted"> | |
| 111 | + | <Link to={`/${session.repo}`} className="shrink-0 hover:text-fg"> | |
| 112 | + | {repoName(session.repo, workspace)} | |
| 113 | + | </Link> | |
| 114 | + | {session.branch && ( | |
| 115 | + | <> | |
| 116 | + | <GitBranch size={11} className="shrink-0 text-faint" /> | |
| 117 | + | <span className="truncate">{session.branch}</span> | |
| 118 | + | </> | |
| 119 | + | )} | |
| 120 | + | </span> | |
| 121 | + | ); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** | |
| 125 | + | * A live session: its state, what it is called, who runs it and in what, | |
| 126 | + | * where, the work item and pull request it is on, its subagents and when | |
| 127 | + | * it was last heard from. Reads at 375px: everything wraps, nothing scrolls. | |
| 128 | + | */ | |
| 129 | + | export function SessionCard({ session, workspace }: { session: WorkspaceSession; workspace: string }) { | |
| 130 | + | return ( | |
| 131 | + | <li> | |
| 132 | + | <Card className="flex h-full flex-col gap-2.5 p-4"> | |
| 133 | + | <div className="flex items-start gap-2.5"> | |
| 134 | + | <span className="mt-1.5"> | |
| 135 | + | <StateDot state={session.state} /> | |
| 136 | + | </span> | |
| 137 | + | <TitleLink session={session} workspace={workspace} className="min-w-0 grow font-medium wrap-break-word" /> | |
| 138 | + | <span className="mt-0.5 shrink-0 text-xs text-faint"> | |
| 139 | + | <TimeAgo at={session.lastActivityAt} /> | |
| 140 | + | </span> | |
| 141 | + | </div> | |
| 142 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-muted"> | |
| 143 | + | {session.actor && ( | |
| 144 | + | <Link to={`/u/${session.actor}`} className="flex items-center gap-1.5 hover:text-fg"> | |
| 145 | + | <Avatar name={session.actor} size={18} agent={session.kind !== "recorded"} /> | |
| 146 | + | <span className="font-medium text-fg-soft">{session.actor}</span> | |
| 147 | + | </Link> | |
| 148 | + | )} | |
| 149 | + | <span className="flex items-center gap-1.5"> | |
| 150 | + | <HarnessMark session={session} /> | |
| 151 | + | {runsIn(session)} | |
| 152 | + | </span> | |
| 153 | + | <Where session={session} workspace={workspace} /> | |
| 154 | + | </div> | |
| 155 | + | {session.step && <p className="line-clamp-1 text-xs text-muted">{session.step}</p>} | |
| 156 | + | {(session.item != null || session.pull != null || session.subagents > 0) && ( | |
| 157 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 158 | + | {session.item != null && <ItemChip workspace={workspace} number={session.item} title={session.itemTitle} />} | |
| 159 | + | {session.pull != null && <PullChip repo={session.repo} number={session.pull} title={session.pullTitle} status={session.pullStatus} />} | |
| 160 | + | {session.subagents > 0 && ( | |
| 161 | + | <Badge size="md" tone="accent"> | |
| 162 | + | <Bot size={11} /> | |
| 163 | + | {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"} | |
| 164 | + | </Badge> | |
| 165 | + | )} | |
| 166 | + | </div> | |
| 167 | + | )} | |
| 168 | + | </Card> | |
| 169 | + | </li> | |
| 170 | + | ); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /** A session in a list of many: one line on a computer, two on a phone. */ | |
| 174 | + | export function SessionRow({ session, workspace }: { session: WorkspaceSession; workspace: string }) { | |
| 175 | + | return ( | |
| 176 | + | <li className="flex items-start gap-3 px-4 py-3 text-sm"> | |
| 177 | + | <span className="mt-1.5"> | |
| 178 | + | <StateDot state={session.state} /> | |
| 179 | + | </span> | |
| 180 | + | <div className="min-w-0 grow"> | |
| 181 | + | <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> | |
| 182 | + | <TitleLink session={session} workspace={workspace} className="min-w-0 font-medium wrap-break-word" /> | |
| 183 | + | {session.item != null && ( | |
| 184 | + | <Link to={itemPath(workspace, session.item)} className="text-xs text-faint hover:text-fg"> | |
| 185 | + | #{session.item} | |
| 186 | + | </Link> | |
| 187 | + | )} | |
| 188 | + | </div> | |
| 189 | + | <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> | |
| 190 | + | {session.actor && <span className="font-medium text-fg-soft">{session.actor}</span>} | |
| 191 | + | <span className="flex items-center gap-1"> | |
| 192 | + | <HarnessMark session={session} size={12} /> | |
| 193 | + | {runsIn(session)} | |
| 194 | + | </span> | |
| 195 | + | <Where session={session} workspace={workspace} /> | |
| 196 | + | {session.subagents > 0 && <span>{session.subagents} subagents</span>} | |
| 197 | + | </div> | |
| 198 | + | </div> | |
| 199 | + | <span className="shrink-0 text-xs text-faint"> | |
| 200 | + | <TimeAgo at={session.lastActivityAt} /> | |
| 201 | + | </span> | |
| 202 | + | </li> | |
| 203 | + | ); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /** | |
| 207 | + | * What is cooking now, over Work's list: the live sessions on its work | |
| 208 | + | * items, a few of them, and the way to all of Sessions. | |
| 209 | + | */ | |
| 210 | + | export function CookingStrip({ sessions, workspace, total, repo }: { sessions: WorkspaceSession[]; workspace: string; total: number; repo?: string | null }) { | |
| 211 | + | const all = sessionsPath(workspace, { repo }); | |
| 212 | + | return ( | |
| 213 | + | <section aria-label="Cooking now" className="mb-5"> | |
| 214 | + | <div className="mb-2 flex items-baseline justify-between gap-3"> | |
| 215 | + | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 216 | + | <StateDot state={total > 0 ? "active" : "ended"} /> | |
| 217 | + | Cooking now | |
| 218 | + | {total > 0 && <span className="font-normal text-muted tabular-nums">{total}</span>} | |
| 219 | + | </h2> | |
| 220 | + | <Link to={all} className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg"> | |
| 221 | + | Sessions | |
| 222 | + | <ChevronRight size={13} /> | |
| 223 | + | </Link> | |
| 224 | + | </div> | |
| 225 | + | {sessions.length === 0 ? ( | |
| 226 | + | <p className="rounded-lg border border-dashed border-line px-4 py-3 text-sm text-muted"> | |
| 227 | + | Nothing is running right now. Sessions people and agents start on these work items show here as they go. | |
| 228 | + | </p> | |
| 229 | + | ) : ( | |
| 230 | + | <ol className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3"> | |
| 231 | + | {sessions.map((session, index) => ( | |
| 232 | + | // A phone shows three; Sessions has the rest. | |
| 233 | + | <li key={session.id} className={cn("min-w-0", index >= 3 && "max-sm:hidden")}> | |
| 234 | + | <div className="flex h-full items-start gap-2.5 rounded-lg border border-line bg-surface px-3 py-2.5"> | |
| 235 | + | <span className="mt-1.5"> | |
| 236 | + | <StateDot state={session.state} /> | |
| 237 | + | </span> | |
| 238 | + | <div className="min-w-0 grow"> | |
| 239 | + | <TitleLink session={session} workspace={workspace} className="line-clamp-1 text-sm font-medium" /> | |
| 240 | + | <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted"> | |
| 241 | + | <HarnessMark session={session} size={12} /> | |
| 242 | + | <span className="truncate"> | |
| 243 | + | {session.actor ?? runsIn(session)} | |
| 244 | + | {session.item != null && ` · #${session.item}`} · {repoName(session.repo, workspace)} | |
| 245 | + | </span> | |
| 246 | + | </p> | |
| 247 | + | </div> | |
| 248 | + | <span className="shrink-0 text-[0.6875rem] text-faint"> | |
| 249 | + | <TimeAgo at={session.lastActivityAt} /> | |
| 250 | + | </span> | |
| 251 | + | </div> | |
| 252 | + | </li> | |
| 253 | + | ))} | |
| 254 | + | </ol> | |
| 255 | + | )} | |
| 256 | + | </section> | |
| 257 | + | ); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /** The two views of Sessions: what is live now, and every session. */ | |
| 261 | + | export function SessionsViews({ workspace, filters }: { workspace: string; filters: SessionsFilters }) { | |
| 262 | + | const tab = (state: SessionsFilters["state"], label: string) => ( | |
| 263 | + | <Link | |
| 264 | + | to={`${sessionsPath(workspace)}${sessionsQuery({ ...filters, state })}`} | |
| 265 | + | aria-current={filters.state === state ? "page" : undefined} | |
| 266 | + | className={cn( | |
| 267 | + | "inline-flex h-full items-center rounded-[5px] px-2.5 text-sm transition-colors", | |
| 268 | + | filters.state === state ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg", | |
| 269 | + | )} | |
| 270 | + | > | |
| 271 | + | {label} | |
| 272 | + | </Link> | |
| 273 | + | ); | |
| 274 | + | return ( | |
| 275 | + | <Card tone="plain" radius="lg" className="flex h-8 gap-0.5 rounded-md p-0.5 max-sm:h-10"> | |
| 276 | + | {tab("live", "Live now")} | |
| 277 | + | {tab("recent", "Every session")} | |
| 278 | + | </Card> | |
| 279 | + | ); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[]; | |
| 283 | + | const KINDS = Object.keys(KIND_NAMES) as SessionKind[]; | |
| 284 | + | ||
| 285 | + | /** Sessions' filters: a menu each on a computer, one sheet on a phone. Each choice is a link. */ | |
| 286 | + | export function SessionsFiltersBar({ workspace, filters, repos, people, me }: { workspace: string; filters: SessionsFilters; repos: string[]; people: string[]; me: string | null }) { | |
| 287 | + | const [open, setOpen] = useState(false); | |
| 288 | + | const to = (change: Partial<SessionsFilters>) => `${sessionsPath(workspace)}${sessionsQuery({ ...filters, ...change })}`; | |
| 289 | + | const count = [filters.repo, filters.person, filters.harness, filters.kind].filter(Boolean).length; | |
| 290 | + | const clear = to({ repo: null, person: null, harness: null, kind: null }); | |
| 291 | + | const everyone = me ? [me, ...people.filter((name) => name !== me)] : people; | |
| 292 | + | const menus: { title: string; active: string | null; any: string; field: keyof SessionsFilters; options: { key: string; label: ReactNode }[] }[] = [ | |
| 293 | + | { title: "Repository", active: filters.repo, any: "Any repository", field: "repo", options: repos.map((name) => ({ key: name, label: <span className="font-mono text-[0.8125rem]">{name}</span> })) }, | |
| 294 | + | { title: "Person", active: filters.person, any: "Anyone", field: "person", options: everyone.map((name) => ({ key: name, label: name === me ? `${name} (you)` : name })) }, | |
| 295 | + | { title: "Harness", active: filters.harness ? HARNESS_NAMES[filters.harness] : null, any: "Any harness", field: "harness", options: HARNESSES.map((key) => ({ key, label: HARNESS_NAMES[key] })) }, | |
| 296 | + | { title: "Kind", active: filters.kind ? KIND_NAMES[filters.kind] : null, any: "Every kind", field: "kind", options: KINDS.map((key) => ({ key, label: KIND_NAMES[key] })) }, | |
| 297 | + | ]; | |
| 298 | + | return ( | |
| 299 | + | <> | |
| 300 | + | <div className="hidden flex-wrap items-center gap-2 sm:flex"> | |
| 301 | + | {menus.map((menu) => ( | |
| 302 | + | <FilterMenu | |
| 303 | + | key={menu.title} | |
| 304 | + | label={menu.title} | |
| 305 | + | active={menu.active ?? undefined} | |
| 306 | + | clearTo={menu.active ? to({ [menu.field]: null }) : undefined} | |
| 307 | + | searchPlaceholder={`Filter ${menu.title.toLowerCase()}`} | |
| 308 | + | emptyText="Nothing matches." | |
| 309 | + | options={menu.options.map((option) => ({ | |
| 310 | + | key: option.key, | |
| 311 | + | to: to({ [menu.field]: option.key }), | |
| 312 | + | keywords: option.key, | |
| 313 | + | selected: filters[menu.field] === option.key, | |
| 314 | + | label: <span className="truncate">{option.label}</span>, | |
| 315 | + | }))} | |
| 316 | + | /> | |
| 317 | + | ))} | |
| 318 | + | {narrowed(filters) && ( | |
| 319 | + | <Link to={clear} className="text-xs text-muted hover:text-fg"> | |
| 320 | + | Clear filters | |
| 321 | + | </Link> | |
| 322 | + | )} | |
| 323 | + | </div> | |
| 324 | + | <div className="flex min-w-0 items-center gap-2 sm:hidden"> | |
| 325 | + | <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)} aria-haspopup="dialog" className="h-10 gap-2 px-3.5"> | |
| 326 | + | <SlidersHorizontal size={15} /> | |
| 327 | + | Filters | |
| 328 | + | {count > 0 && <span className="rounded-full bg-accent px-1.5 text-[0.6875rem] leading-4.5 font-bold text-bg tabular-nums">{count}</span>} | |
| 329 | + | </Button> | |
| 330 | + | {filters.repo && <span className="min-w-0 truncate font-mono text-xs text-muted">{filters.repo}</span>} | |
| 331 | + | </div> | |
| 332 | + | <BottomSheet open={open} onOpenChange={setOpen} title="Filters"> | |
| 333 | + | <div className="flex items-center justify-between px-3 pt-1 pb-2"> | |
| 334 | + | <h2 className="text-base font-semibold">Filters</h2> | |
| 335 | + | {count > 0 && ( | |
| 336 | + | <Link to={clear} onClick={() => setOpen(false)} className="inline-flex h-10 items-center gap-1 px-2 text-sm text-muted active:text-fg"> | |
| 337 | + | <X size={14} /> | |
| 338 | + | Clear all | |
| 339 | + | </Link> | |
| 340 | + | )} | |
| 341 | + | </div> | |
| 342 | + | {menus.map((menu) => ( | |
| 343 | + | <SheetChoices | |
| 344 | + | key={menu.title} | |
| 345 | + | title={menu.title} | |
| 346 | + | onPick={() => setOpen(false)} | |
| 347 | + | choices={[ | |
| 348 | + | { key: "any", label: menu.any, to: to({ [menu.field]: null }), on: !filters[menu.field] }, | |
| 349 | + | ...menu.options.map((option) => ({ key: option.key, label: option.label, to: to({ [menu.field]: option.key }), on: filters[menu.field] === option.key })), | |
| 350 | + | ]} | |
| 351 | + | /> | |
| 352 | + | ))} | |
| 353 | + | </BottomSheet> | |
| 354 | + | </> | |
| 355 | + | ); | |
| 356 | + | } |
| 1 | − | import { Database, Activity, BarChart3, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Database, Activity, BarChart3, Bot, Cpu, Columns3, Radio, ScrollText, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 81 | 81 | import { useLiveBadges } from "../lib/notify-client"; | |
| 82 | 82 | import { OwnPresenceDot, OwnPresenceItems, StatusDialog } from "./presence"; | |
| 83 | 83 | import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch"; | |
| 84 | − | import { newItemPath, repoWorkPath, workPath } from "../lib/work-items"; | |
| 84 | + | import { newItemPath, repoWorkPath, sessionsPath, workPath } from "../lib/work-items"; | |
| 85 | 85 | ||
| 86 | 86 | /** | |
| 87 | 87 | * What the sidebar needs, worked out by the root loader. For a visitor who | |
| ⋯ | |||
| 832 | 832 | <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, `${base}/compare`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}> | |
| 833 | 833 | Code | |
| 834 | 834 | </SidebarLink> | |
| 835 | − | {/* Its work items: the workspace's Work list, filtered to it. */} | |
| 836 | − | {shows.has("work") && ( | |
| 837 | − | <SidebarLink to={repoWorkPath(repo.namespace, repo.name)} icon={<ListTodo size={15} />} count={repo.items}> | |
| 838 | − | Work | |
| 839 | − | </SidebarLink> | |
| 840 | − | )} | |
| 841 | 835 | {shows.has("pulls") && ( | |
| 842 | − | <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`, `${base}/sessions`]} icon={<GitPullRequest size={15} />} count={repo.pulls}> | |
| 836 | + | <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}> | |
| 843 | 837 | Pull requests | |
| 844 | 838 | </SidebarLink> | |
| 845 | 839 | )} | |
| ⋯ | |||
| 1425 | 1419 | /** | |
| 1426 | 1420 | * Work's sidebar, and its menu on a phone (the Work tab tapped again): | |
| 1427 | 1421 | * the views people keep coming back to, saved in the address (open, yours, | |
| 1428 | − | * closed), then the workspace's labels and milestones, which Work holds. | |
| 1422 | + | * closed), the board of them as they move, then the workspace's labels and | |
| 1423 | + | * milestones, which Work holds. On a phone the menu also leads to | |
| 1424 | + | * Sessions, which has no tab of its own there. | |
| 1429 | 1425 | */ | |
| 1430 | 1426 | function WorkSidebar({ slug, username }: { slug: string; username: string }) { | |
| 1431 | 1427 | const { pathname: path, search } = useSidebarLocation(); | |
| ⋯ | |||
| 1449 | 1445 | <SidebarLink to={`${list}?state=closed`} icon={<Check size={15} />} current={view({ state: "closed" })}> | |
| 1450 | 1446 | Closed | |
| 1451 | 1447 | </SidebarLink> | |
| 1448 | + | <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />} current={at("board")}> | |
| 1449 | + | Board | |
| 1450 | + | </SidebarLink> | |
| 1451 | + | <div className="md:hidden"> | |
| 1452 | + | <SidebarLink to={sessionsPath(slug)} icon={<Activity size={15} />}> | |
| 1453 | + | Sessions | |
| 1454 | + | </SidebarLink> | |
| 1455 | + | </div> | |
| 1452 | 1456 | <SidebarLink to={newItemPath(slug)} icon={<Plus size={15} />} current={at("new")}> | |
| 1453 | 1457 | New work item | |
| 1454 | 1458 | </SidebarLink> | |
| ⋯ | |||
| 1468 | 1472 | } | |
| 1469 | 1473 | ||
| 1470 | 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({})}> | |
| 1491 | + | Live now | |
| 1492 | + | </SidebarLink> | |
| 1493 | + | <SidebarLink to={sessionsPath(slug, { state: "recent" })} icon={<History size={15} />} current={view({ state: "recent" })}> | |
| 1494 | + | Recent | |
| 1495 | + | </SidebarLink> | |
| 1496 | + | <SidebarLink to={sessionsPath(slug, { person: username })} icon={<CircleUserRound size={15} />} current={view({ person: username })}> | |
| 1497 | + | Yours | |
| 1498 | + | </SidebarLink> | |
| 1499 | + | </NavRows> | |
| 1500 | + | <Rule /> | |
| 1501 | + | <NavRows> | |
| 1502 | + | <SidebarLink to={sessionsPath(slug, { kind: "harness" })} icon={<Bot size={15} />} current={view({ kind: "harness" })}> | |
| 1503 | + | People's harnesses | |
| 1504 | + | </SidebarLink> | |
| 1505 | + | <SidebarLink to={sessionsPath(slug, { kind: "hosted" })} icon={<Sparkles size={15} />} current={view({ kind: "hosted" })}> | |
| 1506 | + | Started on g1t | |
| 1507 | + | </SidebarLink> | |
| 1508 | + | <SidebarLink to={sessionsPath(slug, { kind: "agent" })} icon={<Cpu size={15} />} current={view({ kind: "agent" })}> | |
| 1509 | + | g1t's agent | |
| 1510 | + | </SidebarLink> | |
| 1511 | + | <SidebarLink to={sessionsPath(slug, { kind: "recorded" })} icon={<ScrollText size={15} />} current={view({ kind: "recorded" })}> | |
| 1512 | + | Recorded on pull requests | |
| 1513 | + | </SidebarLink> | |
| 1514 | + | </NavRows> | |
| 1515 | + | <Rule /> | |
| 1516 | + | <NavRows> | |
| 1517 | + | <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />}> | |
| 1518 | + | Board | |
| 1519 | + | </SidebarLink> | |
| 1520 | + | </NavRows> | |
| 1521 | + | </nav> | |
| 1522 | + | </div> | |
| 1523 | + | ); | |
| 1524 | + | } | |
| 1525 | + | ||
| 1526 | + | /** | |
| 1471 | 1527 | * People's sidebar: everyone in the workspace and its teams. Who belongs, and | |
| 1472 | 1528 | * inviting people, is the workspace's: Workspace mode's Access group. | |
| 1473 | 1529 | */ | |
| ⋯ | |||
| 1673 | 1729 | } else if (third === "work") { | |
| 1674 | 1730 | // Work: Work / #12, Work / Labels, Work / Milestones / #3. | |
| 1675 | 1731 | trail.push({ label: "Work", to: page }); | |
| 1676 | − | const words: Record<string, string> = { new: "New work item", labels: "Labels", milestones: "Milestones" }; | |
| 1732 | + | const words: Record<string, string> = { new: "New work item", board: "Board", labels: "Labels", milestones: "Milestones" }; | |
| 1677 | 1733 | if (fourth && /^\d+$/.test(fourth)) trail.push({ label: `Work item #${fourth}`, short: `#${fourth}`, to: `${page}/${fourth}` }); | |
| 1678 | 1734 | else if (fourth && words[fourth]) trail.push({ label: words[fourth]!, to: `${page}/${fourth}` }); | |
| 1679 | 1735 | const fifth = parts[4]; | |
| 1680 | 1736 | 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}` }); | |
| 1681 | 1741 | } else if (third) { | |
| 1682 | 1742 | // The workspace's settings pages each stand alone: General, Members, Permissions. | |
| 1683 | 1743 | trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page }); | |
| ⋯ | |||
| 1812 | 1872 | { label: "Home", to: homePagePath(ws.slug), icon: <House size={15} /> }, | |
| 1813 | 1873 | { label: "Work", hint: "The workspace's work items", to: workPath(ws.slug), icon: <ListTodo size={15} /> }, | |
| 1814 | 1874 | { label: "New work item", to: newItemPath(ws.slug), icon: <Plus size={15} /> }, | |
| 1875 | + | { label: "Board", hint: "Work items as they move", to: workPath(ws.slug, "board"), icon: <Columns3 size={15} /> }, | |
| 1876 | + | { label: "Sessions", hint: "What is cooking across the workspace", to: sessionsPath(ws.slug), icon: <Activity size={15} /> }, | |
| 1815 | 1877 | { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> }, | |
| 1816 | 1878 | { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> }, | |
| 1817 | 1879 | { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> }, | |
| ⋯ | |||
| 1840 | 1902 | { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> }, | |
| 1841 | 1903 | { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> }, | |
| 1842 | 1904 | { label: "Work", hint: name, to: repoWorkPath(repo.namespace, repo.name), icon: <ListTodo size={15} /> }, | |
| 1905 | + | { label: "Sessions", hint: name, to: sessionsPath(repo.namespace, { repo: repo.name }), icon: <Activity size={15} /> }, | |
| 1843 | 1906 | { label: "New work item", hint: name, to: newItemPath(repo.namespace, repo.name), icon: <Plus size={15} /> }, | |
| 1844 | 1907 | { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> }, | |
| 1845 | 1908 | { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> }, | |
| ⋯ | |||
| 1955 | 2018 | } | |
| 1956 | 2019 | ||
| 1957 | 2020 | /** The sidebars that sit beside the page, one per mode that has one. */ | |
| 1958 | − | type Panel = "work" | "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse"; | |
| 2021 | + | type Panel = "work" | "sessions" | "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse"; | |
| 1959 | 2022 | ||
| 1960 | 2023 | /** Each sidebar's name, for the button that opens it on a phone. */ | |
| 1961 | 2024 | const MODE_MENU: Record<Panel, string> = { | |
| 1962 | 2025 | browse: "Explore", | |
| 1963 | 2026 | work: "Work", | |
| 2027 | + | sessions: "Sessions", | |
| 1964 | 2028 | chat: "Chat", | |
| 1965 | 2029 | artifacts: "Artifacts", | |
| 1966 | 2030 | code: "Code", | |
| ⋯ | |||
| 2120 | 2184 | return <CodeSidebar user={user} shell={shell} missing={missing} browsing={browsing} />; | |
| 2121 | 2185 | case "work": | |
| 2122 | 2186 | return <WorkSidebar slug={ws.slug} username={user.username} />; | |
| 2187 | + | case "sessions": | |
| 2188 | + | return <SessionsSidebar slug={ws.slug} username={user.username} />; | |
| 2123 | 2189 | case "chat": | |
| 2124 | 2190 | return <ChatSidebar slug={ws.slug} />; | |
| 2125 | 2191 | case "artifacts": | |
| 357 | 357 | } | |
| 358 | 358 | ||
| 359 | 359 | /** One filter in the phone's sheet: a heading and its choices, 48px rows, the one on ticked. */ | |
| 360 | − | function SheetChoices({ | |
| 360 | + | export function SheetChoices({ | |
| 361 | 361 | title, | |
| 362 | 362 | choices, | |
| 363 | 363 | failed = false, |
| 14 | 14 | import { Markdown } from "./markdown"; | |
| 15 | 15 | import { Reactions } from "./reactions"; | |
| 16 | 16 | import { ItemIcon, PullIcon } from "./work-icons"; | |
| 17 | + | import { sessionPath } from "../lib/work-items"; | |
| 17 | 18 | import { MentionTextarea } from "./mention-textarea"; | |
| 18 | 19 | import { ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 19 | 20 | import { Badge } from "./ui/badge"; | |
| ⋯ | |||
| 423 | 424 | ) | |
| 424 | 425 | ) : part.kind === "session" ? ( | |
| 425 | 426 | // The session it was closed in. | |
| 426 | − | base ? <SessionChip key={at} href={`${base}/sessions/${part.id}`} id={part.id} /> : <code key={at} className="font-mono text-xs text-fg">{part.text}</code> | |
| 427 | + | base ? <SessionChip key={at} href={sessionPath(base.split("/").filter(Boolean)[0] ?? "", part.id)} id={part.id} /> : <code key={at} className="font-mono text-xs text-fg">{part.text}</code> | |
| 427 | 428 | ) : ( | |
| 428 | 429 | part.text | |
| 429 | 430 | ), | |
| 25 | 25 | } from "./apps.ts"; | |
| 26 | 26 | ||
| 27 | 27 | test("Work is a built-in app right after Home, for every member", () => { | |
| 28 | − | assert.deepEqual(BUILTINS.slice(0, 2).map((app) => app.key), ["home", "work"]); | |
| 28 | + | assert.deepEqual(BUILTINS.slice(0, 3).map((app) => app.key), ["home", "work", "sessions"]); | |
| 29 | 29 | assert.equal(appOf("work").path("acme"), "/acme/-/work"); | |
| 30 | 30 | assert.equal(appOf("work").code, undefined, "not part of Code"); | |
| 31 | 31 | }); | |
| 32 | 32 | ||
| 33 | − | test("a phone's bar is Home, Work, Chat and Notifications, with Code first under More", () => { | |
| 33 | + | test("a phone's bar is Home, Work, Chat and Notifications, with Sessions then Code first under More", () => { | |
| 34 | 34 | assert.deepEqual([...PHONE_TABS], ["home", "work", "chat", "notifications"]); | |
| 35 | 35 | // Four tabs and More: five slots. | |
| 36 | 36 | assert.equal(PHONE_TABS.length + 1, 5); | |
| 37 | − | assert.deepEqual(phoneMoreApps(true), ["code", "artifacts", "routines", "people", "workspace"]); | |
| 37 | + | assert.deepEqual(phoneMoreApps(true), ["sessions", "code", "artifacts", "routines", "people", "workspace"]); | |
| 38 | 38 | // A member without Code access has the rest, Work still on the bar. | |
| 39 | − | assert.deepEqual(phoneMoreApps(false), ["artifacts", "routines", "people", "workspace"]); | |
| 39 | + | assert.deepEqual(phoneMoreApps(false), ["sessions", "artifacts", "routines", "people", "workspace"]); | |
| 40 | 40 | }); | |
| 41 | 41 | ||
| 42 | 42 | 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" | "chat" | "notifications" | "code" | "artifacts" | "routines" | "people" | "workspace"; | |
| 29 | + | export type BuiltinApp = "home" | "work" | "sessions" | "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") }, | |
| 46 | 47 | { key: "chat", name: "Chat", path: under("chat") }, | |
| 47 | 48 | { key: "notifications", name: "Notifications", path: () => "/notifications" }, | |
| 48 | 49 | { key: "code", name: "Code", path: under("overview"), code: true }, | |
| ⋯ | |||
| 56 | 57 | export const PHONE_TABS = ["home", "work", "chat", "notifications"] as const satisfies readonly BuiltinApp[]; | |
| 57 | 58 | ||
| 58 | 59 | /** | |
| 59 | − | * The built-in apps the phone's More sheet has, in its order: Code first, | |
| 60 | − | * for a member with Code access, then the rest of the rail's. | |
| 60 | + | * The built-in apps the phone's More sheet has, in its order: Sessions | |
| 61 | + | * first, then Code for a member with Code access, then the rest of the | |
| 62 | + | * rail's. | |
| 61 | 63 | */ | |
| 62 | 64 | export function phoneMoreApps(code: boolean): BuiltinApp[] { | |
| 63 | − | const rest = BUILTINS.map((app) => app.key).filter((key) => !(PHONE_TABS as readonly string[]).includes(key) && key !== "code"); | |
| 64 | − | return code ? ["code", ...rest] : rest; | |
| 65 | + | const rest = BUILTINS.map((app) => app.key).filter((key) => !(PHONE_TABS as readonly string[]).includes(key) && key !== "code" && key !== "sessions"); | |
| 66 | + | return code ? ["sessions", "code", ...rest] : ["sessions", ...rest]; | |
| 65 | 67 | } | |
| 66 | 68 | ||
| 67 | 69 | /** The built-in app `key`. */ | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { Claim, ItemRepo, WorkItem, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { boardOf, pullsByItem, shownStage, stageOf } from "./board.ts"; | |
| 7 | + | ||
| 8 | + | const NOW = Date.parse("2026-10-17T12:00:00Z"); | |
| 9 | + | const DAY = 86_400_000; | |
| 10 | + | ||
| 11 | + | function repo(path: string, role: ItemRepo["role"], landed = false): ItemRepo { | |
| 12 | + | return { repoId: `rep_${path}`, path, role, legacyNumber: null, landed }; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | function item(number: number, more: Partial<WorkItem> = {}): WorkItem { | |
| 16 | + | return { | |
| 17 | + | id: `wki_${number}`, | |
| 18 | + | workspace: "acme", | |
| 19 | + | number, | |
| 20 | + | title: `Item ${number}`, | |
| 21 | + | body: "", | |
| 22 | + | labels: [], | |
| 23 | + | state: "open", | |
| 24 | + | reason: null, | |
| 25 | + | resolutions: [], | |
| 26 | + | author: { id: "usr_1", username: "ana" } as WorkItem["author"], | |
| 27 | + | requestedBy: null, | |
| 28 | + | createdAt: new Date(NOW - 5 * DAY).toISOString(), | |
| 29 | + | updatedAt: new Date(NOW - number * 1000).toISOString(), | |
| 30 | + | closedAt: null, | |
| 31 | + | pullCount: 0, | |
| 32 | + | commentCount: 0, | |
| 33 | + | assignees: [], | |
| 34 | + | blockedBy: [], | |
| 35 | + | agent: null, | |
| 36 | + | repos: [repo("acme/web", "origin")], | |
| 37 | + | ...more, | |
| 38 | + | }; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | function live(item: number, more: Partial<WorkspaceSession> = {}): WorkspaceSession { | |
| 42 | + | return { | |
| 43 | + | kind: "harness", | |
| 44 | + | id: `ses_${item}`, | |
| 45 | + | repo: "acme/web", | |
| 46 | + | repoId: "rep_acme/web", | |
| 47 | + | title: null, | |
| 48 | + | actor: "bo", | |
| 49 | + | harness: "claude_code", | |
| 50 | + | agent: null, | |
| 51 | + | branch: "main", | |
| 52 | + | item, | |
| 53 | + | itemTitle: null, | |
| 54 | + | pull: null, | |
| 55 | + | pullTitle: null, | |
| 56 | + | pullStatus: null, | |
| 57 | + | state: "active", | |
| 58 | + | startedAt: new Date(NOW).toISOString(), | |
| 59 | + | lastActivityAt: new Date(NOW).toISOString(), | |
| 60 | + | subagents: 0, | |
| 61 | + | step: null, | |
| 62 | + | session: null, | |
| 63 | + | ...more, | |
| 64 | + | }; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | const claim = (number: number, who: string): Claim => | |
| 68 | + | ({ id: `clm_${number}`, item: number, workspace: "acme", state: "active", actor: { id: "usr_9", username: who } }) as Claim; | |
| 69 | + | ||
| 70 | + | test("each work item's stage is the first that holds: done, landing, review, progress, backlog", () => { | |
| 71 | + | assert.equal(stageOf(item(1), {}, NOW), "backlog"); | |
| 72 | + | assert.equal(stageOf(item(2, { assignees: ["ana"] }), {}, NOW), "progress"); | |
| 73 | + | assert.equal(stageOf(item(3), { claimed: true }, NOW), "progress"); | |
| 74 | + | assert.equal(stageOf(item(4), { live: true }, NOW), "progress"); | |
| 75 | + | assert.equal(stageOf(item(5), { pulls: ["draft"] }, NOW), "progress"); | |
| 76 | + | assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: ["draft", "open"] }, NOW), "review"); | |
| 77 | + | // Landed in one target, not yet the other: Landing, even with a pull request open. | |
| 78 | + | const landing = item(7, { repos: [repo("acme/web", "target", true), repo("acme/api", "target")] }); | |
| 79 | + | assert.equal(stageOf(landing, { pulls: ["open"] }, NOW), "landing"); | |
| 80 | + | // A touched repository's landing is not a target's. | |
| 81 | + | assert.equal(stageOf(item(8, { repos: [repo("acme/web", "touched", true), repo("acme/api", "target")] }), {}, NOW), "backlog"); | |
| 82 | + | assert.equal(stageOf(item(9, { state: "closed", closedAt: new Date(NOW - 3 * DAY).toISOString() }), {}, NOW), "done"); | |
| 83 | + | // Closed long ago: off the board. | |
| 84 | + | assert.equal(stageOf(item(10, { state: "closed", closedAt: new Date(NOW - 40 * DAY).toISOString() }), {}, NOW), null); | |
| 85 | + | }); | |
| 86 | + | ||
| 87 | + | test("the board puts each item in its column with who and which session is on it", () => { | |
| 88 | + | const items = [ | |
| 89 | + | item(1), | |
| 90 | + | item(2), | |
| 91 | + | item(3), | |
| 92 | + | item(4), | |
| 93 | + | item(5, { state: "closed", closedAt: new Date(NOW - DAY).toISOString() }), | |
| 94 | + | item(6, { state: "closed", closedAt: new Date(NOW - 2 * DAY).toISOString() }), | |
| 95 | + | ]; | |
| 96 | + | const board = boardOf( | |
| 97 | + | items, | |
| 98 | + | { claims: [claim(2, "cy")], sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], pulls: pullsByItem([{ item: 4, status: "draft" }, { item: null, status: "open" }]) }, | |
| 99 | + | NOW, | |
| 100 | + | ); | |
| 101 | + | assert.deepEqual(board.backlog.map((card) => card.item.number), [1]); | |
| 102 | + | assert.deepEqual(board.progress.map((card) => card.item.number), [2, 4]); | |
| 103 | + | assert.deepEqual(board.progress[0]!.on.people, ["cy"]); | |
| 104 | + | // A live session's open pull request puts its item in review, the session on the card. | |
| 105 | + | assert.deepEqual(board.review.map((card) => card.item.number), [3]); | |
| 106 | + | assert.deepEqual(board.review[0]!.on.sessions.map((session) => session.id), ["ses_3"]); | |
| 107 | + | assert.deepEqual(board.review[0]!.on.people, ["bo"]); | |
| 108 | + | // Done: the most recently closed first. | |
| 109 | + | assert.deepEqual(board.done.map((card) => card.item.number), [5, 6]); | |
| 110 | + | }); | |
| 111 | + | ||
| 112 | + | test("a phone shows the stage asked for, else the first with something in it", () => { | |
| 113 | + | const board = boardOf([item(1, { assignees: ["ana"] })], {}, NOW); | |
| 114 | + | assert.equal(shownStage("review", board), "review"); | |
| 115 | + | assert.equal(shownStage("nonsense", board), "progress"); | |
| 116 | + | assert.equal(shownStage(null, boardOf([], {}, NOW)), "backlog"); | |
| 117 | + | }); |
| 1 | + | /** | |
| 2 | + | * Work's Board: the workspace's work items as they move, in columns read | |
| 3 | + | * from what is so (nothing is dragged): Backlog, In progress, In review, | |
| 4 | + | * Landing and Done. Pure, so it is tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { Claim, PullStatus, WorkItem, WorkspaceSession } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | export type Stage = "backlog" | "progress" | "review" | "landing" | "done"; | |
| 9 | + | ||
| 10 | + | /** The columns, left to right. */ | |
| 11 | + | export const STAGES: Stage[] = ["backlog", "progress", "review", "landing", "done"]; | |
| 12 | + | ||
| 13 | + | export const STAGE_INFO: Record<Stage, { label: string; hint: string }> = { | |
| 14 | + | backlog: { label: "Backlog", hint: "Open, and nobody is on it" }, | |
| 15 | + | progress: { label: "In progress", hint: "Claimed or assigned, a live session on it, or a draft pull request" }, | |
| 16 | + | review: { label: "In review", hint: "A pull request for it is open" }, | |
| 17 | + | landing: { label: "Landing", hint: "Landed in some of its repositories, still missing in others" }, | |
| 18 | + | done: { label: "Done", hint: "Closed in the last 14 days" }, | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | /** How long a closed work item stays in Done. */ | |
| 22 | + | export const DONE_DAYS = 14; | |
| 23 | + | ||
| 24 | + | /** What is known of a work item's pull requests: their statuses. */ | |
| 25 | + | export type ItemPulls = Record<number, PullStatus[]>; | |
| 26 | + | ||
| 27 | + | /** Who or what is on a work item, for its card. */ | |
| 28 | + | export type OnIt = { | |
| 29 | + | /** People: assignees, claimants and whoever runs a live session on it, each once. */ | |
| 30 | + | people: string[]; | |
| 31 | + | /** Live sessions on it. */ | |
| 32 | + | sessions: WorkspaceSession[]; | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * The stage a work item is in, the first that holds: closed recently is | |
| 37 | + | * Done; landed in some target repositories and not others is Landing; an | |
| 38 | + | * open pull request is In review; claimed, assigned, a live session or a | |
| 39 | + | * draft is In progress; anything else open is Backlog. Null for one closed | |
| 40 | + | * longer ago than [`DONE_DAYS`], which the board leaves out. | |
| 41 | + | */ | |
| 42 | + | export function stageOf(item: WorkItem, facts: { pulls?: PullStatus[]; claimed?: boolean; live?: boolean }, now: number): Stage | null { | |
| 43 | + | if (item.state === "closed") { | |
| 44 | + | const closed = item.closedAt ? Date.parse(item.closedAt) : 0; | |
| 45 | + | return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null; | |
| 46 | + | } | |
| 47 | + | const targets = item.repos.filter((repo) => repo.role === "target"); | |
| 48 | + | if (targets.some((repo) => repo.landed) && targets.some((repo) => !repo.landed)) return "landing"; | |
| 49 | + | const pulls = facts.pulls ?? []; | |
| 50 | + | if (pulls.includes("open")) return "review"; | |
| 51 | + | if (facts.claimed || facts.live || item.assignees.length > 0 || pulls.includes("draft")) return "progress"; | |
| 52 | + | return "backlog"; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | export type BoardCard = { item: WorkItem; stage: Stage; on: OnIt }; | |
| 56 | + | ||
| 57 | + | export type Board = Record<Stage, BoardCard[]>; | |
| 58 | + | ||
| 59 | + | /** | |
| 60 | + | * The board of `items`, given the workspace's active claims, its live | |
| 61 | + | * sessions and what is known of each item's pull requests. Each column | |
| 62 | + | * keeps the most recently changed first; Done the most recently closed. | |
| 63 | + | */ | |
| 64 | + | export function boardOf( | |
| 65 | + | items: WorkItem[], | |
| 66 | + | { claims = [], sessions = [], pulls = {} }: { claims?: Claim[]; sessions?: WorkspaceSession[]; pulls?: ItemPulls }, | |
| 67 | + | now: number, | |
| 68 | + | ): Board { | |
| 69 | + | const board: Board = { backlog: [], progress: [], review: [], landing: [], done: [] }; | |
| 70 | + | const seen = new Set<number>(); | |
| 71 | + | for (const item of items) { | |
| 72 | + | if (seen.has(item.number)) continue; | |
| 73 | + | seen.add(item.number); | |
| 74 | + | const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active"); | |
| 75 | + | const live = sessions.filter((session) => session.item === item.number && session.state !== "ended"); | |
| 76 | + | // A live session's pull request counts as one of the item's. | |
| 77 | + | const known = [...(pulls[item.number] ?? []), ...live.flatMap((session) => (session.pullStatus ? [session.pullStatus] : []))]; | |
| 78 | + | const stage = stageOf(item, { pulls: known, claimed: claimed.length > 0, live: live.length > 0 }, now); | |
| 79 | + | if (!stage) continue; | |
| 80 | + | const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))]; | |
| 81 | + | board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live } }); | |
| 82 | + | } | |
| 83 | + | for (const stage of STAGES) { | |
| 84 | + | const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt); | |
| 85 | + | board[stage].sort((a, b) => at(b) - at(a)); | |
| 86 | + | } | |
| 87 | + | return board; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /** Each work item's pull requests' statuses, from pull requests that name their item. */ | |
| 91 | + | export function pullsByItem(pulls: { item: number | null; status: PullStatus }[]): ItemPulls { | |
| 92 | + | const by: ItemPulls = {}; | |
| 93 | + | for (const pull of pulls) { | |
| 94 | + | if (pull.item == null) continue; | |
| 95 | + | (by[pull.item] ??= []).push(pull.status); | |
| 96 | + | } | |
| 97 | + | return by; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */ | |
| 101 | + | export function shownStage(asked: string | null, board: Board): Stage { | |
| 102 | + | if (asked && (STAGES as string[]).includes(asked)) return asked as Stage; | |
| 103 | + | return STAGES.find((stage) => board[stage].length > 0) ?? "backlog"; | |
| 104 | + | } |
| 51 | 51 | ||
| 52 | 52 | test("a project's menu hides member-only pages from everyone else", () => { | |
| 53 | 53 | const visitor = projectPages(false); | |
| 54 | − | assert.deepEqual(visitor.slice(0, 4), ["code", "work", "pulls", "actions"]); | |
| 54 | + | // Work items and sessions are the workspace's: no tab of the project's. | |
| 55 | + | assert.deepEqual(visitor.slice(0, 3), ["code", "pulls", "actions"]); | |
| 55 | 56 | for (const page of ["security", "settings"] as const) { | |
| 56 | 57 | assert.ok(!visitor.includes(page), page); | |
| 57 | 58 | assert.ok(projectPages(true).includes(page), page); | |
| ⋯ | |||
| 69 | 70 | assert.equal(projectPageAt("commit/abc123"), "code"); | |
| 70 | 71 | // Work is the workspace's list, filtered to the project: no page of the project lights for an old issue's address. | |
| 71 | 72 | assert.equal(projectPageAt("issues/12"), null); | |
| 72 | − | assert.equal(projectPageAt("sessions"), "pulls"); | |
| 73 | + | // A repository has no Sessions page: Sessions is the workspace's. | |
| 74 | + | assert.equal(projectPageAt("sessions"), null); | |
| 73 | 75 | assert.equal(projectPageAt("pull/3/files"), "pulls"); | |
| 74 | 76 | assert.equal(projectPageAt("queue"), "pulls"); | |
| 75 | − | assert.equal(projectPageAt("sessions/12"), "pulls"); | |
| 77 | + | assert.equal(projectPageAt("sessions/12"), null); | |
| 76 | 78 | assert.equal(projectPageAt("actions/runs/9"), "actions"); | |
| 77 | 79 | assert.equal(projectPageAt("security/secret-scanning"), "security"); | |
| 78 | 80 | assert.equal(projectPageAt("stargazers"), "insights"); | |
| 63 | 63 | /** The project pages the sidebar lists, by key, for a member or not. */ | |
| 64 | 64 | export type ProjectPage = | |
| 65 | 65 | | "code" | |
| 66 | − | | "work" | |
| 67 | 66 | | "pulls" | |
| 68 | 67 | | "actions" | |
| 69 | 68 | | "deployments" | |
| ⋯ | |||
| 100 | 99 | export function projectPages(member: boolean, can?: Partial<Abilities>): ProjectPage[] { | |
| 101 | 100 | const pages: (ProjectPage | false)[] = [ | |
| 102 | 101 | "code", | |
| 103 | − | "work", | |
| 104 | 102 | "pulls", | |
| 105 | 103 | "actions", | |
| 106 | 104 | // A public repository's deployments and build logs are for anyone, as its code is. | |
| ⋯ | |||
| 127 | 125 | */ | |
| 128 | 126 | export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean; outside?: boolean }> = { | |
| 129 | 127 | code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare"] }, | |
| 130 | − | // The workspace's Work list filtered to the project (`outside`): work items belong to the workspace. | |
| 131 | − | work: { label: "Work", path: "work", also: [], outside: true }, | |
| 132 | − | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue", "sessions"] }, | |
| 128 | + | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue"] }, | |
| 133 | 129 | actions: { label: "Workflows", path: "actions", also: [] }, | |
| 134 | 130 | deployments: { label: "Deployments", path: "deployments", also: ["settings/deployments"] }, | |
| 135 | 131 | observability: { label: "Observability", path: "observability", also: [] }, | |
| 405 | 405 | number: number | null; | |
| 406 | 406 | /** The harness session it is about, by id, for a link to its page. */ | |
| 407 | 407 | session?: string; | |
| 408 | + | /** The repository it happened in, `owner/name`, in a feed across a workspace's. */ | |
| 409 | + | repo?: string; | |
| 408 | 410 | tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info"; | |
| 409 | 411 | }; | |
| 410 | 412 |
| 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/web/work/sessions/ses_1" }); | |
| 747 | + | assert.deepEqual(marked!.agent, { harness: "claude_code", to: "/acme/-/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/web/work/sessions/ses_9"], | |
| 776 | + | ["Fix the bell's count", "web · pushed to main · by ana", "c3", "/acme/web/commit/c3", "/acme/-/sessions/ses_9"], | |
| 777 | 777 | ["2 commits pushed to main", "web · Tidy · by ana", "c2", "/acme/web/activity", null], | |
| 778 | 778 | ], | |
| 779 | 779 | ); | |
| 29 | 29 | import { HARNESS_NAMES } from "./coordination.ts"; | |
| 30 | 30 | import { type PushRecord, pushedByPerson, pushedCommits } from "./mission-control.ts"; | |
| 31 | 31 | import { settledLine } from "./inbox.ts"; | |
| 32 | + | import { sessionPath } from "./work-items.ts"; | |
| 32 | 33 | ||
| 33 | 34 | const MINUTE = 60_000; | |
| 34 | 35 | const HOUR = 60 * MINUTE; | |
| ⋯ | |||
| 748 | 749 | at: Date.parse(push.time), | |
| 749 | 750 | sha: push.data.after, | |
| 750 | 751 | repoId, | |
| 751 | − | agent: session ? { harness: null, to: `/${repo.namespace}/${repo.name}/work/sessions/${session}` } : null, | |
| 752 | + | agent: session ? { harness: null, to: sessionPath(repo.namespace, session) } : null, | |
| 752 | 753 | ...(brought.length === 1 && subject(newest.message) ? { refsIn: `${repo.namespace}/${repo.name}` } : {}), | |
| 753 | 754 | }); | |
| 754 | 755 | } | |
| ⋯ | |||
| 821 | 822 | for (const { repo, pulls } of sessions) { | |
| 822 | 823 | for (const pull of pulls) { | |
| 823 | 824 | const first = pull.sessions[0]; | |
| 824 | − | if (first) found.set(`landed:${pullKey(repo, pull.number)}`, { harness: first.harness, to: `/${repo.namespace}/${repo.name}/work/sessions/${first.id}` }); | |
| 825 | + | if (first) found.set(`landed:${pullKey(repo, pull.number)}`, { harness: first.harness, to: sessionPath(repo.namespace, first.id) }); | |
| 825 | 826 | } | |
| 826 | 827 | } | |
| 827 | 828 | return rows.map((row) => (row.agent || !found.has(row.key) ? row : { ...row, agent: found.get(row.key)! })); | |
| 97 | 97 | ]); | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | − | /** Pull requests, the merge queue and the sessions working on them share a page's tabs too. */ | |
| 100 | + | /** Pull requests and the merge queue share a page's tabs too. */ | |
| 101 | 101 | export const PULLS: Section = { | |
| 102 | 102 | key: "Code", | |
| 103 | 103 | tabs: [ | |
| 104 | 104 | { label: "Pull requests", path: "pulls", also: ["pull"] }, | |
| 105 | 105 | { label: "Merge queue", path: "queue" }, | |
| 106 | − | { label: "Sessions", path: "sessions" }, | |
| 107 | 106 | ], | |
| 108 | 107 | }; | |
| 109 | 108 |
| 161 | 161 | change.kind === "queue.changed"; | |
| 162 | 162 | }, | |
| 163 | 163 | /** | |
| 164 | − | * The Sessions page: claims, sessions starting and ending, dependencies, | |
| 164 | + | * Sessions (the workspace's, over its repositories): claims, sessions starting and ending, dependencies, | |
| 165 | 165 | * overlaps, what the merge queue finds and decisions, the queue moving, | |
| 166 | 166 | * and merges; and sessions going on (heard from, so still active). | |
| 167 | 167 | */ |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { WorkSession, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { narrowed, peopleIn, repoName, runsIn, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 7 | + | ||
| 8 | + | function listed(id: string, more: Partial<WorkspaceSession> = {}): WorkspaceSession { | |
| 9 | + | return { | |
| 10 | + | kind: "harness", | |
| 11 | + | id, | |
| 12 | + | repo: "acme/web", | |
| 13 | + | repoId: "rep_1", | |
| 14 | + | title: null, | |
| 15 | + | actor: "ana", | |
| 16 | + | harness: "claude_code", | |
| 17 | + | agent: null, | |
| 18 | + | branch: "main", | |
| 19 | + | item: null, | |
| 20 | + | itemTitle: null, | |
| 21 | + | pull: null, | |
| 22 | + | pullTitle: null, | |
| 23 | + | pullStatus: null, | |
| 24 | + | state: "active", | |
| 25 | + | startedAt: "2026-10-17T10:00:00Z", | |
| 26 | + | lastActivityAt: "2026-10-17T11:00:00Z", | |
| 27 | + | subagents: 0, | |
| 28 | + | step: null, | |
| 29 | + | session: null, | |
| 30 | + | ...more, | |
| 31 | + | }; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | const own = (more: Partial<WorkSession> = {}): WorkSession => | |
| 35 | + | ({ id: "ses_1", repoId: "rep_1", item: null, pull: null, branch: "fix-it", actor: { id: "u", username: "ana" }, harness: "codex", harnessSessionId: null, title: null, state: "active", headCommit: null, startedAt: "", lastSeenAt: "", endedAt: null, endReason: null, ...more }) as WorkSession; | |
| 36 | + | ||
| 37 | + | test("every kind of session opens where it is shown", () => { | |
| 38 | + | assert.equal(sessionHref("Acme", listed("ses_1")), "/acme/-/sessions/ses_1"); | |
| 39 | + | assert.equal(sessionHref("acme", listed("ses_2", { kind: "hosted", harness: "claude_managed_agents" })), "/acme/-/sessions/ses_2"); | |
| 40 | + | // g1t's agent and a recorded session are their pull request's Session tab. | |
| 41 | + | assert.equal(sessionHref("acme", listed("run_1", { kind: "agent", repo: "acme/api", pull: 8 })), "/acme/api/pull/8/session"); | |
| 42 | + | assert.equal(sessionHref("acme", listed("pr_1", { kind: "recorded", pull: 7 })), "/acme/web/pull/7/session"); | |
| 43 | + | assert.equal(sessionHref("acme", listed("run_2", { kind: "agent", pull: null })), null); | |
| 44 | + | }); | |
| 45 | + | ||
| 46 | + | test("a session is named by its title, its subagent task, its pull request, never nothing", () => { | |
| 47 | + | assert.equal(workspaceSessionTitle(listed("a", { session: own({ title: "Fix login" }) })), "Fix login"); | |
| 48 | + | assert.equal(workspaceSessionTitle(listed("b", { session: own() })), "Session on fix-it"); | |
| 49 | + | assert.equal(workspaceSessionTitle(listed("c", { session: own({ parentId: "ses_0", agentType: "Explore", task: "Find endpoints" }) })), "Explore: Find endpoints"); | |
| 50 | + | assert.equal(workspaceSessionTitle(listed("d", { kind: "recorded", pull: 7, pullTitle: "Retry hooks" })), "Retry hooks"); | |
| 51 | + | assert.equal(workspaceSessionTitle(listed("e", { kind: "agent", pull: 7 })), "Pull request #7"); | |
| 52 | + | assert.equal(runsIn(listed("f")), "Claude Code"); | |
| 53 | + | assert.equal(runsIn(listed("g", { kind: "agent", harness: null, agent: "g1t" })), "g1t's agent"); | |
| 54 | + | assert.equal(runsIn(listed("h", { kind: "recorded", harness: null, agent: "cursor" })), "cursor, recorded"); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | test("Sessions keeps its filters in the address and drops what it does not know", () => { | |
| 58 | + | const filters = sessionsFilters(new URLSearchParams("repo=web&person=@bo&harness=codex&kind=bogus&state=recent")); | |
| 59 | + | assert.deepEqual(filters, { state: "recent", repo: "web", person: "bo", harness: "codex", kind: null }); | |
| 60 | + | assert.equal(sessionsQuery(filters), "?state=recent&repo=web&person=bo&harness=codex"); | |
| 61 | + | assert.equal(sessionsQuery({ state: "live" }), ""); | |
| 62 | + | assert.equal(narrowed(filters), true); | |
| 63 | + | assert.equal(narrowed(sessionsFilters(new URLSearchParams("state=recent"))), false); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("live sessions come apart from those that ended, most recently heard from first", () => { | |
| 67 | + | const sessions = [ | |
| 68 | + | listed("old", { state: "ended" }), | |
| 69 | + | listed("idle", { state: "idle" }), | |
| 70 | + | listed("a", { lastActivityAt: "2026-10-17T09:00:00Z", actor: "bo" }), | |
| 71 | + | listed("b", { lastActivityAt: "2026-10-17T11:30:00Z", actor: "bo" }), | |
| 72 | + | ]; | |
| 73 | + | const { active, idle, ended } = splitLive(sessions); | |
| 74 | + | assert.deepEqual(active.map((session) => session.id), ["b", "a"]); | |
| 75 | + | assert.deepEqual(idle.map((session) => session.id), ["idle"]); | |
| 76 | + | assert.deepEqual(ended.map((session) => session.id), ["old"]); | |
| 77 | + | assert.deepEqual(peopleIn(sessions), ["ana", "bo"]); | |
| 78 | + | assert.equal(repoName("acme/web", "Acme"), "web"); | |
| 79 | + | assert.equal(repoName("other/web", "acme"), "other/web"); | |
| 80 | + | }); |
| 1 | + | /** | |
| 2 | + | * Sessions as the site shows them across a workspace: what each is called | |
| 3 | + | * and where it opens, the filters Sessions keeps in its address, and live | |
| 4 | + | * ones apart from the rest. Pure, so it is tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { Harness, SessionKind, WorkspaceSession } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | import { HARNESS_NAMES, sessionTitle } from "./coordination.ts"; | |
| 9 | + | import { sessionPath } from "./work-items.ts"; | |
| 10 | + | ||
| 11 | + | /** How each kind is named in a filter. */ | |
| 12 | + | export const KIND_NAMES: Record<SessionKind, string> = { | |
| 13 | + | harness: "People's harnesses", | |
| 14 | + | hosted: "Started on g1t", | |
| 15 | + | agent: "g1t's agent", | |
| 16 | + | recorded: "Recorded on pull requests", | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | /** What a session is called, never nothing. */ | |
| 20 | + | export function workspaceSessionTitle(session: WorkspaceSession): string { | |
| 21 | + | if (session.session) { | |
| 22 | + | const own = session.session; | |
| 23 | + | if (!own.title?.trim() && own.parentId) return [own.agentType ?? "Subagent", own.task].filter(Boolean).join(": "); | |
| 24 | + | return sessionTitle(own); | |
| 25 | + | } | |
| 26 | + | return session.title?.trim() || session.pullTitle?.trim() || (session.pull != null ? `Pull request #${session.pull}` : "Untitled session"); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** What runs it, in a few words: the harness, g1t's agent, or the agent that recorded it. */ | |
| 30 | + | export function runsIn(session: Pick<WorkspaceSession, "kind" | "harness" | "agent">): string { | |
| 31 | + | if (session.kind === "agent") return "g1t's agent"; | |
| 32 | + | if (session.harness) return HARNESS_NAMES[session.harness] ?? session.harness; | |
| 33 | + | return session.agent ? `${session.agent}, recorded` : "Recorded"; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** | |
| 37 | + | * Where a session opens: a harness or hosted session's own page; g1t's | |
| 38 | + | * agent and a recorded session are their pull request's Session tab; one | |
| 39 | + | * with neither opens nowhere. | |
| 40 | + | */ | |
| 41 | + | export function sessionHref(workspace: string, session: Pick<WorkspaceSession, "kind" | "id" | "repo" | "pull">): string | null { | |
| 42 | + | if (session.kind === "harness" || session.kind === "hosted") return sessionPath(workspace, session.id); | |
| 43 | + | return session.pull != null ? `/${session.repo}/pull/${session.pull}/session` : null; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** The filters Sessions keeps in its address. */ | |
| 47 | + | export type SessionsFilters = { | |
| 48 | + | /** `recent`: every session in one list; otherwise Live now, then what ended. */ | |
| 49 | + | state: "live" | "recent"; | |
| 50 | + | repo: string | null; | |
| 51 | + | person: string | null; | |
| 52 | + | harness: Harness | null; | |
| 53 | + | kind: SessionKind | null; | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[]; | |
| 57 | + | const KINDS = Object.keys(KIND_NAMES) as SessionKind[]; | |
| 58 | + | ||
| 59 | + | export function sessionsFilters(query: URLSearchParams): SessionsFilters { | |
| 60 | + | const harness = query.get("harness"); | |
| 61 | + | const kind = query.get("kind"); | |
| 62 | + | const text = (key: string) => query.get(key)?.trim().replace(/^@/, "") || null; | |
| 63 | + | return { | |
| 64 | + | state: query.get("state") === "recent" ? "recent" : "live", | |
| 65 | + | repo: text("repo"), | |
| 66 | + | person: text("person"), | |
| 67 | + | harness: harness && (HARNESSES as string[]).includes(harness) ? (harness as Harness) : null, | |
| 68 | + | kind: kind && (KINDS as string[]).includes(kind) ? (kind as SessionKind) : null, | |
| 69 | + | }; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /** The address of Sessions with `filters`, leaving out what is as it is by default. */ | |
| 73 | + | export function sessionsQuery(filters: Partial<SessionsFilters>): string { | |
| 74 | + | const query = new URLSearchParams(); | |
| 75 | + | if (filters.state === "recent") query.set("state", "recent"); | |
| 76 | + | for (const key of ["repo", "person", "harness", "kind"] as const) { | |
| 77 | + | const value = filters[key]; | |
| 78 | + | if (value) query.set(key, value); | |
| 79 | + | } | |
| 80 | + | const text = query.toString(); | |
| 81 | + | return text ? `?${text}` : ""; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /** Whether any filter narrows the list. */ | |
| 85 | + | export function narrowed(filters: SessionsFilters): boolean { | |
| 86 | + | return Boolean(filters.repo || filters.person || filters.harness || filters.kind); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** | |
| 90 | + | * Live sessions apart: those going now (active first, then idle, each the | |
| 91 | + | * most recently heard from first) and those that ended. | |
| 92 | + | */ | |
| 93 | + | export function splitLive(sessions: WorkspaceSession[]): { active: WorkspaceSession[]; idle: WorkspaceSession[]; ended: WorkspaceSession[] } { | |
| 94 | + | const by = (state: WorkspaceSession["state"]) => | |
| 95 | + | sessions.filter((session) => session.state === state).sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)); | |
| 96 | + | return { active: by("active"), idle: by("idle"), ended: by("ended") }; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /** The people running sessions in a list, each once, most active first, for its filter. */ | |
| 100 | + | export function peopleIn(sessions: WorkspaceSession[]): string[] { | |
| 101 | + | const counts = new Map<string, number>(); | |
| 102 | + | for (const session of sessions) if (session.actor) counts.set(session.actor, (counts.get(session.actor) ?? 0) + 1); | |
| 103 | + | return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** `acme/web` as the workspace `acme` names it: `web`. */ | |
| 107 | + | export function repoName(path: string, workspace: string): string { | |
| 108 | + | const [owner, name] = path.split("/"); | |
| 109 | + | return owner?.toLowerCase() === workspace.toLowerCase() && name ? name : path; | |
| 110 | + | } |
| 11 | 11 | repoChips, | |
| 12 | 12 | repoFilter, | |
| 13 | 13 | repoWorkPath, | |
| 14 | + | sessionPath, | |
| 15 | + | sessionsPath, | |
| 14 | 16 | shortPath, | |
| 15 | 17 | workFilters, | |
| 16 | 18 | workListPath, | |
| ⋯ | |||
| 66 | 68 | ||
| 67 | 69 | test("a repository's Work tab, and a new work item filed from it", () => { | |
| 68 | 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"); | |
| 69 | 74 | assert.equal(newItemPath("Acme"), "/acme/-/work/new"); | |
| 70 | 75 | assert.equal(newItemPath("acme", "web"), "/acme/-/work/new?repo=web"); | |
| 71 | 76 | assert.deepEqual(repoFilter("acme", "web"), { namespace: "acme", name: "web" }); | |
| ⋯ | |||
| 98 | 103 | // Milestones were numbered again in the workspace: an old one leads to the list. | |
| 99 | 104 | assert.equal(movedWorkPath("/acme/web/milestones"), "/acme/-/work/milestones"); | |
| 100 | 105 | assert.equal(movedWorkPath("/acme/web/milestones/3"), "/acme/-/work/milestones"); | |
| 101 | − | assert.equal(movedWorkPath("/acme/web/work"), "/acme/web/sessions"); | |
| 102 | − | assert.equal(movedWorkPath("/acme/web/work/sessions/ses_123"), "/acme/web/sessions/ses_123"); | |
| 106 | + | // A repository's old Work and Sessions pages are gone: Sessions is the workspace's. | |
| 107 | + | assert.equal(movedWorkPath("/acme/web/work"), null); | |
| 108 | + | assert.equal(movedWorkPath("/acme/web/work/sessions/ses_123"), null); | |
| 103 | 109 | assert.equal(movedWorkPath("/acme/web/pulls"), null); | |
| 104 | 110 | assert.equal(movedWorkPath("/acme/-/issues"), null); | |
| 105 | 111 | }); | |
| 12 | 12 | ||
| 13 | 13 | /** | |
| 14 | 14 | * Where an old address under a repository is now, when it named something | |
| 15 | − | * that moved to Work or to Sessions; null when it did not: | |
| 15 | + | * that moved to Work; null when it did not: | |
| 16 | 16 | * | |
| 17 | 17 | * - `/<owner>/<repo>/issues` is the Work list filtered to the repository, | |
| 18 | 18 | * keeping its filters (`?state=closed&label=bug`); | |
| ⋯ | |||
| 21 | 21 | * the work service can say: `{ resolve: n }`; | |
| 22 | 22 | * - `/<owner>/<repo>/labels` and `/milestones[/<n>]` the workspace's | |
| 23 | 23 | * (milestones were numbered again in the workspace, so an old one's | |
| 24 | − | * number leads to the list); | |
| 25 | − | * - `/<owner>/<repo>/work` and `/work/sessions/<id>` its Sessions page and | |
| 26 | − | * a session's. | |
| 24 | + | * number leads to the list). | |
| 27 | 25 | */ | |
| 28 | 26 | export function movedWorkPath(pathname: string, search = ""): string | { resolve: number } | null { | |
| 29 | 27 | const parts = pathname.replace(/\.data$/, "").split("/").filter(Boolean); | |
| ⋯ | |||
| 48 | 46 | return rest === undefined ? workPath(workspace, "labels") : null; | |
| 49 | 47 | case "milestones": | |
| 50 | 48 | return more === undefined ? workPath(workspace, "milestones") : null; | |
| 51 | − | case "work": | |
| 52 | − | if (rest === undefined) return `/${owner}/${repo}/sessions`; | |
| 53 | − | if (rest === "sessions" && more) return `/${owner}/${repo}/sessions/${more}`; | |
| 54 | − | return null; | |
| 55 | 49 | } | |
| 56 | 50 | return null; | |
| 57 | 51 | } | |
| ⋯ | |||
| 61 | 55 | return `/${workspace.toLowerCase()}/-/work${page ? `/${page}` : ""}`; | |
| 62 | 56 | } | |
| 63 | 57 | ||
| 64 | − | /** The Work list filtered to one repository of the workspace: a repository's Work tab. */ | |
| 58 | + | /** The Work list filtered to one repository of the workspace. */ | |
| 65 | 59 | export function repoWorkPath(workspace: string, repo: string): string { | |
| 66 | 60 | return `${workPath(workspace)}?repo=${encodeURIComponent(repo)}`; | |
| 67 | 61 | } | |
| 68 | 62 | ||
| 63 | + | /** Sessions, the workspace's: what is cooking in it, with filters (`?repo=web&person=ana`). */ | |
| 64 | + | export function sessionsPath(workspace: string, filters: Record<string, string | null | undefined> = {}): string { | |
| 65 | + | const query = new URLSearchParams(); | |
| 66 | + | for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value); | |
| 67 | + | const text = query.toString(); | |
| 68 | + | return `/${workspace.toLowerCase()}/-/sessions${text ? `?${text}` : ""}`; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /** One session's page, in whichever of the workspace's repositories it runs. */ | |
| 72 | + | export function sessionPath(workspace: string, id: string): string { | |
| 73 | + | return `/${workspace.toLowerCase()}/-/sessions/${encodeURIComponent(id)}`; | |
| 74 | + | } | |
| 75 | + | ||
| 69 | 76 | /** A new work item, started from a repository (its origin) when `repo` is given. */ | |
| 70 | 77 | export function newItemPath(workspace: string, repo?: string | null): string { | |
| 71 | 78 | return `${workPath(workspace, "new")}${repo ? `?repo=${encodeURIComponent(repo)}` : ""}`; | |
| 231 | 231 | export type ModeKey = | |
| 232 | 232 | | "home" | |
| 233 | 233 | | "work" | |
| 234 | + | | "sessions" | |
| 234 | 235 | | "chat" | |
| 235 | 236 | | "notifications" | |
| 236 | 237 | | "code" | |
| ⋯ | |||
| 247 | 248 | const PAGE_MODES: Record<string, ModeKey> = { | |
| 248 | 249 | home: "home", | |
| 249 | 250 | work: "work", | |
| 251 | + | sessions: "sessions", | |
| 250 | 252 | chat: "chat", | |
| 251 | 253 | artifacts: "artifacts", | |
| 252 | 254 | routines: "routines", | |
| ⋯ | |||
| 310 | 312 | return homePagePath(slug); | |
| 311 | 313 | case "work": | |
| 312 | 314 | return `/${slug}/-/work`; | |
| 315 | + | case "sessions": | |
| 316 | + | return `/${slug}/-/sessions`; | |
| 313 | 317 | case "notifications": | |
| 314 | 318 | return "/notifications"; | |
| 315 | 319 | case "chat": | |
| 227 | 227 | route("-/home", "routes/workspace/home.tsx"), | |
| 228 | 228 | route("-/home/seen", "routes/workspace/home-seen.ts"), | |
| 229 | 229 | // Work: the workspace's work items, for every member. The list (its | |
| 230 | − | // filters in the address), a new one, the labels and milestones they | |
| 231 | − | // share with pull requests, `#12` read in the workspace, and each item. | |
| 230 | + | // filters in the address), the board of them as they move, a new one, | |
| 231 | + | // the labels and milestones they share with pull requests, `#12` read | |
| 232 | + | // in the workspace, and each item. | |
| 232 | 233 | route("-/work", "routes/workspace/work/list.tsx"), | |
| 234 | + | route("-/work/board", "routes/workspace/work/board.tsx"), | |
| 233 | 235 | route("-/work/new", "routes/workspace/work/new.tsx"), | |
| 234 | 236 | route("-/work/labels", "routes/workspace/work/labels.tsx"), | |
| 235 | 237 | route("-/work/milestones", "routes/workspace/work/milestones.tsx"), | |
| 236 | 238 | route("-/work/milestones/:number", "routes/workspace/work/milestone.tsx"), | |
| 237 | 239 | route("-/work/ref/:number", "routes/workspace/work/ref.ts"), | |
| 238 | 240 | 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"), | |
| 239 | 246 | route("-/today", "routes/workspace/moved.ts", { id: "routes/workspace/moved-today" }), | |
| 240 | 247 | route("-/apps", "routes/workspace/apps.tsx"), | |
| 241 | 248 | // Routines: standing work for @g1t, on a schedule or when something | |
| ⋯ | |||
| 261 | 268 | // the card over a `#12` in a commit message or on a timeline. | |
| 262 | 269 | route(":owner/:repo/ref/:number", "routes/repo/ref-card.ts"), | |
| 263 | 270 | // A repository's issues, labels and milestones moved to its workspace's | |
| 264 | − | // Work, and its Work page became Sessions: old links are sent on | |
| 265 | − | // (routes/repo/moved-work.ts), before the repository's own pages. | |
| 271 | + | // Work: old links are sent on (routes/repo/moved-work.ts), before the | |
| 272 | + | // repository's own pages. | |
| 266 | 273 | route(":owner/:repo/issues", "routes/repo/moved-work.ts", { id: "routes/repo/moved-issues" }), | |
| 267 | 274 | route(":owner/:repo/issues/new", "routes/repo/moved-work.ts", { id: "routes/repo/moved-issue-new" }), | |
| 268 | 275 | route(":owner/:repo/issues/:number", "routes/repo/moved-work.ts", { id: "routes/repo/moved-issue" }), | |
| 269 | 276 | route(":owner/:repo/labels", "routes/repo/moved-work.ts", { id: "routes/repo/moved-labels" }), | |
| 270 | 277 | route(":owner/:repo/milestones", "routes/repo/moved-work.ts", { id: "routes/repo/moved-milestones" }), | |
| 271 | 278 | route(":owner/:repo/milestones/:number", "routes/repo/moved-work.ts", { id: "routes/repo/moved-milestone" }), | |
| 272 | − | route(":owner/:repo/work", "routes/repo/moved-work.ts", { id: "routes/repo/moved-work" }), | |
| 273 | − | route(":owner/:repo/work/sessions/:id", "routes/repo/moved-work.ts", { id: "routes/repo/moved-work-session" }), | |
| 274 | 279 | // Why a line is the way it is, fetched by the blame view. | |
| 275 | 280 | route(":owner/:repo/why/:hash", "routes/repo/why.ts"), | |
| 276 | 281 | // Files of a large change with their lines, fetched as the diff view reaches them. | |
| ⋯ | |||
| 332 | 337 | route("pull/:number/:tab?", "routes/repo/pull.tsx"), | |
| 333 | 338 | route("queue", "routes/repo/queue.tsx"), | |
| 334 | 339 | route("usage", "routes/repo/usage.tsx"), | |
| 335 | − | // Sessions: who is working on what, live (claims, harness sessions, | |
| 336 | − | // the queue, coordination events), and the sessions recorded on its | |
| 337 | − | // pull requests. One session's page by its id (`ses_…`), or a pull | |
| 338 | − | // request's recorded session by its number. | |
| 339 | − | route("sessions", "routes/repo/sessions.tsx"), | |
| 340 | − | route("sessions/:id", "routes/repo/session.tsx"), | |
| 341 | 340 | route("actions", "routes/repo/actions.tsx"), | |
| 342 | 341 | route("actions/runs/:id", "routes/repo/actions-run.tsx"), | |
| 343 | 342 | route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"), | |
| 1 | 1 | /** | |
| 2 | 2 | * A repository's issues, labels and milestones moved to its workspace's | |
| 3 | − | * Work, and its Work page became its Sessions page: old links are sent on | |
| 3 | + | * Work: old links are sent on | |
| 4 | 4 | * (lib/work-items.ts `movedWorkPath` says where). An old issue's address | |
| 5 | 5 | * is read as `#n` in the repository (lib/refs.ts): the work item its issue | |
| 6 | 6 | * became, or a pull request. A work item belongs to the workspace, so these |
| 36 | 36 | import { type LivePull, LiveNowList, StartHere } from "../../components/coordination"; | |
| 37 | 37 | import { awakeAndIdle, liveByItem } from "../../lib/coordination"; | |
| 38 | 38 | import { type Need, ageBuckets, rankNeeds, runHealth } from "../../lib/mission"; | |
| 39 | − | import { repoWorkPath } from "../../lib/work-items"; | |
| 39 | + | import { repoWorkPath, sessionsPath } from "../../lib/work-items"; | |
| 40 | 40 | import { count, duration, percent } from "../../lib/observability"; | |
| 41 | 41 | import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages"; | |
| 42 | 42 | import { | |
| ⋯ | |||
| 861 | 861 | </span> | |
| 862 | 862 | ) : null | |
| 863 | 863 | } | |
| 864 | − | all={{ to: `${base}/sessions`, label: "Sessions" }} | |
| 864 | + | all={{ to: sessionsPath(base.split("/")[1]!, { repo: base.split("/")[2] }), label: "Sessions" }} | |
| 865 | 865 | > | |
| 866 | 866 | {!loaderData.liveLoaded ? ( | |
| 867 | 867 | <Unavailable what="Who is working" /> | |
| ⋯ | |||
| 879 | 879 | ender={loaderData.ender} | |
| 880 | 880 | /> | |
| 881 | 881 | {moreItems > 0 && ( | |
| 882 | − | <Link to={`${base}/sessions`} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg"> | |
| 882 | + | <Link to={sessionsPath(base.split("/")[1]!, { repo: base.split("/")[2] })} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg"> | |
| 883 | 883 | {plural(moreItems, "more work item")} being worked on | |
| 884 | 884 | </Link> | |
| 885 | 885 | )} | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.