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.

159 linesCodeBlameRaw
1import { Stack, router, useLocalSearchParams } from "expo-router";
2import { useHeaderHeight } from "expo-router/react-navigation";
3import { Check } from "lucide-react-native";
4import { useRef, useState } from "react";
5import { KeyboardAvoidingView, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
6
7import { Button, Text } from "@/components/ui";
8import { Chip } from "@/components/work";
9import { useRepos } from "@/lib/code";
10import { useSignedIn } from "@/lib/session";
11import { useLabels, useOpenWorkItem } from "@/lib/work";
12import { radius, space, useColors } from "@/theme";
13
14/**
15 * A new work item: a title, what it is about, its labels, and the
16 * repositories where it is meant to land (none is fine: it can be linked
17 * later). Opened from a repository's Work, that repository is its origin.
18 * Made for the on-screen keyboard: the title's return goes on to the
19 * description, and Create stays in the header above the keyboard.
20 */
21export default function NewWorkItem() {
22 const params = useLocalSearchParams<{ workspace?: string; repo?: string }>();
23 const { workspace: current } = useSignedIn();
24 const workspace = params.workspace ?? current?.slug ?? "";
25 const origin = params.repo ? `${workspace}/${params.repo}` : undefined;
26 const colors = useColors();
27 const headerHeight = useHeaderHeight();
28 const [title, setTitle] = useState("");
29 const [body, setBody] = useState("");
30 const [labels, setLabels] = useState<string[]>([]);
31 const [targets, setTargets] = useState<string[]>([]);
32 const bodyField = useRef<TextInput>(null);
33 const known = useLabels(workspace);
34 const repos = useRepos("");
35 const open = useOpenWorkItem(workspace);
36 const ready = title.trim().length > 0 && !open.isPending;
37
38 const toggle = (list: string[], value: string) => (list.includes(value) ? list.filter((one) => one !== value) : [...list, value]);
39 const create = () => {
40 if (!ready) return;
41 open.mutate(
42 { title: title.trim(), body: body.trim(), labels, targets, ...(origin ? { repo: origin } : {}) },
43 { onSuccess: (item) => router.replace({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } }) },
44 );
45 };
46
47 return (
48 <View style={{ flex: 1, backgroundColor: colors.bg }}>
49 <Stack.Screen
50 options={{
51 title: "New work item",
52 headerRight: () => (
53 <Pressable accessibilityRole="button" accessibilityLabel="Create" hitSlop={10} disabled={!ready} onPress={create}>
54 <Text style={{ color: ready ? colors.accent : colors.faint, fontWeight: "700" }}>Create</Text>
55 </Pressable>
56 ),
57 }}
58 />
59 <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
60 <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
61 {origin ? (
62 <Text size="small" tone="muted">
63 Filed from {params.repo}, in {workspace}
64 </Text>
65 ) : (
66 <Text size="small" tone="muted">
67 In {workspace}
68 </Text>
69 )}
70 <TextInput
71 autoFocus
72 value={title}
73 onChangeText={setTitle}
74 placeholder="Title"
75 placeholderTextColor={colors.faint}
76 returnKeyType="next"
77 submitBehavior="submit"
78 onSubmitEditing={() => bodyField.current?.focus()}
79 style={[styles.title, { color: colors.fg, borderBottomColor: colors.line }]}
80 />
81 <TextInput
82 ref={bodyField}
83 value={body}
84 onChangeText={setBody}
85 placeholder="What is it about? Markdown works. Say what done means, under a “Definition of done” heading."
86 placeholderTextColor={colors.faint}
87 multiline
88 textAlignVertical="top"
89 style={[styles.body, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
90 />
91
92 <Group title="Labels" empty={known.isPending ? "…" : "This workspace has no labels yet."}>
93 {(known.data ?? []).map((label) => (
94 <Chip
95 key={label.name}
96 label={label.name}
97 tone={labels.includes(label.name) ? "accent" : "neutral"}
98 leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />}
99 onPress={() => setLabels(toggle(labels, label.name))}
100 />
101 ))}
102 </Group>
103
104 <Group
105 title="Where it lands"
106 hint="The repositories the work is meant to land in. It closes once it has landed in each. Leave them out to pick later."
107 empty={repos.isPending ? "…" : "No repositories you can see."}
108 >
109 {(repos.data ?? []).map((repo) => {
110 const path = `${repo.namespace}/${repo.name}`;
111 const on = targets.includes(path);
112 return (
113 <Chip
114 key={repo.id}
115 label={repo.name}
116 tone={on ? "accent" : "neutral"}
117 leading={on ? <Check size={12} color={colors.accent} /> : null}
118 onPress={() => setTargets(toggle(targets, path))}
119 />
120 );
121 })}
122 </Group>
123
124 {open.error ? <Text tone="danger">{open.error.message}</Text> : null}
125 <Button title="Create" busy={open.isPending} disabled={!ready} onPress={create} />
126 </ScrollView>
127 </KeyboardAvoidingView>
128 </View>
129 );
130}
131
132function Group({ title, hint, empty, children }: { title: string; hint?: string; empty: string; children: React.ReactNode[] }) {
133 return (
134 <View style={{ gap: space.sm }}>
135 <Text size="small" tone="muted" style={{ fontWeight: "600" }}>
136 {title}
137 </Text>
138 {hint ? (
139 <Text size="small" tone="faint">
140 {hint}
141 </Text>
142 ) : null}
143 {children.length ? (
144 <View style={styles.chips}>{children}</View>
145 ) : (
146 <Text size="small" tone="faint">
147 {empty}
148 </Text>
149 )}
150 </View>
151 );
152}
153
154const styles = StyleSheet.create({
155 content: { padding: space.lg, gap: space.lg, paddingBottom: space.xxl },
156 title: { fontSize: 20, fontWeight: "600", paddingVertical: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
157 body: { minHeight: 160, borderWidth: 1, borderRadius: radius.md, padding: space.md, fontSize: 15, lineHeight: 21 },
158 chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
159});