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.

Commit

Phone app: a Work tab, and work items replace a repository's issues

Work comes right after Home, for every member: the list with its filters in a sheet, a work item with its repositories, what landed and what is missing, suggested targets and comments, and a new one with targets. Code moves to the top of More. g1t://<workspace>/-/work links open them, and old <owner>/<repo>/issues/<n> links resolve to the work item or pull request they name. #N reads by the same rules as the site, and the app asks for work:read and work:write.

G1T-Session:
ses_01m560bz6eeqc8v1hrrha03pap
syntaqxcommitted Parent0781d4dBrowse files
22 files+1555−2190/22 viewed
+9−6
11 # g1t for Android and iOS
22
33 The phone app: [Expo](https://expo.dev) (SDK 57, React Native 0.86) with
4−Expo Router, in TypeScript. Chat and Code, signed in with g1t's
4+Expo Router, in TypeScript. Work, Chat and Code, signed in with g1t's
55 own OAuth. Android is the first target. What it does for people is in the
66 guide, [The phone app](../docs/src/content/docs/guides/mobile.md).
77
1717 sign-in.tsx Sign in with g1t, an access token, or another server
1818 oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback)
1919 (app)/ signed in: the tabs
20+ work/ the workspace's work items: the list, an item, new
2021 chat/ the list, a conversation, a thread, new, browse
21− code/ repositories, pull requests, issues, diffs
22− more/ notifications, workspaces, sign out
22+ code/ repositories, pull requests, diffs (opened from More)
23+ more/ Code, notifications, workspaces, sign out
2324 home/, notifications/ coming: they open the site
24− +native-intent.tsx g1t:// links to screens (lib/links.ts)
25− components/ the building blocks, themed (ui.tsx); chat/ and Code's
25+ ref.tsx what #N names (a work item or a pull request), opened
26+ +native-intent.tsx g1t:// and https links to screens (lib/links.ts)
27+ components/ the building blocks, themed (ui.tsx); chat/, Code's and Work's
2628 lib/
2729 server.ts which g1t: site and API addresses
2830 oauth.ts OAuth 2.1 with PKCE against the server's metadata
2931 credentials.ts the server and credentials, in the phone's keystore
3032 client.ts every request: refresh, errors, the site or the API
3133 session.tsx who is signed in, to which workspace
32− chat.ts, code.ts the data for Chat and Code (TanStack Query)
34+ chat.ts, code.ts, work.ts the data for Chat, Code and Work (TanStack Query)
3335 live.ts the site's live sockets, with tickets, kept open
3436 feed.tsx counts, banners and presence while the app is open
3537 push.ts notifications on the phone
4446 | What | Where | How it is signed in |
4547 | --- | --- | --- |
4648 | Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` |
49+| Work | `{api}/workspaces/<workspace>/work…`, `…/labels`, `…/milestones` REST | the same |
4750 | Code | `{api}/repos…` REST | the same |
4851 | Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, with `chat:*` ([below](#chat-with-the-apps-sign-in)); a socket gets a ticket from `{site}/-/live/ticket` first |
4952 | Notifications | `{site}/-/notify` and the feed socket `{site}/-/live` | the same, with `notifications:*` |
+1−1
22 "name": "@g1t/mobile",
33 "private": true,
44 "version": "0.1.0",
5− "description": "The g1t phone app (Expo): Chat and Code first, signed in with OAuth, for Android and iOS.",
5+ "description": "The g1t phone app (Expo): Work, Chat and Code first, signed in with OAuth, for Android and iOS.",
66 "main": "expo-router/entry",
77 "scripts": {
88 "start": "expo start",
+8−7
11 import * as Notifications from "expo-notifications";
22 import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router";
3−import { Bell, Code2, House, Menu, MessagesSquare } from "lucide-react-native";
3+import { Bell, House, ListTodo, Menu, MessagesSquare } from "lucide-react-native";
44 import type { LucideIcon } from "lucide-react-native";
55 import { type ComponentType, useEffect, useRef } from "react";
66 import { StyleSheet, View } from "react-native";
1010 import { routeFor } from "@/lib/links";
1111 import { type PushData, registerForPush } from "@/lib/push";
1212 import { useSignedIn } from "@/lib/session";
13−import { hasCodeAccess } from "@g1t/contracts";
1413 import { useColors } from "@/theme";
1514
1615 /**
1716 * The signed-in app. Its bar is the site's phone bar
18− * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Code,
19− * and More. Chat and Code have their screens; the others say so and open
20− * the site. Your account is under More.
17+ * (apps/web/app/components/mobile.tsx): Home, Work, Chat, Notifications
18+ * and More. Work and Chat have their screens; Home and Notifications say
19+ * so and open the site. Code is at the top of More, for those who use it,
20+ * and your account is under it.
2121 */
2222 export default function AppLayout() {
2323 return (
3838 useEffect(() => {
3939 if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked);
4040 }, [linked, workspace?.slug, selectWorkspace]);
41− const code = !workspace || hasCodeAccess(workspace);
4241 // A conversation or a thread takes the whole screen, as on the site: the bar steps aside.
4342 const segments = useSegments() as string[];
4443 const inConversation = segments[1] === "chat" && (segments[2] === "[channel]" || segments[2] === "thread");
6362 >
6463 <Tabs.Screen name="index" options={{ href: null }} />
6564 <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: (props) => <BarIcon icon={House} {...props} /> }} />
65+ <Tabs.Screen name="work" options={{ title: "Work", tabBarIcon: (props) => <BarIcon icon={ListTodo} {...props} /> }} />
6666 <Tabs.Screen
6767 name="chat"
6868 options={{
7373 }}
7474 />
7575 <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: (props) => <BarIcon icon={Bell} {...props} /> }} />
76− <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: (props) => <BarIcon icon={Code2} {...props} /> }} />
76+ {/* Code opens from More, so it has no place in the bar. */}
77+ <Tabs.Screen name="code" options={{ title: "Code", href: null }} />
7778 <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} />
7879 </Tabs>
7980 );
+25−27
11 import { Stack, router, useLocalSearchParams } from "expo-router";
2−import { CircleDot, GitPullRequest } from "lucide-react-native";
2+import { GitPullRequest, ListTodo } from "lucide-react-native";
33 import { useState } from "react";
4−import { FlatList, RefreshControl, View } from "react-native";
4+import { ActivityIndicator, FlatList, RefreshControl, View } from "react-native";
55
6−import { Checks, IssueIcon, PullIcon } from "@/components/code";
6+import { Checks, PullIcon } from "@/components/code";
77 import { Segments } from "@/components/segments";
88 import { Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
9−import { type ApiIssue, type ApiPull, useDescriptions, useIssues, usePulls } from "@/lib/code";
9+import { ItemRow } from "@/components/work";
10+import { type ApiPull, useDescriptions, usePulls } from "@/lib/code";
1011 import { ago } from "@/lib/time";
12+import { useWorkItems } from "@/lib/work";
1113 import { space, useColors } from "@/theme";
1214
13−type Tab = "pulls" | "issues";
15+type Tab = "pulls" | "work";
1416 type State = "open" | "closed";
1517
16−/** A repository: its pull requests and issues. */
18+/**
19+ * A repository: its pull requests, and its Work: the workspace's work
20+ * items linked to it, in any role.
21+ */
1722 export default function Repository() {
1823 const { owner, repo } = useLocalSearchParams<{ owner: string; repo: string }>();
1924 const [tab, setTab] = useState<Tab>("pulls");
2025 const [state, setState] = useState<State>("open");
2126 const description = useDescriptions(owner).data?.[`${owner}/${repo}`.toLowerCase()];
2227 const pulls = usePulls(owner, repo, state);
23− const issues = useIssues(owner, repo, state);
24− const list = tab === "pulls" ? pulls : issues;
28+ const work = useWorkItems(owner, { state, repo });
29+ const list = tab === "pulls" ? pulls : work;
2530 const colors = useColors();
2631
2732 return (
3742 onChange={setTab}
3843 options={[
3944 { value: "pulls", label: "Pull requests" },
40− { value: "issues", label: "Issues" },
45+ { value: "work", label: "Work" },
4146 ]}
4247 />
4348 <Segments
6368 />
6469 ) : (
6570 <FlatList
66− data={issues.data}
67− keyExtractor={(issue) => issue.id}
68− refreshControl={<RefreshControl refreshing={issues.isRefetching} onRefresh={() => issues.refetch()} tintColor={colors.accent} />}
69− ListEmptyComponent={<Empty icon={CircleDot} title={`No ${state} issues`} />}
70− renderItem={({ item }) => <IssueRow issue={item} owner={owner} repo={repo} />}
71+ data={work.items}
72+ keyExtractor={(item) => item.id}
73+ refreshControl={<RefreshControl refreshing={work.isRefetching && !work.isFetchingNextPage} onRefresh={() => work.refetch()} tintColor={colors.accent} />}
74+ onEndReached={() => work.hasNextPage && !work.isFetchingNextPage && work.fetchNextPage()}
75+ onEndReachedThreshold={0.5}
76+ ListFooterComponent={work.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null}
77+ ListEmptyComponent={<Empty icon={ListTodo} title={`No ${state} work items`} body={`Work items linked to ${repo} show here.`} />}
78+ renderItem={({ item }) => (
79+ <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} />
80+ )}
7181 />
7282 )}
7383 </Screen>
8090 <Row
8191 leading={<PullIcon pull={pull} />}
8292 title={pull.title}
83− subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""} · ${ago(pull.updated_at)}`}
93+ subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""}${pull.item ? ` · for #${pull.item}` : ""} · ${ago(pull.updated_at)}`}
8494 trailing={<Checks status={pull.check_status} />}
8595 onPress={() => router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo, number: String(pull.number) } })}
86− />
87− );
88−}
89−
90−function IssueRow({ issue, owner, repo }: { issue: ApiIssue; owner: string; repo: string }) {
91− const by = issue.author.display_username ?? issue.author.username;
92− return (
93− <Row
94− leading={<IssueIcon issue={issue} />}
95− title={issue.title}
96− subtitle={`#${issue.number} · ${by} · ${ago(issue.updated_at)}${issue.comment_count ? ` · ${issue.comment_count} comments` : ""}`}
97− onPress={() => router.push({ pathname: "/code/[owner]/[repo]/issues/[number]", params: { owner, repo, number: String(issue.number) } })}
9896 />
9997 );
10098 }
+0−86
1−import { Stack, router, useLocalSearchParams } from "expo-router";
2−import * as WebBrowser from "expo-web-browser";
3−import { ExternalLink, Send } from "lucide-react-native";
4−import { useState } from "react";
5−import { View } from "react-native";
6−
7−import { Body, IssueIcon, PullIcon, Timeline } from "@/components/code";
8−import { Button, ErrorView, Field, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
9−import { useComment, useIssue } from "@/lib/code";
10−import { siteUrl } from "@/lib/server";
11−import { useSignedIn } from "@/lib/session";
12−import { ago } from "@/lib/time";
13−import { space } from "@/theme";
14−
15−/** An issue: what it asks for, the pull requests made for it, and its conversation. */
16−export default function Issue() {
17− const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>();
18− const number = Number(params.number);
19− const { server } = useSignedIn();
20− const issue = useIssue(params.owner, params.repo, number);
21− const comment = useComment(params.owner, params.repo, number);
22− const [draft, setDraft] = useState("");
23−
24− if (issue.isPending || issue.error) {
25− return (
26− <Screen>
27− <Stack.Screen options={{ title: `#${number}` }} />
28− {issue.error ? <ErrorView error={issue.error} retry={() => issue.refetch()} /> : <Loading />}
29− </Screen>
30− );
31− }
32−
33− const { issue: item, pulls, comments } = issue.data;
34− const by = item.author.display_username ?? item.author.username;
35−
36− return (
37− <Screen scroll>
38− <Stack.Screen options={{ title: `#${number}` }} />
39− <View style={{ gap: space.sm }}>
40− <Text size="heading">{item.title}</Text>
41− <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
42− <IssueIcon issue={item} />
43− <Text size="small" tone="muted">
44− {item.state === "open" ? "Open" : "Closed"} · {by} · {ago(item.created_at)}
45− {item.labels.length ? ` · ${item.labels.join(", ")}` : ""}
46− </Text>
47− </View>
48− </View>
49− <Body text={item.body} />
50− {pulls.length ? (
51− <View style={{ marginHorizontal: -space.lg }}>
52− <SectionHeader title="Pull requests" />
53− {pulls.map((pull) => (
54− <Row
55− key={pull.id}
56− leading={<PullIcon pull={pull} />}
57− title={pull.title}
58− subtitle={`#${pull.number} · ${pull.status}`}
59− onPress={() =>
60− router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner: params.owner, repo: params.repo, number: String(pull.number) } })
61− }
62− />
63− ))}
64− </View>
65− ) : null}
66− <Timeline comments={comments} />
67− <View style={{ gap: space.sm }}>
68− <Field label="Comment" placeholder="Add to the conversation" value={draft} onChangeText={setDraft} multiline />
69− <Button
70− title="Comment"
71− icon={Send}
72− busy={comment.isPending}
73− disabled={!draft.trim()}
74− onPress={() => comment.mutate(draft.trim(), { onSuccess: () => setDraft("") })}
75− />
76− {comment.error ? <Text tone="danger">{comment.error.message}</Text> : null}
77− </View>
78− <Button
79− title="Open on g1t"
80− variant="secondary"
81− icon={ExternalLink}
82− onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/issues/${number}`))}
83− />
84− </Screen>
85− );
86−}
+18−5
1−import { Stack, useLocalSearchParams } from "expo-router";
1+import { Stack, router, useLocalSearchParams } from "expo-router";
22 import * as WebBrowser from "expo-web-browser";
33 import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native";
44 import { useState } from "react";
66
77 import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code";
88 import { Segments } from "@/components/segments";
9−import { Button, ErrorView, Field, Loading, Screen, Text } from "@/components/ui";
9+import { Button, ErrorView, Field, Loading, Row, Screen, Text } from "@/components/ui";
10+import { ItemIcon, stateLabel } from "@/components/work";
1011 import { usePull, usePullChanges, useReview } from "@/lib/code";
1112 import { siteUrl } from "@/lib/server";
1213 import { useSignedIn } from "@/lib/session";
4748 );
4849 }
4950
50− const { pull: pr, comments } = pull.data;
51+ const { pull: pr, item, comments } = pull.data;
5152 const by = pr.author.display_username ?? pr.author.username;
5253 const reviewable = pr.status === "open";
54+ const refs = { repo: `${params.owner}/${params.repo}` };
5355
5456 return (
5557 <Screen scroll>
6668 </View>
6769 </View>
6870
71+ {item ? (
72+ <View style={{ marginHorizontal: -space.lg }}>
73+ <Row
74+ leading={<ItemIcon item={item} />}
75+ title={item.title}
76+ subtitle={`For work item #${item.number} · ${stateLabel(item)}`}
77+ onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })}
78+ />
79+ </View>
80+ ) : null}
81+
6982 <Segments
7083 value={tab}
7184 onChange={setTab}
7790
7891 {tab === "conversation" ? (
7992 <>
80− <Body text={pr.body} />
81− <Timeline comments={comments} />
93+ <Body text={pr.body} refs={refs} />
94+ <Timeline comments={comments} refs={refs} />
8295 {reviewable ? (
8396 <View style={{ gap: space.md }}>
8497 <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline />
+16−3
11 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22 import Constants from "expo-constants";
3−import { Stack } from "expo-router";
3+import { hasCodeAccess } from "@g1t/contracts";
4+import { Stack, router } from "expo-router";
45 import * as WebBrowser from "expo-web-browser";
5−import { Bell, BellRing, Check, ExternalLink, LogOut } from "lucide-react-native";
6+import { Bell, BellRing, Check, Code2, ExternalLink, LogOut } from "lucide-react-native";
67 import { Linking, View } from "react-native";
78
89 import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
1112 import { useSignedIn } from "@/lib/session";
1213 import { space, useColors } from "@/theme";
1314
14−/** More: your workspaces, notifications on this phone, how you signed in, and signing out. */
15+/** More: Code, notifications on this phone, your workspaces, how you signed in, and signing out. */
1516 export default function More() {
1617 const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
18+ const code = !workspace || hasCodeAccess(workspace);
1719 const colors = useColors();
1820 const queries = useQueryClient();
1921 const name = me.display_username ?? me.username;
4244 </View>
4345 </View>
4446
47+ {code ? (
48+ <View>
49+ <Row
50+ leading={<Code2 size={22} color={colors.accent} />}
51+ title="Code"
52+ subtitle="Repositories, their pull requests and changes"
53+ onPress={() => router.push("/code")}
54+ />
55+ </View>
56+ ) : null}
57+
4558 <View>
4659 <Row
4760 leading={<Bell size={22} color={colors.accent} />}
+348−0
1+import * as Clipboard from "expo-clipboard";
2+import { Stack, router, useLocalSearchParams } from "expo-router";
3+import { useHeaderHeight } from "expo-router/react-navigation";
4+import * as WebBrowser from "expo-web-browser";
5+import {
6+ CircleCheck,
7+ CircleDot,
8+ CircleX,
9+ Copy,
10+ ExternalLink,
11+ FolderGit2,
12+ Link2,
13+ Plus,
14+ Search,
15+ Unlink,
16+} from "lucide-react-native";
17+import { useState } from "react";
18+import { Alert, KeyboardAvoidingView, RefreshControl, ScrollView, StyleSheet, TextInput, View, useWindowDimensions } from "react-native";
19+
20+import { Sheet, SheetAction } from "@/components/chat/sheet";
21+import { Body, Checks, PullIcon, Timeline } from "@/components/code";
22+import { Badge, Button, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
23+import { Chip, CommentBar, ItemIcon, repoName, roleLabel, stateLabel } from "@/components/work";
24+import { useRepos } from "@/lib/code";
25+import { siteUrl } from "@/lib/server";
26+import { useSignedIn } from "@/lib/session";
27+import { ago } from "@/lib/time";
28+import { type ApiItemRepo, type ApiWorkPull, type ApiWorkRepo, useItemActions, useWorkContext, useWorkItem } from "@/lib/work";
29+import { radius, space, useColors } from "@/theme";
30+
31+/**
32+ * A work item: what it asks for, the repositories it is linked to (where
33+ * it came from, where it is meant to land, and what has landed where),
34+ * each one's pull requests for it, and its conversation. The bar at the
35+ * foot comments, and its ⋯ closes, reopens and links a repository.
36+ */
37+export default function WorkItem() {
38+ const params = useLocalSearchParams<{ number: string; workspace?: string }>();
39+ const { server, workspace: current } = useSignedIn();
40+ const workspace = params.workspace ?? current?.slug ?? "";
41+ const number = Number(params.number);
42+ const detail = useWorkItem(workspace, number);
43+ const context = useWorkContext(workspace, number);
44+ const actions = useItemActions(workspace, number);
45+ const headerHeight = useHeaderHeight();
46+ const colors = useColors();
47+ const [more, setMore] = useState(false);
48+ const [linking, setLinking] = useState(false);
49+ const [picked, setPicked] = useState<ApiItemRepo | null>(null);
50+
51+ const title = <Stack.Screen options={{ title: `#${number}` }} />;
52+ if (detail.isPending || detail.error) {
53+ return (
54+ <Screen>
55+ {title}
56+ {detail.error ? <ErrorView error={detail.error} retry={() => detail.refetch()} /> : <Loading />}
57+ </Screen>
58+ );
59+ }
60+
61+ const { item, comments } = detail.data;
62+ const by = item.author.display_username ?? item.author.username;
63+ const refs = { workspace };
64+ const sitePath = `/${workspace}/-/work/${number}`;
65+ // The context has each repository's pull requests; the item alone still has its repositories.
66+ const repos: ApiWorkRepo[] = context.data?.repos ?? item.repos.map((repo) => ({ ...repo, pulls: [] }));
67+ const missing = context.data?.missing ?? item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path);
68+ const linked = new Set(repos.map((repo) => repo.path.toLowerCase()));
69+ const suggested = (context.data?.suggested_targets ?? []).filter((one) => !linked.has(one.path.toLowerCase()));
70+
71+ const failed = (what: string) => (error: unknown) => Alert.alert(what, error instanceof Error ? error.message : "Something went wrong.");
72+ const close = (reason: "completed" | "not_planned") => {
73+ setMore(false);
74+ actions.close.mutate(reason, { onError: failed("Could not close it") });
75+ };
76+ const reopen = () => {
77+ setMore(false);
78+ actions.reopen.mutate(undefined, { onError: failed("Could not reopen it") });
79+ };
80+ const link = (repo: string) => {
81+ setLinking(false);
82+ actions.link.mutate({ repo, role: "target" }, { onError: failed("Could not link it") });
83+ };
84+ const openRepo = (path: string) => {
85+ const [owner, name] = path.split("/", 2);
86+ router.push({ pathname: "/code/[owner]/[repo]", params: { owner, repo: name } });
87+ };
88+ const openPull = (pull: ApiWorkPull) => {
89+ const [owner, name] = pull.repo.split("/", 2);
90+ router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(pull.number) } });
91+ };
92+ const busy = actions.close.isPending || actions.reopen.isPending;
93+
94+ return (
95+ <Screen>
96+ {title}
97+ <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
98+ <ScrollView
99+ contentContainerStyle={styles.content}
100+ keyboardShouldPersistTaps="handled"
101+ keyboardDismissMode="interactive"
102+ refreshControl={
103+ <RefreshControl
104+ refreshing={detail.isRefetching}
105+ onRefresh={() => Promise.all([detail.refetch(), context.refetch()])}
106+ tintColor={colors.accent}
107+ />
108+ }
109+ >
110+ <View style={{ gap: space.sm }}>
111+ <Text size="heading" selectable>
112+ {item.title}
113+ </Text>
114+ <View style={styles.wrap}>
115+ <ItemIcon item={item} size={18} />
116+ <Text size="small" style={{ fontWeight: "600", color: item.state === "open" ? colors.success : colors.muted }}>
117+ {stateLabel(item)}
118+ </Text>
119+ <Text size="small" tone="muted">
120+ #{item.number} · {by} · {ago(item.created_at)}
121+ </Text>
122+ </View>
123+ {item.labels.length || item.milestone || item.assignees.length ? (
124+ <View style={styles.wrap}>
125+ {item.labels.map((label) => (
126+ <Chip key={label} label={label} />
127+ ))}
128+ {item.milestone ? <Chip label={item.milestone.title} /> : null}
129+ {item.assignees.map((name) => (
130+ <Chip key={name} label={`@${name}`} />
131+ ))}
132+ </View>
133+ ) : null}
134+ </View>
135+
136+ <Body text={item.body} refs={refs} />
137+
138+ <View style={{ marginHorizontal: -space.lg }}>
139+ <SectionHeader title="Repositories" />
140+ {repos.length === 0 ? (
141+ <Text tone="muted" style={{ paddingHorizontal: space.lg }}>
142+ Not linked to a repository yet. Link the ones where the work is meant to land.
143+ </Text>
144+ ) : (
145+ repos.map((repo) => (
146+ <View key={repo.repo_id}>
147+ <Row
148+ leading={<FolderGit2 size={20} color={colors.muted} />}
149+ title={repoName(repo.path, workspace)}
150+ subtitle={`${roleLabel(repo.role)}${repo.legacy_number ? ` · was #${repo.legacy_number} there` : ""}`}
151+ trailing={
152+ repo.landed ? (
153+ <Badge label="Landed" tone="merged" />
154+ ) : repo.role === "target" ? (
155+ <Badge label="Not landed" tone="warn" />
156+ ) : null
157+ }
158+ onPress={() => setPicked(repo)}
159+ />
160+ {repo.pulls.map((pull) => (
161+ <View key={`${pull.repo}#${pull.number}`} style={{ paddingLeft: space.xl }}>
162+ <Row
163+ leading={<PullIcon pull={pull} />}
164+ title={pull.title}
165+ subtitle={`#${pull.number} · ${pull.status}`}
166+ trailing={<Checks status={pull.check_status} />}
167+ onPress={() => openPull(pull)}
168+ />
169+ </View>
170+ ))}
171+ </View>
172+ ))
173+ )}
174+ {missing.length && item.state === "open" ? (
175+ <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}>
176+ Closes once it lands in {missing.map((path) => repoName(path, workspace)).join(", ")}.
177+ </Text>
178+ ) : null}
179+ <View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}>
180+ <Button title="Link a repository" variant="secondary" icon={Link2} onPress={() => setLinking(true)} />
181+ </View>
182+ </View>
183+
184+ {suggested.length ? (
185+ <View style={{ marginHorizontal: -space.lg }}>
186+ <SectionHeader title="Suggested" />
187+ {suggested.map((one) => (
188+ <Row
189+ key={one.repo_id}
190+ leading={<FolderGit2 size={20} color={colors.faint} />}
191+ title={repoName(one.path, workspace)}
192+ subtitle={one.why}
193+ chevron={false}
194+ trailing={
195+ <Button
196+ title="Add as target"
197+ variant="secondary"
198+ icon={Plus}
199+ busy={actions.link.isPending && actions.link.variables?.repo === one.path}
200+ onPress={() => link(one.path)}
201+ />
202+ }
203+ />
204+ ))}
205+ </View>
206+ ) : null}
207+
208+ <Timeline comments={comments} refs={refs} />
209+
210+ {item.state === "open" ? (
211+ <Button title="Close" variant="secondary" icon={CircleCheck} busy={busy} onPress={() => setMore(true)} />
212+ ) : (
213+ <Button title="Reopen" variant="secondary" icon={CircleDot} busy={busy} onPress={reopen} />
214+ )}
215+ </ScrollView>
216+ <CommentBar
217+ busy={actions.comment.isPending}
218+ onMore={() => setMore(true)}
219+ onSend={(body) => actions.comment.mutateAsync(body).catch((error: unknown) => (failed("Could not comment")(error), Promise.reject(error)))}
220+ />
221+ </KeyboardAvoidingView>
222+
223+ <Sheet visible={more} onClose={() => setMore(false)}>
224+ {item.state === "open" ? (
225+ <>
226+ <SheetAction icon={CircleCheck} label="Close as done" onPress={() => close("completed")} />
227+ <SheetAction icon={CircleX} label="Close as not planned" onPress={() => close("not_planned")} />
228+ </>
229+ ) : (
230+ <SheetAction icon={CircleDot} label="Reopen" onPress={reopen} />
231+ )}
232+ <SheetAction
233+ icon={Link2}
234+ label="Link a repository"
235+ onPress={() => {
236+ setMore(false);
237+ setLinking(true);
238+ }}
239+ />
240+ <SheetAction
241+ icon={Copy}
242+ label="Copy link"
243+ onPress={() => {
244+ setMore(false);
245+ void Clipboard.setStringAsync(siteUrl(server, sitePath));
246+ }}
247+ />
248+ <SheetAction
249+ icon={ExternalLink}
250+ label="Open on g1t"
251+ onPress={() => {
252+ setMore(false);
253+ void WebBrowser.openBrowserAsync(siteUrl(server, sitePath));
254+ }}
255+ />
256+ </Sheet>
257+
258+ <Sheet visible={picked !== null} onClose={() => setPicked(null)}>
259+ {picked ? (
260+ <>
261+ <Text size="heading" style={{ paddingHorizontal: space.lg }}>
262+ {picked.path}
263+ </Text>
264+ <SheetAction
265+ icon={FolderGit2}
266+ label="Open the repository"
267+ onPress={() => {
268+ setPicked(null);
269+ openRepo(picked.path);
270+ }}
271+ />
272+ {picked.role !== "origin" ? (
273+ <SheetAction
274+ icon={Unlink}
275+ label="Unlink it"
276+ danger
277+ onPress={() => {
278+ setPicked(null);
279+ actions.unlink.mutate(picked.path, { onError: failed("Could not unlink it") });
280+ }}
281+ />
282+ ) : null}
283+ </>
284+ ) : null}
285+ </Sheet>
286+
287+ <LinkSheet visible={linking} linked={linked} onClose={() => setLinking(false)} onPick={link} />
288+ </Screen>
289+ );
290+}
291+
292+/** Picks a repository of the workspace to link as a target. */
293+function LinkSheet({ visible, linked, onClose, onPick }: { visible: boolean; linked: Set<string>; onClose: () => void; onPick: (path: string) => void }) {
294+ const colors = useColors();
295+ const { height } = useWindowDimensions();
296+ const [search, setSearch] = useState("");
297+ const repos = useRepos(visible ? search.trim() : "");
298+ const choices = (repos.data ?? []).filter((repo) => !linked.has(`${repo.namespace}/${repo.name}`.toLowerCase()));
299+ return (
300+ <Sheet visible={visible} onClose={onClose}>
301+ <View style={{ paddingHorizontal: space.lg, gap: space.sm }}>
302+ <Text size="heading">Link a repository</Text>
303+ <Text size="small" tone="muted">
304+ As a target: where the work is meant to land. It closes once it has landed in every target.
305+ </Text>
306+ <View style={[styles.search, { borderColor: colors.lineStrong, backgroundColor: colors.bg }]}>
307+ <Search size={16} color={colors.faint} />
308+ <TextInput
309+ value={search}
310+ onChangeText={setSearch}
311+ placeholder="Find a repository"
312+ placeholderTextColor={colors.faint}
313+ autoCapitalize="none"
314+ autoCorrect={false}
315+ style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: space.sm }}
316+ />
317+ </View>
318+ </View>
319+ <ScrollView style={{ maxHeight: height * 0.5 }} keyboardShouldPersistTaps="handled">
320+ {repos.isPending ? (
321+ <Loading />
322+ ) : choices.length === 0 ? (
323+ <Text tone="muted" style={{ padding: space.lg }}>
324+ {search ? "Nothing matches." : "Every repository you can see is linked already."}
325+ </Text>
326+ ) : (
327+ choices.map((repo) => (
328+ <Row
329+ key={repo.id}
330+ leading={<FolderGit2 size={20} color={colors.muted} />}
331+ title={repo.name}
332+ subtitle={`${repo.namespace}/${repo.name}`}
333+ chevron={false}
334+ trailing={<Plus size={18} color={colors.accent} />}
335+ onPress={() => onPick(`${repo.namespace}/${repo.name}`)}
336+ />
337+ ))
338+ )}
339+ </ScrollView>
340+ </Sheet>
341+ );
342+}
343+
344+const styles = StyleSheet.create({
345+ content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl },
346+ wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
347+ search: { flexDirection: "row", alignItems: "center", gap: space.sm, borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md },
348+});
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+310−0
1+import { Stack, router, useLocalSearchParams, useNavigation } from "expo-router";
2+import { Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native";
3+import { useEffect, useMemo, useState } from "react";
4+import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native";
5+
6+import { Sheet } from "@/components/chat/sheet";
7+import { Segments } from "@/components/segments";
8+import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
9+import { Chip, ItemRow, roleLabel } from "@/components/work";
10+import { useRepos } from "@/lib/code";
11+import { useSignedIn } from "@/lib/session";
12+import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work";
13+import { radius, space, useColors } from "@/theme";
14+
15+type Params = { workspace?: string; state?: string; label?: string; milestone?: string; assignee?: string; repo?: string; role?: string };
16+
17+/** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */
18+function filterOf(params: Params): WorkFilter {
19+ const state = params.state === "closed" || params.state === "all" ? params.state : "open";
20+ const role = params.role === "origin" || params.role === "target" || params.role === "touched" ? params.role : undefined;
21+ const milestone = Number(params.milestone);
22+ return {
23+ state,
24+ label: params.label || undefined,
25+ milestone: Number.isInteger(milestone) && milestone > 0 ? milestone : undefined,
26+ assignee: params.assignee || undefined,
27+ repo: params.repo || undefined,
28+ role,
29+ };
30+}
31+
32+/**
33+ * Work: the workspace's work items, newest first, for every member,
34+ * Code or not. Filters are in a sheet (the ⚙ in the header, the chips
35+ * above the list, or Work in the bar again); pull down to read again,
36+ * and the list loads more as it nears its end.
37+ */
38+export default function WorkList() {
39+ const params = useLocalSearchParams<Params>();
40+ const { workspace, me } = useSignedIn();
41+ const slug = params.workspace ?? workspace?.slug;
42+ const colors = useColors();
43+ const linked = useMemo(() => filterOf(params), [params.state, params.label, params.milestone, params.assignee, params.repo, params.role]);
44+ const [filter, setFilter] = useState<WorkFilter>(linked);
45+ useEffect(() => setFilter(linked), [linked]);
46+ const [filtering, setFiltering] = useState(false);
47+ const items = useWorkItems(slug, filter);
48+ const milestones = useMilestones(filter.milestone ? slug : undefined);
49+
50+ // Work in the bar again, on the list itself, opens its filters, as the site's bar opens Work's menu.
51+ const navigation = useNavigation();
52+ useEffect(() => {
53+ const tabs = navigation.getParent();
54+ if (!tabs) return;
55+ return tabs.addListener("tabPress" as never, () => {
56+ if (navigation.isFocused()) setFiltering(true);
57+ });
58+ }, [navigation]);
59+
60+ const active = [
61+ filter.assignee ? { key: "assignee", label: filter.assignee === me.username ? "Assigned to me" : `@${filter.assignee}` } : null,
62+ filter.label ? { key: "label", label: filter.label } : null,
63+ filter.milestone ? { key: "milestone", label: milestones.data?.find((one) => one.number === filter.milestone)?.title ?? `Milestone ${filter.milestone}` } : null,
64+ filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null,
65+ ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null);
66+
67+ const header = (
68+ <Stack.Screen
69+ options={{
70+ title: "Work",
71+ headerRight: () => (
72+ <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}>
73+ <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} />
74+ </Pressable>
75+ ),
76+ }}
77+ />
78+ );
79+
80+ if (!slug) {
81+ return (
82+ <Screen>
83+ {header}
84+ <Empty icon={ListTodo} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
85+ </Screen>
86+ );
87+ }
88+
89+ return (
90+ <Screen>
91+ {header}
92+ <View style={[styles.bar, { borderBottomColor: colors.line }]}>
93+ <Segments
94+ value={filter.state}
95+ onChange={(state) => setFilter({ ...filter, state })}
96+ options={[
97+ { value: "open", label: "Open" },
98+ { value: "closed", label: "Closed" },
99+ { value: "all", label: "All" },
100+ ]}
101+ />
102+ {active.length ? (
103+ <View style={styles.chips}>
104+ {active.map((one) => (
105+ <Chip
106+ key={one.key}
107+ label={one.label}
108+ tone="accent"
109+ leading={<X size={12} color={colors.accent} />}
110+ onPress={() => setFilter({ ...filter, [one.key]: undefined, ...(one.key === "repo" ? { role: undefined } : {}) })}
111+ />
112+ ))}
113+ </View>
114+ ) : null}
115+ </View>
116+
117+ {items.isPending ? (
118+ <Loading />
119+ ) : items.error ? (
120+ <ErrorView error={items.error} retry={() => items.refetch()} />
121+ ) : (
122+ <FlatList
123+ data={items.items}
124+ keyExtractor={(item) => item.id}
125+ refreshControl={<RefreshControl refreshing={items.isRefetching && !items.isFetchingNextPage} onRefresh={() => items.refetch()} tintColor={colors.accent} />}
126+ onEndReached={() => items.hasNextPage && !items.isFetchingNextPage && items.fetchNextPage()}
127+ onEndReachedThreshold={0.5}
128+ contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }}
129+ ListFooterComponent={items.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null}
130+ ListEmptyComponent={
131+ <Empty
132+ icon={ListTodo}
133+ title={active.length ? "Nothing matches" : filter.state === "closed" ? "Nothing closed yet" : "No open work items"}
134+ body={active.length ? "Try fewer filters." : "Work items are any tracked work: a feature, a bug, a chore."}
135+ />
136+ }
137+ renderItem={({ item }) => (
138+ <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} />
139+ )}
140+ />
141+ )}
142+
143+ <Pressable
144+ accessibilityRole="button"
145+ accessibilityLabel="New work item"
146+ onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug } })}
147+ style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]}
148+ >
149+ <Plus size={20} color={colors.onAccent} strokeWidth={2.5} />
150+ <Text style={{ color: colors.onAccent, fontWeight: "600" }}>New</Text>
151+ </Pressable>
152+
153+ <Filters visible={filtering} workspace={slug} filter={filter} me={me.username} onClose={() => setFiltering(false)} onChange={setFilter} />
154+ </Screen>
155+ );
156+}
157+
158+/** The list's filters, in a sheet: state, assigned to you, label, milestone, repository and its role. */
159+function Filters({
160+ visible,
161+ workspace,
162+ filter,
163+ me,
164+ onClose,
165+ onChange,
166+}: {
167+ visible: boolean;
168+ workspace: string;
169+ filter: WorkFilter;
170+ me: string;
171+ onClose: () => void;
172+ onChange: (filter: WorkFilter) => void;
173+}) {
174+ const colors = useColors();
175+ const { height } = useWindowDimensions();
176+ const labels = useLabels(visible ? workspace : undefined);
177+ const milestones = useMilestones(visible ? workspace : undefined);
178+ const repos = useRepos("");
179+ const set = (change: Partial<WorkFilter>) => onChange({ ...filter, ...change });
180+ const toggle = <K extends keyof WorkFilter>(key: K, value: WorkFilter[K]) => set({ [key]: filter[key] === value ? undefined : value } as Partial<WorkFilter>);
181+
182+ return (
183+ <Sheet visible={visible} onClose={onClose}>
184+ <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}>
185+ <Text size="heading">Filters</Text>
186+ <Segments
187+ value={filter.state}
188+ onChange={(state) => set({ state })}
189+ options={[
190+ { value: "open", label: "Open" },
191+ { value: "closed", label: "Closed" },
192+ { value: "all", label: "All" },
193+ ]}
194+ />
195+ <View style={{ marginHorizontal: -space.lg }}>
196+ <Row
197+ title="Assigned to me"
198+ chevron={false}
199+ trailing={<Check on={filter.assignee === me} />}
200+ onPress={() => toggle("assignee", me)}
201+ />
202+ </View>
203+ <Group title="Label" empty={labels.isPending ? "…" : "No labels yet"}>
204+ {(labels.data ?? []).map((label) => (
205+ <Chip
206+ key={label.name}
207+ label={label.name}
208+ tone={filter.label === label.name ? "accent" : "neutral"}
209+ leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />}
210+ onPress={() => toggle("label", label.name)}
211+ />
212+ ))}
213+ </Group>
214+ <Group title="Milestone" empty={milestones.isPending ? "…" : "No open milestones"}>
215+ {(milestones.data ?? []).map((milestone) => (
216+ <Chip
217+ key={milestone.number}
218+ label={milestone.title}
219+ tone={filter.milestone === milestone.number ? "accent" : "neutral"}
220+ onPress={() => toggle("milestone", milestone.number)}
221+ />
222+ ))}
223+ </Group>
224+ <Group title="Repository" empty={repos.isPending ? "…" : "No repositories you can see"}>
225+ {(repos.data ?? []).map((repo) => (
226+ <Chip
227+ key={repo.id}
228+ label={repo.name}
229+ tone={filter.repo === repo.name ? "accent" : "neutral"}
230+ onPress={() => set({ repo: filter.repo === repo.name ? undefined : repo.name, role: undefined })}
231+ />
232+ ))}
233+ </Group>
234+ {filter.repo ? (
235+ <View style={{ gap: space.sm }}>
236+ <Text size="small" tone="muted">
237+ Linked to {filter.repo} as
238+ </Text>
239+ <Segments
240+ value={filter.role ?? "any"}
241+ onChange={(role) => set({ role: role === "any" ? undefined : (role as LinkRole) })}
242+ options={[
243+ { value: "any", label: "Any" },
244+ { value: "origin", label: "Origin" },
245+ { value: "target", label: "Target" },
246+ { value: "touched", label: "Touched" },
247+ ]}
248+ />
249+ </View>
250+ ) : null}
251+ <View style={{ flexDirection: "row", gap: space.sm }}>
252+ <View style={{ flex: 1 }}>
253+ <Button title="Clear" variant="secondary" onPress={() => onChange({ state: "open" })} />
254+ </View>
255+ <View style={{ flex: 1 }}>
256+ <Button title="Done" onPress={onClose} />
257+ </View>
258+ </View>
259+ </ScrollView>
260+ </Sheet>
261+ );
262+}
263+
264+function Group({ title, empty, children }: { title: string; empty: string; children: React.ReactNode[] }) {
265+ return (
266+ <View style={{ gap: space.sm }}>
267+ <Text size="small" tone="muted">
268+ {title}
269+ </Text>
270+ {children.length ? (
271+ <View style={styles.chips}>{children}</View>
272+ ) : (
273+ <Text size="small" tone="faint">
274+ {empty}
275+ </Text>
276+ )}
277+ </View>
278+ );
279+}
280+
281+function Check({ on }: { on: boolean }) {
282+ const colors = useColors();
283+ return (
284+ <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}>
285+ {on ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null}
286+ </View>
287+ );
288+}
289+
290+const styles = StyleSheet.create({
291+ bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
292+ chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
293+ fab: {
294+ position: "absolute",
295+ right: space.lg,
296+ bottom: space.lg,
297+ flexDirection: "row",
298+ alignItems: "center",
299+ gap: space.xs,
300+ paddingHorizontal: space.lg,
301+ height: 48,
302+ borderRadius: radius.full,
303+ elevation: 4,
304+ shadowColor: "#000",
305+ shadowOpacity: 0.25,
306+ shadowRadius: 8,
307+ shadowOffset: { width: 0, height: 2 },
308+ },
309+ check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" },
310+});
+159−0
1+import { Stack, router, useLocalSearchParams } from "expo-router";
2+import { useHeaderHeight } from "expo-router/react-navigation";
3+import { Check } from "lucide-react-native";
4+import { useRef, useState } from "react";
5+import { KeyboardAvoidingView, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
6+
7+import { Button, Text } from "@/components/ui";
8+import { Chip } from "@/components/work";
9+import { useRepos } from "@/lib/code";
10+import { useSignedIn } from "@/lib/session";
11+import { useLabels, useOpenWorkItem } from "@/lib/work";
12+import { 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+ */
21+export 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+
132+function 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+
154+const 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+});
+2−8
1−import { routeFor, sitePathOf } from "@/lib/links";
1+import { linkPathOf, routeFor } from "@/lib/links";
22
33 /**
44 * Where a link that opens the app goes. `g1t://` links name a site path,
99 */
1010 export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
1111 try {
12− const sitePath = sitePathOf(path) ?? sitePathOfWeb(path) ?? path;
12+ const sitePath = linkPathOf(path) ?? path;
1313 const route = routeFor(sitePath);
1414 return route ?? `/open?path=${encodeURIComponent(sitePath)}`;
1515 } catch {
1616 return "/";
1717 }
18−}
19−
20−function sitePathOfWeb(link: string): string | null {
21− if (!/^https?:\/\//i.test(link)) return null;
22− const url = new URL(link);
23− return url.pathname + url.search;
2418 }
+1−0
5656 <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
5757 <Stack.Protected guard={signedIn}>
5858 <Stack.Screen name="(app)" />
59+ <Stack.Screen name="ref" />
5960 </Stack.Protected>
6061 <Stack.Protected guard={!signedIn}>
6162 <Stack.Screen name="sign-in" />
+80−0
1+import { router, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { ExternalLink, SearchX } from "lucide-react-native";
4+import { useEffect } from "react";
5+
6+import { Button, Empty, Loading, Screen } from "@/components/ui";
7+import { ApiError } from "@/lib/client";
8+import { type RefContext, refSitePath, targetRoute } from "@/lib/links";
9+import { siteUrl } from "@/lib/server";
10+import { useSignedIn } from "@/lib/session";
11+import { useRefTarget } from "@/lib/work";
12+
13+/**
14+ * `#N` read in a repository or a workspace: g1t says what it names (a
15+ * work item, or a pull request), and that opens in its place. Old links
16+ * to a repository's issues come here too, since an old issue's number
17+ * still names the work item it became.
18+ */
19+export default function Ref() {
20+ const params = useLocalSearchParams<{ repo?: string; workspace?: string; number: string }>();
21+ const { server } = useSignedIn();
22+ const number = Number(params.number);
23+ const context: RefContext | null = params.repo ? { repo: params.repo } : params.workspace ? { workspace: params.workspace } : null;
24+ const target = useRefTarget(context, number);
25+ const route = target.data ? targetRoute(target.data) : null;
26+
27+ const leave = () => (router.canGoBack() ? router.back() : router.replace("/"));
28+ const onSite = () => {
29+ if (!context) return leave();
30+ void WebBrowser.openBrowserAsync(siteUrl(server, refSitePath(context, number))).finally(leave);
31+ };
32+
33+ useEffect(() => {
34+ if (route) router.replace(route as never);
35+ }, [route]);
36+
37+ // A g1t that cannot say (or a repository the app cannot read): the site reads the link itself.
38+ const unanswered = target.error instanceof ApiError && (target.error.code === "not_found" || target.error.code === "forbidden");
39+ useEffect(() => {
40+ if (unanswered) onSite();
41+ }, [unanswered]);
42+
43+ if (!context || !Number.isInteger(number)) {
44+ return (
45+ <Screen safe>
46+ <Empty icon={SearchX} title="Not a reference" action={<Button title="Back" variant="secondary" onPress={leave} />} />
47+ </Screen>
48+ );
49+ }
50+ if (target.data && !route) {
51+ const where = "repo" in context ? context.repo : context.workspace;
52+ return (
53+ <Screen safe>
54+ <Empty
55+ icon={SearchX}
56+ title={`Nothing is #${number} in ${where}`}
57+ body="It may have been deleted, or be somewhere you cannot see."
58+ action={<Button title="Back" variant="secondary" onPress={leave} />}
59+ />
60+ </Screen>
61+ );
62+ }
63+ if (target.error && !unanswered) {
64+ return (
65+ <Screen safe>
66+ <Empty
67+ icon={SearchX}
68+ title={`Could not find #${number}`}
69+ body={target.error.message}
70+ action={<Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={onSite} />}
71+ />
72+ </Screen>
73+ );
74+ }
75+ return (
76+ <Screen safe>
77+ <Loading />
78+ </Screen>
79+ );
80+}
+15−5
11 import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown";
22 import { router } from "expo-router";
33 import * as WebBrowser from "expo-web-browser";
4−import { Fragment, useMemo } from "react";
4+import { Fragment, createContext, useContext, useMemo } from "react";
55 import { ScrollView, StyleSheet, Text, View } from "react-native";
66
7−import { routeFor } from "@/lib/links";
7+import { type RefContext, refContext, refRoute, routeFor } from "@/lib/links";
88 import { siteUrl } from "@/lib/server";
99 import { useSignedIn } from "@/lib/session";
1010 import { fonts, radius, space, useColors } from "@/theme";
1515 * message is HTML, links go only to the web, mail or a page on g1t, and
1616 * `@mentions`, `#channels` and `#123` references stand out. A link to a
1717 * page the app has opens there; any other opens in the browser.
18+ *
19+ * `#123` is read in `refs`, where the text was written: a repository for a
20+ * pull request's, the workspace for Chat's and a work item's. g1t says
21+ * what it names when it is tapped (app/ref.tsx).
1822 */
19−export function Markdown({ text, muted }: { text: string; muted?: boolean }) {
23+export function Markdown({ text, muted, refs }: { text: string; muted?: boolean; refs?: RefContext }) {
2024 const parsed = useMemo(() => blocks(text), [text]);
21− return (
25+ const body = (
2226 <View style={{ gap: space.xs }}>
2327 {parsed.map((block, index) => (
2428 <BlockView key={index} block={block} muted={muted} />
2529 ))}
2630 </View>
2731 );
32+ return refs ? <RefsHere.Provider value={refs}>{body}</RefsHere.Provider> : body;
2833 }
2934
35+/** Where `#123` in the text being drawn is read; the workspace you are in when nothing says. */
36+const RefsHere = createContext<RefContext | null>(null);
37+
3038 function useOpen() {
3139 const { server, workspace } = useSignedIn();
3240 return (href: string) => {
118126 const colors = useColors();
119127 const open = useOpen();
120128 const { workspace } = useSignedIn();
129+ const given = useContext(RefsHere);
130+ const here = given ?? (workspace ? { workspace: workspace.slug } : null);
121131 return (
122132 <>
123133 {spans.map((span, index) => {
168178 );
169179 case "ref":
170180 return (
171− <Text key={index} style={{ color: colors.accent }} onPress={() => span.repo && open(`/${span.repo}/issues/${span.number}`)}>
181+ <Text key={index} style={{ color: colors.accent }} onPress={() => here && router.push(refRoute(refContext(span.repo, here), span.number) as never)}>
172182 {span.repo ? `${span.repo}#${span.number}` : `#${span.number}`}
173183 </Text>
174184 );
+10−14
1−import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native";
1+import { CircleCheck, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native";
22 import { ScrollView, StyleSheet, View } from "react-native";
33
4+import { Markdown } from "@/components/chat/markdown";
45 import { Badge, Text } from "@/components/ui";
5−import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code";
6+import type { ApiChanges, ApiComment, ApiPull, CheckStatus } from "@/lib/code";
7+import type { RefContext } from "@/lib/links";
68 import { ago } from "@/lib/time";
79 import { fonts, radius, space, useColors } from "@/theme";
810
1315 if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />;
1416 if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />;
1517 return <GitPullRequest size={20} color={colors.success} />;
16−}
17−
18−export function IssueIcon({ issue }: { issue: Pick<ApiIssue, "state" | "reason"> }) {
19− const colors = useColors();
20− if (issue.state === "open") return <CircleDot size={20} color={colors.success} />;
21− return issue.reason === "not_planned" ? <CircleX size={20} color={colors.muted} /> : <CircleCheck size={20} color={colors.merged} />;
2218 }
2319
2420 export function Checks({ status }: { status: CheckStatus }) {
3430 return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />;
3531 }
3632
37−/** Markdown, shown as it was written until the app renders it. */
38−export function Body({ text }: { text: string | null }) {
33+/** A description in Markdown; `#123` in it is read in `refs`. */
34+export function Body({ text, refs }: { text: string | null; refs: RefContext }) {
3935 const colors = useColors();
4036 if (!text?.trim()) {
4137 return (
4642 }
4743 return (
4844 <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}>
49− <Text selectable>{text}</Text>
45+ <Markdown text={text} refs={refs} />
5046 </View>
5147 );
5248 }
5349
54−/** The conversation under an issue or pull request: comments, reviews and what happened. */
55−export function Timeline({ comments }: { comments: ApiComment[] }) {
50+/** The conversation under a work item or pull request: comments, reviews and what happened. */
51+export function Timeline({ comments, refs }: { comments: ApiComment[]; refs: RefContext }) {
5652 const colors = useColors();
5753 if (comments.length === 0) return null;
5854 return (
7874 {comment.line ? `:${comment.line}` : ""}
7975 </Text>
8076 ) : null}
81− <Text selectable>{comment.body}</Text>
77+ <Markdown text={comment.body} refs={refs} />
8278 </View>
8379 ),
8480 )}
+174−0
1+import { CircleCheck, CircleDot, CircleX, Ellipsis, FolderGit2, MessageSquare, SendHorizontal } from "lucide-react-native";
2+import { type ReactNode, useEffect, useState } from "react";
3+import { ActivityIndicator, Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native";
4+import { useSafeAreaInsets } from "react-native-safe-area-context";
5+
6+import { Text } from "@/components/ui";
7+import { ago } from "@/lib/time";
8+import type { ApiItemRepo, ApiWorkItem, LinkRole } from "@/lib/work";
9+import { 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. */
12+export 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+
22+export 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. */
28+export 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. */
50+export 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+ */
59+export 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. */
75+export 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+
112+const ROLES: Record<LinkRole, string> = { origin: "Origin", target: "Target", touched: "Touched" };
113+
114+export 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+ */
123+export 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+
167+const 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+});
+4−45
11 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22
33 import { useSignedIn } from "./session";
4+import type { ApiWorkItem } from "./work";
45
56 /**
67 * Code, through the REST API (docs.g1t.sh/reference/api/). The API
2223 archived_at?: string | null;
2324 };
2425
25−export type ApiIssue = {
26− id: string;
27− number: number;
28− title: string;
29− body: string;
30− labels: string[];
31− state: "open" | "closed";
32− reason: "completed" | "not_planned" | null;
33− author: ApiUser;
34− created_at: string;
35− updated_at: string;
36− comment_count: number;
37− pull_count: number;
38−};
39−
4026 export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored" | null;
4127
4228 export type ApiPull = {
4329 id: string;
4430 number: number;
45− issue: number | null;
31+ /** The work item it is for, by number in the workspace. */
32+ item: number | null;
4633 title: string;
4734 body: string | null;
4835 /** The coding agent it was made with (`claude-code`, `codex`), from the session recorded on it; null when none was. */
129116 });
130117 }
131118
132−export function useIssues(owner: string, name: string, state: "open" | "closed" | "all") {
133− const { client } = useSignedIn();
134− return useQuery({
135− queryKey: ["code", "issues", owner, name, state],
136− queryFn: () => client.api<ApiIssue[]>(`${repoPath(owner, name)}/issues?state=${state}`),
137− });
138−}
139−
140119 export function usePull(owner: string, name: string, number: number) {
141120 const { client } = useSignedIn();
142121 return useQuery({
143122 queryKey: ["code", "pull", owner, name, number],
144123 queryFn: () =>
145− client.api<{ pull: ApiPull; issue: ApiIssue | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`),
124+ client.api<{ pull: ApiPull; item: ApiWorkItem | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`),
146125 });
147126 }
148127
152131 queryKey: ["code", "pull-changes", owner, name, number],
153132 queryFn: () => client.api<ApiChanges>(`${repoPath(owner, name)}/pulls/${number}/changes`),
154133 enabled,
155− });
156−}
157−
158−export function useIssue(owner: string, name: string, number: number) {
159− const { client } = useSignedIn();
160− return useQuery({
161− queryKey: ["code", "issue", owner, name, number],
162− queryFn: () =>
163− client.api<{ issue: ApiIssue; pulls: ApiPull[]; comments: ApiComment[] }>(`${repoPath(owner, name)}/issues/${number}`),
164− });
165−}
166−
167−/** Comments on an issue (`POST /repos/{owner}/{name}/issues/{number}/comments`). */
168−export function useComment(owner: string, name: string, number: number) {
169− const { client } = useSignedIn();
170− const queries = useQueryClient();
171− return useMutation({
172− mutationFn: (body: string) =>
173− client.api<ApiComment>(`${repoPath(owner, name)}/issues/${number}/comments`, { method: "POST", body: JSON.stringify({ body }) }),
174− onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "issue", owner, name, number] }),
175134 });
176135 }
177136
+50−3
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { routeFor, sitePathOf } from "./links.ts";
4+import { linkPathOf, refContext, refRoute, refSitePath, routeFor, sitePathOf, targetRoute } from "./links.ts";
55
66 test("a g1t:// link names a site path, as on the desktop", () => {
77 assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1");
99 assert.equal(sitePathOf("https://g1t.sh/acme"), null);
1010 });
1111
12+test("an https link to the site names the same path as a g1t:// one", () => {
13+ assert.equal(linkPathOf("https://g1t.sh/acme/-/work/12"), "/acme/-/work/12");
14+ assert.equal(linkPathOf("g1t://acme/-/work/12"), "/acme/-/work/12");
15+ assert.equal(linkPathOf("https://g1t.sh/acme/-/work?repo=web#top"), "/acme/-/work?repo=web");
16+ assert.equal(linkPathOf("https://g1t.sh"), "/");
17+ assert.equal(linkPathOf("mailto:someone@example.com"), null);
18+});
19+
1220 test("Chat's pages open the app's Chat", () => {
1321 assert.equal(routeFor("/acme/-/chat"), "/chat?workspace=acme");
1422 assert.equal(routeFor("/acme/-/chat/general"), "/chat/general?workspace=acme");
1624 assert.equal(routeFor("/acme/-/chat/browse"), null);
1725 });
1826
27+test("Work's pages open the app's Work", () => {
28+ assert.equal(routeFor("/acme/-/work"), "/work?workspace=acme");
29+ assert.equal(routeFor("/acme/-/work/12"), "/work/12?workspace=acme");
30+ assert.equal(routeFor(linkPathOf("g1t://acme/-/work/12")!), "/work/12?workspace=acme");
31+ assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/work/12")!), "/work/12?workspace=acme");
32+ assert.equal(routeFor("/acme/-/work/new"), "/work/new?workspace=acme");
33+ // The list keeps the filters the site's does, and drops anything else.
34+ assert.equal(routeFor("/acme/-/work?repo=web&label=bug&page=3"), "/work?workspace=acme&label=bug&repo=web");
35+ assert.equal(routeFor("/acme/-/work/labels"), null);
36+ assert.equal(routeFor("/acme/-/work/milestones/2"), null);
37+ assert.equal(routeFor("/acme/-/work/12/edit"), null);
38+});
39+
40+test("an old link to an issue asks g1t what its number names in its repository", () => {
41+ assert.equal(routeFor("/acme/rocket/issues/12"), "/ref?repo=acme%2Frocket&number=12");
42+ assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/rocket/issues/12")!), "/ref?repo=acme%2Frocket&number=12");
43+ assert.equal(routeFor("/acme/rocket/issues"), "/work?workspace=acme&repo=rocket");
44+ assert.equal(routeFor("/acme/rocket/issues/new"), "/work/new?workspace=acme&repo=rocket");
45+ assert.equal(routeFor("/acme/rocket/issues/12/edit"), null);
46+});
47+
1948 test("Code's pages open the app's Code", () => {
2049 assert.equal(routeFor("/acme/rocket"), "/code/acme/rocket");
2150 assert.equal(routeFor("/acme/rocket/pull/14"), "/code/acme/rocket/pull/14");
22− assert.equal(routeFor("/acme/rocket/issues/12"), "/code/acme/rocket/issues/12");
2351 assert.equal(routeFor("/acme/-/overview"), "/code?workspace=acme");
2452 assert.equal(routeFor("/acme/rocket/pull/14/files"), null);
25− assert.equal(routeFor("/acme/rocket/issues/new"), null);
53+ assert.equal(routeFor("/acme/rocket/sessions"), null);
54+});
55+
56+test("#N is read where it was written, owner/name#N in that repository, workspace#N in that workspace", () => {
57+ const here = { workspace: "acme" };
58+ assert.deepEqual(refContext(null, here), { workspace: "acme" });
59+ assert.deepEqual(refContext(null, { repo: "acme/web" }), { repo: "acme/web" });
60+ assert.deepEqual(refContext("acme/api", here), { repo: "acme/api" });
61+ assert.deepEqual(refContext("globex", here), { workspace: "globex" });
62+ assert.equal(refRoute({ workspace: "acme" }, 7), "/ref?workspace=acme&number=7");
63+ assert.equal(refRoute({ repo: "acme/web" }, 7), "/ref?repo=acme%2Fweb&number=7");
64+});
65+
66+test("what a reference names opens its screen, and the site reads one the app cannot", () => {
67+ assert.equal(targetRoute({ kind: "work_item", workspace: "acme", number: 31, item_id: "wki_1" }), "/work/31?workspace=acme");
68+ assert.equal(targetRoute({ kind: "pull", repo: "acme/web", number: 9 }), "/code/acme/web/pull/9");
69+ assert.equal(targetRoute({ kind: "pull", repo: "nonsense", number: 9 }), null);
70+ assert.equal(targetRoute({ kind: "none" }), null);
71+ assert.equal(refSitePath({ repo: "acme/web" }, 9), "/acme/web/issues/9");
72+ assert.equal(refSitePath({ workspace: "acme" }, 9), "/acme/-/work/9");
2673 });
2774
2875 test("old links to Agents, Customize and the Marketplace open Home", () => {
+92−6
11 /**
22 * Links into the app. A `g1t://` link names a site path, as on the desktop
33 * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is
4− * the page `/acme/-/chat/dm/chn_1`. The app has screens for some of the
5− * site's pages; `routeFor` says which screen shows a site path, and the
6− * rest open on the site.
4+ * the page `/acme/-/chat/dm/chn_1`, as `https://g1t.sh/acme/-/chat/dm/chn_1`
5+ * is. The app has screens for some of the site's pages; `routeFor` says
6+ * which screen shows a site path, and the rest open on the site.
77 *
88 * Kept free of React Native imports so `npm test` runs it under Node.
99 */
2020 /** Path segments a workspace or account can never be named (the site's own pages). */
2121 const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]);
2222
23+/** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */
24+export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const;
25+
2326 /**
2427 * The app's screen for a site path, or null when the app has none and the
2528 * page should open on the site.
4447 if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`;
4548 return null;
4649 }
50+ if (third === "work") {
51+ if (!fourth) return `/work?${workspace}${filtersOf(query)}`;
52+ if (fourth === "new" && !fifth) return `/work/new?${workspace}`;
53+ if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`;
54+ // Labels and milestones stay on the site.
55+ return null;
56+ }
4757 if (third === "overview" && !fourth) return `/code?${workspace}`;
4858 if (third && RETIRED.has(third)) return `/home?${workspace}`;
4959 return null;
5060 }
5161
52− // A repository's pages: /<owner>/<repo>[/pull/<n> | /issues/<n>].
62+ // A repository's pages: /<owner>/<repo>[/pull/<n>].
5363 if (!second) return null;
5464 const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`;
5565 if (!third) return repo;
56− if (third === "pull" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/pull/${fourth}`;
57− if (third === "issues" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/issues/${fourth}`;
66+ if (third === "pull" && isNumber(fourth) && !fifth) return `${repo}/pull/${fourth}`;
67+ // Issues became the workspace's work items. An old issue's number is
68+ // read in its repository, where it may name a pull request (they shared
69+ // numbers then): the ref screen asks g1t which.
70+ if (third === "issues") {
71+ const name = `repo=${encodeURIComponent(second)}`;
72+ if (!fourth) return `/work?${workspace}&${name}`;
73+ if (fourth === "new" && !fifth) return `/work/new?${workspace}&${name}`;
74+ if (isNumber(fourth) && !fifth) return refRoute({ repo: `${owner}/${second}` }, Number(fourth));
75+ return null;
76+ }
77+ return null;
78+}
79+
80+/** Where `#N` is read: in a repository (`owner/name`), or in a workspace alone. */
81+export type RefContext = { repo: string } | { workspace: string };
82+
83+/** The screen that asks g1t what `#N` names in a context, and opens it (app/ref.tsx). */
84+export function refRoute(context: RefContext, number: number): string {
85+ const where = "repo" in context ? `repo=${encodeURIComponent(context.repo)}` : `workspace=${encodeURIComponent(context.workspace)}`;
86+ return `/ref?${where}&number=${number}`;
87+}
88+
89+/**
90+ * The context a reference in text is read in (crates/contracts/src/work/refs.rs):
91+ * `owner/name#N` in that repository, `workspace#N` in that workspace, and
92+ * `#N` where it was written.
93+ */
94+export function refContext(written: string | null, here: RefContext): RefContext {
95+ if (!written) return here;
96+ return written.includes("/") ? { repo: written } : { workspace: written };
97+}
98+
99+/** What `#N` names, as the API answers (`RefTarget`, in snake_case). */
100+export type RefTarget =
101+ | { kind: "work_item"; workspace: string; number: number; item_id?: string }
102+ | { kind: "pull"; repo: string; number: number }
103+ | { kind: "none" };
104+
105+/** The app's screen for what a reference names, or null for nothing. */
106+export function targetRoute(target: RefTarget): string | null {
107+ if (target.kind === "work_item") return `/work/${target.number}?workspace=${encodeURIComponent(target.workspace)}`;
108+ if (target.kind === "pull") {
109+ const [owner, name] = target.repo.split("/", 2);
110+ if (!owner || !name) return null;
111+ return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${target.number}`;
112+ }
58113 return null;
59114 }
60115
61116 /**
117+ * The site's page for a reference, for when the app cannot ask: the site
118+ * reads it the same way and redirects.
119+ */
120+export function refSitePath(context: RefContext, number: number): string {
121+ return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`;
122+}
123+
124+/**
62125 * The site path a `g1t://` link names, or null for any other link.
63126 * `g1t://acme/-/chat?x` is `/acme/-/chat?x`.
64127 */
69132 return path + (match[2] ?? "");
70133 }
71134
135+/**
136+ * The site path any link that opens the app names: a `g1t://` one, or an
137+ * `https` one to the site (Android App Links, iOS Universal Links), as
138+ * `https://g1t.sh/acme/-/work/12` is `/acme/-/work/12`. Null for others.
139+ */
140+export function linkPathOf(link: string): string | null {
141+ const own = sitePathOf(link);
142+ if (own) return own;
143+ const web = /^https?:\/\/[^/?#]+(\/[^?#]*)?(\?[^#]*)?/i.exec(link);
144+ return web ? (web[1] || "/") + (web[2] ?? "") : null;
145+}
146+
147+function filtersOf(query: string): string {
148+ const params = new URLSearchParams(query);
149+ return WORK_FILTERS.filter((name) => params.get(name))
150+ .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`)
151+ .join("");
152+}
153+
154+function isNumber(part: string | undefined): part is string {
155+ return Boolean(part && /^\d+$/.test(part));
156+}
157+
72158 function decodeURIComponentSafe(part: string): string {
73159 try {
74160 return decodeURIComponent(part);
+3−3
2727 WebBrowser.maybeCompleteAuthSession();
2828
2929 /**
30− * What the app asks for: what Chat and Code show and do. The
30+ * What the app asks for: what Work, Chat and Code show and do. The
3131 * person can untick any of these on the approval page. `chat:*` opens
3232 * Chat's routes and live sockets on the site, and `notifications:*` the
3333 * phone's registration for pushes (`/-/notify`), and nothing else there.
3939 "workspace:read",
4040 "repo:read",
4141 "code:read",
42− "issues:read",
43− "issues:write",
42+ "work:read",
43+ "work:write",
4444 "pull_requests:read",
4545 "pull_requests:write",
4646 "checks:read",
+227−0
Loading the diff…

Comments

Sign in to comment.