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.

220 linesCodeBlameRaw
1import { Activity, Check as CheckIcon, X } from "lucide-react-native";
2import type { ReactNode } from "react";
3import { RefreshControl, ScrollView, SectionList, StyleSheet, View, useWindowDimensions } from "react-native";
4
5import { Sheet } from "@/components/chat/sheet";
6import { Segments } from "@/components/segments";
7import { SessionCard } from "@/components/sessions";
8import { Button, Empty, ErrorView, Loading, Row, SectionHeader, Text } from "@/components/ui";
9import { Chip } from "@/components/work";
10import { type ApiWorkspaceSession, type SessionsFilter, isLive } from "@/lib/sessions";
11import { useWorkspaceSessions } from "@/lib/work";
12import { space, useColors } from "@/theme";
13
14type Shown = "all" | "live" | "ended";
15
16/** What a link to Work's sessions may name (`/acme/-/work/sessions?state=live&repo=web`). */
17export type SessionsParams = { state?: string; person?: string; repo?: string; item?: string };
18
19/** The filter a link names: every session when it names none. */
20export function sessionsFilterOf(params: SessionsParams): 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
26const SHOWN: Record<Shown, SessionsFilter["state"]> = { all: "recent", live: "live", ended: "ended" };
27
28/** Whether any filter but which sessions are shown is set: the header's filter button is lit then. */
29export function sessionsFiltered(filter: SessionsFilter): boolean {
30 return Boolean(filter.person || filter.repo || filter.item);
31}
32
33/**
34 * Work's Sessions: what is cooking across the workspace. The ones going
35 * now first, then those that ended lately, each with who runs it in what,
36 * where, and the work item and pull request it is on. Pull down to read
37 * again; while one is live it is read again every 15 s. `top` is Work's
38 * own segments (Items, Board, Sessions), over this view's.
39 */
40export function SessionsPanel({
41 workspace,
42 me,
43 filter,
44 onChange: setFilter,
45 focused,
46 filtering,
47 onFiltering,
48 top,
49}: {
50 workspace: string;
51 me: string;
52 filter: SessionsFilter;
53 onChange: (filter: SessionsFilter) => void;
54 focused: boolean;
55 filtering: boolean;
56 onFiltering: (open: boolean) => void;
57 top: ReactNode;
58}) {
59 const colors = useColors();
60 const sessions = useWorkspaceSessions(workspace, filter, focused);
61 const shown: Shown = filter.state === "live" ? "live" : filter.state === "ended" ? "ended" : "all";
62
63 const active = [
64 filter.person ? { key: "person", label: filter.person === me ? "Started by me" : `@${filter.person}` } : null,
65 filter.repo ? { key: "repo", label: filter.repo } : null,
66 filter.item ? { key: "item", label: `#${filter.item}` } : null,
67 ].filter((one): one is { key: "person" | "repo" | "item"; label: string } => one !== null);
68
69 const all = sessions.data?.sessions ?? [];
70 const sections: { title: string; data: ApiWorkspaceSession[] }[] =
71 shown === "all"
72 ? [
73 { title: "Live now", data: all.filter(isLive) },
74 { title: "Recent", data: all.filter((one) => !isLive(one)) },
75 ].filter((section) => section.data.length > 0)
76 : all.length
77 ? [{ title: shown === "live" ? "Live now" : "Recent", data: all }]
78 : [];
79
80 return (
81 <>
82 <View style={[styles.bar, { borderBottomColor: colors.line }]}>
83 {top}
84 <Segments
85 value={shown}
86 onChange={(next) => setFilter({ ...filter, state: SHOWN[next] })}
87 options={[
88 { value: "all", label: "Everything" },
89 { value: "live", label: "Live" },
90 { value: "ended", label: "Recent" },
91 ]}
92 />
93 {active.length ? (
94 <View style={styles.chips}>
95 {active.map((one) => (
96 <Chip
97 key={one.key}
98 label={one.label}
99 tone="accent"
100 leading={<X size={12} color={colors.accent} />}
101 onPress={() => setFilter({ ...filter, [one.key]: undefined })}
102 />
103 ))}
104 </View>
105 ) : null}
106 </View>
107
108 {sessions.isPending ? (
109 <Loading />
110 ) : sessions.error ? (
111 <ErrorView error={sessions.error} retry={() => sessions.refetch()} />
112 ) : (
113 <SectionList
114 sections={sections}
115 keyExtractor={(session) => `${session.kind}:${session.id}`}
116 stickySectionHeadersEnabled={false}
117 refreshControl={<RefreshControl refreshing={sessions.isRefetching} onRefresh={() => sessions.refetch()} tintColor={colors.accent} />}
118 contentContainerStyle={{ paddingBottom: space.xl, flexGrow: 1 }}
119 renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
120 renderItem={({ item }) => <SessionCard session={item} workspace={workspace} />}
121 ListEmptyComponent={
122 <Empty
123 icon={Activity}
124 title={active.length ? "Nothing matches" : shown === "live" ? "Nothing running now" : "No sessions yet"}
125 body={
126 active.length
127 ? "Try fewer filters."
128 : "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."
129 }
130 />
131 }
132 />
133 )}
134
135 <Filters
136 visible={filtering}
137 filter={filter}
138 me={me}
139 repos={(sessions.data?.repos ?? []).map((repo) => repo.path.split("/", 2)[1] ?? repo.path)}
140 onClose={() => onFiltering(false)}
141 onChange={setFilter}
142 />
143 </>
144 );
145}
146
147/** The sessions' filters, in a sheet: started by you, and the repository. */
148function Filters({
149 visible,
150 filter,
151 me,
152 repos,
153 onClose,
154 onChange,
155}: {
156 visible: boolean;
157 filter: SessionsFilter;
158 me: string;
159 repos: string[];
160 onClose: () => void;
161 onChange: (filter: SessionsFilter) => void;
162}) {
163 const colors = useColors();
164 const { height } = useWindowDimensions();
165 const mine = filter.person === me;
166 return (
167 <Sheet visible={visible} onClose={onClose}>
168 <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}>
169 <Text size="heading">Filters</Text>
170 <View style={{ marginHorizontal: -space.lg }}>
171 <Row
172 title="Started by me"
173 chevron={false}
174 trailing={
175 <View style={[styles.check, { borderColor: mine ? colors.accent : colors.lineStrong, backgroundColor: mine ? colors.accent : "transparent" }]}>
176 {mine ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null}
177 </View>
178 }
179 onPress={() => onChange({ ...filter, person: mine ? undefined : me })}
180 />
181 </View>
182 <View style={{ gap: space.sm }}>
183 <Text size="small" tone="muted">
184 Repository
185 </Text>
186 {repos.length ? (
187 <View style={styles.chips}>
188 {repos.map((name) => (
189 <Chip
190 key={name}
191 label={name}
192 tone={filter.repo === name ? "accent" : "neutral"}
193 onPress={() => onChange({ ...filter, repo: filter.repo === name ? undefined : name })}
194 />
195 ))}
196 </View>
197 ) : (
198 <Text size="small" tone="faint">
199 No repositories you can see
200 </Text>
201 )}
202 </View>
203 <View style={{ flexDirection: "row", gap: space.sm }}>
204 <View style={{ flex: 1 }}>
205 <Button title="Clear" variant="secondary" onPress={() => onChange({ state: filter.state })} />
206 </View>
207 <View style={{ flex: 1 }}>
208 <Button title="Done" onPress={onClose} />
209 </View>
210 </View>
211 </ScrollView>
212 </Sheet>
213 );
214}
215
216const styles = StyleSheet.create({
217 bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
218 chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
219 check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" },
220});