| 1 | import { hasCodeAccess } from "@g1t/contracts"; |
| 2 | import { router } from "expo-router"; |
| 3 | import { Bot, GitPullRequest, ListTodo, Network, Sparkles, StickyNote, Terminal } from "lucide-react-native"; |
| 4 | import type { LucideIcon } from "lucide-react-native"; |
| 5 | import { Pressable, StyleSheet, View } from "react-native"; |
| 6 | |
| 7 | import { Text } from "@/components/ui"; |
| 8 | import { Chip, repoName } from "@/components/work"; |
| 9 | import { sessionRoute } from "@/lib/links"; |
| 10 | import { useSignedIn } from "@/lib/session"; |
| 11 | import { type ApiWorkspaceSession, type SessionKind, type SessionState, listTitle, runsIn } from "@/lib/sessions"; |
| 12 | import { ago } from "@/lib/time"; |
| 13 | import { space, useColors } from "@/theme"; |
| 14 | |
| 15 | const KIND_ICONS: Record<SessionKind, LucideIcon> = { harness: Terminal, hosted: Sparkles, agent: Bot, recorded: StickyNote }; |
| 16 | |
| 17 | /** What a session runs in, as an icon. */ |
| 18 | export 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 | |
| 24 | const STATE_NAMES: Record<SessionState, string> = { active: "Live", idle: "Idle", ended: "Ended" }; |
| 25 | |
| 26 | export function stateName(state: SessionState): string { |
| 27 | return STATE_NAMES[state]; |
| 28 | } |
| 29 | |
| 30 | /** A session's state as a dot: live, idle, or ended. */ |
| 31 | export 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. */ |
| 38 | export function useCode(): boolean { |
| 39 | const { workspace } = useSignedIn(); |
| 40 | return !workspace || hasCodeAccess(workspace); |
| 41 | } |
| 42 | |
| 43 | export 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. */ |
| 48 | export 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 | */ |
| 59 | export 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={<GitPullRequest size={12} color={session.pull_status === "merged" ? colors.merged : session.pull_status === "open" ? colors.success : colors.muted} />} |
| 108 | onPress={() => openPull(session.repo, session.pull!, code)} |
| 109 | /> |
| 110 | ) : null} |
| 111 | {session.subagents ? ( |
| 112 | <View style={styles.count}> |
| 113 | <Network size={13} color={colors.faint} /> |
| 114 | <Text size="small" tone="faint"> |
| 115 | {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"} |
| 116 | </Text> |
| 117 | </View> |
| 118 | ) : null} |
| 119 | </View> |
| 120 | ) : null} |
| 121 | </View> |
| 122 | </Pressable> |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | const styles = StyleSheet.create({ |
| 127 | card: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth }, |
| 128 | meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }, |
| 129 | count: { flexDirection: "row", alignItems: "center", gap: 3 }, |
| 130 | }); |