Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Phone app: Sessions and the board

Work has Items and Board, and its header opens Sessions, as does the top of More: everything going on, filtered, and each session with what it worked on, its subagents, what it touched and its timeline.

G1T-Session:
ses_01m560bz6eeqc8v1hrrha03pap
syntaqxcommitted Parentee49872Browse files
15 files+1409−340/15 viewed
+2−2
1616 * The signed-in app. Its bar is the site's phone bar
1717 * (apps/web/app/components/mobile.tsx): Home, Work, Chat, Notifications
1818 * and More. Work and Chat have their screens; Home and Notifications say
19− * so and open the site. Code is at the top of More, for those who use it,
20− * and your account is under it.
19+ * so and open the site. Sessions is at the top of More (and in Work's
20+ * header), then Code, for those who use it, and your account is under it.
2121 */
2222 export default function AppLayout() {
2323 return (
+12−6
33 import { hasCodeAccess } from "@g1t/contracts";
44 import { Stack, router } from "expo-router";
55 import * as WebBrowser from "expo-web-browser";
6−import { Bell, BellRing, Check, Code2, ExternalLink, LogOut } from "lucide-react-native";
6+import { Activity, Bell, BellRing, Check, Code2, ExternalLink, LogOut } from "lucide-react-native";
77 import { Linking, View } from "react-native";
88
99 import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
1212 import { useSignedIn } from "@/lib/session";
1313 import { space, useColors } from "@/theme";
1414
15−/** More: Code, notifications on this phone, your workspaces, how you signed in, and signing out. */
15+/** More: Sessions, Code, notifications on this phone, your workspaces, how you signed in, and signing out. */
1616 export default function More() {
1717 const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
1818 const code = !workspace || hasCodeAccess(workspace);
4444 </View>
4545 </View>
4646
47− {code ? (
48− <View>
47+ <View>
48+ <Row
49+ leading={<Activity size={22} color={colors.accent} />}
50+ title="Sessions"
51+ subtitle="What is cooking: the agents at work across the workspace"
52+ onPress={() => router.push({ pathname: "/work/sessions", params: workspace ? { workspace: workspace.slug } : {} })}
53+ />
54+ {code ? (
4955 <Row
5056 leading={<Code2 size={22} color={colors.accent} />}
5157 title="Code"
5258 subtitle="Repositories, their pull requests and changes"
5359 onPress={() => router.push("/code")}
5460 />
55− </View>
56− ) : null}
61+ ) : null}
62+ </View>
5763
5864 <View>
5965 <Row
+3−0
11 import { TabStack } from "@/components/stack";
22
3+/** A link straight to a screen under Work (Sessions, an item) still has the list to go back to. */
4+export const unstable_settings = { initialRouteName: "index" };
5+
36 export default TabStack;
+57−20
1−import { Stack, router, useLocalSearchParams, useNavigation } from "expo-router";
2−import { Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native";
3−import { useEffect, useMemo, useState } from "react";
1+import { Stack, router, useFocusEffect, useLocalSearchParams, useNavigation } from "expo-router";
2+import { Activity, Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native";
3+import { useCallback, useEffect, useMemo, useState } from "react";
44 import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native";
55
6+import { Board } from "@/components/board";
67 import { Sheet } from "@/components/chat/sheet";
78 import { Segments } from "@/components/segments";
89 import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
1213 import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work";
1314 import { radius, space, useColors } from "@/theme";
1415
15−type Params = { workspace?: string; state?: string; label?: string; milestone?: string; assignee?: string; repo?: string; role?: string };
16+type Params = { workspace?: string; view?: string; state?: string; label?: string; milestone?: string; assignee?: string; repo?: string; role?: string };
17+
18+/** The list of items, or the board. */
19+type Shown = "items" | "board";
1620
1721 /** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */
1822 function filterOf(params: Params): WorkFilter {
3135
3236 /**
3337 * Work: the workspace's work items, newest first, for every member,
34− * Code or not. Filters are in a sheet (the ⚙ in the header, the chips
35− * above the list, or Work in the bar again); pull down to read again,
36− * and the list loads more as it nears its end.
38+ * Code or not, or its board (`/acme/-/work/board`). Filters are in a
39+ * sheet (the ⚙ in the header, the chips above the list, or Work in the
40+ * bar again); pull down to read again, and the list loads more as it
41+ * nears its end. Sessions opens from the header.
3742 */
3843 export default function WorkList() {
3944 const params = useLocalSearchParams<Params>();
4449 const [filter, setFilter] = useState<WorkFilter>(linked);
4550 useEffect(() => setFilter(linked), [linked]);
4651 const [filtering, setFiltering] = useState(false);
47− const items = useWorkItems(slug, filter);
52+ const linkedView: Shown = params.view === "board" ? "board" : "items";
53+ const [view, setView] = useState<Shown>(linkedView);
54+ useEffect(() => setView(linkedView), [linkedView]);
55+ const [focused, setFocused] = useState(false);
56+ useFocusEffect(
57+ useCallback(() => {
58+ setFocused(true);
59+ return () => setFocused(false);
60+ }, []),
61+ );
62+ const items = useWorkItems(view === "items" ? slug : undefined, filter);
4863 const milestones = useMilestones(filter.milestone ? slug : undefined);
4964
5065 // Work in the bar again, on the list itself, opens its filters, as the site's bar opens Work's menu.
5368 const tabs = navigation.getParent();
5469 if (!tabs) return;
5570 return tabs.addListener("tabPress" as never, () => {
56− if (navigation.isFocused()) setFiltering(true);
71+ if (navigation.isFocused() && view === "items") setFiltering(true);
5772 });
58− }, [navigation]);
73+ }, [navigation, view]);
5974
6075 const active = [
6176 filter.assignee ? { key: "assignee", label: filter.assignee === me.username ? "Assigned to me" : `@${filter.assignee}` } : null,
6984 options={{
7085 title: "Work",
7186 headerRight: () => (
72− <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}>
73− <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} />
74− </Pressable>
87+ <View style={styles.actions}>
88+ {slug ? (
89+ <Pressable accessibilityLabel="Sessions" hitSlop={10} onPress={() => router.push({ pathname: "/work/sessions", params: { workspace: slug } })}>
90+ <Activity size={22} color={colors.fg} />
91+ </Pressable>
92+ ) : null}
93+ {view === "items" ? (
94+ <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}>
95+ <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} />
96+ </Pressable>
97+ ) : null}
98+ </View>
7599 ),
76100 }}
77101 />
91115 {header}
92116 <View style={[styles.bar, { borderBottomColor: colors.line }]}>
93117 <Segments
94− value={filter.state}
95− onChange={(state) => setFilter({ ...filter, state })}
118+ value={view}
119+ onChange={setView}
96120 options={[
97− { value: "open", label: "Open" },
98− { value: "closed", label: "Closed" },
99− { value: "all", label: "All" },
121+ { value: "items", label: "Items" },
122+ { value: "board", label: "Board" },
100123 ]}
101124 />
102− {active.length ? (
125+ {view === "items" ? (
126+ <Segments
127+ value={filter.state}
128+ onChange={(state) => setFilter({ ...filter, state })}
129+ options={[
130+ { value: "open", label: "Open" },
131+ { value: "closed", label: "Closed" },
132+ { value: "all", label: "All" },
133+ ]}
134+ />
135+ ) : null}
136+ {view === "items" && active.length ? (
103137 <View style={styles.chips}>
104138 {active.map((one) => (
105139 <Chip
114148 ) : null}
115149 </View>
116150
117− {items.isPending ? (
151+ {view === "board" ? (
152+ <Board workspace={slug} focused={focused} />
153+ ) : items.isPending ? (
118154 <Loading />
119155 ) : items.error ? (
120156 <ErrorView error={items.error} retry={() => items.refetch()} />
290326 const styles = StyleSheet.create({
291327 bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
292328 chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
329+ actions: { flexDirection: "row", alignItems: "center", gap: space.lg },
293330 fab: {
294331 position: "absolute",
295332 right: space.lg,
+250−0
1+import { Stack, router, useFocusEffect, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { ExternalLink, FileCode, GitPullRequest, ListTodo, MessageSquareText, Sparkles, StickyNote, Terminal, User, Wrench } from "lucide-react-native";
4+import type { LucideIcon } from "lucide-react-native";
5+import { useCallback, useState } from "react";
6+import { Pressable, RefreshControl, ScrollView, StyleSheet, View } from "react-native";
7+
8+import { StateDot, openItem, openPull, stateName, useCode } from "@/components/sessions";
9+import { Badge, Button, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
10+import { Chip, repoName } from "@/components/work";
11+import { siteUrl } from "@/lib/server";
12+import { useSignedIn } from "@/lib/session";
13+import { type ApiSessionEntry, type ApiSessionLink, type EntryKind, harnessName, sessionTitle, subagentTree, touchedFiles } from "@/lib/sessions";
14+import { ago, clock } from "@/lib/time";
15+import { useWorkspaceSession } from "@/lib/work";
16+import { radius, space, useColors } from "@/theme";
17+
18+/** How many files show before "Show all". */
19+const FILES = 20;
20+
21+/**
22+ * One session: who runs it in what, where, and its state; the work item
23+ * and pull request it is on; its subagents as a tree; the files it
24+ * touched; and what it recorded, step by step. While it is live it is
25+ * read again every 15 s.
26+ */
27+export default function SessionScreen() {
28+ const params = useLocalSearchParams<{ id: string; workspace?: string }>();
29+ const { server, workspace: current } = useSignedIn();
30+ const workspace = params.workspace ?? current?.slug ?? "";
31+ const colors = useColors();
32+ const code = useCode();
33+ const [focused, setFocused] = useState(false);
34+ useFocusEffect(
35+ useCallback(() => {
36+ setFocused(true);
37+ return () => setFocused(false);
38+ }, []),
39+ );
40+ const query = useWorkspaceSession(workspace, params.id, focused);
41+ const [allFiles, setAllFiles] = useState(false);
42+ const sitePath = `/${workspace}/-/sessions/${params.id}`;
43+
44+ const title = (
45+ <Stack.Screen
46+ options={{
47+ title: "Session",
48+ headerRight: () => (
49+ <Pressable accessibilityLabel="Open on g1t" hitSlop={10} onPress={() => void WebBrowser.openBrowserAsync(siteUrl(server, sitePath))}>
50+ <ExternalLink size={20} color={colors.fg} />
51+ </Pressable>
52+ ),
53+ }}
54+ />
55+ );
56+ if (query.isPending || query.error || !query.detail) {
57+ return (
58+ <Screen>
59+ {title}
60+ {query.error ? <ErrorView error={query.error} retry={() => query.refetch()} /> : <Loading />}
61+ </Screen>
62+ );
63+ }
64+
65+ const { session, repo, children = [], touched, skills = [], root, limited } = query.detail;
66+ const links: ApiSessionLink[] = query.detail.links?.length
67+ ? query.detail.links
68+ : [
69+ ...(session.item ? [{ kind: "item" as const, number: session.item, title: "", state: "", via: ["attached"], session_ids: [session.id] }] : []),
70+ ...(session.pull ? [{ kind: "pull" as const, number: session.pull, title: "", state: "", via: ["attached"], session_ids: [session.id] }] : []),
71+ ];
72+ const tree = subagentTree(session.id, children);
73+ const files = touchedFiles(touched);
74+ const facts = [harnessName(session.harness), `@${session.actor.username}`, session.model].filter(Boolean).join(" · ");
75+ const openSession = (id: string) => router.push({ pathname: "/work/sessions/[id]", params: { id, workspace } });
76+
77+ return (
78+ <Screen>
79+ {title}
80+ <ScrollView
81+ contentContainerStyle={styles.content}
82+ refreshControl={<RefreshControl refreshing={query.isRefetching && !query.isFetchingNextPage} onRefresh={() => query.refetch()} tintColor={colors.accent} />}
83+ >
84+ <View style={{ gap: space.sm }}>
85+ <Text size="heading" selectable>
86+ {sessionTitle(session)}
87+ </Text>
88+ <View style={styles.wrap}>
89+ <StateDot state={session.state} />
90+ <Text size="small" style={{ fontWeight: "600", color: session.state === "active" ? colors.success : session.state === "idle" ? colors.warn : colors.muted }}>
91+ {stateName(session.state)}
92+ </Text>
93+ <Text size="small" tone="muted">
94+ {facts}
95+ </Text>
96+ </View>
97+ <Text size="small" tone="muted">
98+ {repoName(repo, workspace)}
99+ {session.branch ? ` · ${session.branch}` : ""}
100+ </Text>
101+ <Text size="small" tone="muted">
102+ Started {ago(session.started_at)} · last heard from {ago(session.last_seen_at)}
103+ {session.ended_at ? ` · ended ${ago(session.ended_at)}${session.end_reason ? ` (${session.end_reason.replace(/_/g, " ")})` : ""}` : ""}
104+ </Text>
105+ {session.agent_type || session.task ? (
106+ <Text size="small" tone="muted">
107+ {[session.agent_type, session.task].filter(Boolean).join(": ")}
108+ </Text>
109+ ) : null}
110+ {root && root.id !== session.id ? (
111+ <View style={styles.wrap}>
112+ <Text size="small" tone="muted">
113+ A subagent of
114+ </Text>
115+ <Chip label={sessionTitle(root)} onPress={() => openSession(root.id)} />
116+ </View>
117+ ) : null}
118+ {skills.length ? (
119+ <View style={styles.wrap}>
120+ {skills.map((skill) => (
121+ <Chip key={`${skill.session_id}:${skill.name}`} label={skill.uses > 1 ? `${skill.name} ×${skill.uses}` : skill.name} leading={<Sparkles size={12} color={colors.muted} />} />
122+ ))}
123+ </View>
124+ ) : null}
125+ {limited ? (
126+ <Text size="small" tone="faint">
127+ You are not a member of this workspace: you see that it runs, who runs it and what it is tied to, nothing more.
128+ </Text>
129+ ) : null}
130+ </View>
131+
132+ {links.length ? (
133+ <View style={{ marginHorizontal: -space.lg }}>
134+ <SectionHeader title="Working on" />
135+ {links.map((link) => (
136+ <Row
137+ key={`${link.kind}:${link.number}`}
138+ leading={link.kind === "item" ? <ListTodo size={20} color={colors.muted} /> : <GitPullRequest size={20} color={colors.muted} />}
139+ title={`#${link.number}${link.title ? ` ${link.title}` : ""}`}
140+ subtitle={[link.kind === "item" ? "Work item" : "Pull request", link.state, link.via.join(", ")].filter(Boolean).join(" · ")}
141+ onPress={() => (link.kind === "item" ? openItem(workspace, link.number) : openPull(repo, link.number, code))}
142+ />
143+ ))}
144+ </View>
145+ ) : null}
146+
147+ {tree.length ? (
148+ <View style={{ marginHorizontal: -space.lg }}>
149+ <SectionHeader title={`Subagents · ${tree.length}`} />
150+ {tree.map(({ session: child, depth }) => (
151+ <View key={child.id} style={{ paddingLeft: (depth - 1) * space.lg }}>
152+ <Row
153+ leading={<StateDot state={child.state} />}
154+ title={sessionTitle(child)}
155+ subtitle={[child.agent_type, child.state === "ended" ? `ended ${ago(child.ended_at ?? child.last_seen_at)}` : `heard from ${ago(child.last_seen_at)}`].filter(Boolean).join(" · ")}
156+ onPress={() => openSession(child.id)}
157+ />
158+ </View>
159+ ))}
160+ </View>
161+ ) : null}
162+
163+ {files.length ? (
164+ <View style={{ gap: space.xs }}>
165+ <View style={{ marginHorizontal: -space.lg }}>
166+ <SectionHeader title={`Touched · ${files.length}`} />
167+ </View>
168+ {(allFiles ? files : files.slice(0, FILES)).map((path) => (
169+ <View key={path} style={styles.file}>
170+ <FileCode size={14} color={colors.faint} />
171+ <Text size="mono" tone="muted" numberOfLines={1} style={{ flex: 1 }}>
172+ {path}
173+ </Text>
174+ </View>
175+ ))}
176+ {files.length > FILES && !allFiles ? (
177+ <Pressable onPress={() => setAllFiles(true)} hitSlop={8}>
178+ <Text size="small" tone="accent">
179+ Show all {files.length}
180+ </Text>
181+ </Pressable>
182+ ) : null}
183+ </View>
184+ ) : null}
185+
186+ <View style={{ gap: space.sm }}>
187+ <View style={{ marginHorizontal: -space.lg }}>
188+ <SectionHeader title="Timeline" />
189+ </View>
190+ {query.entries.length === 0 ? (
191+ <Text tone="muted">{limited ? "Its steps are not shown to you." : "Nothing recorded yet."}</Text>
192+ ) : (
193+ query.entries.map((entry) => <Entry key={entry.seq} entry={entry} />)
194+ )}
195+ {query.hasNextPage ? (
196+ <Button title="Show later steps" variant="secondary" busy={query.isFetchingNextPage} onPress={() => void query.fetchNextPage()} />
197+ ) : null}
198+ </View>
199+ </ScrollView>
200+ </Screen>
201+ );
202+}
203+
204+const ENTRY_KINDS: Record<EntryKind, { label: string; icon: LucideIcon }> = {
205+ prompt: { label: "Prompt", icon: User },
206+ message: { label: "Message", icon: MessageSquareText },
207+ tool_call: { label: "Tool", icon: Wrench },
208+ tool_result: { label: "Result", icon: Terminal },
209+ note: { label: "Note", icon: StickyNote },
210+ skill: { label: "Skill", icon: Sparkles },
211+};
212+
213+/** One recorded step: its kind, tool and time, then its text, a few lines until tapped. */
214+function Entry({ entry }: { entry: ApiSessionEntry }) {
215+ const colors = useColors();
216+ const [open, setOpen] = useState(false);
217+ const kind = ENTRY_KINDS[entry.kind] ?? { label: entry.kind, icon: StickyNote };
218+ const Icon = kind.icon;
219+ const result = entry.kind === "tool_result" || entry.kind === "tool_call";
220+ return (
221+ <Pressable onPress={() => setOpen(!open)} style={[styles.entry, { borderColor: colors.line, backgroundColor: entry.kind === "prompt" ? colors.raised : colors.surface }]}>
222+ <View style={styles.wrap}>
223+ <Icon size={13} color={entry.kind === "prompt" ? colors.accent : colors.muted} />
224+ <Text size="small" style={{ fontWeight: "600", color: colors.fgSoft }}>
225+ {kind.label}
226+ </Text>
227+ {entry.tool ? <Badge label={entry.tool} /> : null}
228+ <Text size="small" tone="faint" style={{ marginLeft: "auto" }}>
229+ {clock(entry.at)}
230+ </Text>
231+ </View>
232+ {entry.withheld ? (
233+ <Text size="small" tone="faint" style={{ fontStyle: "italic" }}>
234+ Not shown to you.
235+ </Text>
236+ ) : entry.text ? (
237+ <Text size={result ? "mono" : "small"} tone={result ? "muted" : "default"} numberOfLines={open ? undefined : 4} selectable={open}>
238+ {entry.text}
239+ </Text>
240+ ) : null}
241+ </Pressable>
242+ );
243+}
244+
245+const styles = StyleSheet.create({
246+ content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl },
247+ wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
248+ file: { flexDirection: "row", alignItems: "center", gap: space.sm },
249+ entry: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.xs },
250+});
+232−0
1+import { Stack, useFocusEffect, useLocalSearchParams } from "expo-router";
2+import { Activity, Check as CheckIcon, SlidersHorizontal, X } from "lucide-react-native";
3+import { useCallback, useEffect, useMemo, useState } from "react";
4+import { Pressable, RefreshControl, ScrollView, SectionList, StyleSheet, View, useWindowDimensions } from "react-native";
5+
6+import { Sheet } from "@/components/chat/sheet";
7+import { Segments } from "@/components/segments";
8+import { SessionCard } from "@/components/sessions";
9+import { Button, Empty, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
10+import { Chip } from "@/components/work";
11+import { useSignedIn } from "@/lib/session";
12+import { type ApiWorkspaceSession, type SessionsFilter, isLive } from "@/lib/sessions";
13+import { useWorkspaceSessions } from "@/lib/work";
14+import { space, useColors } from "@/theme";
15+
16+type Shown = "all" | "live" | "ended";
17+type Params = { workspace?: string; state?: string; person?: string; repo?: string; item?: string };
18+
19+/** The filter a link names (`/acme/-/sessions?state=live&repo=web`): every session when it names none. */
20+function filterOf(params: Params): SessionsFilter {
21+ const state = params.state === "live" || params.state === "ended" ? params.state : "recent";
22+ const item = Number(params.item);
23+ return { state, person: params.person || undefined, repo: params.repo || undefined, item: Number.isInteger(item) && item > 0 ? item : undefined };
24+}
25+
26+const SHOWN: Record<Shown, SessionsFilter["state"]> = { all: "recent", live: "live", ended: "ended" };
27+
28+/**
29+ * Sessions: what is cooking across the workspace. The ones going now
30+ * first, then those that ended lately, each with who runs it in what,
31+ * where, and the work item and pull request it is on. Pull down to read
32+ * again; while one is live it is read again every 15 s.
33+ */
34+export default function Sessions() {
35+ const params = useLocalSearchParams<Params>();
36+ const { workspace: current, me } = useSignedIn();
37+ const workspace = params.workspace ?? current?.slug;
38+ const colors = useColors();
39+ const linked = useMemo(() => filterOf(params), [params.state, params.person, params.repo, params.item]);
40+ const [filter, setFilter] = useState<SessionsFilter>(linked);
41+ useEffect(() => setFilter(linked), [linked]);
42+ const [filtering, setFiltering] = useState(false);
43+ const [focused, setFocused] = useState(false);
44+ useFocusEffect(
45+ useCallback(() => {
46+ setFocused(true);
47+ return () => setFocused(false);
48+ }, []),
49+ );
50+ const sessions = useWorkspaceSessions(workspace, filter, focused);
51+ const shown: Shown = filter.state === "live" ? "live" : filter.state === "ended" ? "ended" : "all";
52+
53+ const active = [
54+ filter.person ? { key: "person", label: filter.person === me.username ? "Started by me" : `@${filter.person}` } : null,
55+ filter.repo ? { key: "repo", label: filter.repo } : null,
56+ filter.item ? { key: "item", label: `#${filter.item}` } : null,
57+ ].filter((one): one is { key: "person" | "repo" | "item"; label: string } => one !== null);
58+
59+ const header = (
60+ <Stack.Screen
61+ options={{
62+ title: "Sessions",
63+ headerRight: () => (
64+ <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}>
65+ <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} />
66+ </Pressable>
67+ ),
68+ }}
69+ />
70+ );
71+
72+ if (!workspace) {
73+ return (
74+ <Screen>
75+ {header}
76+ <Empty icon={Activity} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
77+ </Screen>
78+ );
79+ }
80+
81+ const all = sessions.data?.sessions ?? [];
82+ const sections: { title: string; data: ApiWorkspaceSession[] }[] =
83+ shown === "all"
84+ ? [
85+ { title: "Live now", data: all.filter(isLive) },
86+ { title: "Recent", data: all.filter((one) => !isLive(one)) },
87+ ].filter((section) => section.data.length > 0)
88+ : all.length
89+ ? [{ title: shown === "live" ? "Live now" : "Recent", data: all }]
90+ : [];
91+
92+ return (
93+ <Screen>
94+ {header}
95+ <View style={[styles.bar, { borderBottomColor: colors.line }]}>
96+ <Segments
97+ value={shown}
98+ onChange={(next) => setFilter({ ...filter, state: SHOWN[next] })}
99+ options={[
100+ { value: "all", label: "Everything" },
101+ { value: "live", label: "Live" },
102+ { value: "ended", label: "Recent" },
103+ ]}
104+ />
105+ {active.length ? (
106+ <View style={styles.chips}>
107+ {active.map((one) => (
108+ <Chip
109+ key={one.key}
110+ label={one.label}
111+ tone="accent"
112+ leading={<X size={12} color={colors.accent} />}
113+ onPress={() => setFilter({ ...filter, [one.key]: undefined })}
114+ />
115+ ))}
116+ </View>
117+ ) : null}
118+ </View>
119+
120+ {sessions.isPending ? (
121+ <Loading />
122+ ) : sessions.error ? (
123+ <ErrorView error={sessions.error} retry={() => sessions.refetch()} />
124+ ) : (
125+ <SectionList
126+ sections={sections}
127+ keyExtractor={(session) => `${session.kind}:${session.id}`}
128+ stickySectionHeadersEnabled={false}
129+ refreshControl={<RefreshControl refreshing={sessions.isRefetching} onRefresh={() => sessions.refetch()} tintColor={colors.accent} />}
130+ contentContainerStyle={{ paddingBottom: space.xl, flexGrow: 1 }}
131+ renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
132+ renderItem={({ item }) => <SessionCard session={item} workspace={workspace} />}
133+ ListEmptyComponent={
134+ <Empty
135+ icon={Activity}
136+ title={active.length ? "Nothing matches" : shown === "live" ? "Nothing running now" : "No sessions yet"}
137+ body={
138+ active.length
139+ ? "Try fewer filters."
140+ : "A session is one run of an agent on a repository: someone's Claude Code or Codex, or g1t's own on a pull request."
141+ }
142+ />
143+ }
144+ />
145+ )}
146+
147+ <Filters
148+ visible={filtering}
149+ filter={filter}
150+ me={me.username}
151+ repos={(sessions.data?.repos ?? []).map((repo) => repo.path.split("/", 2)[1] ?? repo.path)}
152+ onClose={() => setFiltering(false)}
153+ onChange={setFilter}
154+ />
155+ </Screen>
156+ );
157+}
158+
159+/** The list's filters, in a sheet: started by you, and the repository. */
160+function Filters({
161+ visible,
162+ filter,
163+ me,
164+ repos,
165+ onClose,
166+ onChange,
167+}: {
168+ visible: boolean;
169+ filter: SessionsFilter;
170+ me: string;
171+ repos: string[];
172+ onClose: () => void;
173+ onChange: (filter: SessionsFilter) => void;
174+}) {
175+ const colors = useColors();
176+ const { height } = useWindowDimensions();
177+ const mine = filter.person === me;
178+ return (
179+ <Sheet visible={visible} onClose={onClose}>
180+ <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}>
181+ <Text size="heading">Filters</Text>
182+ <View style={{ marginHorizontal: -space.lg }}>
183+ <Row
184+ title="Started by me"
185+ chevron={false}
186+ trailing={
187+ <View style={[styles.check, { borderColor: mine ? colors.accent : colors.lineStrong, backgroundColor: mine ? colors.accent : "transparent" }]}>
188+ {mine ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null}
189+ </View>
190+ }
191+ onPress={() => onChange({ ...filter, person: mine ? undefined : me })}
192+ />
193+ </View>
194+ <View style={{ gap: space.sm }}>
195+ <Text size="small" tone="muted">
196+ Repository
197+ </Text>
198+ {repos.length ? (
199+ <View style={styles.chips}>
200+ {repos.map((name) => (
201+ <Chip
202+ key={name}
203+ label={name}
204+ tone={filter.repo === name ? "accent" : "neutral"}
205+ onPress={() => onChange({ ...filter, repo: filter.repo === name ? undefined : name })}
206+ />
207+ ))}
208+ </View>
209+ ) : (
210+ <Text size="small" tone="faint">
211+ No repositories you can see
212+ </Text>
213+ )}
214+ </View>
215+ <View style={{ flexDirection: "row", gap: space.sm }}>
216+ <View style={{ flex: 1 }}>
217+ <Button title="Clear" variant="secondary" onPress={() => onChange({ state: filter.state })} />
218+ </View>
219+ <View style={{ flex: 1 }}>
220+ <Button title="Done" onPress={onClose} />
221+ </View>
222+ </View>
223+ </ScrollView>
224+ </Sheet>
225+ );
226+}
227+
228+const styles = StyleSheet.create({
229+ bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
230+ chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
231+ check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" },
232+});
+107−0
1+import { router } from "expo-router";
2+import { ListTodo, Users } from "lucide-react-native";
3+import { useState } from "react";
4+import { Pressable, RefreshControl, SectionList, StyleSheet, View } from "react-native";
5+
6+import { Empty, ErrorView, Loading, SectionHeader, Text } from "@/components/ui";
7+import { Chip, ItemIcon, RepoChips } from "@/components/work";
8+import { type Card, STAGES, type Stage, stageName } from "@/lib/board";
9+import { harnessName } from "@/lib/sessions";
10+import { ago } from "@/lib/time";
11+import { type ApiWorkItem, useBoard } from "@/lib/work";
12+import { space, useColors } from "@/theme";
13+
14+/**
15+ * Work's board on a phone: the work as it moves, a stage under the next
16+ * (Backlog, In progress, In review, Landing, Done), and chips above to
17+ * show one stage alone. Pull down to read again; while a session is live
18+ * and the board is in view, who is on what is read again every 15 s.
19+ */
20+export function Board({ workspace, focused }: { workspace: string; focused: boolean }) {
21+ const colors = useColors();
22+ const board = useBoard(workspace, focused);
23+ const [stage, setStage] = useState<Stage | null>(null);
24+
25+ if (board.isPending) return <Loading />;
26+ if (board.error) return <ErrorView error={board.error} retry={() => void board.refetch()} />;
27+
28+ const total = STAGES.reduce((sum, one) => sum + board.board[one].length, 0);
29+ const sections = (stage ? [stage] : STAGES)
30+ .map((one) => ({ stage: one, title: `${stageName(one)} · ${board.board[one].length}`, data: board.board[one] }))
31+ .filter((section) => stage !== null || section.data.length > 0);
32+
33+ return (
34+ <SectionList
35+ sections={sections}
36+ keyExtractor={(card) => card.item.id}
37+ stickySectionHeadersEnabled={false}
38+ refreshControl={<RefreshControl refreshing={board.isRefetching} onRefresh={() => void board.refetch()} tintColor={colors.accent} />}
39+ contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }}
40+ ListHeaderComponent={
41+ <View style={[styles.stages, { borderBottomColor: colors.line }]}>
42+ <Chip label={`All ${total}`} tone={stage === null ? "accent" : "neutral"} onPress={() => setStage(null)} />
43+ {STAGES.map((one) => (
44+ <Chip
45+ key={one}
46+ label={`${stageName(one)} ${board.board[one].length}`}
47+ tone={stage === one ? "accent" : "neutral"}
48+ onPress={() => setStage(stage === one ? null : one)}
49+ />
50+ ))}
51+ </View>
52+ }
53+ renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
54+ renderSectionFooter={({ section }) =>
55+ section.data.length === 0 ? (
56+ <Text size="small" tone="faint" style={{ paddingHorizontal: space.lg }}>
57+ {section.stage === "done" ? "Nothing closed in the last two weeks." : "Nothing here."}
58+ </Text>
59+ ) : null
60+ }
61+ renderItem={({ item }) => <BoardCard card={item} />}
62+ ListEmptyComponent={<Empty icon={ListTodo} title="No work yet" body="Work items are any tracked work: a feature, a bug, a chore." />}
63+ />
64+ );
65+}
66+
67+/** A work item on the board: its number and title, who is on it, and its repositories. */
68+function BoardCard({ card }: { card: Card<ApiWorkItem> }) {
69+ const colors = useColors();
70+ const { item, people } = card;
71+ const who = people.map((one) => (one.harness ? `@${one.name} (${harnessName(one.harness)})` : `@${one.name}`)).join(", ");
72+ return (
73+ <Pressable
74+ onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })}
75+ style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
76+ >
77+ <View style={{ paddingTop: 1 }}>
78+ <ItemIcon item={item} size={18} />
79+ </View>
80+ <View style={{ flex: 1, gap: 6 }}>
81+ <Text numberOfLines={2} style={{ fontWeight: "600" }}>
82+ {item.title}
83+ </Text>
84+ {who ? (
85+ <View style={styles.meta}>
86+ <Users size={13} color={colors.faint} />
87+ <Text size="small" tone="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
88+ {who}
89+ </Text>
90+ </View>
91+ ) : null}
92+ <View style={styles.meta}>
93+ <Text size="small" tone="muted">
94+ #{item.number} · {ago(item.closed_at ?? item.updated_at)}
95+ </Text>
96+ <RepoChips repos={item.repos} workspace={item.workspace} />
97+ </View>
98+ </View>
99+ </Pressable>
100+ );
101+}
102+
103+const styles = StyleSheet.create({
104+ stages: { flexDirection: "row", flexWrap: "wrap", gap: 6, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth },
105+ row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth },
106+ meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
107+});
+130−0
1+import { hasCodeAccess } from "@g1t/contracts";
2+import { router } from "expo-router";
3+import { Bot, GitPullRequest, ListTodo, Network, Sparkles, StickyNote, Terminal } from "lucide-react-native";
4+import type { LucideIcon } from "lucide-react-native";
5+import { Pressable, StyleSheet, View } from "react-native";
6+
7+import { Text } from "@/components/ui";
8+import { Chip, repoName } from "@/components/work";
9+import { sessionRoute } from "@/lib/links";
10+import { useSignedIn } from "@/lib/session";
11+import { type ApiWorkspaceSession, type SessionKind, type SessionState, listTitle, runsIn } from "@/lib/sessions";
12+import { ago } from "@/lib/time";
13+import { space, useColors } from "@/theme";
14+
15+const KIND_ICONS: Record<SessionKind, LucideIcon> = { harness: Terminal, hosted: Sparkles, agent: Bot, recorded: StickyNote };
16+
17+/** What a session runs in, as an icon. */
18+export function KindIcon({ kind, size = 14, color }: { kind: SessionKind; size?: number; color?: string }) {
19+ const colors = useColors();
20+ const Icon = KIND_ICONS[kind];
21+ return <Icon size={size} color={color ?? colors.muted} />;
22+}
23+
24+const STATE_NAMES: Record<SessionState, string> = { active: "Live", idle: "Idle", ended: "Ended" };
25+
26+export function stateName(state: SessionState): string {
27+ return STATE_NAMES[state];
28+}
29+
30+/** A session's state as a dot: live, idle, or ended. */
31+export function StateDot({ state, size = 10 }: { state: SessionState; size?: number }) {
32+ const colors = useColors();
33+ const color = state === "active" ? colors.success : state === "idle" ? colors.warn : colors.faint;
34+ return <View accessibilityLabel={stateName(state)} style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
35+}
36+
37+/** Whether the person signed in has Code in the workspace, so a pull request opens in the app. */
38+export function useCode(): boolean {
39+ const { workspace } = useSignedIn();
40+ return !workspace || hasCodeAccess(workspace);
41+}
42+
43+export function openItem(workspace: string, number: number) {
44+ router.push({ pathname: "/work/[number]", params: { number: String(number), workspace } });
45+}
46+
47+/** A pull request: its screen in Code, or its page on the site without Code. */
48+export function openPull(repo: string, number: number, code: boolean, session = false) {
49+ const [owner, name] = repo.split("/", 2);
50+ if (!owner || !name) return;
51+ if (code) router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(number) } });
52+ else router.push({ pathname: "/open", params: { path: `/${owner}/${name}/pull/${number}${session ? "/session" : ""}` } });
53+}
54+
55+/**
56+ * One session in a list: what it is, who runs it in what, where, the work
57+ * item and pull request it is on, and when it was last heard from.
58+ */
59+export function SessionCard({ session, workspace }: { session: ApiWorkspaceSession; workspace: string }) {
60+ const colors = useColors();
61+ const code = useCode();
62+ const route = sessionRoute(session, workspace, code);
63+ const where = [repoName(session.repo, workspace), session.branch].filter(Boolean).join(" · ");
64+ return (
65+ <Pressable
66+ onPress={route ? () => router.push(route as never) : undefined}
67+ disabled={!route}
68+ style={({ pressed }) => [styles.card, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
69+ >
70+ <View style={{ paddingTop: 6 }}>
71+ <StateDot state={session.state} />
72+ </View>
73+ <View style={{ flex: 1, gap: 6 }}>
74+ <View style={{ flexDirection: "row", gap: space.sm }}>
75+ <Text numberOfLines={2} style={{ fontWeight: "600", flex: 1 }}>
76+ {listTitle(session)}
77+ </Text>
78+ <Text size="small" tone="faint">
79+ {ago(session.last_activity_at)}
80+ </Text>
81+ </View>
82+ <View style={styles.meta}>
83+ <KindIcon kind={session.kind} />
84+ <Text size="small" tone="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
85+ {runsIn(session)}
86+ {session.actor ? ` · @${session.actor}` : ""}
87+ {where ? ` · ${where}` : ""}
88+ </Text>
89+ </View>
90+ {session.step ? (
91+ <Text size="small" tone="muted" numberOfLines={1}>
92+ {session.step}
93+ </Text>
94+ ) : null}
95+ {session.item || session.pull || session.subagents ? (
96+ <View style={styles.meta}>
97+ {session.item ? (
98+ <Chip
99+ label={`#${session.item}${session.item_title ? ` ${session.item_title}` : ""}`}
100+ leading={<ListTodo size={12} color={colors.muted} />}
101+ onPress={() => openItem(workspace, session.item!)}
102+ />
103+ ) : null}
104+ {session.pull ? (
105+ <Chip
106+ label={`#${session.pull}${session.pull_status ? ` · ${session.pull_status}` : ""}`}
107+ leading={<GitPullRequest size={12} color={session.pull_status === "merged" ? colors.merged : session.pull_status === "open" ? colors.success : colors.muted} />}
108+ onPress={() => openPull(session.repo, session.pull!, code)}
109+ />
110+ ) : null}
111+ {session.subagents ? (
112+ <View style={styles.count}>
113+ <Network size={13} color={colors.faint} />
114+ <Text size="small" tone="faint">
115+ {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"}
116+ </Text>
117+ </View>
118+ ) : null}
119+ </View>
120+ ) : null}
121+ </View>
122+ </Pressable>
123+ );
124+}
125+
126+const styles = StyleSheet.create({
127+ card: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth },
128+ meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
129+ count: { flexDirection: "row", alignItems: "center", gap: 3 },
130+});
+92−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type BoardItem, type BoardSession, boardOf, peopleOn, stageOf } from "./board.ts";
5+
6+const NOW = Date.parse("2026-10-17T12:00:00Z");
7+const DAY = 86_400_000;
8+
9+function item(number: number, change: Partial<BoardItem> = {}): BoardItem {
10+ return { number, state: "open", assignees: [], repos: [], closed_at: null, ...change };
11+}
12+
13+function target(path: string, landed: boolean) {
14+ return { repo_id: path, path, role: "target" as const, legacy_number: null, landed };
15+}
16+
17+function session(change: Partial<BoardSession>): BoardSession {
18+ return { item: null, state: "active", actor: "ada", harness: "claude_code", pull: null, pull_status: null, ...change };
19+}
20+
21+const none = { pulls: [], sessions: [], now: NOW };
22+
23+test("an open item nobody is on is in the backlog", () => {
24+ assert.equal(stageOf(item(1), none), "backlog");
25+ // A session that ended, or one on another item, puts nobody on it.
26+ assert.equal(stageOf(item(1), { ...none, sessions: [session({ item: 1, state: "ended" }), session({ item: 2 })] }), "backlog");
27+ // So does a pull request that merged or closed.
28+ assert.equal(stageOf(item(1), { ...none, pulls: [{ item: 1, status: "closed" }] }), "backlog");
29+});
30+
31+test("an item someone is on, or with a draft, is in progress", () => {
32+ assert.equal(stageOf(item(1, { assignees: ["ada"] }), none), "in_progress");
33+ assert.equal(stageOf(item(1, { claimed: true }), none), "in_progress");
34+ assert.equal(stageOf(item(1), { ...none, sessions: [session({ item: 1 })] }), "in_progress");
35+ assert.equal(stageOf(item(1), { ...none, sessions: [session({ item: 1, state: "idle" })] }), "in_progress");
36+ assert.equal(stageOf(item(1), { ...none, pulls: [{ item: 1, status: "draft" }] }), "in_progress");
37+});
38+
39+test("an item with an open pull request is in review, even with someone on it", () => {
40+ assert.equal(stageOf(item(1, { assignees: ["ada"] }), { ...none, pulls: [{ item: 1, status: "open" }] }), "in_review");
41+ // A session names its pull request's status too.
42+ assert.equal(stageOf(item(1), { ...none, sessions: [session({ item: 1, pull: 4, pull_status: "open" })] }), "in_review");
43+ assert.equal(stageOf(item(1), { ...none, pulls: [{ item: 2, status: "open" }] }), "backlog");
44+});
45+
46+test("an item landed in some targets but not all is landing, ahead of review", () => {
47+ const repos = [target("acme/web", true), target("acme/api", false)];
48+ assert.equal(stageOf(item(1, { repos }), { ...none, pulls: [{ item: 1, status: "open" }] }), "landing");
49+ // Landed in none of them is not landing.
50+ assert.equal(stageOf(item(1, { repos: [target("acme/web", false), target("acme/api", false)] }), none), "backlog");
51+ // Where it only touched does not count.
52+ const touched = { ...target("acme/docs", true), role: "touched" as const };
53+ assert.equal(stageOf(item(1, { repos: [touched, target("acme/api", false)] }), none), "backlog");
54+});
55+
56+test("an item closed in the last two weeks is done, and older ones are left out", () => {
57+ assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 3 * DAY).toISOString() }), none), "done");
58+ assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 15 * DAY).toISOString() }), none), null);
59+ assert.equal(stageOf(item(1, { state: "closed", closed_at: null }), none), null);
60+ // Done wins over everything else.
61+ const repos = [target("acme/web", true), target("acme/api", false)];
62+ assert.equal(stageOf(item(1, { state: "closed", repos, closed_at: new Date(NOW - DAY).toISOString() }), { ...none, pulls: [{ item: 1, status: "open" }] }), "done");
63+});
64+
65+test("the board puts each item in its stage once, done newest first", () => {
66+ const items = [
67+ item(1),
68+ item(2, { assignees: ["ada"] }),
69+ item(3, { state: "closed", closed_at: new Date(NOW - 5 * DAY).toISOString() }),
70+ item(4, { state: "closed", closed_at: new Date(NOW - DAY).toISOString() }),
71+ item(5, { state: "closed", closed_at: new Date(NOW - 30 * DAY).toISOString() }),
72+ item(1),
73+ ];
74+ const board = boardOf(items, none);
75+ assert.deepEqual(board.backlog.map((card) => card.item.number), [1]);
76+ assert.deepEqual(board.in_progress.map((card) => card.item.number), [2]);
77+ assert.deepEqual(board.done.map((card) => card.item.number), [4, 3]);
78+ assert.deepEqual([...board.in_review, ...board.landing], []);
79+});
80+
81+test("who is on an item: its assignees, then who runs a live session on it, in what", () => {
82+ const sessions = [
83+ session({ item: 1, actor: "ada", harness: "claude_code" }),
84+ session({ item: 1, actor: "bob", harness: "codex" }),
85+ session({ item: 1, actor: "cy", state: "ended" }),
86+ session({ item: 2, actor: "dee" }),
87+ ];
88+ assert.deepEqual(peopleOn(item(1, { assignees: ["ada"] }), sessions), [
89+ { name: "ada", harness: "claude_code" },
90+ { name: "bob", harness: "codex" },
91+ ]);
92+});
+109−0
1+import type { ApiWorkspaceSession, Harness, PullStatus } from "./sessions";
2+import type { ApiWorkItem } from "./work";
3+
4+/**
5+ * Work's board: where each work item stands as it moves, read from the
6+ * item, its pull requests and the sessions on it. Done is closed in the
7+ * last two weeks; Landing has landed in some targets but not all; In
8+ * review has an open pull request; In progress has someone on it (an
9+ * assignee, a claim, a live session) or a draft; Backlog has nobody.
10+ * The first that holds, in that order, is its stage.
11+ *
12+ * Kept free of React Native imports so `npm test` runs it under Node.
13+ */
14+
15+export type Stage = "backlog" | "in_progress" | "in_review" | "landing" | "done";
16+
17+/** The stages in the order work moves through them. */
18+export const STAGES: Stage[] = ["backlog", "in_progress", "in_review", "landing", "done"];
19+
20+const NAMES: Record<Stage, string> = {
21+ backlog: "Backlog",
22+ in_progress: "In progress",
23+ in_review: "In review",
24+ landing: "Landing",
25+ done: "Done",
26+};
27+
28+export function stageName(stage: Stage): string {
29+ return NAMES[stage];
30+}
31+
32+/** How long a closed item stays in Done. */
33+export const DONE_DAYS = 14;
34+
35+export type BoardItem = Pick<ApiWorkItem, "number" | "state" | "assignees" | "repos" | "closed_at"> & {
36+ /** Someone has claimed it. */
37+ claimed?: boolean;
38+};
39+
40+/** A pull request for a work item, by the item's number in the workspace. */
41+export type BoardPull = { item: number | null; status: PullStatus };
42+
43+export type BoardSession = Pick<ApiWorkspaceSession, "item" | "state" | "actor" | "harness" | "pull" | "pull_status">;
44+
45+export type BoardContext = {
46+ pulls: BoardPull[];
47+ sessions: BoardSession[];
48+ now?: number;
49+};
50+
51+/** Someone on an item: an assignee, or who runs a live session on it and in what. */
52+export type OnIt = { name: string; harness: Harness | null };
53+
54+export type Card<T extends BoardItem> = { item: T; stage: Stage; people: OnIt[] };
55+
56+/** The stage of one item, or null for one closed too long ago to show. */
57+export function stageOf(item: BoardItem, context: BoardContext): Stage | null {
58+ const now = context.now ?? Date.now();
59+ if (item.state === "closed") {
60+ const closed = item.closed_at ? Date.parse(item.closed_at) : NaN;
61+ return !Number.isNaN(closed) && now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
62+ }
63+ const targets = item.repos.filter((repo) => repo.role === "target");
64+ if (targets.some((repo) => repo.landed) && targets.some((repo) => !repo.landed)) return "landing";
65+ const statuses = pullsOf(item.number, context);
66+ if (statuses.includes("open")) return "in_review";
67+ const live = context.sessions.some((session) => session.item === item.number && session.state !== "ended");
68+ if (item.assignees.length > 0 || item.claimed || live || statuses.includes("draft")) return "in_progress";
69+ return "backlog";
70+}
71+
72+/** Who is on an item: its assignees, then whoever runs a live session on it. */
73+export function peopleOn(item: BoardItem, sessions: BoardSession[]): OnIt[] {
74+ const people: OnIt[] = item.assignees.map((name) => ({ name, harness: null }));
75+ for (const session of sessions) {
76+ if (session.item !== item.number || session.state === "ended" || !session.actor) continue;
77+ const known = people.find((one) => one.name.toLowerCase() === session.actor!.toLowerCase() && (one.harness === null || one.harness === session.harness));
78+ if (known) known.harness ??= session.harness;
79+ else people.push({ name: session.actor, harness: session.harness });
80+ }
81+ return people;
82+}
83+
84+/**
85+ * The board: every item in its stage, in the order given, Done newest
86+ * closed first. Items closed too long ago are left out; an item given
87+ * twice is shown once.
88+ */
89+export function boardOf<T extends BoardItem>(items: T[], context: BoardContext): Record<Stage, Card<T>[]> {
90+ const board: Record<Stage, Card<T>[]> = { backlog: [], in_progress: [], in_review: [], landing: [], done: [] };
91+ const seen = new Set<number>();
92+ for (const item of items) {
93+ if (seen.has(item.number)) continue;
94+ seen.add(item.number);
95+ const stage = stageOf(item, context);
96+ if (stage) board[stage].push({ item, stage, people: peopleOn(item, context.sessions) });
97+ }
98+ board.done.sort((a, b) => (b.item.closed_at ?? "").localeCompare(a.item.closed_at ?? ""));
99+ return board;
100+}
101+
102+/** An item's pull requests' statuses: from the pull requests read, and those sessions name. */
103+function pullsOf(number: number, context: BoardContext): PullStatus[] {
104+ const statuses = context.pulls.filter((pull) => pull.item === number).map((pull) => pull.status);
105+ for (const session of context.sessions) {
106+ if (session.item === number && session.pull && session.pull_status) statuses.push(session.pull_status);
107+ }
108+ return statuses;
109+}
+25−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { linkPathOf, refContext, refRoute, refSitePath, routeFor, sitePathOf, targetRoute } from "./links.ts";
4+import { linkPathOf, refContext, refRoute, refSitePath, routeFor, sessionRoute, sitePathOf, targetRoute } from "./links.ts";
55
66 test("a g1t:// link names a site path, as on the desktop", () => {
77 assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1");
3737 assert.equal(routeFor("/acme/-/work/12/edit"), null);
3838 });
3939
40+test("Work's board opens Work on its board", () => {
41+ assert.equal(routeFor("/acme/-/work/board"), "/work?workspace=acme&view=board");
42+ assert.equal(routeFor(linkPathOf("g1t://acme/-/work/board")!), "/work?workspace=acme&view=board");
43+ assert.equal(routeFor("/acme/-/work/board/x"), null);
44+});
45+
46+test("Sessions and a session open the app's Sessions", () => {
47+ assert.equal(routeFor(linkPathOf("g1t://acme/-/sessions")!), "/work/sessions?workspace=acme");
48+ assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/sessions")!), "/work/sessions?workspace=acme");
49+ assert.equal(routeFor(linkPathOf("g1t://acme/-/sessions/ses_01abc")!), "/work/sessions/ses_01abc?workspace=acme");
50+ assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/sessions/ses_01abc")!), "/work/sessions/ses_01abc?workspace=acme");
51+ // The list keeps the filters the site's does, and drops anything else.
52+ assert.equal(routeFor("/acme/-/sessions?state=live&repo=web&page=2"), "/work/sessions?workspace=acme&state=live&repo=web");
53+ assert.equal(routeFor("/acme/-/sessions/ses_1/entries"), null);
54+});
55+
56+test("a session opens its page, or its pull request's for g1t's agent and a recorded one", () => {
57+ assert.equal(sessionRoute({ kind: "harness", id: "ses_1", repo: "acme/web", pull: 3 }, "acme", true), "/work/sessions/ses_1?workspace=acme");
58+ assert.equal(sessionRoute({ kind: "hosted", id: "ses_2", repo: "acme/web", pull: null }, "acme", false), "/work/sessions/ses_2?workspace=acme");
59+ assert.equal(sessionRoute({ kind: "agent", id: "run_1", repo: "acme/web", pull: 3 }, "acme", true), "/code/acme/web/pull/3");
60+ assert.equal(sessionRoute({ kind: "recorded", id: "pr_1", repo: "acme/web", pull: 3 }, "acme", false), "/open?path=%2Facme%2Fweb%2Fpull%2F3%2Fsession");
61+ assert.equal(sessionRoute({ kind: "recorded", id: "pr_1", repo: "acme/web", pull: null }, "acme", true), null);
62+});
63+
4064 test("an old link to an issue asks g1t what its number names in its repository", () => {
4165 assert.equal(routeFor("/acme/rocket/issues/12"), "/ref?repo=acme%2Frocket&number=12");
4266 assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/rocket/issues/12")!), "/ref?repo=acme%2Frocket&number=12");
+31−3
2323 /** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */
2424 export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const;
2525
26+/** The filters a link to Sessions may carry: `/acme/-/sessions?state=live&repo=web`. */
27+export const SESSION_FILTERS = ["state", "person", "repo", "item"] as const;
28+
2629 /**
2730 * The app's screen for a site path, or null when the app has none and the
2831 * page should open on the site.
4851 return null;
4952 }
5053 if (third === "work") {
51− if (!fourth) return `/work?${workspace}${filtersOf(query)}`;
54+ if (!fourth) return `/work?${workspace}${filtersOf(query, WORK_FILTERS)}`;
5255 if (fourth === "new" && !fifth) return `/work/new?${workspace}`;
56+ if (fourth === "board" && !fifth) return `/work?${workspace}&view=board`;
5357 if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`;
5458 // Labels and milestones stay on the site.
5559 return null;
5660 }
61+ if (third === "sessions") {
62+ if (!fourth) return `/work/sessions?${workspace}${filtersOf(query, SESSION_FILTERS)}`;
63+ if (!fifth) return `/work/sessions/${encodeURIComponent(fourth)}?${workspace}`;
64+ return null;
65+ }
5766 if (third === "overview" && !fourth) return `/code?${workspace}`;
5867 if (third && RETIRED.has(third)) return `/home?${workspace}`;
5968 return null;
121130 return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`;
122131 }
123132
133+/** A session in a list: what kind it is, its repository (`owner/name`) and pull request. */
134+export type SessionTarget = { kind: "harness" | "hosted" | "agent" | "recorded"; id: string; repo: string; pull: number | null };
135+
136+/**
137+ * Where tapping a session goes. Someone's harness and hosted ones have a
138+ * page of their own; g1t's agent and a recorded one are their pull
139+ * request's: its screen in Code, or its session on the site for someone
140+ * without Code. Null when there is nowhere to go.
141+ */
142+export function sessionRoute(session: SessionTarget, workspace: string, code: boolean): string | null {
143+ if (session.kind === "harness" || session.kind === "hosted") {
144+ return `/work/sessions/${encodeURIComponent(session.id)}?workspace=${encodeURIComponent(workspace)}`;
145+ }
146+ const [owner, name] = session.repo.split("/", 2);
147+ if (!session.pull || !owner || !name) return null;
148+ if (code) return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${session.pull}`;
149+ return `/open?path=${encodeURIComponent(`/${owner}/${name}/pull/${session.pull}/session`)}`;
150+}
151+
124152 /**
125153 * The site path a `g1t://` link names, or null for any other link.
126154 * `g1t://acme/-/chat?x` is `/acme/-/chat?x`.
144172 return web ? (web[1] || "/") + (web[2] ?? "") : null;
145173 }
146174
147−function filtersOf(query: string): string {
175+function filtersOf(query: string, names: readonly string[]): string {
148176 const params = new URLSearchParams(query);
149− return WORK_FILTERS.filter((name) => params.get(name))
177+ return names.filter((name) => params.get(name))
150178 .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`)
151179 .join("");
152180 }
+46−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type ApiSessionTouch, sessionTitle, sessionsQuery, subagentTree, touchedFiles } from "./sessions.ts";
5+
6+test("subagents are a tree under their session, in the order they started", () => {
7+ const children = [
8+ { id: "b", parent_id: "root", started_at: "2026-10-17T10:02:00Z" },
9+ { id: "a1", parent_id: "a", started_at: "2026-10-17T10:03:00Z" },
10+ { id: "a", parent_id: "root", started_at: "2026-10-17T10:01:00Z" },
11+ // Its parent is not in the list: it sits under the root.
12+ { id: "c", parent_id: "gone", started_at: "2026-10-17T10:04:00Z" },
13+ ];
14+ assert.deepEqual(
15+ subagentTree("root", children).map(({ session, depth }) => [session.id, depth]),
16+ [
17+ ["a", 1],
18+ ["a1", 2],
19+ ["b", 1],
20+ ["c", 1],
21+ ],
22+ );
23+});
24+
25+test("the files a session touched, once each", () => {
26+ const touched: ApiSessionTouch[] = [
27+ { session_id: "s", kind: "file", value: "src/b.ts", at: "2026-10-17T10:02:00Z" },
28+ { session_id: "s", kind: "commit", value: "abc", at: "2026-10-17T10:00:00Z" },
29+ { session_id: "t", kind: "file", value: "src/a.ts", at: "2026-10-17T10:01:00Z" },
30+ { session_id: "t", kind: "file", value: "src/b.ts", at: "2026-10-17T10:03:00Z" },
31+ ];
32+ assert.deepEqual(touchedFiles(touched), ["src/a.ts", "src/b.ts"]);
33+ assert.deepEqual(touchedFiles(undefined), []);
34+});
35+
36+test("a session's name is its title, else its task or first prompt", () => {
37+ assert.equal(sessionTitle({ title: "Fix login", task: null, first_prompt: null, agent_type: null }), "Fix login");
38+ assert.equal(sessionTitle({ title: null, task: null, first_prompt: "Look at the queue\nand more", agent_type: null }), "Look at the queue");
39+ assert.equal(sessionTitle({ title: null, task: null, first_prompt: null, agent_type: "Explore" }), "Explore subagent");
40+ assert.equal(sessionTitle(null), "Untitled session");
41+});
42+
43+test("the list's filters are the API's", () => {
44+ assert.equal(sessionsQuery({ state: "live" }), "state=live");
45+ assert.equal(sessionsQuery({ state: "ended", person: "ada", repo: "web", item: 4 }), "state=ended&person=ada&repo=web&item=4");
46+});
+213−0
1+/**
2+ * Sessions: the runs of agents across a workspace, through the REST API
3+ * (`GET /workspaces/{workspace}/sessions`). Not the app's sign-in, which
4+ * is lib/session.tsx. As in lib/work.ts these are the API's snake_case
5+ * shapes (the camelCase ones are in @g1t/contracts), and only the fields
6+ * the app shows are named. The hooks are in lib/work.ts.
7+ *
8+ * Kept free of React Native imports so `npm test` runs it under Node.
9+ */
10+
11+export type Harness = "claude_code" | "claude_managed_agents" | "codex" | "cursor" | "copilot" | "other";
12+
13+/**
14+ * Someone's own `harness`, one g1t started on the workspace's Anthropic
15+ * provider (`hosted`), g1t's own `agent` on a pull request, or what a
16+ * coding agent `recorded` on a pull request with no session of its own.
17+ */
18+export type SessionKind = "harness" | "hosted" | "agent" | "recorded";
19+
20+/** Active: heard from in 15 minutes; idle: not since; ended: ended, or quiet for 2 hours. */
21+export type SessionState = "active" | "idle" | "ended";
22+
23+export type PullStatus = "draft" | "open" | "merged" | "closed";
24+
25+/** One run of a harness on a repository: a session, or one of its subagents. */
26+export type ApiSession = {
27+ id: string;
28+ repo_id: string;
29+ item: number | null;
30+ pull: number | null;
31+ branch: string | null;
32+ actor: { id: string; username: string };
33+ harness: Harness;
34+ title: string | null;
35+ state: SessionState;
36+ started_at: string;
37+ last_seen_at: string;
38+ ended_at: string | null;
39+ end_reason: string | null;
40+ parent_id?: string | null;
41+ agent_type?: string | null;
42+ task?: string | null;
43+ model?: string | null;
44+ subagents?: number;
45+ first_prompt?: string | null;
46+};
47+
48+/** One session of a workspace, whatever its kind. */
49+export type ApiWorkspaceSession = {
50+ kind: SessionKind;
51+ /** `ses_…`, `run_…` for g1t's agent, the pull request's id for a recorded one. */
52+ id: string;
53+ /** `owner/name`. */
54+ repo: string;
55+ repo_id: string;
56+ title: string | null;
57+ /** Who runs it, by username; `g1t` for g1t's own. */
58+ actor: string | null;
59+ harness: Harness | null;
60+ /** For g1t's agent and a recorded one: the agent's label. */
61+ agent: string | null;
62+ branch: string | null;
63+ item: number | null;
64+ item_title: string | null;
65+ pull: number | null;
66+ pull_title: string | null;
67+ pull_status: PullStatus | null;
68+ state: SessionState;
69+ started_at: string;
70+ last_activity_at: string;
71+ subagents: number;
72+ /** For g1t's agent: what it is doing now. */
73+ step: string | null;
74+ session: ApiSession | null;
75+ children?: ApiSession[];
76+};
77+
78+export type ApiWorkspaceSessions = {
79+ sessions: ApiWorkspaceSession[];
80+ repos: { id: string; path: string; default_branch: string }[];
81+};
82+
83+/** What the list shows; each is one of the API's filters. */
84+export type SessionsFilter = {
85+ /** `live`: going now; `recent`: every one, newest first; `ended`: those that ended. */
86+ state: "live" | "recent" | "ended";
87+ /** A username. */
88+ person?: string;
89+ /** A repository of the workspace, by name. */
90+ repo?: string;
91+ item?: number;
92+};
93+
94+export type EntryKind = "prompt" | "message" | "tool_call" | "tool_result" | "note" | "skill";
95+
96+/** One step a session recorded. */
97+export type ApiSessionEntry = {
98+ seq: number;
99+ kind: EntryKind;
100+ text: string;
101+ tool: string | null;
102+ commit: string | null;
103+ at: string;
104+ /** Its text is not shown to this viewer. */
105+ withheld?: boolean;
106+};
107+
108+export type ApiSessionTouch = { session_id: string; kind: "file" | "commit" | "branch" | "pull" | "item"; value: string; at: string };
109+
110+/** A work item or pull request the session, or one of its subagents, worked on. */
111+export type ApiSessionLink = { kind: "item" | "pull"; number: number; title: string; state: string; via: string[]; session_ids: string[] };
112+
113+/** `GET /workspaces/{workspace}/sessions/{id}`: a session, what it recorded, and its repository. */
114+export type ApiSessionDetail = {
115+ session: ApiSession;
116+ entries: ApiSessionEntry[];
117+ children?: ApiSession[];
118+ touched?: ApiSessionTouch[];
119+ links?: ApiSessionLink[];
120+ skills?: { session_id: string; name: string; uses: number }[];
121+ root?: ApiSession | null;
122+ /** The viewer sees that it runs, who runs it and what it is tied to, nothing more. */
123+ limited?: boolean;
124+ /** `owner/name`. */
125+ repo: string;
126+};
127+
128+/** How many entries one answer brings at most; more come with `after`. */
129+export const ENTRY_PAGE = 500;
130+
131+const HARNESS_NAMES: Record<Harness, string> = {
132+ claude_code: "Claude Code",
133+ claude_managed_agents: "Claude Managed Agents",
134+ codex: "Codex",
135+ cursor: "Cursor",
136+ copilot: "Copilot",
137+ other: "Another harness",
138+};
139+
140+export function harnessName(harness: Harness | null | undefined): string | null {
141+ return harness ? (HARNESS_NAMES[harness] ?? harness) : null;
142+}
143+
144+/** What runs it: the harness, or the agent's label for g1t's agent and a recorded one. */
145+export function runsIn(session: Pick<ApiWorkspaceSession, "kind" | "harness" | "agent">): string {
146+ if (session.kind === "agent") return session.agent ?? "g1t";
147+ if (session.kind === "recorded") return session.agent ?? "Recorded";
148+ return harnessName(session.harness) ?? "Session";
149+}
150+
151+/** Live: active or idle, not ended. */
152+export function isLive(session: { state: SessionState }): boolean {
153+ return session.state !== "ended";
154+}
155+
156+/** A session's name: its title, else the task or first prompt it recorded. */
157+export function sessionTitle(session: Pick<ApiSession, "title" | "task" | "first_prompt" | "agent_type"> | null, fallback?: string | null): string {
158+ const text = session?.title ?? fallback ?? session?.task ?? session?.first_prompt ?? null;
159+ const line = text?.trim().split("\n", 1)[0];
160+ return line || (session?.agent_type ? `${session.agent_type} subagent` : "Untitled session");
161+}
162+
163+/** A workspace session's name in a list. */
164+export function listTitle(session: ApiWorkspaceSession): string {
165+ if (session.session) return sessionTitle(session.session, session.title);
166+ return session.title?.trim() || session.pull_title || (session.pull ? `Pull request #${session.pull}` : "Untitled session");
167+}
168+
169+/** Whether a session has a page of its own in the app: someone's harness and hosted ones do. */
170+export function hasPage(session: Pick<ApiWorkspaceSession, "kind">): boolean {
171+ return session.kind === "harness" || session.kind === "hosted";
172+}
173+
174+/**
175+ * A session's subagents as a tree, in the order they started, each with
176+ * how deep it is under `root` (1 for its own). Ones whose parent is not
177+ * in the list sit under the root.
178+ */
179+export function subagentTree<T extends { id: string; parent_id?: string | null; started_at: string }>(root: string, children: T[]): { session: T; depth: number }[] {
180+ const ids = new Set(children.map((child) => child.id));
181+ const under = new Map<string, T[]>();
182+ for (const child of [...children].sort((a, b) => a.started_at.localeCompare(b.started_at))) {
183+ const parent = child.parent_id && child.parent_id !== child.id && ids.has(child.parent_id) ? child.parent_id : root;
184+ under.set(parent, [...(under.get(parent) ?? []), child]);
185+ }
186+ const out: { session: T; depth: number }[] = [];
187+ const seen = new Set<string>();
188+ const walk = (id: string, depth: number) => {
189+ for (const child of under.get(id) ?? []) {
190+ if (seen.has(child.id)) continue;
191+ seen.add(child.id);
192+ out.push({ session: child, depth });
193+ walk(child.id, depth + 1);
194+ }
195+ };
196+ walk(root, 1);
197+ return out;
198+}
199+
200+/** The files a session and its subagents touched, once each, in the order first touched. */
201+export function touchedFiles(touched: ApiSessionTouch[] | undefined): string[] {
202+ const files = (touched ?? []).filter((one) => one.kind === "file").sort((a, b) => a.at.localeCompare(b.at));
203+ return [...new Set(files.map((one) => one.value))];
204+}
205+
206+/** The query string for a filter. */
207+export function sessionsQuery(filter: SessionsFilter): string {
208+ const params = new URLSearchParams({ state: filter.state });
209+ if (filter.person) params.set("person", filter.person);
210+ if (filter.repo) params.set("repo", filter.repo);
211+ if (filter.item) params.set("item", String(filter.item));
212+ return params.toString();
213+}
+100−2
1−import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1+import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
22
3−import type { ApiComment, ApiUser, CheckStatus } from "./code";
3+import { boardOf } from "./board";
4+import type { Client } from "./client";
5+import type { ApiComment, ApiPull, ApiUser, CheckStatus } from "./code";
46 import type { RefContext, RefTarget } from "./links";
57 import { useSignedIn } from "./session";
8+import { type ApiSessionDetail, type ApiWorkspaceSessions, ENTRY_PAGE, type SessionsFilter, isLive, sessionsQuery } from "./sessions";
69
710 /**
811 * Work, through the REST API (docs.g1t.sh/reference/api/work-items/): a
225228 if ("repo" in context) return `/repos/${context.repo.split("/").map(encodeURIComponent).join("/")}`;
226229 return `/workspaces/${encodeURIComponent(context.workspace)}`;
227230 }
231+
232+const sessionsPath = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/sessions`;
233+
234+/** How often Sessions and a live session are read again while on screen. */
235+export const POLL_MS = 15_000;
236+
237+/**
238+ * A workspace's sessions, most recently heard from first. With `poll`
239+ * (the screen is in view) they are read again every 15 s while one is live.
240+ */
241+export function useWorkspaceSessions(workspace: string | undefined, filter: SessionsFilter, poll = false) {
242+ const { client } = useSignedIn();
243+ return useQuery({
244+ queryKey: ["work", "sessions", workspace?.toLowerCase(), filter],
245+ enabled: Boolean(workspace),
246+ queryFn: () => client.api<ApiWorkspaceSessions>(`${sessionsPath(workspace!)}?${sessionsQuery(filter)}`),
247+ refetchInterval: (query) => (poll && query.state.data?.sessions.some(isLive) ? POLL_MS : false),
248+ });
249+}
250+
251+/**
252+ * One session and what it recorded, 500 steps at a time. With `poll` it
253+ * is read again every 15 s while it is live.
254+ */
255+export function useWorkspaceSession(workspace: string, id: string, poll = false) {
256+ const { client } = useSignedIn();
257+ const query = useInfiniteQuery({
258+ queryKey: ["work", "session", workspace.toLowerCase(), id],
259+ initialPageParam: undefined as number | undefined,
260+ queryFn: ({ pageParam }) =>
261+ client.api<ApiSessionDetail>(`${sessionsPath(workspace)}/${encodeURIComponent(id)}${pageParam === undefined ? "" : `?after=${pageParam}`}`),
262+ getNextPageParam: (last) => (last.entries.length >= ENTRY_PAGE ? last.entries[last.entries.length - 1].seq : undefined),
263+ refetchInterval: (query) => {
264+ const first = query.state.data?.pages[0];
265+ return poll && first && isLive(first.session) ? POLL_MS : false;
266+ },
267+ });
268+ const pages = query.data?.pages ?? [];
269+ const detail = pages.length ? pages[pages.length - 1] : undefined;
270+ const entries = (() => {
271+ const seen = new Set<number>();
272+ return pages.flatMap((page) => page.entries).filter((entry) => (seen.has(entry.seq) ? false : (seen.add(entry.seq), true)));
273+ })();
274+ return { ...query, detail, entries };
275+}
276+
277+/** Every page of a list of items, up to `pages` of them. */
278+async function allItems(client: Client, workspace: string, state: "open" | "closed", pages: number): Promise<ApiWorkItem[]> {
279+ const items: ApiWorkItem[] = [];
280+ const seen = new Set<string>();
281+ for (let page = 1; page <= pages; page++) {
282+ const batch = await client.api<ApiWorkItem[]>(`${workPath(workspace)}?${filterQuery({ state }, page)}`);
283+ const fresh = batch.filter((item) => !seen.has(item.id));
284+ fresh.forEach((item) => seen.add(item.id));
285+ items.push(...fresh);
286+ if (batch.length < PAGE || fresh.length === 0) break;
287+ }
288+ return items;
289+}
290+
291+/** The repositories whose open pull requests the board reads, at most. */
292+const BOARD_REPOS = 20;
293+
294+/**
295+ * Work's board (lib/board.ts): the open items, those closed lately, the
296+ * live sessions, and each repository's open pull requests. With `poll`,
297+ * the sessions are read again every 15 s while one is live.
298+ */
299+export function useBoard(workspace: string | undefined, poll = false) {
300+ const { client } = useSignedIn();
301+ const key = workspace?.toLowerCase();
302+ const open = useQuery({ queryKey: ["work", "board", key, "open"], enabled: Boolean(workspace), queryFn: () => allItems(client, workspace!, "open", 10) });
303+ const closed = useQuery({ queryKey: ["work", "board", key, "closed"], enabled: Boolean(workspace), queryFn: () => allItems(client, workspace!, "closed", 3) });
304+ const sessions = useWorkspaceSessions(workspace, { state: "live" }, poll);
305+ const repos = (sessions.data?.repos ?? []).slice(0, BOARD_REPOS);
306+ const pulls = useQueries({
307+ queries: repos.map((repo) => {
308+ const [owner, name] = repo.path.split("/", 2);
309+ return {
310+ queryKey: ["code", "pulls", owner, name, "open"],
311+ queryFn: () => client.api<ApiPull[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?state=open`),
312+ retry: false,
313+ };
314+ }),
315+ });
316+ const read = pulls.flatMap((one) => one.data ?? []);
317+ const board = boardOf([...(open.data ?? []), ...(closed.data ?? [])], { pulls: read, sessions: sessions.data?.sessions ?? [] });
318+ return {
319+ board,
320+ isPending: open.isPending || closed.isPending,
321+ error: open.error ?? closed.error,
322+ isRefetching: open.isRefetching || closed.isRefetching || sessions.isRefetching,
323+ refetch: () => Promise.all([open.refetch(), closed.refetch(), sessions.refetch(), ...pulls.map((one) => one.refetch())]),
324+ };
325+}

Comments

Sign in to comment.