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
15 files+1409−340/15 viewed
| 16 | 16 | * The signed-in app. Its bar is the site's phone bar | |
| 17 | 17 | * (apps/web/app/components/mobile.tsx): Home, Work, Chat, Notifications | |
| 18 | 18 | * 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. | |
| 21 | 21 | */ | |
| 22 | 22 | export default function AppLayout() { | |
| 23 | 23 | return ( |
| 3 | 3 | import { hasCodeAccess } from "@g1t/contracts"; | |
| 4 | 4 | import { Stack, router } from "expo-router"; | |
| 5 | 5 | 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"; | |
| 7 | 7 | import { Linking, View } from "react-native"; | |
| 8 | 8 | ||
| 9 | 9 | import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| ⋯ | |||
| 12 | 12 | import { useSignedIn } from "@/lib/session"; | |
| 13 | 13 | import { space, useColors } from "@/theme"; | |
| 14 | 14 | ||
| 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. */ | |
| 16 | 16 | export default function More() { | |
| 17 | 17 | const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn(); | |
| 18 | 18 | const code = !workspace || hasCodeAccess(workspace); | |
| ⋯ | |||
| 44 | 44 | </View> | |
| 45 | 45 | </View> | |
| 46 | 46 | ||
| 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 ? ( | |
| 49 | 55 | <Row | |
| 50 | 56 | leading={<Code2 size={22} color={colors.accent} />} | |
| 51 | 57 | title="Code" | |
| 52 | 58 | subtitle="Repositories, their pull requests and changes" | |
| 53 | 59 | onPress={() => router.push("/code")} | |
| 54 | 60 | /> | |
| 55 | − | </View> | |
| 56 | − | ) : null} | |
| 61 | + | ) : null} | |
| 62 | + | </View> | |
| 57 | 63 | ||
| 58 | 64 | <View> | |
| 59 | 65 | <Row | |
| 1 | 1 | import { TabStack } from "@/components/stack"; | |
| 2 | 2 | ||
| 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 | + | ||
| 3 | 6 | export default TabStack; |
| 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"; | |
| 4 | 4 | import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native"; | |
| 5 | 5 | ||
| 6 | + | import { Board } from "@/components/board"; | |
| 6 | 7 | import { Sheet } from "@/components/chat/sheet"; | |
| 7 | 8 | import { Segments } from "@/components/segments"; | |
| 8 | 9 | import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui"; | |
| ⋯ | |||
| 12 | 13 | import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work"; | |
| 13 | 14 | import { radius, space, useColors } from "@/theme"; | |
| 14 | 15 | ||
| 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"; | |
| 16 | 20 | ||
| 17 | 21 | /** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */ | |
| 18 | 22 | function filterOf(params: Params): WorkFilter { | |
| ⋯ | |||
| 31 | 35 | ||
| 32 | 36 | /** | |
| 33 | 37 | * 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. | |
| 37 | 42 | */ | |
| 38 | 43 | export default function WorkList() { | |
| 39 | 44 | const params = useLocalSearchParams<Params>(); | |
| ⋯ | |||
| 44 | 49 | const [filter, setFilter] = useState<WorkFilter>(linked); | |
| 45 | 50 | useEffect(() => setFilter(linked), [linked]); | |
| 46 | 51 | 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); | |
| 48 | 63 | const milestones = useMilestones(filter.milestone ? slug : undefined); | |
| 49 | 64 | ||
| 50 | 65 | // Work in the bar again, on the list itself, opens its filters, as the site's bar opens Work's menu. | |
| ⋯ | |||
| 53 | 68 | const tabs = navigation.getParent(); | |
| 54 | 69 | if (!tabs) return; | |
| 55 | 70 | return tabs.addListener("tabPress" as never, () => { | |
| 56 | − | if (navigation.isFocused()) setFiltering(true); | |
| 71 | + | if (navigation.isFocused() && view === "items") setFiltering(true); | |
| 57 | 72 | }); | |
| 58 | − | }, [navigation]); | |
| 73 | + | }, [navigation, view]); | |
| 59 | 74 | ||
| 60 | 75 | const active = [ | |
| 61 | 76 | filter.assignee ? { key: "assignee", label: filter.assignee === me.username ? "Assigned to me" : `@${filter.assignee}` } : null, | |
| ⋯ | |||
| 69 | 84 | options={{ | |
| 70 | 85 | title: "Work", | |
| 71 | 86 | 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> | |
| 75 | 99 | ), | |
| 76 | 100 | }} | |
| 77 | 101 | /> | |
| ⋯ | |||
| 91 | 115 | {header} | |
| 92 | 116 | <View style={[styles.bar, { borderBottomColor: colors.line }]}> | |
| 93 | 117 | <Segments | |
| 94 | − | value={filter.state} | |
| 95 | − | onChange={(state) => setFilter({ ...filter, state })} | |
| 118 | + | value={view} | |
| 119 | + | onChange={setView} | |
| 96 | 120 | 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" }, | |
| 100 | 123 | ]} | |
| 101 | 124 | /> | |
| 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 ? ( | |
| 103 | 137 | <View style={styles.chips}> | |
| 104 | 138 | {active.map((one) => ( | |
| 105 | 139 | <Chip | |
| ⋯ | |||
| 114 | 148 | ) : null} | |
| 115 | 149 | </View> | |
| 116 | 150 | ||
| 117 | − | {items.isPending ? ( | |
| 151 | + | {view === "board" ? ( | |
| 152 | + | <Board workspace={slug} focused={focused} /> | |
| 153 | + | ) : items.isPending ? ( | |
| 118 | 154 | <Loading /> | |
| 119 | 155 | ) : items.error ? ( | |
| 120 | 156 | <ErrorView error={items.error} retry={() => items.refetch()} /> | |
| ⋯ | |||
| 290 | 326 | const styles = StyleSheet.create({ | |
| 291 | 327 | bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, | |
| 292 | 328 | chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, | |
| 329 | + | actions: { flexDirection: "row", alignItems: "center", gap: space.lg }, | |
| 293 | 330 | fab: { | |
| 294 | 331 | position: "absolute", | |
| 295 | 332 | right: space.lg, | |
| 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 | + | }); |
| 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 | + | }); |
| 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 | + | }); |
| 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 | + | }); |
| 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 | + | }); |
| 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 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 5 | ||
| 6 | 6 | test("a g1t:// link names a site path, as on the desktop", () => { | |
| 7 | 7 | assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1"); | |
| ⋯ | |||
| 37 | 37 | assert.equal(routeFor("/acme/-/work/12/edit"), null); | |
| 38 | 38 | }); | |
| 39 | 39 | ||
| 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 | + | ||
| 40 | 64 | test("an old link to an issue asks g1t what its number names in its repository", () => { | |
| 41 | 65 | assert.equal(routeFor("/acme/rocket/issues/12"), "/ref?repo=acme%2Frocket&number=12"); | |
| 42 | 66 | assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/rocket/issues/12")!), "/ref?repo=acme%2Frocket&number=12"); | |
| 23 | 23 | /** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */ | |
| 24 | 24 | export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const; | |
| 25 | 25 | ||
| 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 | + | ||
| 26 | 29 | /** | |
| 27 | 30 | * The app's screen for a site path, or null when the app has none and the | |
| 28 | 31 | * page should open on the site. | |
| ⋯ | |||
| 48 | 51 | return null; | |
| 49 | 52 | } | |
| 50 | 53 | if (third === "work") { | |
| 51 | − | if (!fourth) return `/work?${workspace}${filtersOf(query)}`; | |
| 54 | + | if (!fourth) return `/work?${workspace}${filtersOf(query, WORK_FILTERS)}`; | |
| 52 | 55 | if (fourth === "new" && !fifth) return `/work/new?${workspace}`; | |
| 56 | + | if (fourth === "board" && !fifth) return `/work?${workspace}&view=board`; | |
| 53 | 57 | if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`; | |
| 54 | 58 | // Labels and milestones stay on the site. | |
| 55 | 59 | return null; | |
| 56 | 60 | } | |
| 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 | + | } | |
| 57 | 66 | if (third === "overview" && !fourth) return `/code?${workspace}`; | |
| 58 | 67 | if (third && RETIRED.has(third)) return `/home?${workspace}`; | |
| 59 | 68 | return null; | |
| ⋯ | |||
| 121 | 130 | return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`; | |
| 122 | 131 | } | |
| 123 | 132 | ||
| 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 | + | ||
| 124 | 152 | /** | |
| 125 | 153 | * The site path a `g1t://` link names, or null for any other link. | |
| 126 | 154 | * `g1t://acme/-/chat?x` is `/acme/-/chat?x`. | |
| ⋯ | |||
| 144 | 172 | return web ? (web[1] || "/") + (web[2] ?? "") : null; | |
| 145 | 173 | } | |
| 146 | 174 | ||
| 147 | − | function filtersOf(query: string): string { | |
| 175 | + | function filtersOf(query: string, names: readonly string[]): string { | |
| 148 | 176 | const params = new URLSearchParams(query); | |
| 149 | − | return WORK_FILTERS.filter((name) => params.get(name)) | |
| 177 | + | return names.filter((name) => params.get(name)) | |
| 150 | 178 | .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`) | |
| 151 | 179 | .join(""); | |
| 152 | 180 | } | |
| 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 | + | }); |
| 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 | + | } |
| 1 | − | import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 1 | + | import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | 2 | ||
| 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"; | |
| 4 | 6 | import type { RefContext, RefTarget } from "./links"; | |
| 5 | 7 | import { useSignedIn } from "./session"; | |
| 8 | + | import { type ApiSessionDetail, type ApiWorkspaceSessions, ENTRY_PAGE, type SessionsFilter, isLive, sessionsQuery } from "./sessions"; | |
| 6 | 9 | ||
| 7 | 10 | /** | |
| 8 | 11 | * Work, through the REST API (docs.g1t.sh/reference/api/work-items/): a | |
| ⋯ | |||
| 225 | 228 | if ("repo" in context) return `/repos/${context.repo.split("/").map(encodeURIComponent).join("/")}`; | |
| 226 | 229 | return `/workspaces/${encodeURIComponent(context.workspace)}`; | |
| 227 | 230 | } | |
| 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.