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.

174 linesCodeBlameRaw
1import { CircleCheck, CircleDot, CircleX, Ellipsis, FolderGit2, MessageSquare, SendHorizontal } from "lucide-react-native";
2import { type ReactNode, useEffect, useState } from "react";
3import { ActivityIndicator, Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";
5
6import { Text } from "@/components/ui";
7import { ago } from "@/lib/time";
8import type { ApiItemRepo, ApiWorkItem, LinkRole } from "@/lib/work";
9import { radius, space, useColors } from "@/theme";
10
11/** A work item's state, as an icon in its colour: open, done, or closed as not planned. */
12export function ItemIcon({ item, size = 20 }: { item: Pick<ApiWorkItem, "state" | "reason">; size?: number }) {
13 const colors = useColors();
14 if (item.state === "open") return <CircleDot size={size} color={colors.success} accessibilityLabel="Open" />;
15 return item.reason === "not_planned" ? (
16 <CircleX size={size} color={colors.muted} accessibilityLabel="Closed as not planned" />
17 ) : (
18 <CircleCheck size={size} color={colors.merged} accessibilityLabel="Closed" />
19 );
20}
21
22export function stateLabel(item: Pick<ApiWorkItem, "state" | "reason">): string {
23 if (item.state === "open") return "Open";
24 return item.reason === "not_planned" ? "Not planned" : "Closed";
25}
26
27/** A small rounded tag: a label, a repository, a filter. */
28export function Chip({ label, tone = "neutral", onPress, leading }: { label: string; tone?: "neutral" | "accent"; onPress?: () => void; leading?: ReactNode }) {
29 const colors = useColors();
30 const on = tone === "accent";
31 return (
32 <Pressable
33 onPress={onPress}
34 disabled={!onPress}
35 accessibilityRole={onPress ? "button" : undefined}
36 style={({ pressed }) => [
37 styles.chip,
38 { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accentLow : pressed ? colors.raised : "transparent" },
39 ]}
40 >
41 {leading}
42 <Text size="small" numberOfLines={1} style={{ color: on ? colors.accent : colors.fgSoft, fontWeight: "500", maxWidth: 180 }}>
43 {label}
44 </Text>
45 </Pressable>
46 );
47}
48
49/** A repository's name in a chip: just the name when it is in the item's workspace. */
50export function repoName(path: string, workspace: string): string {
51 const [owner, name] = path.split("/", 2);
52 return name && owner.toLowerCase() === workspace.toLowerCase() ? name : path;
53}
54
55/**
56 * The repositories an item is linked to, as chips that fit a phone's row:
57 * the first `max`, then "+2" for the rest. Origin and targets come first.
58 */
59export function RepoChips({ repos, workspace, max = 2 }: { repos: ApiItemRepo[]; workspace: string; max?: number }) {
60 const colors = useColors();
61 const shown = repos.filter((repo) => repo.role !== "touched").concat(repos.filter((repo) => repo.role === "touched"));
62 if (shown.length === 0) return null;
63 const rest = shown.length - max;
64 return (
65 <>
66 {shown.slice(0, max).map((repo) => (
67 <Chip key={repo.repo_id} label={repoName(repo.path, workspace)} leading={<FolderGit2 size={12} color={colors.muted} />} />
68 ))}
69 {rest > 0 ? <Chip label={`+${rest}`} /> : null}
70 </>
71 );
72}
73
74/** One work item in a list: its number and title, then state, repositories and labels on a line that wraps. */
75export function ItemRow({ item, onPress }: { item: ApiWorkItem; onPress: () => void }) {
76 const colors = useColors();
77 return (
78 <Pressable
79 onPress={onPress}
80 style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
81 >
82 <View style={{ paddingTop: 1 }}>
83 <ItemIcon item={item} size={18} />
84 </View>
85 <View style={{ flex: 1, gap: 6 }}>
86 <Text numberOfLines={2} style={{ fontWeight: "600" }}>
87 {item.title}
88 </Text>
89 <View style={styles.meta}>
90 <Text size="small" tone="muted">
91 #{item.number} · {ago(item.updated_at)}
92 </Text>
93 <RepoChips repos={item.repos} workspace={item.workspace} />
94 {item.labels.slice(0, 2).map((label) => (
95 <Chip key={label} label={label} />
96 ))}
97 {item.labels.length > 2 ? <Chip label={`+${item.labels.length - 2}`} /> : null}
98 {item.comment_count ? (
99 <View style={{ flexDirection: "row", alignItems: "center", gap: 3 }}>
100 <MessageSquare size={13} color={colors.faint} />
101 <Text size="small" tone="faint">
102 {item.comment_count}
103 </Text>
104 </View>
105 ) : null}
106 </View>
107 </View>
108 </Pressable>
109 );
110}
111
112const ROLES: Record<LinkRole, string> = { origin: "Origin", target: "Target", touched: "Touched" };
113
114export function roleLabel(role: LinkRole): string {
115 return ROLES[role];
116}
117
118/**
119 * The bar at the foot of an item: a box to comment in, the arrow to send
120 * it, and ⋯ for everything else (close, reopen, link a repository), all
121 * where a thumb reaches. What was typed stays until it is sent.
122 */
123export function CommentBar({ busy, onSend, onMore }: { busy: boolean; onSend: (body: string) => Promise<unknown>; onMore: () => void }) {
124 const colors = useColors();
125 const insets = useSafeAreaInsets();
126 const [text, setText] = useState("");
127 const [keyboard, setKeyboard] = useState(false);
128 useEffect(() => {
129 const shown = Keyboard.addListener("keyboardDidShow", () => setKeyboard(true));
130 const hidden = Keyboard.addListener("keyboardDidHide", () => setKeyboard(false));
131 return () => {
132 shown.remove();
133 hidden.remove();
134 };
135 }, []);
136 const ready = text.trim().length > 0 && !busy;
137 const send = () => {
138 if (!ready) return;
139 void onSend(text.trim()).then(() => setText(""), () => undefined);
140 };
141 return (
142 <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) }]}>
143 <Pressable accessibilityRole="button" accessibilityLabel="More actions" onPress={onMore} style={[styles.round, { backgroundColor: colors.raised }]}>
144 <Ellipsis size={20} color={colors.fg} />
145 </Pressable>
146 <TextInput
147 value={text}
148 onChangeText={setText}
149 placeholder="Comment"
150 placeholderTextColor={colors.faint}
151 multiline
152 style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
153 />
154 <Pressable
155 accessibilityRole="button"
156 accessibilityLabel="Comment"
157 onPress={send}
158 disabled={!ready}
159 style={[styles.round, { backgroundColor: ready ? colors.accent : colors.raised }]}
160 >
161 {busy ? <ActivityIndicator color={colors.fg} /> : <SendHorizontal size={18} color={ready ? colors.onAccent : colors.faint} />}
162 </Pressable>
163 </View>
164 );
165}
166
167const styles = StyleSheet.create({
168 chip: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 },
169 row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth },
170 meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
171 bar: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth },
172 input: { flex: 1, minHeight: 40, maxHeight: 140, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingTop: 10, paddingBottom: 10, fontSize: 15 },
173 round: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
174});