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