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.

347 linesCodeBlameRaw
1import { Stack, router, useFocusEffect, useLocalSearchParams, useNavigation } from "expo-router";
2import { Activity, Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native";
3import { useCallback, useEffect, useMemo, useState } from "react";
4import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native";
5
6import { Board } from "@/components/board";
7import { Sheet } from "@/components/chat/sheet";
8import { Segments } from "@/components/segments";
9import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
10import { Chip, ItemRow, roleLabel } from "@/components/work";
11import { useRepos } from "@/lib/code";
12import { useSignedIn } from "@/lib/session";
13import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work";
14import { radius, space, useColors } from "@/theme";
15
16type 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. */
19type Shown = "items" | "board";
20
21/** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */
22function filterOf(params: Params): WorkFilter {
23 const state = params.state === "closed" || params.state === "all" ? params.state : "open";
24 const role = params.role === "origin" || params.role === "target" || params.role === "touched" ? params.role : undefined;
25 const milestone = Number(params.milestone);
26 return {
27 state,
28 label: params.label || undefined,
29 milestone: Number.isInteger(milestone) && milestone > 0 ? milestone : undefined,
30 assignee: params.assignee || undefined,
31 repo: params.repo || undefined,
32 role,
33 };
34}
35
36/**
37 * Work: the workspace's work items, newest first, for every member,
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.
42 */
43export default function WorkList() {
44 const params = useLocalSearchParams<Params>();
45 const { workspace, me } = useSignedIn();
46 const slug = params.workspace ?? workspace?.slug;
47 const colors = useColors();
48 const linked = useMemo(() => filterOf(params), [params.state, params.label, params.milestone, params.assignee, params.repo, params.role]);
49 const [filter, setFilter] = useState<WorkFilter>(linked);
50 useEffect(() => setFilter(linked), [linked]);
51 const [filtering, setFiltering] = useState(false);
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);
63 const milestones = useMilestones(filter.milestone ? slug : undefined);
64
65 // Work in the bar again, on the list itself, opens its filters, as the site's bar opens Work's menu.
66 const navigation = useNavigation();
67 useEffect(() => {
68 const tabs = navigation.getParent();
69 if (!tabs) return;
70 return tabs.addListener("tabPress" as never, () => {
71 if (navigation.isFocused() && view === "items") setFiltering(true);
72 });
73 }, [navigation, view]);
74
75 const active = [
76 filter.assignee ? { key: "assignee", label: filter.assignee === me.username ? "Assigned to me" : `@${filter.assignee}` } : null,
77 filter.label ? { key: "label", label: filter.label } : null,
78 filter.milestone ? { key: "milestone", label: milestones.data?.find((one) => one.number === filter.milestone)?.title ?? `Milestone ${filter.milestone}` } : null,
79 filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null,
80 ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null);
81
82 const header = (
83 <Stack.Screen
84 options={{
85 title: "Work",
86 headerRight: () => (
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>
99 ),
100 }}
101 />
102 );
103
104 if (!slug) {
105 return (
106 <Screen>
107 {header}
108 <Empty icon={ListTodo} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
109 </Screen>
110 );
111 }
112
113 return (
114 <Screen>
115 {header}
116 <View style={[styles.bar, { borderBottomColor: colors.line }]}>
117 <Segments
118 value={view}
119 onChange={setView}
120 options={[
121 { value: "items", label: "Items" },
122 { value: "board", label: "Board" },
123 ]}
124 />
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 ? (
137 <View style={styles.chips}>
138 {active.map((one) => (
139 <Chip
140 key={one.key}
141 label={one.label}
142 tone="accent"
143 leading={<X size={12} color={colors.accent} />}
144 onPress={() => setFilter({ ...filter, [one.key]: undefined, ...(one.key === "repo" ? { role: undefined } : {}) })}
145 />
146 ))}
147 </View>
148 ) : null}
149 </View>
150
151 {view === "board" ? (
152 <Board workspace={slug} focused={focused} />
153 ) : items.isPending ? (
154 <Loading />
155 ) : items.error ? (
156 <ErrorView error={items.error} retry={() => items.refetch()} />
157 ) : (
158 <FlatList
159 data={items.items}
160 keyExtractor={(item) => item.id}
161 refreshControl={<RefreshControl refreshing={items.isRefetching && !items.isFetchingNextPage} onRefresh={() => items.refetch()} tintColor={colors.accent} />}
162 onEndReached={() => items.hasNextPage && !items.isFetchingNextPage && items.fetchNextPage()}
163 onEndReachedThreshold={0.5}
164 contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }}
165 ListFooterComponent={items.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null}
166 ListEmptyComponent={
167 <Empty
168 icon={ListTodo}
169 title={active.length ? "Nothing matches" : filter.state === "closed" ? "Nothing closed yet" : "No open work items"}
170 body={active.length ? "Try fewer filters." : "Work items are any tracked work: a feature, a bug, a chore."}
171 />
172 }
173 renderItem={({ item }) => (
174 <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} />
175 )}
176 />
177 )}
178
179 <Pressable
180 accessibilityRole="button"
181 accessibilityLabel="New work item"
182 onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug } })}
183 style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]}
184 >
185 <Plus size={20} color={colors.onAccent} strokeWidth={2.5} />
186 <Text style={{ color: colors.onAccent, fontWeight: "600" }}>New</Text>
187 </Pressable>
188
189 <Filters visible={filtering} workspace={slug} filter={filter} me={me.username} onClose={() => setFiltering(false)} onChange={setFilter} />
190 </Screen>
191 );
192}
193
194/** The list's filters, in a sheet: state, assigned to you, label, milestone, repository and its role. */
195function Filters({
196 visible,
197 workspace,
198 filter,
199 me,
200 onClose,
201 onChange,
202}: {
203 visible: boolean;
204 workspace: string;
205 filter: WorkFilter;
206 me: string;
207 onClose: () => void;
208 onChange: (filter: WorkFilter) => void;
209}) {
210 const colors = useColors();
211 const { height } = useWindowDimensions();
212 const labels = useLabels(visible ? workspace : undefined);
213 const milestones = useMilestones(visible ? workspace : undefined);
214 const repos = useRepos("");
215 const set = (change: Partial<WorkFilter>) => onChange({ ...filter, ...change });
216 const toggle = <K extends keyof WorkFilter>(key: K, value: WorkFilter[K]) => set({ [key]: filter[key] === value ? undefined : value } as Partial<WorkFilter>);
217
218 return (
219 <Sheet visible={visible} onClose={onClose}>
220 <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}>
221 <Text size="heading">Filters</Text>
222 <Segments
223 value={filter.state}
224 onChange={(state) => set({ state })}
225 options={[
226 { value: "open", label: "Open" },
227 { value: "closed", label: "Closed" },
228 { value: "all", label: "All" },
229 ]}
230 />
231 <View style={{ marginHorizontal: -space.lg }}>
232 <Row
233 title="Assigned to me"
234 chevron={false}
235 trailing={<Check on={filter.assignee === me} />}
236 onPress={() => toggle("assignee", me)}
237 />
238 </View>
239 <Group title="Label" empty={labels.isPending ? "…" : "No labels yet"}>
240 {(labels.data ?? []).map((label) => (
241 <Chip
242 key={label.name}
243 label={label.name}
244 tone={filter.label === label.name ? "accent" : "neutral"}
245 leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />}
246 onPress={() => toggle("label", label.name)}
247 />
248 ))}
249 </Group>
250 <Group title="Milestone" empty={milestones.isPending ? "…" : "No open milestones"}>
251 {(milestones.data ?? []).map((milestone) => (
252 <Chip
253 key={milestone.number}
254 label={milestone.title}
255 tone={filter.milestone === milestone.number ? "accent" : "neutral"}
256 onPress={() => toggle("milestone", milestone.number)}
257 />
258 ))}
259 </Group>
260 <Group title="Repository" empty={repos.isPending ? "…" : "No repositories you can see"}>
261 {(repos.data ?? []).map((repo) => (
262 <Chip
263 key={repo.id}
264 label={repo.name}
265 tone={filter.repo === repo.name ? "accent" : "neutral"}
266 onPress={() => set({ repo: filter.repo === repo.name ? undefined : repo.name, role: undefined })}
267 />
268 ))}
269 </Group>
270 {filter.repo ? (
271 <View style={{ gap: space.sm }}>
272 <Text size="small" tone="muted">
273 Linked to {filter.repo} as
274 </Text>
275 <Segments
276 value={filter.role ?? "any"}
277 onChange={(role) => set({ role: role === "any" ? undefined : (role as LinkRole) })}
278 options={[
279 { value: "any", label: "Any" },
280 { value: "origin", label: "Origin" },
281 { value: "target", label: "Target" },
282 { value: "touched", label: "Touched" },
283 ]}
284 />
285 </View>
286 ) : null}
287 <View style={{ flexDirection: "row", gap: space.sm }}>
288 <View style={{ flex: 1 }}>
289 <Button title="Clear" variant="secondary" onPress={() => onChange({ state: "open" })} />
290 </View>
291 <View style={{ flex: 1 }}>
292 <Button title="Done" onPress={onClose} />
293 </View>
294 </View>
295 </ScrollView>
296 </Sheet>
297 );
298}
299
300function Group({ title, empty, children }: { title: string; empty: string; children: React.ReactNode[] }) {
301 return (
302 <View style={{ gap: space.sm }}>
303 <Text size="small" tone="muted">
304 {title}
305 </Text>
306 {children.length ? (
307 <View style={styles.chips}>{children}</View>
308 ) : (
309 <Text size="small" tone="faint">
310 {empty}
311 </Text>
312 )}
313 </View>
314 );
315}
316
317function Check({ on }: { on: boolean }) {
318 const colors = useColors();
319 return (
320 <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}>
321 {on ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null}
322 </View>
323 );
324}
325
326const styles = StyleSheet.create({
327 bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
328 chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
329 actions: { flexDirection: "row", alignItems: "center", gap: space.lg },
330 fab: {
331 position: "absolute",
332 right: space.lg,
333 bottom: space.lg,
334 flexDirection: "row",
335 alignItems: "center",
336 gap: space.xs,
337 paddingHorizontal: space.lg,
338 height: 48,
339 borderRadius: radius.full,
340 elevation: 4,
341 shadowColor: "#000",
342 shadowOpacity: 0.25,
343 shadowRadius: 8,
344 shadowOffset: { width: 0, height: 2 },
345 },
346 check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" },
347});