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.

114 linesCodeBlameRaw
1import { router } from "expo-router";
2import { ListTodo, Users } from "lucide-react-native";
3import { useState } from "react";
4import { Pressable, RefreshControl, SectionList, StyleSheet, View } from "react-native";
5
6import { Empty, ErrorView, Loading, SectionHeader, Text } from "@/components/ui";
7import { Chip, ItemIcon, RepoChips } from "@/components/work";
8import { type Card, STAGES, type Stage, stageName } from "@/lib/board";
9import { harnessName } from "@/lib/sessions";
10import { ago } from "@/lib/time";
11import { type ApiWorkItem, useBoard } from "@/lib/work";
12import { space, useColors } from "@/theme";
13
14/**
15 * Work's board on a phone: the work as it moves, a stage under the next
16 * (Backlog, In progress, In review, Landing, Done), and chips above to
17 * show one stage alone. Pull down to read again; while a session is live
18 * and the board is in view, who is on what is read again every 15 s.
19 */
20export function Board({ workspace, focused }: { workspace: string; focused: boolean }) {
21 const colors = useColors();
22 const board = useBoard(workspace, focused);
23 const [stage, setStage] = useState<Stage | null>(null);
24
25 if (board.isPending) return <Loading />;
26 if (board.error) return <ErrorView error={board.error} retry={() => void board.refetch()} />;
27
28 const total = STAGES.reduce((sum, one) => sum + board.board[one].length, 0);
29 const sections = (stage ? [stage] : STAGES)
30 .map((one) => ({ stage: one, title: `${stageName(one)} · ${board.board[one].length}`, data: board.board[one] }))
31 .filter((section) => stage !== null || section.data.length > 0);
32
33 return (
34 <SectionList
35 sections={sections}
36 keyExtractor={(card) => card.item.id}
37 stickySectionHeadersEnabled={false}
38 refreshControl={<RefreshControl refreshing={board.isRefetching} onRefresh={() => void board.refetch()} tintColor={colors.accent} />}
39 contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }}
40 ListHeaderComponent={
41 <View style={[styles.stages, { borderBottomColor: colors.line }]}>
42 <Chip label={`All ${total}`} tone={stage === null ? "accent" : "neutral"} onPress={() => setStage(null)} />
43 {STAGES.map((one) => (
44 <Chip
45 key={one}
46 label={`${stageName(one)} ${board.board[one].length}`}
47 tone={stage === one ? "accent" : "neutral"}
48 onPress={() => setStage(stage === one ? null : one)}
49 />
50 ))}
51 </View>
52 }
53 renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
54 renderSectionFooter={({ section }) =>
55 section.data.length === 0 ? (
56 <Text size="small" tone="faint" style={{ paddingHorizontal: space.lg }}>
57 {section.stage === "done" ? "Nothing closed in the last two weeks." : "Nothing here."}
58 </Text>
59 ) : null
60 }
61 renderItem={({ item }) => <BoardCard card={item} />}
62 ListEmptyComponent={<Empty icon={ListTodo} title="No work yet" body="Work items are any tracked work: a feature, a bug, a chore." />}
63 />
64 );
65}
66
67/** A work item on the board: its number and title, who is on it, and its repositories. */
68function BoardCard({ card }: { card: Card<ApiWorkItem> }) {
69 const colors = useColors();
70 const { item, people } = card;
71 const who = people.map((one) => (one.harness ? `@${one.name} (${harnessName(one.harness)})` : `@${one.name}`)).join(", ");
72 return (
73 <Pressable
74 onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })}
75 style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
76 >
77 <View style={{ paddingTop: 1 }}>
78 <ItemIcon item={item} size={18} />
79 </View>
80 <View style={{ flex: 1, gap: 6 }}>
81 <Text numberOfLines={2} style={{ fontWeight: "600" }}>
82 {item.title}
83 </Text>
84 {who ? (
85 <View style={styles.meta}>
86 <Users size={13} color={colors.faint} />
87 <Text size="small" tone="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
88 {who}
89 </Text>
90 </View>
91 ) : null}
92 <View style={styles.meta}>
93 <Text size="small" tone="muted">
94 #{item.number} · {ago(item.closed_at ?? item.updated_at)}
95 </Text>
96 <RepoChips repos={item.repos} workspace={item.workspace} />
97 </View>
98 </View>
99 </Pressable>
100 );
101}
102
103const styles = StyleSheet.create({
104 stages: {
105 flexDirection: "row",
106 flexWrap: "wrap",
107 gap: 6,
108 paddingHorizontal: space.lg,
109 paddingVertical: space.md,
110 borderBottomWidth: StyleSheet.hairlineWidth,
111 },
112 row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth },
113 meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
114});