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.

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