| 1 | import { Stack, useFocusEffect, useLocalSearchParams } from "expo-router"; |
| 2 | import { Activity, Check as CheckIcon, SlidersHorizontal, X } from "lucide-react-native"; |
| 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | import { Pressable, RefreshControl, ScrollView, SectionList, StyleSheet, View, useWindowDimensions } from "react-native"; |
| 5 | |
| 6 | import { Sheet } from "@/components/chat/sheet"; |
| 7 | import { Segments } from "@/components/segments"; |
| 8 | import { SessionCard } from "@/components/sessions"; |
| 9 | import { Button, Empty, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; |
| 10 | import { Chip } from "@/components/work"; |
| 11 | import { useSignedIn } from "@/lib/session"; |
| 12 | import { type ApiWorkspaceSession, type SessionsFilter, isLive } from "@/lib/sessions"; |
| 13 | import { useWorkspaceSessions } from "@/lib/work"; |
| 14 | import { space, useColors } from "@/theme"; |
| 15 | |
| 16 | type Shown = "all" | "live" | "ended"; |
| 17 | type Params = { workspace?: string; state?: string; person?: string; repo?: string; item?: string }; |
| 18 | |
| 19 | /** The filter a link names (`/acme/-/sessions?state=live&repo=web`): every session when it names none. */ |
| 20 | function filterOf(params: Params): SessionsFilter { |
| 21 | const state = params.state === "live" || params.state === "ended" ? params.state : "recent"; |
| 22 | const item = Number(params.item); |
| 23 | return { state, person: params.person || undefined, repo: params.repo || undefined, item: Number.isInteger(item) && item > 0 ? item : undefined }; |
| 24 | } |
| 25 | |
| 26 | const SHOWN: Record<Shown, SessionsFilter["state"]> = { all: "recent", live: "live", ended: "ended" }; |
| 27 | |
| 28 | /** |
| 29 | * Sessions: what is cooking across the workspace. The ones going now |
| 30 | * first, then those that ended lately, each with who runs it in what, |
| 31 | * where, and the work item and pull request it is on. Pull down to read |
| 32 | * again; while one is live it is read again every 15 s. |
| 33 | */ |
| 34 | export default function Sessions() { |
| 35 | const params = useLocalSearchParams<Params>(); |
| 36 | const { workspace: current, me } = useSignedIn(); |
| 37 | const workspace = params.workspace ?? current?.slug; |
| 38 | const colors = useColors(); |
| 39 | const linked = useMemo(() => filterOf(params), [params.state, params.person, params.repo, params.item]); |
| 40 | const [filter, setFilter] = useState<SessionsFilter>(linked); |
| 41 | useEffect(() => setFilter(linked), [linked]); |
| 42 | const [filtering, setFiltering] = useState(false); |
| 43 | const [focused, setFocused] = useState(false); |
| 44 | useFocusEffect( |
| 45 | useCallback(() => { |
| 46 | setFocused(true); |
| 47 | return () => setFocused(false); |
| 48 | }, []), |
| 49 | ); |
| 50 | const sessions = useWorkspaceSessions(workspace, filter, focused); |
| 51 | const shown: Shown = filter.state === "live" ? "live" : filter.state === "ended" ? "ended" : "all"; |
| 52 | |
| 53 | const active = [ |
| 54 | filter.person ? { key: "person", label: filter.person === me.username ? "Started by me" : `@${filter.person}` } : null, |
| 55 | filter.repo ? { key: "repo", label: filter.repo } : null, |
| 56 | filter.item ? { key: "item", label: `#${filter.item}` } : null, |
| 57 | ].filter((one): one is { key: "person" | "repo" | "item"; label: string } => one !== null); |
| 58 | |
| 59 | const header = ( |
| 60 | <Stack.Screen |
| 61 | options={{ |
| 62 | title: "Sessions", |
| 63 | headerRight: () => ( |
| 64 | <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}> |
| 65 | <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} /> |
| 66 | </Pressable> |
| 67 | ), |
| 68 | }} |
| 69 | /> |
| 70 | ); |
| 71 | |
| 72 | if (!workspace) { |
| 73 | return ( |
| 74 | <Screen> |
| 75 | {header} |
| 76 | <Empty icon={Activity} title="No workspace yet" body="Create or join a workspace on g1t, then come back." /> |
| 77 | </Screen> |
| 78 | ); |
| 79 | } |
| 80 | |
| 81 | const all = sessions.data?.sessions ?? []; |
| 82 | const sections: { title: string; data: ApiWorkspaceSession[] }[] = |
| 83 | shown === "all" |
| 84 | ? [ |
| 85 | { title: "Live now", data: all.filter(isLive) }, |
| 86 | { title: "Recent", data: all.filter((one) => !isLive(one)) }, |
| 87 | ].filter((section) => section.data.length > 0) |
| 88 | : all.length |
| 89 | ? [{ title: shown === "live" ? "Live now" : "Recent", data: all }] |
| 90 | : []; |
| 91 | |
| 92 | return ( |
| 93 | <Screen> |
| 94 | {header} |
| 95 | <View style={[styles.bar, { borderBottomColor: colors.line }]}> |
| 96 | <Segments |
| 97 | value={shown} |
| 98 | onChange={(next) => setFilter({ ...filter, state: SHOWN[next] })} |
| 99 | options={[ |
| 100 | { value: "all", label: "Everything" }, |
| 101 | { value: "live", label: "Live" }, |
| 102 | { value: "ended", label: "Recent" }, |
| 103 | ]} |
| 104 | /> |
| 105 | {active.length ? ( |
| 106 | <View style={styles.chips}> |
| 107 | {active.map((one) => ( |
| 108 | <Chip |
| 109 | key={one.key} |
| 110 | label={one.label} |
| 111 | tone="accent" |
| 112 | leading={<X size={12} color={colors.accent} />} |
| 113 | onPress={() => setFilter({ ...filter, [one.key]: undefined })} |
| 114 | /> |
| 115 | ))} |
| 116 | </View> |
| 117 | ) : null} |
| 118 | </View> |
| 119 | |
| 120 | {sessions.isPending ? ( |
| 121 | <Loading /> |
| 122 | ) : sessions.error ? ( |
| 123 | <ErrorView error={sessions.error} retry={() => sessions.refetch()} /> |
| 124 | ) : ( |
| 125 | <SectionList |
| 126 | sections={sections} |
| 127 | keyExtractor={(session) => `${session.kind}:${session.id}`} |
| 128 | stickySectionHeadersEnabled={false} |
| 129 | refreshControl={<RefreshControl refreshing={sessions.isRefetching} onRefresh={() => sessions.refetch()} tintColor={colors.accent} />} |
| 130 | contentContainerStyle={{ paddingBottom: space.xl, flexGrow: 1 }} |
| 131 | renderSectionHeader={({ section }) => <SectionHeader title={section.title} />} |
| 132 | renderItem={({ item }) => <SessionCard session={item} workspace={workspace} />} |
| 133 | ListEmptyComponent={ |
| 134 | <Empty |
| 135 | icon={Activity} |
| 136 | title={active.length ? "Nothing matches" : shown === "live" ? "Nothing running now" : "No sessions yet"} |
| 137 | body={ |
| 138 | active.length |
| 139 | ? "Try fewer filters." |
| 140 | : "A session is one run of an agent on a repository: someone's Claude Code or Codex, or g1t's own on a pull request." |
| 141 | } |
| 142 | /> |
| 143 | } |
| 144 | /> |
| 145 | )} |
| 146 | |
| 147 | <Filters |
| 148 | visible={filtering} |
| 149 | filter={filter} |
| 150 | me={me.username} |
| 151 | repos={(sessions.data?.repos ?? []).map((repo) => repo.path.split("/", 2)[1] ?? repo.path)} |
| 152 | onClose={() => setFiltering(false)} |
| 153 | onChange={setFilter} |
| 154 | /> |
| 155 | </Screen> |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | /** The list's filters, in a sheet: started by you, and the repository. */ |
| 160 | function Filters({ |
| 161 | visible, |
| 162 | filter, |
| 163 | me, |
| 164 | repos, |
| 165 | onClose, |
| 166 | onChange, |
| 167 | }: { |
| 168 | visible: boolean; |
| 169 | filter: SessionsFilter; |
| 170 | me: string; |
| 171 | repos: string[]; |
| 172 | onClose: () => void; |
| 173 | onChange: (filter: SessionsFilter) => void; |
| 174 | }) { |
| 175 | const colors = useColors(); |
| 176 | const { height } = useWindowDimensions(); |
| 177 | const mine = filter.person === me; |
| 178 | return ( |
| 179 | <Sheet visible={visible} onClose={onClose}> |
| 180 | <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}> |
| 181 | <Text size="heading">Filters</Text> |
| 182 | <View style={{ marginHorizontal: -space.lg }}> |
| 183 | <Row |
| 184 | title="Started by me" |
| 185 | chevron={false} |
| 186 | trailing={ |
| 187 | <View style={[styles.check, { borderColor: mine ? colors.accent : colors.lineStrong, backgroundColor: mine ? colors.accent : "transparent" }]}> |
| 188 | {mine ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null} |
| 189 | </View> |
| 190 | } |
| 191 | onPress={() => onChange({ ...filter, person: mine ? undefined : me })} |
| 192 | /> |
| 193 | </View> |
| 194 | <View style={{ gap: space.sm }}> |
| 195 | <Text size="small" tone="muted"> |
| 196 | Repository |
| 197 | </Text> |
| 198 | {repos.length ? ( |
| 199 | <View style={styles.chips}> |
| 200 | {repos.map((name) => ( |
| 201 | <Chip |
| 202 | key={name} |
| 203 | label={name} |
| 204 | tone={filter.repo === name ? "accent" : "neutral"} |
| 205 | onPress={() => onChange({ ...filter, repo: filter.repo === name ? undefined : name })} |
| 206 | /> |
| 207 | ))} |
| 208 | </View> |
| 209 | ) : ( |
| 210 | <Text size="small" tone="faint"> |
| 211 | No repositories you can see |
| 212 | </Text> |
| 213 | )} |
| 214 | </View> |
| 215 | <View style={{ flexDirection: "row", gap: space.sm }}> |
| 216 | <View style={{ flex: 1 }}> |
| 217 | <Button title="Clear" variant="secondary" onPress={() => onChange({ state: filter.state })} /> |
| 218 | </View> |
| 219 | <View style={{ flex: 1 }}> |
| 220 | <Button title="Done" onPress={onClose} /> |
| 221 | </View> |
| 222 | </View> |
| 223 | </ScrollView> |
| 224 | </Sheet> |
| 225 | ); |
| 226 | } |
| 227 | |
| 228 | const styles = StyleSheet.create({ |
| 229 | bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, |
| 230 | chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, |
| 231 | check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" }, |
| 232 | }); |