Live now lists the sessions working now: on a repository's overview and Work, a session not heard from for 15 minutes (idle) is folded into one row under the list, "N idle sessions", last seen when, which opens to show them; each moves back up when it is heard from and drops off when it times out. A session that has done nothing since it started (no title, issue or pull request, never heard from again: a Claude Code tab opened and not typed in) is not listed at all (startedOnly). The panel's counts are of the sessions working now, with "N idle" beside them, and its icon is green only for those. Sessions on the default branch are headed "On main" (the branch's name), not "No branch yet", which is kept for sessions git has named no branch for, and their rows leave the branch out. An untitled session's title, read from its first prompt, skips what VS Code puts ahead of what was typed (<ide_opened_file>, <ide_selection>): such a prompt read as a turn Claude Code started itself, so a session started from VS Code took its second prompt as its title. Checked: apps/web tsc -b; npm test (986; new: awake and idle split, startedOnly, an IDE-prefixed prompt's intent).
- G1t-Session:
- ses_01m4zzpc96ect8d41mffgr1a45
5 files+140−180/5 viewed
| 9 | 9 | Activity, | |
| 10 | 10 | ArrowRightLeft, | |
| 11 | 11 | Bot, | |
| 12 | + | ChevronRight, | |
| 12 | 13 | CircleCheck, | |
| 13 | 14 | CircleDot, | |
| 14 | 15 | CircleX, | |
| ⋯ | |||
| 57 | 58 | harnessName, | |
| 58 | 59 | type LiveGroup, | |
| 59 | 60 | type LiveIssue, | |
| 61 | + | awakeAndIdle, | |
| 60 | 62 | liveByIssue, | |
| 61 | 63 | mergeSessions, | |
| 62 | 64 | numberedParts, | |
| ⋯ | |||
| 70 | 72 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 71 | 73 | import { Button } from "./ui/button"; | |
| 72 | 74 | import { Card } from "./ui/card"; | |
| 75 | + | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | |
| 73 | 76 | import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 74 | 77 | import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from "./ui/field"; | |
| 75 | 78 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 879 | 882 | export type LivePull = { title: string; status: Pull["status"] }; | |
| 880 | 883 | ||
| 881 | 884 | /** What a group of sessions on no issue is on, as its card's heading: the pull request, the branch, or neither. */ | |
| 882 | − | function LiveGroupHeading({ group, pulls, base }: { group: LiveGroup; pulls: Record<number, LivePull>; base: string }) { | |
| 885 | + | function LiveGroupHeading({ | |
| 886 | + | group, | |
| 887 | + | pulls, | |
| 888 | + | base, | |
| 889 | + | defaultBranch, | |
| 890 | + | }: { | |
| 891 | + | group: LiveGroup; | |
| 892 | + | pulls: Record<number, LivePull>; | |
| 893 | + | base: string; | |
| 894 | + | defaultBranch?: string | null; | |
| 895 | + | }) { | |
| 883 | 896 | if (group.kind === "pull") { | |
| 884 | 897 | const pull = pulls[group.pull]; | |
| 885 | 898 | return ( | |
| ⋯ | |||
| 905 | 918 | </div> | |
| 906 | 919 | ); | |
| 907 | 920 | } | |
| 921 | + | // Working on the default branch itself, or somewhere git has not said yet. | |
| 922 | + | const onDefault = defaultBranch != null && group.sessions.some((session) => session.branch === defaultBranch); | |
| 908 | 923 | return ( | |
| 909 | 924 | <div className="flex items-center gap-2.5 px-4 py-3"> | |
| 910 | − | <Activity size={16} className="shrink-0 text-faint" /> | |
| 911 | − | <Hint label="Sessions on no issue, pull request or branch of their own yet, each titled with what it is for. One opens or claims an issue when its work becomes a change."> | |
| 912 | − | <span className="text-sm text-muted">No branch yet</span> | |
| 925 | + | {onDefault ? <GitBranch size={16} className="shrink-0 text-faint" /> : <Activity size={16} className="shrink-0 text-faint" />} | |
| 926 | + | <Hint label="Sessions on no issue, pull request or branch of their own, each titled with what it is for. One opens or claims an issue when its work becomes a change."> | |
| 927 | + | <span className="text-sm text-muted"> | |
| 928 | + | {onDefault ? ( | |
| 929 | + | <> | |
| 930 | + | On <span className="font-mono">{defaultBranch}</span> | |
| 931 | + | </> | |
| 932 | + | ) : ( | |
| 933 | + | "No branch yet" | |
| 934 | + | )} | |
| 935 | + | </span> | |
| 913 | 936 | </Hint> | |
| 914 | 937 | </div> | |
| 915 | 938 | ); | |
| 916 | 939 | } | |
| 917 | 940 | ||
| 918 | 941 | /** Sessions on no issue yet that share a pull request or a branch, or share neither: a heading, then each session. */ | |
| 919 | − | export function LiveGroupCard({ group, pulls, base }: { group: LiveGroup; pulls: Record<number, LivePull>; base: string }) { | |
| 942 | + | export function LiveGroupCard({ | |
| 943 | + | group, | |
| 944 | + | pulls, | |
| 945 | + | base, | |
| 946 | + | defaultBranch, | |
| 947 | + | }: { | |
| 948 | + | group: LiveGroup; | |
| 949 | + | pulls: Record<number, LivePull>; | |
| 950 | + | base: string; | |
| 951 | + | defaultBranch?: string | null; | |
| 952 | + | }) { | |
| 920 | 953 | return ( | |
| 921 | 954 | <Card asChild divided> | |
| 922 | 955 | <li> | |
| 923 | − | <LiveGroupHeading group={group} pulls={pulls} base={base} /> | |
| 956 | + | <LiveGroupHeading group={group} pulls={pulls} base={base} defaultBranch={defaultBranch} /> | |
| 924 | 957 | {group.sessions.map((session) => ( | |
| 925 | 958 | <div key={session.id} className="px-4 py-3"> | |
| 926 | 959 | {/* Its pull request and branch are the heading's, unless it has neither of its own. */} | |
| 927 | − | <SessionLine session={session} base={base} showWhere={group.kind === "none"} /> | |
| 960 | + | <SessionLine session={session} base={base} showWhere={group.kind === "none"} showBranch={group.kind === "none" && session.branch !== defaultBranch} /> | |
| 928 | 961 | </div> | |
| 929 | 962 | ))} | |
| 930 | 963 | </li> | |
| ⋯ | |||
| 959 | 992 | now: number; | |
| 960 | 993 | limit?: number; | |
| 961 | 994 | }) { | |
| 962 | − | const live = liveByIssue(claims, sessions, defaultBranch); | |
| 995 | + | const { awake, idle } = awakeAndIdle(sessions); | |
| 996 | + | const live = liveByIssue(claims, awake, defaultBranch); | |
| 963 | 997 | const issues = limit == null ? live.issues : live.issues.slice(0, limit); | |
| 964 | 998 | return ( | |
| 965 | 999 | <ol className="space-y-3"> | |
| ⋯ | |||
| 972 | 1006 | group={group} | |
| 973 | 1007 | pulls={pulls} | |
| 974 | 1008 | base={base} | |
| 1009 | + | defaultBranch={defaultBranch} | |
| 975 | 1010 | /> | |
| 976 | 1011 | ))} | |
| 1012 | + | {idle.length > 0 && <IdleSessions sessions={idle} base={base} />} | |
| 977 | 1013 | </ol> | |
| 978 | 1014 | ); | |
| 979 | 1015 | } | |
| 980 | 1016 | ||
| 1017 | + | /** | |
| 1018 | + | * Sessions not heard from for 15 minutes, folded under Live now: usually | |
| 1019 | + | * an open tab, not work going on. Each moves back up when it is heard from, | |
| 1020 | + | * and drops off when it times out. | |
| 1021 | + | */ | |
| 1022 | + | function IdleSessions({ sessions, base }: { sessions: WorkSession[]; base: string }) { | |
| 1023 | + | return ( | |
| 1024 | + | <Card asChild divided> | |
| 1025 | + | <li> | |
| 1026 | + | <Collapsible> | |
| 1027 | + | <CollapsibleTrigger className="w-full px-4 py-2.5"> | |
| 1028 | + | <ChevronRight size={14} className="shrink-0" /> | |
| 1029 | + | {sessions.length === 1 ? "1 idle session" : `${sessions.length} idle sessions`} | |
| 1030 | + | <span className="ml-auto text-xs text-faint"> | |
| 1031 | + | last seen <TimeAgo at={sessions[0].lastSeenAt} /> | |
| 1032 | + | </span> | |
| 1033 | + | </CollapsibleTrigger> | |
| 1034 | + | <CollapsibleContent className="divide-y divide-line border-t border-line"> | |
| 1035 | + | {sessions.map((session) => ( | |
| 1036 | + | <div key={session.id} className="px-4 py-3"> | |
| 1037 | + | <SessionLine session={session} base={base} /> | |
| 1038 | + | </div> | |
| 1039 | + | ))} | |
| 1040 | + | </CollapsibleContent> | |
| 1041 | + | </Collapsible> | |
| 1042 | + | </li> | |
| 1043 | + | </Card> | |
| 1044 | + | ); | |
| 1045 | + | } | |
| 1046 | + | ||
| 981 | 1047 | /** What to do when nobody is working on a project: bring your own harness. */ | |
| 982 | 1048 | export function StartHere({ title }: { title: string }) { | |
| 983 | 1049 | return ( | |
| 10 | 10 | feedLine, | |
| 11 | 11 | harnessName, | |
| 12 | 12 | intentTitle, | |
| 13 | + | awakeAndIdle, | |
| 13 | 14 | liveByIssue, | |
| 14 | 15 | sessionTitle, | |
| 15 | 16 | duration, | |
| ⋯ | |||
| 105 | 106 | assert.equal(span(2 * 3_600_000), "2h"); | |
| 106 | 107 | }); | |
| 107 | 108 | ||
| 109 | + | test("Live now lists sessions heard from lately and folds away the idle, the latest first", () => { | |
| 110 | + | const { awake, idle } = awakeAndIdle([ | |
| 111 | + | session("ses_a", { state: "active" }), | |
| 112 | + | session("ses_b", { state: "idle", title: "Tidy the README", lastSeenAt: "2026-10-12T10:00:00Z" }), | |
| 113 | + | session("ses_c", { state: "idle", lastSeenAt: "2026-10-12T10:10:00Z" }), | |
| 114 | + | ]); | |
| 115 | + | assert.deepEqual(awake.map((one) => one.id), ["ses_a"]); | |
| 116 | + | assert.deepEqual(idle.map((one) => one.id), ["ses_c", "ses_b"]); | |
| 117 | + | // A tab opened and never typed in is in neither; once it says what it is for, it shows. | |
| 118 | + | const opened = session("ses_d", { state: "active", title: null, startedAt: "2026-10-12T10:00:00Z", lastSeenAt: "2026-10-12T10:00:00Z" }); | |
| 119 | + | assert.deepEqual(awakeAndIdle([opened]), { awake: [], idle: [] }); | |
| 120 | + | assert.equal(awakeAndIdle([{ ...opened, lastSeenAt: "2026-10-12T10:00:05Z" }]).awake.length, 1); | |
| 121 | + | assert.equal(awakeAndIdle([{ ...opened, title: "Fix the build" }]).awake.length, 1); | |
| 122 | + | }); | |
| 123 | + | ||
| 108 | 124 | test("live work is grouped by issue, most recent first, with sessions on no issue apart", () => { | |
| 109 | 125 | const { issues, loose } = liveByIssue( | |
| 110 | 126 | [claim(1), claim(2, { createdAt: "2026-10-12T10:20:00Z" }), claim(2, { actor: bo })], | |
| ⋯ | |||
| 162 | 178 | ||
| 163 | 179 | test("a prompt's intent is its first line or sentence, plain, clipped on a word", () => { | |
| 164 | 180 | assert.equal(intentTitle("Make preview builds faster. They time out on big repositories."), "Make preview builds faster"); | |
| 181 | + | // What an editor puts ahead of the prompt is not the prompt. | |
| 182 | + | assert.equal(intentTitle("<ide_opened_file>The user opened a.ts in the IDE.</ide_opened_file>\nWhy is the page crashing?"), "Why is the page crashing?"); | |
| 165 | 183 | assert.equal(intentTitle("Why does `src/app.ts` fail? It worked yesterday."), "Why does src/app.ts fail?"); | |
| 166 | 184 | assert.equal(intentTitle("```js\nconst a = 1;\n```\n## Clean **this** up\nmore"), "Clean this up"); | |
| 167 | 185 | assert.equal(intentTitle("\n\n- look at [the docs](https://docs.g1t.sh) for g1t.mjs"), "Look at the docs for g1t.mjs"); | |
| 80 | 80 | export const SESSION_STATES: Record<SessionState, { label: string; hint: string }> = { | |
| 81 | 81 | active: { label: "Active", hint: "Heard from in the last 15 minutes." }, | |
| 82 | 82 | idle: { label: "Idle", hint: "Not heard from for 15 minutes. It may still come back." }, | |
| 83 | − | ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." }, | |
| 83 | + | ended: { label: "Ended", hint: "Ended, or not heard from for 30 minutes. One that went quiet comes back when it is heard from again." }, | |
| 84 | 84 | }; | |
| 85 | 85 | ||
| 86 | 86 | // ── Session titles ────────────────────────────────────────────────────── | |
| ⋯ | |||
| 91 | 91 | /** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */ | |
| 92 | 92 | const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i; | |
| 93 | 93 | ||
| 94 | + | /** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */ | |
| 95 | + | const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i; | |
| 96 | + | ||
| 94 | 97 | /** | |
| 95 | 98 | * What a prompt is for, as a session's title: its first line or sentence, | |
| 96 | 99 | * without code blocks or markdown, whitespace collapsed, clipped at | |
| ⋯ | |||
| 106 | 109 | text = text | |
| 107 | 110 | .replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n") | |
| 108 | 111 | .replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " "); | |
| 112 | + | // Context an editor puts before what was typed (VS Code's | |
| 113 | + | // <ide_opened_file>, <ide_selection>) is not a turn the harness started. | |
| 114 | + | while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, ""); | |
| 109 | 115 | if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null; | |
| 110 | 116 | text = text | |
| 111 | 117 | .replace(/```[\s\S]*?(```|$)/g, "\n") | |
| ⋯ | |||
| 158 | 164 | | { kind: "branch"; branch: string; sessions: WorkSession[] } | |
| 159 | 165 | | { kind: "none"; sessions: WorkSession[] }; | |
| 160 | 166 | ||
| 167 | + | /** | |
| 168 | + | * Whether a session has done nothing since it started: no title, no issue | |
| 169 | + | * or pull request, never heard from again. Opening a Claude Code tab starts | |
| 170 | + | * one before anything is typed in it, and it is nothing to show yet. | |
| 171 | + | */ | |
| 172 | + | export function startedOnly(session: WorkSession): boolean { | |
| 173 | + | return !session.title && session.issue == null && session.pull == null && session.lastSeenAt === session.startedAt; | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /** | |
| 177 | + | * The sessions Live now lists, and those it folds away: one not heard from | |
| 178 | + | * for 15 minutes (idle) is someone's open tab more often than work going | |
| 179 | + | * on, so it waits under the list until it is heard from or times out. One | |
| 180 | + | * that has done nothing since it started is in neither. | |
| 181 | + | */ | |
| 182 | + | export function awakeAndIdle(sessions: WorkSession[]): { awake: WorkSession[]; idle: WorkSession[] } { | |
| 183 | + | const awake: WorkSession[] = []; | |
| 184 | + | const idle: WorkSession[] = []; | |
| 185 | + | for (const session of sessions) { | |
| 186 | + | if (!startedOnly(session)) (session.state === "idle" ? idle : awake).push(session); | |
| 187 | + | } | |
| 188 | + | idle.sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt)); | |
| 189 | + | return { awake, idle }; | |
| 190 | + | } | |
| 191 | + | ||
| 161 | 192 | const latestSeen = (sessions: WorkSession[]) => Math.max(0, ...sessions.map((session) => Date.parse(session.lastSeenAt))); | |
| 162 | 193 | ||
| 163 | 194 | /** | |
| 34 | 34 | import { ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 35 | 35 | import { Card } from "../../components/ui/card"; | |
| 36 | 36 | import { type LivePull, LiveNowList, StartHere } from "../../components/coordination"; | |
| 37 | − | import { liveByIssue } from "../../lib/coordination"; | |
| 37 | + | import { awakeAndIdle, liveByIssue } from "../../lib/coordination"; | |
| 38 | 38 | import { type Need, ageBuckets, firstPassRate, passRate, rankNeeds, runHealth } from "../../lib/mission"; | |
| 39 | 39 | import { count, duration, percent } from "../../lib/observability"; | |
| 40 | 40 | import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages"; | |
| ⋯ | |||
| 817 | 817 | : null; | |
| 818 | 818 | const addImage = canChange ? `${base}/settings#production-image` : null; | |
| 819 | 819 | const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null; | |
| 820 | − | const working = claims.length + sessions.length > 0; | |
| 820 | + | // Idle sessions fold under the list (LiveNowList); the counts are of the rest. | |
| 821 | + | const { awake, idle } = awakeAndIdle(sessions); | |
| 822 | + | const busy = claims.length + awake.length > 0; | |
| 823 | + | const working = busy || idle.length > 0; | |
| 821 | 824 | // Deployments, checks, releases, claims and sessions show as they happen. | |
| 822 | 825 | useRepoLive({ match: repoPages.overview(loaderData.commit?.hash) }); | |
| 823 | 826 | // Claims lapse and sessions go idle with no event to say so. | |
| 824 | 827 | useRefreshWhile(working, 15_000); | |
| 825 | − | const live = liveByIssue(claims, sessions); | |
| 828 | + | const live = liveByIssue(claims, awake); | |
| 826 | 829 | const moreIssues = live.issues.length - LIVE_SHOWN; | |
| 827 | 830 | ||
| 828 | 831 | return ( | |
| ⋯ | |||
| 892 | 895 | ||
| 893 | 896 | <Panel | |
| 894 | 897 | title="Live now" | |
| 895 | − | icon={<Radio size={14} className={working ? "text-success" : undefined} />} | |
| 898 | + | icon={<Radio size={14} className={busy ? "text-success" : undefined} />} | |
| 896 | 899 | extra={ | |
| 897 | 900 | working ? ( | |
| 898 | 901 | <span className="text-xs text-muted"> | |
| 899 | − | {plural(sessions.length, "session")} · {plural(claims.length, "claim")} | |
| 902 | + | {plural(awake.length, "session")} · {plural(claims.length, "claim")} | |
| 903 | + | {idle.length > 0 && ` · ${idle.length} idle`} | |
| 900 | 904 | </span> | |
| 901 | 905 | ) : null | |
| 902 | 906 | } | |
| 12 | 12 | import { Card } from "../../components/ui/card"; | |
| 13 | 13 | import { PullIcon } from "../../components/work-icons"; | |
| 14 | 14 | import { requireRepo } from "../../lib/access.server"; | |
| 15 | − | import { COORDINATION_TYPES, feedLine } from "../../lib/coordination"; | |
| 15 | + | import { COORDINATION_TYPES, awakeAndIdle, feedLine } from "../../lib/coordination"; | |
| 16 | 16 | import { page } from "../../lib/meta"; | |
| 17 | 17 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 18 | 18 | import { repoPages } from "../../lib/repo-live"; | |
| ⋯ | |||
| 121 | 121 | const { claims, sessions, queue, feed, titles, pulls, landed, defaultBranch } = loaderData; | |
| 122 | 122 | const base = `/${params.owner}/${params.repo}`; | |
| 123 | 123 | const now = Date.now(); | |
| 124 | − | const working = claims.length > 0 || sessions.length > 0; | |
| 124 | + | // Idle sessions fold under the list (LiveNowList); the counts are of the rest. | |
| 125 | + | const { awake, idle } = awakeAndIdle(sessions); | |
| 126 | + | const busy = claims.length + awake.length > 0; | |
| 127 | + | const working = busy || idle.length > 0; | |
| 125 | 128 | // Claims, sessions, overlaps, integration results and merges as they happen. | |
| 126 | 129 | useRepoLive({ match: repoPages.work() }); | |
| 127 | 130 | // Claims lapse and sessions go idle with no event to say so. | |
| ⋯ | |||
| 139 | 142 | </p> | |
| 140 | 143 | ||
| 141 | 144 | <Section | |
| 142 | − | icon={<Radio size={15} className={working ? "text-success" : "text-faint"} />} | |
| 145 | + | icon={<Radio size={15} className={busy ? "text-success" : "text-faint"} />} | |
| 143 | 146 | title="Live now" | |
| 144 | 147 | aside={ | |
| 145 | 148 | working | |
| 146 | − | ? `${claims.length} ${claims.length === 1 ? "claim" : "claims"}, ${sessions.length} ${sessions.length === 1 ? "session" : "sessions"}` | |
| 149 | + | ? `${claims.length} ${claims.length === 1 ? "claim" : "claims"}, ${awake.length} ${awake.length === 1 ? "session" : "sessions"}${idle.length > 0 ? `, ${idle.length} idle` : ""}` | |
| 147 | 150 | : undefined | |
| 148 | 151 | } | |
| 149 | 152 | > | |
Comments
Sign in to comment.