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.

135 linesCodeBlameRaw
1import { hasCodeAccess } from "@g1t/contracts";
2import { router } from "expo-router";
3import { Bot, GitPullRequest, ListTodo, Network, Sparkles, StickyNote, Terminal } from "lucide-react-native";
4import type { LucideIcon } from "lucide-react-native";
5import { Pressable, StyleSheet, View } from "react-native";
6
7import { Text } from "@/components/ui";
8import { Chip, repoName } from "@/components/work";
9import { sessionRoute } from "@/lib/links";
10import { useSignedIn } from "@/lib/session";
11import { type ApiWorkspaceSession, type SessionKind, type SessionState, listTitle, runsIn } from "@/lib/sessions";
12import { ago } from "@/lib/time";
13import { space, useColors } from "@/theme";
14
15const KIND_ICONS: Record<SessionKind, LucideIcon> = { harness: Terminal, hosted: Sparkles, agent: Bot, recorded: StickyNote };
16
17/** What a session runs in, as an icon. */
18export function KindIcon({ kind, size = 14, color }: { kind: SessionKind; size?: number; color?: string }) {
19 const colors = useColors();
20 const Icon = KIND_ICONS[kind];
21 return <Icon size={size} color={color ?? colors.muted} />;
22}
23
24const STATE_NAMES: Record<SessionState, string> = { active: "Live", idle: "Idle", ended: "Ended" };
25
26export function stateName(state: SessionState): string {
27 return STATE_NAMES[state];
28}
29
30/** A session's state as a dot: live, idle, or ended. */
31export function StateDot({ state, size = 10 }: { state: SessionState; size?: number }) {
32 const colors = useColors();
33 const color = state === "active" ? colors.success : state === "idle" ? colors.warn : colors.faint;
34 return <View accessibilityLabel={stateName(state)} style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
35}
36
37/** Whether the person signed in has Code in the workspace, so a pull request opens in the app. */
38export function useCode(): boolean {
39 const { workspace } = useSignedIn();
40 return !workspace || hasCodeAccess(workspace);
41}
42
43export function openItem(workspace: string, number: number) {
44 router.push({ pathname: "/work/[number]", params: { number: String(number), workspace } });
45}
46
47/** A pull request: its screen in Code, or its page on the site without Code. */
48export function openPull(repo: string, number: number, code: boolean, session = false) {
49 const [owner, name] = repo.split("/", 2);
50 if (!owner || !name) return;
51 if (code) router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(number) } });
52 else router.push({ pathname: "/open", params: { path: `/${owner}/${name}/pull/${number}${session ? "/session" : ""}` } });
53}
54
55/**
56 * One session in a list: what it is, who runs it in what, where, the work
57 * item and pull request it is on, and when it was last heard from.
58 */
59export function SessionCard({ session, workspace }: { session: ApiWorkspaceSession; workspace: string }) {
60 const colors = useColors();
61 const code = useCode();
62 const route = sessionRoute(session, workspace, code);
63 const where = [repoName(session.repo, workspace), session.branch].filter(Boolean).join(" · ");
64 return (
65 <Pressable
66 onPress={route ? () => router.push(route as never) : undefined}
67 disabled={!route}
68 style={({ pressed }) => [styles.card, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
69 >
70 <View style={{ paddingTop: 6 }}>
71 <StateDot state={session.state} />
72 </View>
73 <View style={{ flex: 1, gap: 6 }}>
74 <View style={{ flexDirection: "row", gap: space.sm }}>
75 <Text numberOfLines={2} style={{ fontWeight: "600", flex: 1 }}>
76 {listTitle(session)}
77 </Text>
78 <Text size="small" tone="faint">
79 {ago(session.last_activity_at)}
80 </Text>
81 </View>
82 <View style={styles.meta}>
83 <KindIcon kind={session.kind} />
84 <Text size="small" tone="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
85 {runsIn(session)}
86 {session.actor ? ` · @${session.actor}` : ""}
87 {where ? ` · ${where}` : ""}
88 </Text>
89 </View>
90 {session.step ? (
91 <Text size="small" tone="muted" numberOfLines={1}>
92 {session.step}
93 </Text>
94 ) : null}
95 {session.item || session.pull || session.subagents ? (
96 <View style={styles.meta}>
97 {session.item ? (
98 <Chip
99 label={`#${session.item}${session.item_title ? ` ${session.item_title}` : ""}`}
100 leading={<ListTodo size={12} color={colors.muted} />}
101 onPress={() => openItem(workspace, session.item!)}
102 />
103 ) : null}
104 {session.pull ? (
105 <Chip
106 label={`#${session.pull}${session.pull_status ? ` · ${session.pull_status}` : ""}`}
107 leading={
108 <GitPullRequest
109 size={12}
110 color={session.pull_status === "merged" ? colors.merged : session.pull_status === "open" ? colors.success : colors.muted}
111 />
112 }
113 onPress={() => openPull(session.repo, session.pull!, code)}
114 />
115 ) : null}
116 {session.subagents ? (
117 <View style={styles.count}>
118 <Network size={13} color={colors.faint} />
119 <Text size="small" tone="faint">
120 {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"}
121 </Text>
122 </View>
123 ) : null}
124 </View>
125 ) : null}
126 </View>
127 </Pressable>
128 );
129}
130
131const styles = StyleSheet.create({
132 card: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth },
133 meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
134 count: { flexDirection: "row", alignItems: "center", gap: 3 },
135});