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.

Commit

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
syntaqxcommitted Parentfe9dc02Browse files
9 files+327−2780/9 viewed
+3−2
1616 * The signed-in app. Its bar is the site's phone bar
1717 * (apps/web/app/components/mobile.tsx): Home, Work, Chat, Notifications
1818 * 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.
2122 */
2223 export default function AppLayout() {
2324 return (
+12−6
33 import { hasCodeAccess } from "@g1t/contracts";
44 import { Stack, router } from "expo-router";
55 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";
77 import { Linking, View } from "react-native";
88
99 import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
1212 import { useSignedIn } from "@/lib/session";
1313 import { space, useColors } from "@/theme";
1414
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+ */
1620 export default function More() {
1721 const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
1822 const code = !workspace || hasCodeAccess(workspace);
4650
4751 <View>
4852 <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"))}
5359 />
5460 {code ? (
5561 <Row
+1−1
11 import { TabStack } from "@/components/stack";
22
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. */
44 export const unstable_settings = { initialRouteName: "index" };
55
66 export default TabStack;
+73−23
11 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";
33 import { useCallback, useEffect, useMemo, useState } from "react";
44 import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native";
55
66 import { Board } from "@/components/board";
77 import { Sheet } from "@/components/chat/sheet";
88 import { Segments } from "@/components/segments";
9+import { SessionsPanel, sessionsFilterOf, sessionsFiltered } from "@/components/sessions-panel";
910 import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
1011 import { Chip, ItemRow, roleLabel } from "@/components/work";
1112 import { useRepos } from "@/lib/code";
13+import type { SessionsFilter } from "@/lib/sessions";
1214 import { useSignedIn } from "@/lib/session";
1315 import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work";
1416 import { radius, space, useColors } from "@/theme";
1517
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";
1734
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+}
2038
2139 /** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */
2240 function filterOf(params: Params): WorkFilter {
3553
3654 /**
3755 * 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.
4262 */
4363 export default function WorkList() {
4464 const params = useLocalSearchParams<Params>();
4969 const [filter, setFilter] = useState<WorkFilter>(linked);
5070 useEffect(() => setFilter(linked), [linked]);
5171 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);
5377 const [view, setView] = useState<Shown>(linkedView);
5478 useEffect(() => setView(linkedView), [linkedView]);
5579 const [focused, setFocused] = useState(false);
6892 const tabs = navigation.getParent();
6993 if (!tabs) return;
7094 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);
7298 });
7399 }, [navigation, view]);
74100
79105 filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null,
80106 ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null);
81107
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+
82121 const header = (
83122 <Stack.Screen
84123 options={{
85124 title: "Work",
86125 headerRight: () => (
87126 <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}
93127 {view === "items" ? (
94128 <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}>
95129 <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} />
96130 </Pressable>
97131 ) : 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}
98137 </View>
99138 ),
100139 }}
110149 );
111150 }
112151
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+
113170 return (
114171 <Screen>
115172 {header}
116173 <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}
125175 {view === "items" ? (
126176 <Segments
127177 value={filter.state}
+1−1
3939 );
4040 const query = useWorkspaceSession(workspace, params.id, focused);
4141 const [allFiles, setAllFiles] = useState(false);
42− const sitePath = `/${workspace}/-/sessions/${params.id}`;
42+ const sitePath = `/${workspace}/-/work/sessions/${params.id}`;
4343
4444 const title = (
4545 <Stack.Screen
+0−232
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−});
+220−0
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+});
+10−7
4343 assert.equal(routeFor("/acme/-/work/board/x"), null);
4444 });
4545
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");
5151 // 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);
5457 });
5558
5659 test("a session opens its page, or its pull request's for g1t's agent and a recorded one", () => {
+7−6
2323 /** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */
2424 export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const;
2525
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`. */
2727 export const SESSION_FILTERS = ["state", "person", "repo", "item"] as const;
2828
2929 /**
5454 if (!fourth) return `/work?${workspace}${filtersOf(query, WORK_FILTERS)}`;
5555 if (fourth === "new" && !fifth) return `/work/new?${workspace}`;
5656 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+ }
5763 if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`;
5864 // 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}`;
6465 return null;
6566 }
6667 if (third === "overview" && !fourth) return `/code?${workspace}`;

Comments

Sign in to comment.