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.

398 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 ? { key: "milestone", label: milestones.data?.find((one) => one.number === filter.milestone)?.title ?? `Milestone ${filter.milestone}` } : null,
105 filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null,
106 ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null);
107
108 const sessionsLit = sessionsFiltered(sessionsFilter);
109 const views = (
110 <Segments
111 value={view}
112 onChange={setView}
113 options={[
114 { value: "items", label: "Items" },
115 { value: "board", label: "Board" },
116 { value: "sessions", label: "Sessions" },
117 ]}
118 />
119 );
120
121 const header = (
122 <Stack.Screen
123 options={{
124 title: "Work",
125 headerRight: () => (
126 <View style={styles.actions}>
127 {view === "items" ? (
128 <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}>
129 <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} />
130 </Pressable>
131 ) : null}
132 {view === "sessions" ? (
133 <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFilteringSessions(true)}>
134 <SlidersHorizontal size={22} color={sessionsLit ? colors.accent : colors.fg} />
135 </Pressable>
136 ) : null}
137 </View>
138 ),
139 }}
140 />
141 );
142
143 if (!slug) {
144 return (
145 <Screen>
146 {header}
147 <Empty icon={ListTodo} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
148 </Screen>
149 );
150 }
151
152 if (view === "sessions") {
153 return (
154 <Screen>
155 {header}
156 <SessionsPanel
157 workspace={slug}
158 me={me.username}
159 filter={sessionsFilter}
160 onChange={setSessionsFilter}
161 focused={focused}
162 filtering={filteringSessions}
163 onFiltering={setFilteringSessions}
164 top={views}
165 />
166 </Screen>
167 );
168 }
169
170 return (
171 <Screen>
172 {header}
173 <View style={[styles.bar, { borderBottomColor: colors.line }]}>
174 {views}
175 {view === "items" ? (
176 <Segments
177 value={filter.state}
178 onChange={(state) => setFilter({ ...filter, state })}
179 options={[
180 { value: "open", label: "Open" },
181 { value: "closed", label: "Closed" },
182 { value: "all", label: "All" },
183 ]}
184 />
185 ) : null}
186 {view === "items" && active.length ? (
187 <View style={styles.chips}>
188 {active.map((one) => (
189 <Chip
190 key={one.key}
191 label={one.label}
192 tone="accent"
193 leading={<X size={12} color={colors.accent} />}
194 onPress={() => setFilter({ ...filter, [one.key]: undefined, ...(one.key === "repo" ? { role: undefined } : {}) })}
195 />
196 ))}
197 </View>
198 ) : null}
199 </View>
200
201 {view === "board" ? (
202 <Board workspace={slug} focused={focused} />
203 ) : items.isPending ? (
204 <Loading />
205 ) : items.error ? (
206 <ErrorView error={items.error} retry={() => items.refetch()} />
207 ) : (
208 <FlatList
209 data={items.items}
210 keyExtractor={(item) => item.id}
211 refreshControl={<RefreshControl refreshing={items.isRefetching && !items.isFetchingNextPage} onRefresh={() => items.refetch()} tintColor={colors.accent} />}
212 onEndReached={() => items.hasNextPage && !items.isFetchingNextPage && items.fetchNextPage()}
213 onEndReachedThreshold={0.5}
214 contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }}
215 ListFooterComponent={items.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null}
216 ListEmptyComponent={
217 <Empty
218 icon={ListTodo}
219 title={active.length ? "Nothing matches" : filter.state === "closed" ? "Nothing closed yet" : "No open work items"}
220 body={active.length ? "Try fewer filters." : "Work items are any tracked work: a feature, a bug, a chore."}
221 />
222 }
223 renderItem={({ item }) => (
224 <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} />
225 )}
226 />
227 )}
228
229 <Pressable
230 accessibilityRole="button"
231 accessibilityLabel="New work item"
232 // Filtered to a repository, a new item is filed from it.
233 onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug, ...(filter.repo ? { repo: filter.repo } : {}) } })}
234 style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]}
235 >
236 <Plus size={20} color={colors.onAccent} strokeWidth={2.5} />
237 <Text style={{ color: colors.onAccent, fontWeight: "600" }}>New</Text>
238 </Pressable>
239
240 <Filters visible={filtering} workspace={slug} filter={filter} me={me.username} onClose={() => setFiltering(false)} onChange={setFilter} />
241 </Screen>
242 );
243}
244
245/** The list's filters, in a sheet: state, assigned to you, label, milestone, repository and its role. */
246function Filters({
247 visible,
248 workspace,
249 filter,
250 me,
251 onClose,
252 onChange,
253}: {
254 visible: boolean;
255 workspace: string;
256 filter: WorkFilter;
257 me: string;
258 onClose: () => void;
259 onChange: (filter: WorkFilter) => void;
260}) {
261 const colors = useColors();
262 const { height } = useWindowDimensions();
263 const labels = useLabels(visible ? workspace : undefined);
264 const milestones = useMilestones(visible ? workspace : undefined);
265 const repos = useRepos("");
266 const set = (change: Partial<WorkFilter>) => onChange({ ...filter, ...change });
267 const toggle = <K extends keyof WorkFilter>(key: K, value: WorkFilter[K]) => set({ [key]: filter[key] === value ? undefined : value } as Partial<WorkFilter>);
268
269 return (
270 <Sheet visible={visible} onClose={onClose}>
271 <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}>
272 <Text size="heading">Filters</Text>
273 <Segments
274 value={filter.state}
275 onChange={(state) => set({ state })}
276 options={[
277 { value: "open", label: "Open" },
278 { value: "closed", label: "Closed" },
279 { value: "all", label: "All" },
280 ]}
281 />
282 <View style={{ marginHorizontal: -space.lg }}>
283 <Row
284 title="Assigned to me"
285 chevron={false}
286 trailing={<Check on={filter.assignee === me} />}
287 onPress={() => toggle("assignee", me)}
288 />
289 </View>
290 <Group title="Label" empty={labels.isPending ? "…" : "No labels yet"}>
291 {(labels.data ?? []).map((label) => (
292 <Chip
293 key={label.name}
294 label={label.name}
295 tone={filter.label === label.name ? "accent" : "neutral"}
296 leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />}
297 onPress={() => toggle("label", label.name)}
298 />
299 ))}
300 </Group>
301 <Group title="Milestone" empty={milestones.isPending ? "…" : "No open milestones"}>
302 {(milestones.data ?? []).map((milestone) => (
303 <Chip
304 key={milestone.number}
305 label={milestone.title}
306 tone={filter.milestone === milestone.number ? "accent" : "neutral"}
307 onPress={() => toggle("milestone", milestone.number)}
308 />
309 ))}
310 </Group>
311 <Group title="Repository" empty={repos.isPending ? "…" : "No repositories you can see"}>
312 {(repos.data ?? []).map((repo) => (
313 <Chip
314 key={repo.id}
315 label={repo.name}
316 tone={filter.repo === repo.name ? "accent" : "neutral"}
317 onPress={() => set({ repo: filter.repo === repo.name ? undefined : repo.name, role: undefined })}
318 />
319 ))}
320 </Group>
321 {filter.repo ? (
322 <View style={{ gap: space.sm }}>
323 <Text size="small" tone="muted">
324 Linked to {filter.repo} as
325 </Text>
326 <Segments
327 value={filter.role ?? "any"}
328 onChange={(role) => set({ role: role === "any" ? undefined : (role as LinkRole) })}
329 options={[
330 { value: "any", label: "Any" },
331 { value: "origin", label: "Origin" },
332 { value: "target", label: "Target" },
333 { value: "touched", label: "Touched" },
334 ]}
335 />
336 </View>
337 ) : null}
338 <View style={{ flexDirection: "row", gap: space.sm }}>
339 <View style={{ flex: 1 }}>
340 <Button title="Clear" variant="secondary" onPress={() => onChange({ state: "open" })} />
341 </View>
342 <View style={{ flex: 1 }}>
343 <Button title="Done" onPress={onClose} />
344 </View>
345 </View>
346 </ScrollView>
347 </Sheet>
348 );
349}
350
351function Group({ title, empty, children }: { title: string; empty: string; children: React.ReactNode[] }) {
352 return (
353 <View style={{ gap: space.sm }}>
354 <Text size="small" tone="muted">
355 {title}
356 </Text>
357 {children.length ? (
358 <View style={styles.chips}>{children}</View>
359 ) : (
360 <Text size="small" tone="faint">
361 {empty}
362 </Text>
363 )}
364 </View>
365 );
366}
367
368function Check({ on }: { on: boolean }) {
369 const colors = useColors();
370 return (
371 <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}>
372 {on ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null}
373 </View>
374 );
375}
376
377const styles = StyleSheet.create({
378 bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
379 chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
380 actions: { flexDirection: "row", alignItems: "center", gap: space.lg },
381 fab: {
382 position: "absolute",
383 right: space.lg,
384 bottom: space.lg,
385 flexDirection: "row",
386 alignItems: "center",
387 gap: space.xs,
388 paddingHorizontal: space.lg,
389 height: 48,
390 borderRadius: radius.full,
391 elevation: 4,
392 shadowColor: "#000",
393 shadowOpacity: 0.25,
394 shadowRadius: 8,
395 shadowOffset: { width: 0, height: 2 },
396 },
397 check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" },
398});