Phone app: Sessions is a view of Work, and More starts with Explore
Work's top reads Items, Board and Sessions; Sessions shows in place, with its own filters, rather than as a screen of its own. More has no Sessions row; Explore heads it and opens on the site. Links to /<ws>/-/work/sessions open Work on Sessions, keeping its filters, and /<ws>/-/work/sessions/<id> the session; the old /-/sessions addresses are gone.
- G1T-Session:
- ses_01m56vafhge7ttrgs5288dpxvb
9 files+327−2780/9 viewed
| 16 | 16 | * The signed-in app. Its bar is the site's phone bar | |
| 17 | 17 | * (apps/web/app/components/mobile.tsx): Home, Work, Chat, Notifications | |
| 18 | 18 | * and More. Work and Chat have their screens; Home and Notifications say | |
| 19 | − | * so and open the site. Sessions is at the top of More (and in Work's | |
| 20 | − | * header), then Code, for those who use it, and your account is under it. | |
| 19 | + | * so and open the site. Sessions are Work's (Items, Board, Sessions at | |
| 20 | + | * its top, as on the site). More starts with Explore, which opens on the | |
| 21 | + | * site, then Code, for those who use it, and your account is under it. | |
| 21 | 22 | */ | |
| 22 | 23 | export default function AppLayout() { | |
| 23 | 24 | return ( |
| 3 | 3 | import { hasCodeAccess } from "@g1t/contracts"; | |
| 4 | 4 | import { Stack, router } from "expo-router"; | |
| 5 | 5 | import * as WebBrowser from "expo-web-browser"; | |
| 6 | − | import { Activity, Bell, BellRing, Check, Code2, ExternalLink, LogOut } from "lucide-react-native"; | |
| 6 | + | import { Bell, BellRing, Check, Code2, Compass, ExternalLink, LogOut } from "lucide-react-native"; | |
| 7 | 7 | import { Linking, View } from "react-native"; | |
| 8 | 8 | ||
| 9 | 9 | import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| ⋯ | |||
| 12 | 12 | import { useSignedIn } from "@/lib/session"; | |
| 13 | 13 | import { space, useColors } from "@/theme"; | |
| 14 | 14 | ||
| 15 | − | /** More: Sessions, Code, notifications on this phone, your workspaces, how you signed in, and signing out. */ | |
| 15 | + | /** | |
| 16 | + | * More: Explore (second in the site's rail, so first here; it opens on | |
| 17 | + | * the site), Code, notifications on this phone, your workspaces, how you | |
| 18 | + | * signed in, and signing out. Sessions are Work's, a view of its own there. | |
| 19 | + | */ | |
| 16 | 20 | export default function More() { | |
| 17 | 21 | const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn(); | |
| 18 | 22 | const code = !workspace || hasCodeAccess(workspace); | |
| ⋯ | |||
| 46 | 50 | ||
| 47 | 51 | <View> | |
| 48 | 52 | <Row | |
| 49 | − | leading={<Activity size={22} color={colors.accent} />} | |
| 50 | − | title="Sessions" | |
| 51 | − | subtitle="What is cooking: the agents at work across the workspace" | |
| 52 | − | onPress={() => router.push({ pathname: "/work/sessions", params: workspace ? { workspace: workspace.slug } : {} })} | |
| 53 | + | leading={<Compass size={22} color={colors.accent} />} | |
| 54 | + | title="Explore" | |
| 55 | + | subtitle="g1t beyond your workspaces: public projects, people and topics" | |
| 56 | + | trailing={<ExternalLink size={16} color={colors.faint} />} | |
| 57 | + | chevron={false} | |
| 58 | + | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, "/explore"))} | |
| 53 | 59 | /> | |
| 54 | 60 | {code ? ( | |
| 55 | 61 | <Row | |
| 1 | 1 | import { TabStack } from "@/components/stack"; | |
| 2 | 2 | ||
| 3 | − | /** A link straight to a screen under Work (Sessions, an item) still has the list to go back to. */ | |
| 3 | + | /** A link straight to a screen under Work (a session, an item) still has the list to go back to. */ | |
| 4 | 4 | export const unstable_settings = { initialRouteName: "index" }; | |
| 5 | 5 | ||
| 6 | 6 | export default TabStack; |
| 1 | 1 | import { Stack, router, useFocusEffect, useLocalSearchParams, useNavigation } from "expo-router"; | |
| 2 | − | import { Activity, Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native"; | |
| 2 | + | import { Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native"; | |
| 3 | 3 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 4 | 4 | import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native"; | |
| 5 | 5 | ||
| 6 | 6 | import { Board } from "@/components/board"; | |
| 7 | 7 | import { Sheet } from "@/components/chat/sheet"; | |
| 8 | 8 | import { Segments } from "@/components/segments"; | |
| 9 | + | import { SessionsPanel, sessionsFilterOf, sessionsFiltered } from "@/components/sessions-panel"; | |
| 9 | 10 | import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui"; | |
| 10 | 11 | import { Chip, ItemRow, roleLabel } from "@/components/work"; | |
| 11 | 12 | import { useRepos } from "@/lib/code"; | |
| 13 | + | import type { SessionsFilter } from "@/lib/sessions"; | |
| 12 | 14 | import { useSignedIn } from "@/lib/session"; | |
| 13 | 15 | import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work"; | |
| 14 | 16 | import { radius, space, useColors } from "@/theme"; | |
| 15 | 17 | ||
| 16 | − | type Params = { workspace?: string; view?: string; state?: string; label?: string; milestone?: string; assignee?: string; repo?: string; role?: string }; | |
| 18 | + | type Params = { | |
| 19 | + | workspace?: string; | |
| 20 | + | view?: string; | |
| 21 | + | state?: string; | |
| 22 | + | label?: string; | |
| 23 | + | milestone?: string; | |
| 24 | + | assignee?: string; | |
| 25 | + | repo?: string; | |
| 26 | + | role?: string; | |
| 27 | + | // Sessions' own filters (`state` and `repo` are shared names). | |
| 28 | + | person?: string; | |
| 29 | + | item?: string; | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | /** Work's three views: the list of items, the board, and the sessions working on them. */ | |
| 33 | + | type Shown = "items" | "board" | "sessions"; | |
| 17 | 34 | ||
| 18 | − | /** The list of items, or the board. */ | |
| 19 | − | type Shown = "items" | "board"; | |
| 35 | + | function shownOf(view: string | undefined): Shown { | |
| 36 | + | return view === "board" || view === "sessions" ? view : "items"; | |
| 37 | + | } | |
| 20 | 38 | ||
| 21 | 39 | /** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */ | |
| 22 | 40 | function filterOf(params: Params): WorkFilter { | |
| ⋯ | |||
| 35 | 53 | ||
| 36 | 54 | /** | |
| 37 | 55 | * Work: the workspace's work items, newest first, for every member, | |
| 38 | − | * Code or not, or its board (`/acme/-/work/board`). Filters are in a | |
| 39 | − | * sheet (the ⚙ in the header, the chips above the list, or Work in the | |
| 40 | − | * bar again); pull down to read again, and the list loads more as it | |
| 41 | − | * nears its end. Sessions opens from the header. | |
| 56 | + | * Code or not; its board (`/acme/-/work/board`); and its sessions | |
| 57 | + | * (`/acme/-/work/sessions`), what is cooking on them: Items, Board and | |
| 58 | + | * Sessions at the top, as the site's Work holds all three. Filters are | |
| 59 | + | * in a sheet (the ⚙ in the header, the chips above the list, or Work in | |
| 60 | + | * the bar again); pull down to read again, and the list loads more as it | |
| 61 | + | * nears its end. | |
| 42 | 62 | */ | |
| 43 | 63 | export default function WorkList() { | |
| 44 | 64 | const params = useLocalSearchParams<Params>(); | |
| ⋯ | |||
| 49 | 69 | const [filter, setFilter] = useState<WorkFilter>(linked); | |
| 50 | 70 | useEffect(() => setFilter(linked), [linked]); | |
| 51 | 71 | const [filtering, setFiltering] = useState(false); | |
| 52 | − | const linkedView: Shown = params.view === "board" ? "board" : "items"; | |
| 72 | + | const [filteringSessions, setFilteringSessions] = useState(false); | |
| 73 | + | const linkedSessions = useMemo(() => sessionsFilterOf(params), [params.state, params.person, params.repo, params.item]); | |
| 74 | + | const [sessionsFilter, setSessionsFilter] = useState<SessionsFilter>(linkedSessions); | |
| 75 | + | useEffect(() => setSessionsFilter(linkedSessions), [linkedSessions]); | |
| 76 | + | const linkedView = shownOf(params.view); | |
| 53 | 77 | const [view, setView] = useState<Shown>(linkedView); | |
| 54 | 78 | useEffect(() => setView(linkedView), [linkedView]); | |
| 55 | 79 | const [focused, setFocused] = useState(false); | |
| ⋯ | |||
| 68 | 92 | const tabs = navigation.getParent(); | |
| 69 | 93 | if (!tabs) return; | |
| 70 | 94 | return tabs.addListener("tabPress" as never, () => { | |
| 71 | − | if (navigation.isFocused() && view === "items") setFiltering(true); | |
| 95 | + | if (!navigation.isFocused()) return; | |
| 96 | + | if (view === "items") setFiltering(true); | |
| 97 | + | if (view === "sessions") setFilteringSessions(true); | |
| 72 | 98 | }); | |
| 73 | 99 | }, [navigation, view]); | |
| 74 | 100 | ||
| ⋯ | |||
| 79 | 105 | filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null, | |
| 80 | 106 | ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null); | |
| 81 | 107 | ||
| 108 | + | const sessionsLit = sessionsFiltered(sessionsFilter); | |
| 109 | + | const views = ( | |
| 110 | + | <Segments | |
| 111 | + | value={view} | |
| 112 | + | onChange={setView} | |
| 113 | + | options={[ | |
| 114 | + | { value: "items", label: "Items" }, | |
| 115 | + | { value: "board", label: "Board" }, | |
| 116 | + | { value: "sessions", label: "Sessions" }, | |
| 117 | + | ]} | |
| 118 | + | /> | |
| 119 | + | ); | |
| 120 | + | ||
| 82 | 121 | const header = ( | |
| 83 | 122 | <Stack.Screen | |
| 84 | 123 | options={{ | |
| 85 | 124 | title: "Work", | |
| 86 | 125 | headerRight: () => ( | |
| 87 | 126 | <View style={styles.actions}> | |
| 88 | − | {slug ? ( | |
| 89 | − | <Pressable accessibilityLabel="Sessions" hitSlop={10} onPress={() => router.push({ pathname: "/work/sessions", params: { workspace: slug } })}> | |
| 90 | − | <Activity size={22} color={colors.fg} /> | |
| 91 | − | </Pressable> | |
| 92 | − | ) : null} | |
| 93 | 127 | {view === "items" ? ( | |
| 94 | 128 | <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}> | |
| 95 | 129 | <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} /> | |
| 96 | 130 | </Pressable> | |
| 97 | 131 | ) : null} | |
| 132 | + | {view === "sessions" ? ( | |
| 133 | + | <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFilteringSessions(true)}> | |
| 134 | + | <SlidersHorizontal size={22} color={sessionsLit ? colors.accent : colors.fg} /> | |
| 135 | + | </Pressable> | |
| 136 | + | ) : null} | |
| 98 | 137 | </View> | |
| 99 | 138 | ), | |
| 100 | 139 | }} | |
| ⋯ | |||
| 110 | 149 | ); | |
| 111 | 150 | } | |
| 112 | 151 | ||
| 152 | + | if (view === "sessions") { | |
| 153 | + | return ( | |
| 154 | + | <Screen> | |
| 155 | + | {header} | |
| 156 | + | <SessionsPanel | |
| 157 | + | workspace={slug} | |
| 158 | + | me={me.username} | |
| 159 | + | filter={sessionsFilter} | |
| 160 | + | onChange={setSessionsFilter} | |
| 161 | + | focused={focused} | |
| 162 | + | filtering={filteringSessions} | |
| 163 | + | onFiltering={setFilteringSessions} | |
| 164 | + | top={views} | |
| 165 | + | /> | |
| 166 | + | </Screen> | |
| 167 | + | ); | |
| 168 | + | } | |
| 169 | + | ||
| 113 | 170 | return ( | |
| 114 | 171 | <Screen> | |
| 115 | 172 | {header} | |
| 116 | 173 | <View style={[styles.bar, { borderBottomColor: colors.line }]}> | |
| 117 | − | <Segments | |
| 118 | − | value={view} | |
| 119 | − | onChange={setView} | |
| 120 | − | options={[ | |
| 121 | − | { value: "items", label: "Items" }, | |
| 122 | − | { value: "board", label: "Board" }, | |
| 123 | − | ]} | |
| 124 | − | /> | |
| 174 | + | {views} | |
| 125 | 175 | {view === "items" ? ( | |
| 126 | 176 | <Segments | |
| 127 | 177 | value={filter.state} | |
| 39 | 39 | ); | |
| 40 | 40 | const query = useWorkspaceSession(workspace, params.id, focused); | |
| 41 | 41 | const [allFiles, setAllFiles] = useState(false); | |
| 42 | − | const sitePath = `/${workspace}/-/sessions/${params.id}`; | |
| 42 | + | const sitePath = `/${workspace}/-/work/sessions/${params.id}`; | |
| 43 | 43 | ||
| 44 | 44 | const title = ( | |
| 45 | 45 | <Stack.Screen |
| 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 | − | }); |
| 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 | + | }); |
| 43 | 43 | assert.equal(routeFor("/acme/-/work/board/x"), null); | |
| 44 | 44 | }); | |
| 45 | 45 | ||
| 46 | − | test("Sessions and a session open the app's Sessions", () => { | |
| 47 | − | assert.equal(routeFor(linkPathOf("g1t://acme/-/sessions")!), "/work/sessions?workspace=acme"); | |
| 48 | − | assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/sessions")!), "/work/sessions?workspace=acme"); | |
| 49 | − | assert.equal(routeFor(linkPathOf("g1t://acme/-/sessions/ses_01abc")!), "/work/sessions/ses_01abc?workspace=acme"); | |
| 50 | − | assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/sessions/ses_01abc")!), "/work/sessions/ses_01abc?workspace=acme"); | |
| 46 | + | test("Work's sessions open Work on its Sessions, and a session its screen", () => { | |
| 47 | + | assert.equal(routeFor(linkPathOf("g1t://acme/-/work/sessions")!), "/work?workspace=acme&view=sessions"); | |
| 48 | + | assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/work/sessions")!), "/work?workspace=acme&view=sessions"); | |
| 49 | + | assert.equal(routeFor(linkPathOf("g1t://acme/-/work/sessions/ses_01abc")!), "/work/sessions/ses_01abc?workspace=acme"); | |
| 50 | + | assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/work/sessions/ses_01abc")!), "/work/sessions/ses_01abc?workspace=acme"); | |
| 51 | 51 | // The list keeps the filters the site's does, and drops anything else. | |
| 52 | − | assert.equal(routeFor("/acme/-/sessions?state=live&repo=web&page=2"), "/work/sessions?workspace=acme&state=live&repo=web"); | |
| 53 | − | assert.equal(routeFor("/acme/-/sessions/ses_1/entries"), null); | |
| 52 | + | assert.equal(routeFor("/acme/-/work/sessions?state=live&repo=web&page=2"), "/work?workspace=acme&view=sessions&state=live&repo=web"); | |
| 53 | + | assert.equal(routeFor("/acme/-/work/sessions/ses_1/entries"), null); | |
| 54 | + | // Sessions were once a page of their own; that address is gone, so the app has no screen for it. | |
| 55 | + | assert.equal(routeFor("/acme/-/sessions"), null); | |
| 56 | + | assert.equal(routeFor("/acme/-/sessions/ses_1"), null); | |
| 54 | 57 | }); | |
| 55 | 58 | ||
| 56 | 59 | test("a session opens its page, or its pull request's for g1t's agent and a recorded one", () => { |
| 23 | 23 | /** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */ | |
| 24 | 24 | export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const; | |
| 25 | 25 | ||
| 26 | − | /** The filters a link to Sessions may carry: `/acme/-/sessions?state=live&repo=web`. */ | |
| 26 | + | /** The filters a link to Work's sessions may carry: `/acme/-/work/sessions?state=live&repo=web`. */ | |
| 27 | 27 | export const SESSION_FILTERS = ["state", "person", "repo", "item"] as const; | |
| 28 | 28 | ||
| 29 | 29 | /** | |
| ⋯ | |||
| 54 | 54 | if (!fourth) return `/work?${workspace}${filtersOf(query, WORK_FILTERS)}`; | |
| 55 | 55 | if (fourth === "new" && !fifth) return `/work/new?${workspace}`; | |
| 56 | 56 | if (fourth === "board" && !fifth) return `/work?${workspace}&view=board`; | |
| 57 | + | // Sessions are Work's: its Sessions view, and one session's screen. | |
| 58 | + | if (fourth === "sessions") { | |
| 59 | + | if (!fifth) return `/work?${workspace}&view=sessions${filtersOf(query, SESSION_FILTERS)}`; | |
| 60 | + | if (parts.length === 5) return `/work/sessions/${encodeURIComponent(fifth)}?${workspace}`; | |
| 61 | + | return null; | |
| 62 | + | } | |
| 57 | 63 | if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`; | |
| 58 | 64 | // Labels and milestones stay on the site. | |
| 59 | − | return null; | |
| 60 | − | } | |
| 61 | − | if (third === "sessions") { | |
| 62 | − | if (!fourth) return `/work/sessions?${workspace}${filtersOf(query, SESSION_FILTERS)}`; | |
| 63 | − | if (!fifth) return `/work/sessions/${encodeURIComponent(fourth)}?${workspace}`; | |
| 64 | 65 | return null; | |
| 65 | 66 | } | |
| 66 | 67 | if (third === "overview" && !fourth) return `/code?${workspace}`; | |
Comments
Sign in to comment.