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.

232 linesCodeBlameRaw
1import { Stack, useFocusEffect, useLocalSearchParams } from "expo-router";
2import { Activity, Check as CheckIcon, SlidersHorizontal, X } from "lucide-react-native";
3import { useCallback, useEffect, useMemo, useState } from "react";
4import { Pressable, RefreshControl, ScrollView, SectionList, StyleSheet, View, useWindowDimensions } from "react-native";
5
6import { Sheet } from "@/components/chat/sheet";
7import { Segments } from "@/components/segments";
8import { SessionCard } from "@/components/sessions";
9import { Button, Empty, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
10import { Chip } from "@/components/work";
11import { useSignedIn } from "@/lib/session";
12import { type ApiWorkspaceSession, type SessionsFilter, isLive } from "@/lib/sessions";
13import { useWorkspaceSessions } from "@/lib/work";
14import { space, useColors } from "@/theme";
15
16type Shown = "all" | "live" | "ended";
17type 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. */
20function 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
26const 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 */
34export 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. */
160function 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
228const 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});