Skip to content

Commit

Merge branch 'mobile-agent-leftovers' into rm-agents

syntaqxcommitted Parents385b8a1ae5a25cBrowse files
23 files+80−3800/23 viewed
+3−9
11 # g1t for Android and iOS
22
33 The phone app: [Expo](https://expo.dev) (SDK 57, React Native 0.86) with
4−Expo Router, in TypeScript. Chat, Code and Customize, signed in with g1t's
4+Expo Router, in TypeScript. Chat and Code, signed in with g1t's
55 own OAuth. Android is the first target. What it does for people is in the
66 guide, [The phone app](../docs/src/content/docs/guides/mobile.md).
77
1919 (app)/ signed in: the tabs
2020 chat/ the list, a conversation, a thread, new, browse
2121 code/ repositories, pull requests, issues, diffs
22− more/ Customize, notifications, workspaces, sign out
23− customize/ Skills, Connectors, Plugins (from More)
24− home/, notifications/, agents/ coming: they open the site
22+ more/ notifications, workspaces, sign out
23+ home/, notifications/ coming: they open the site
2524 +native-intent.tsx g1t:// links to screens (lib/links.ts)
2625 components/ the building blocks, themed (ui.tsx); chat/ and Code's
2726 lib/
3433 live.ts the site's live sockets, with tickets, kept open
3534 feed.tsx counts, banners and presence while the app is open
3635 push.ts notifications on the phone
37− customize.ts Customize's listings, from @g1t/contracts' catalogs
3836 theme/ packages/theme/tokens.css's colours, for React Native
3937 ```
4038
4139 Shared types and catalogs come from `@g1t/contracts` (by subpath, such as
4240 `@g1t/contracts/oauth`, so the app does not bundle every module).
43−Customize's three parts are defined there too (`customize.ts`), so the
44−site can move the Marketplace onto the same model.
4541
4642 ## How it talks to g1t
4743
260256 - **The brand's faces**: TTF copies of Hanken Grotesk, Bricolage
261257 Grotesque and IBM Plex Mono (packages/theme ships woff2), loaded with
262258 `expo-font`.
263−- **Agents' faces**: `components/ui.tsx`'s `Avatar` draws a letter for an
264− agent; the site's `AgentFace` should be shared and drawn here.
265259 - **The store listing**: screenshots, a privacy policy URL, and Play's
266260 data safety form (the app keeps the server and credentials in the
267261 keystore, registers a push token with the g1t it is pointed at, and
+4−6
11 import * as Notifications from "expo-notifications";
22 import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router";
3−import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native";
3+import { Bell, Code2, House, Menu, MessagesSquare } from "lucide-react-native";
44 import type { LucideIcon } from "lucide-react-native";
55 import { type ComponentType, useEffect, useRef } from "react";
66 import { StyleSheet, View } from "react-native";
1515
1616 /**
1717 * The signed-in app. Its bar is the site's phone bar
18− * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Agents,
19− * Code, and More. Chat and Code have their screens; the others say so and
20− * open the site. Customize and your account are under More.
18+ * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Code,
19+ * and More. Chat and Code have their screens; the others say so and open
20+ * the site. Your account is under More.
2121 */
2222 export default function AppLayout() {
2323 return (
7373 }}
7474 />
7575 <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: (props) => <BarIcon icon={Bell} {...props} /> }} />
76− <Tabs.Screen name="agents" options={{ title: "Agents", tabBarIcon: (props) => <BarIcon icon={Bot} {...props} /> }} />
7776 <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: (props) => <BarIcon icon={Code2} {...props} /> }} />
78− <Tabs.Screen name="customize" options={{ href: null }} />
7977 <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} />
8078 </Tabs>
8179 );
+0−3
1−import { TabStack } from "@/components/stack";
2−
3−export default TabStack;
+0−15
1−import { Bot } from "lucide-react-native";
2−
3−import { ComingSoon } from "@/components/coming-soon";
4−
5−/** Agents: the workspace's agents, their sessions, budgets and memory. */
6−export default function Agents() {
7− return (
8− <ComingSoon
9− title="Agents"
10− icon={Bot}
11− body="Your workspace's agents, what they are doing and what they cost. Message any of them from Chat meanwhile."
12− path={(workspace) => `/${workspace}/-/agents`}
13− />
14− );
15−}
+2−2
9191 search ? (
9292 <Empty title="Nothing matches" />
9393 ) : (
94− <Empty icon={MessagesSquare} title="No conversations yet" body="Message a person or an agent, or join a channel." action={<Button title="New message" onPress={() => router.push("/chat/new")} />} />
94+ <Empty icon={MessagesSquare} title="No conversations yet" body="Message a person, or join a channel." action={<Button title="New message" onPress={() => router.push("/chat/new")} />} />
9595 )
9696 }
9797 ListFooterComponent={
151151 </View>
152152 <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
153153 <Text size="small" tone="muted" numberOfLines={1} style={{ flex: 1 }}>
154− {dm ? (other?.kind === "agent" ? (other.title ?? other.role ?? "Agent") : `@${other?.name ?? ""}`) : (channel.topic ?? (entry.muted ? "Muted" : " "))}
154+ {dm ? (other?.kind === "agent" ? "Deleted agent" : `@${other?.name ?? ""}`) : (channel.topic ?? (entry.muted ? "Muted" : " "))}
155155 </Text>
156156 {entry.mentions > 0 ? <Badge label={`@ ${entry.mentions}`} tone="accent" /> : loud ? <View style={[styles.dot, { backgroundColor: colors.accent }]} /> : null}
157157 </View>
+11−26
1−import type { MemberProfile } from "@g1t/contracts";
21 import { useQuery } from "@tanstack/react-query";
32 import { Stack, router } from "expo-router";
43 import { Check, Hash, Search } from "lucide-react-native";
76
87 import { PromptSheet } from "@/components/chat/sheet";
98 import { Avatar, Button, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
10−import { useChatSidebar, useStartConversation } from "@/lib/chat";
9+import { useStartConversation } from "@/lib/chat";
1110 import { useSignedIn } from "@/lib/session";
1211 import { radius, space, useColors } from "@/theme";
1312
14−type Person = { key: string; name: string; handle: string; avatar: string | null; agent: boolean; detail: string | null };
13+type Person = { key: string; name: string; handle: string; avatar: string | null };
1514
1615 /**
17− * A new conversation: pick people and agents for a direct message (one,
18− * or a group), or make a channel. People come from the workspace's
19− * members (`GET /workspaces/<ws>/members`); agents from the conversations
20− * you already have with them.
16+ * A new conversation: pick people for a direct message (one, or a
17+ * group), or make a channel. People come from the workspace's members
18+ * (`GET /workspaces/<ws>/members`).
2119 */
2220 export default function NewConversation() {
2321 const { client, workspace, me, server } = useSignedIn();
2422 const slug = workspace?.slug ?? "";
2523 const colors = useColors();
26− const sidebar = useChatSidebar();
2724 const start = useStartConversation();
2825 const [search, setSearch] = useState("");
2926 const [picked, setPicked] = useState<Person[]>([]);
3835 });
3936
4037 const everyone = useMemo(() => {
41− const agents = new Map<string, MemberProfile>();
42− for (const entry of sidebar.data?.entries ?? []) for (const other of entry.others) if (other.kind === "agent") agents.set(other.id, other);
4338 const people: Person[] = (members.data ?? [])
4439 .filter((member) => member.username.toLowerCase() !== me.username.toLowerCase())
4540 .map((member) => ({
4742 name: member.name || member.display_username || member.username,
4843 handle: member.display_username ?? member.username,
4944 avatar: member.avatar ?? null,
50− agent: false,
51− detail: null,
5245 }));
53− const bots: Person[] = [...agents.values()].map((agent) => ({
54− key: `agent:${agent.id}`,
55− name: agent.display_name,
56− handle: agent.name,
57− avatar: agent.avatar,
58− agent: true,
59− detail: agent.title ?? agent.role,
60− }));
6146 const words = search.trim().toLowerCase();
62− return [...bots, ...people].filter((one) => !words || `${one.name} ${one.handle}`.toLowerCase().includes(words));
63− }, [members.data, sidebar.data, me.username, search]);
47+ return people.filter((one) => !words || `${one.name} ${one.handle}`.toLowerCase().includes(words));
48+ }, [members.data, me.username, search]);
6449
6550 const toggle = (person: Person) =>
6651 setPicked((now) => (now.some((one) => one.key === person.key) ? now.filter((one) => one.key !== person.key) : [...now, person]));
8570 autoFocus
8671 value={search}
8772 onChangeText={setSearch}
88− placeholder="Find a person or an agent"
73+ placeholder="Find a person"
8974 placeholderTextColor={colors.faint}
9075 style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }}
9176 autoCorrect={false}
10792 ListHeaderComponent={
10893 <>
10994 <Row leading={<Hash size={20} color={colors.accent} />} title="Make a channel" subtitle="A conversation for a team or a topic" onPress={() => setMaking(true)} />
110− <SectionHeader title="People and agents" />
95+ <SectionHeader title="People" />
11196 </>
11297 }
11398 renderItem={({ item }) => {
11499 const on = picked.some((one) => one.key === item.key);
115100 return (
116101 <Pressable onPress={() => toggle(item)} style={({ pressed }) => [styles.person, { backgroundColor: pressed ? colors.raised : "transparent" }]}>
117− <Avatar name={item.name} uri={item.avatar ? `${server.site}/avatars/${item.avatar}` : null} agent={item.agent} size={36} />
102+ <Avatar name={item.name} uri={item.avatar ? `${server.site}/avatars/${item.avatar}` : null} size={36} />
118103 <View style={{ flex: 1 }}>
119104 <Text style={{ fontWeight: "600" }} numberOfLines={1}>
120105 {item.name}
121106 </Text>
122107 <Text size="small" tone="muted" numberOfLines={1}>
123− {item.agent ? (item.detail ?? "Agent") : `@${item.handle}`}
108+ @{item.handle}
124109 </Text>
125110 </View>
126111 <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}>
+1−1
2121 return (
2222 <Screen>
2323 <Stack.Screen options={{ title: "Code" }} />
24− <Empty icon={Code2} title="Code is not on for you here" body={`${workspace.name ?? workspace.slug} gives you Chat, Docs and agents. Ask an owner for Code.`} />
24+ <Empty icon={Code2} title="Code is not on for you here" body={`${workspace.name ?? workspace.slug} gives you Chat and Docs. Ask an owner for Code.`} />
2525 </Screen>
2626 );
2727 }
+0−47
1−import { CUSTOMIZE_SECTIONS, type CustomizeSection } from "@g1t/contracts/customize";
2−import { Redirect, Stack, useLocalSearchParams } from "expo-router";
3−import * as WebBrowser from "expo-web-browser";
4−import { ExternalLink } from "lucide-react-native";
5−import { SectionList, View } from "react-native";
6−
7−import { Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
8−import { CUSTOMIZE, type CustomizeItem, customizeItems, customizePath } from "@/lib/customize";
9−import { siteUrl } from "@/lib/server";
10−import { useSignedIn } from "@/lib/session";
11−import { space } from "@/theme";
12−
13−/** One part of Customize: its listings, grouped, each opening where it is set up. */
14−export default function CustomizeSectionScreen() {
15− const { section } = useLocalSearchParams<{ section: string }>();
16− const { workspace, server } = useSignedIn();
17− if (!CUSTOMIZE_SECTIONS.includes(section as CustomizeSection)) return <Redirect href="/customize" />;
18− const id = section as CustomizeSection;
19− const slug = workspace?.slug ?? "";
20− const items = customizeItems(id, slug);
21− const groups = [...new Set(items.map((item) => item.group))].map((group) => ({ title: group, data: items.filter((item) => item.group === group) }));
22− const open = (path: string) => WebBrowser.openBrowserAsync(siteUrl(server, path));
23−
24− return (
25− <Screen>
26− <Stack.Screen options={{ title: CUSTOMIZE[id].title }} />
27− <SectionList
28− sections={groups}
29− keyExtractor={(item) => item.id}
30− stickySectionHeadersEnabled={false}
31− ListHeaderComponent={
32− <View style={{ padding: space.lg, gap: space.md }}>
33− <Text tone="muted">{CUSTOMIZE[id].description}</Text>
34− <Button title={`Manage ${CUSTOMIZE[id].title.toLowerCase()} on g1t`} variant="secondary" icon={ExternalLink} onPress={() => open(customizePath(id, slug))} />
35− </View>
36− }
37− renderSectionHeader={({ section: group }) => <SectionHeader title={group.title} />}
38− renderItem={({ item }) => <Item item={item} onPress={() => open(item.sitePath)} />}
39− />
40− </Screen>
41− );
42−}
43−
44−function Item({ item, onPress }: { item: CustomizeItem; onPress: () => void }) {
45− const badge = item.status === "available" ? <Badge label="Available" tone="success" /> : item.status === "soon" ? <Badge label="Soon" /> : <Badge label="Built in" tone="accent" />;
46− return <Row title={item.name} subtitle={item.description} trailing={badge} onPress={item.status === "soon" ? undefined : onPress} />;
47−}
+0−3
1−import { TabStack } from "@/components/stack";
2−
3−export default TabStack;
+0−43
1−import { CUSTOMIZE_SECTIONS } from "@g1t/contracts/customize";
2−import { Stack, router } from "expo-router";
3−import { Blocks, Plug, Sparkles } from "lucide-react-native";
4−import { View } from "react-native";
5−
6−import { Row, Screen, Text } from "@/components/ui";
7−import { CUSTOMIZE, customizeItems } from "@/lib/customize";
8−import { useSignedIn } from "@/lib/session";
9−import { space, useColors } from "@/theme";
10−
11−const ICONS = { skills: Sparkles, connectors: Plug, plugins: Blocks } as const;
12−
13−/**
14− * Customize: what shapes how this workspace's agents work. Skills are what
15− * they know how to do, Connectors what they can reach, Plugins what adds
16− * to the workspace. It replaces the Marketplace.
17− */
18−export default function Customize() {
19− const { workspace } = useSignedIn();
20− const colors = useColors();
21− const slug = workspace?.slug ?? "";
22− return (
23− <Screen>
24− <Stack.Screen options={{ title: "Customize" }} />
25− <View style={{ padding: space.lg }}>
26− <Text tone="muted">Skills, connectors and plugins for {workspace?.name ?? slug}. Owners add them; anyone else asks an owner.</Text>
27− </View>
28− {CUSTOMIZE_SECTIONS.map((section) => {
29− const Icon = ICONS[section];
30− const count = customizeItems(section, slug).length;
31− return (
32− <Row
33− key={section}
34− leading={<Icon size={22} color={colors.accent} />}
35− title={`${CUSTOMIZE[section].title} · ${count}`}
36− subtitle={CUSTOMIZE[section].description}
37− onPress={() => router.push({ pathname: "/customize/[section]", params: { section } })}
38− />
39− );
40− })}
41− </Screen>
42− );
43−}
+5−6
11 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22 import Constants from "expo-constants";
3−import { Stack, router } from "expo-router";
3+import { Stack } from "expo-router";
44 import * as WebBrowser from "expo-web-browser";
5−import { Bell, BellRing, Blocks, Check, ExternalLink, LogOut } from "lucide-react-native";
5+import { Bell, BellRing, Check, ExternalLink, LogOut } from "lucide-react-native";
66 import { Linking, View } from "react-native";
77
88 import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
1111 import { useSignedIn } from "@/lib/session";
1212 import { space, useColors } from "@/theme";
1313
14−/** More: Customize, your workspaces, notifications on this phone, how you signed in, and signing out. */
14+/** More: your workspaces, notifications on this phone, how you signed in, and signing out. */
1515 export default function More() {
1616 const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
1717 const colors = useColors();
2525 unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." },
2626 off: { label: "Off", detail: `${new URL(server.site).host} sends no notifications to phones. Chat still updates while the app is open.` },
2727 denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." },
28− undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and agents waiting on you." },
29− granted: { label: "On", detail: "Messages to you, mentions, replies and agents waiting on you, when you're not at g1t elsewhere." },
28+ undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and replies." },
29+ granted: { label: "On", detail: "Messages to you, mentions and replies, when you're not at g1t elsewhere." },
3030 }[push.data ?? "undetermined"];
3131
3232 return (
4343 </View>
4444
4545 <View>
46− <Row leading={<Blocks size={22} color={colors.accent} />} title="Customize" subtitle="Skills, connectors and plugins for your agents" onPress={() => router.push("/customize")} />
4746 <Row
4847 leading={<Bell size={22} color={colors.accent} />}
4948 title="Notifications on this phone"
+1−1
5050 <View style={{ gap: space.md }}>
5151 <G1tMark size={44} />
5252 <Text size="title">{hosted ? "Sign in to g1t" : `Sign in to ${host}`}</Text>
53− <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text>
53+ <Text tone="muted">Chat with your team, and review and merge what you and your coding agents make.</Text>
5454 </View>
5555
5656 {mode === "oauth" ? (
+1−6
3939 return (
4040 <Animated.View pointerEvents="box-none" style={[styles.wrap, { top: insets.top + space.sm, transform: [{ translateY: offset }] }]}>
4141 <Pressable onPress={go} style={[styles.banner, { backgroundColor: colors.raised, borderColor: colors.lineStrong }]}>
42− <Avatar
43− name={banner.actor.name}
44− uri={banner.actor.avatar ? `${server.site}/avatars/${banner.actor.avatar}` : null}
45− agent={banner.actor.kind === "agent"}
46− size={36}
47− />
42+ <Avatar name={banner.actor.name} uri={banner.actor.avatar ? `${server.site}/avatars/${banner.actor.avatar}` : null} size={36} />
4843 <View style={{ flex: 1 }}>
4944 <Text style={{ fontWeight: "700" }} numberOfLines={1}>
5045 {banner.title}
+1−11
1−import type { CardAction, ChatMessage } from "@g1t/contracts";
1+import type { ChatMessage } from "@g1t/contracts";
22 import { useQueryClient } from "@tanstack/react-query";
33 import * as Clipboard from "expo-clipboard";
44 import { router, useFocusEffect } from "expo-router";
8888 useEffect(() => {
8989 if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest);
9090 }, [client, queries, slug, channelId, newest, thread]);
91−
92− const onCardAction = useCallback(
93− async (message: ChatMessage, action: CardAction, input?: string | null) => {
94− const answer = await actions.cardAction.mutateAsync({ message, action: action.id, input });
95− if (!answer.ok) throw new Error(answer.message ?? "That didn't work.");
96− return answer.message;
97− },
98− [actions.cardAction],
99− );
10091
10192 const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]);
10293 const onRetry = useCallback((message: Shown) => send(message.body, message), [send]);
137128 onLongPress={setPicked}
138129 onReact={onReact}
139130 onRetry={onRetry}
140− onCardAction={onCardAction}
141131 />
142132 )
143133 }
+27−103
1−import type { CardAction, ChatMessage, MessageCard } from "@g1t/contracts";
1+import type { ChatMessage, MessageCard } from "@g1t/contracts";
22 import * as Haptics from "expo-haptics";
33 import { router } from "expo-router";
44 import * as WebBrowser from "expo-web-browser";
55 import { AlertCircle, Clock, MessageSquareReply } from "lucide-react-native";
6−import { memo, useState } from "react";
7−import { ActivityIndicator, Alert, Pressable, StyleSheet, View } from "react-native";
6+import { memo } from "react";
7+import { Pressable, StyleSheet, View } from "react-native";
88
99 import { Markdown } from "@/components/chat/markdown";
10−import { PromptSheet } from "@/components/chat/sheet";
1110 import { Avatar, Badge, Text } from "@/components/ui";
1211 import type { Shown } from "@/lib/chat";
1312 import { routeFor } from "@/lib/links";
2524 onLongPress: (message: Shown) => void;
2625 onReact: (message: ChatMessage, emoji: string, add: boolean) => void;
2726 onRetry: (message: Shown) => void;
28− onCardAction: (message: ChatMessage, action: CardAction, input?: string | null) => Promise<string | null>;
2927 };
3028
3129 /**
3230 * One message in a conversation. Messages by the same author within a few
33− * minutes run together under one name, as on the site; an agent carries
34− * its marker and an Agent badge, and its cards' buttons work here.
31+ * minutes run together under one name, as on the site. A message an agent
32+ * posted before g1t's own agents were retired still shows, under the name
33+ * chat gives it, with its marker and a Deleted agent badge.
3534 */
36−export const Message = memo(function Message({ message, head, inThread, onLongPress, onReact, onRetry, onCardAction }: Props) {
35+export const Message = memo(function Message({ message, head, inThread, onLongPress, onReact, onRetry }: Props) {
3736 const colors = useColors();
3837 const { server } = useSignedIn();
3938 const { author } = message;
6564 <Text style={{ fontWeight: "700" }} numberOfLines={1}>
6665 {author.display_name}
6766 </Text>
68− {agent ? <Badge label={author.title ?? "Agent"} tone="accent" /> : null}
67+ {agent ? <Badge label="Deleted agent" tone="neutral" /> : null}
6968 <Text size="small" tone="faint">
7069 {clock(message.created_at)}
7170 </Text>
7776 (edited)
7877 </Text>
7978 ) : null}
80− {message.kind === "card" && message.card ? <Card card={message.card} message={message} onAction={onCardAction} site={server.site} /> : null}
79+ {message.kind === "card" && message.card ? <Card card={message.card} site={server.site} /> : null}
8180 {message.reactions && message.reactions.length > 0 ? (
8281 <View style={styles.reactions}>
8382 {message.reactions.map((reaction) => (
130129 });
131130
132131 /**
133− * An agent's card: what it is about, its state, and the buttons that act
134− * on it (approve more budget, stop a session, file a draft issue). A button
135− * may ask first, or ask for a value; its owner answers, and the card
136− * updates in place through the live socket.
132+ * A card an agent posted before g1t's own agents were retired: what it was
133+ * about, its state, and its links. Its other buttons no longer do anything,
134+ * so they are not drawn.
137135 */
138−function Card({
139− card,
140− message,
141− onAction,
142− site,
143−}: {
144− card: MessageCard;
145− message: ChatMessage;
146− onAction: Props["onCardAction"];
147− site: string;
148−}) {
136+function Card({ card, site }: { card: MessageCard; site: string }) {
149137 const colors = useColors();
150− const [busy, setBusy] = useState<string | null>(null);
151− const [asking, setAsking] = useState<CardAction | null>(null);
152− const [said, setSaid] = useState<string | null>(null);
138+ const links = (card.actions ?? []).filter((action) => !!action.href);
153139
154140 const open = (href: string) => {
155141 const route = href.startsWith("/") ? routeFor(href) : null;
156142 if (route) router.push(route as never);
157143 else void WebBrowser.openBrowserAsync(href.startsWith("/") ? siteUrl({ site, api: "" }, href) : href);
158− };
159−
160− const run = async (action: CardAction, input?: string | null) => {
161− setBusy(action.id);
162− setSaid(null);
163− try {
164− const answer = await onAction(message, action, input);
165− if (answer) setSaid(answer);
166− void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined);
167− } catch (error) {
168− setSaid(error instanceof Error ? error.message : "That didn't work.");
169− void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => undefined);
170− } finally {
171− setBusy(null);
172− }
173− };
174−
175− const press = (action: CardAction) => {
176− if (action.href) return open(action.href);
177− if (action.input) return setAsking(action);
178− if (action.confirm) {
179− return Alert.alert(action.label, action.confirm, [
180− { text: "Cancel", style: "cancel" },
181− { text: action.label, style: action.style === "danger" ? "destructive" : "default", onPress: () => void run(action) },
182− ]);
183− }
184− void run(action);
185144 };
186145
187146 return (
212171 </View>
213172 ) : null}
214173 </Pressable>
215− {card.actions?.length ? (
174+ {links.length ? (
216175 <View style={styles.actions}>
217− {card.actions.map((action) => {
218− const primary = action.style === "primary";
219− const danger = action.style === "danger";
220− return (
221− <Pressable
222− key={action.id}
223− disabled={!!busy}
224− onPress={() => press(action)}
225− style={({ pressed }) => [
226− styles.action,
227− {
228− backgroundColor: primary ? colors.accent : colors.bg,
229− borderColor: primary ? colors.accent : danger ? colors.danger : colors.lineStrong,
230− opacity: pressed || (busy && busy !== action.id) ? 0.6 : 1,
231− },
232− ]}
233− >
234− {busy === action.id ? (
235− <ActivityIndicator size="small" color={primary ? colors.onAccent : colors.accent} />
236− ) : (
237− <Text size="small" style={{ fontWeight: "700", color: primary ? colors.onAccent : danger ? colors.danger : colors.fg }}>
238− {action.label}
239− </Text>
240− )}
241− </Pressable>
242− );
243− })}
176+ {links.map((action) => (
177+ <Pressable
178+ key={action.id}
179+ accessibilityRole="link"
180+ onPress={() => action.href && open(action.href)}
181+ style={({ pressed }) => [styles.action, { backgroundColor: colors.bg, borderColor: colors.lineStrong, opacity: pressed ? 0.6 : 1 }]}
182+ >
183+ <Text size="small" style={{ fontWeight: "700", color: colors.fg }}>
184+ {action.label}
185+ </Text>
186+ </Pressable>
187+ ))}
244188 </View>
245− ) : null}
246− {said ? (
247− <Text size="small" tone="muted">
248− {said}
249− </Text>
250189 ) : null}
251− <PromptSheet
252− visible={!!asking}
253− title={asking?.label ?? ""}
254− label={asking?.input?.label ?? ""}
255− placeholder={asking?.input?.placeholder ?? undefined}
256− initial={asking?.input?.initial ?? ""}
257− numeric={asking?.input?.kind === "money"}
258− confirm={asking?.label ?? "Done"}
259− onCancel={() => setAsking(null)}
260− onDone={(value) => {
261− const action = asking;
262− setAsking(null);
263− if (action) void run(action, value);
264− }}
265− />
266190 </View>
267191 );
268192 }
+2−8
239239 onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
240240 });
241241
242− /** Presses a button on an agent's card: approve, stop, file. */
243− const cardAction = useMutation({
244− mutationFn: ({ message, action, input }: { message: ChatMessage; action: string; input?: string | null }) =>
245− sendIntent<{ ok: boolean; message: string | null }>(client, slug, "card_action", { channel_id: channelId, message_id: message.id, action_id: action, input: input ?? null }),
246− });
247−
248242 const preferences = useMutation({
249243 mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }),
250244 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
255249 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
256250 });
257251
258− return { react, edit, remove, cardAction, preferences, leave };
252+ return { react, edit, remove, preferences, leave };
259253 }
260254
261255 /** Marks a conversation read up to a message; the sidebar's counts follow. */
271265 const queries = useQueryClient();
272266 const slug = workspace?.slug ?? "";
273267 return {
274− /** Opens (or finds) a direct message with these members: `user:<username>` or `agent:<id>`. */
268+ /** Opens (or finds) a direct message with these people: `user:<username>`. */
275269 dm: useMutation({
276270 mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }),
277271 onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
+3−1
99 * fields the app shows are named.
1010 */
1111
12+/** `agent` only on what g1t's own agents wrote before they were retired; shown with an Agent badge. */
1213 export type ApiUser = { id: string; username: string; display_username?: string; kind?: "user" | "workspace" | "agent" | "system" };
1314
1415 export type ApiRepo = {
4546 issue: number | null;
4647 title: string;
4748 body: string | null;
48− agent: string;
49+ /** The coding agent it was made with (`claude-code`, `codex`), from the session recorded on it; null when none was. */
50+ agent: string | null;
4951 status: "draft" | "open" | "merged" | "closed";
5052 branch: string | null;
5153 base: string;
+0−61
1−import { pluginRef } from "@g1t/contracts/claude-plugin";
2−import { connectorPath, connectorsFor } from "@g1t/contracts/connectors";
3−import { CUSTOMIZE, type CustomizeSection, customizePath } from "@g1t/contracts/customize";
4−import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
5−import { FOUNDATIONAL_SKILLS } from "@g1t/contracts/skills";
6−
7−/**
8− * Customize on the phone: Skills, Connectors and Plugins
9− * (packages/contracts/src/customize.ts). What is listed comes from the
10− * catalogs every g1t ships with; a workspace's own library, connections
11− * and installs are read on the site for now, and added here once the API
12− * serves them. Every item opens where the site shows it: a connector on
13− * Connectors' Discover (an MCP server on its connect page), g1t's own
14− * announced plugins on their pages in the `g1t` marketplace.
15− */
16−
17−export type CustomizeItem = {
18− id: string;
19− name: string;
20− description: string;
21− /** Available, Soon, or what else a listing says about itself. */
22− status: "available" | "soon" | "built-in";
23− /** A group to list it under. */
24− group: string;
25− /** The site page that sets it up, for this workspace. */
26− sitePath: string;
27−};
28−
29−export function customizeItems(section: CustomizeSection, workspace: string): CustomizeItem[] {
30− const fallback = customizePath(section, workspace);
31− if (section === "skills") {
32− return FOUNDATIONAL_SKILLS.map((skill) => ({
33− id: skill.id,
34− name: skill.name,
35− description: skill.description,
36− status: "built-in" as const,
37− group: "In every agent",
38− sitePath: fallback,
39− }));
40− }
41− if (section === "connectors") {
42− return connectorsFor("workspace").map((connector) => ({
43− id: connector.id,
44− name: connector.name,
45− description: connector.description,
46− status: connector.status,
47− group: connector.status === "available" ? "Connect now" : "Coming",
48− sitePath: connector.href ? connectorPath(connector.href, workspace) : `${fallback}?view=discover&directory=${encodeURIComponent(connector.id)}`,
49− }));
50− }
51− return FIRST_PARTY_EXTENSIONS.map((extension) => ({
52− id: extension.id,
53− name: extension.name,
54− description: extension.tagline,
55− status: extension.status,
56− group: `${extension.publisher.name} · ${extension.category}`,
57− sitePath: customizePath("plugins", workspace, encodeURIComponent(pluginRef(extension.id, "g1t"))),
58− }));
59−}
60−
61−export { CUSTOMIZE, customizePath, type CustomizeSection };
+10−10
2525 assert.equal(routeFor("/acme/rocket/issues/new"), null);
2626 });
2727
28−test("Customize's tabs open the app's Customize; its other pages stay on the site", () => {
29− assert.equal(routeFor("/acme/-/customize"), "/customize?workspace=acme");
30− assert.equal(routeFor("/acme/-/customize/plugins"), "/customize/plugins?workspace=acme");
31− assert.equal(routeFor("/acme/-/customize/connectors?view=discover"), "/customize/connectors?workspace=acme");
32− assert.equal(routeFor("/acme/-/customize/plugins/pdf%40anthropic-agent-skills"), null);
33− assert.equal(routeFor("/acme/-/customize/requests"), null);
28+test("old links to Agents, Customize and the Marketplace open Home", () => {
29+ assert.equal(routeFor("/acme/-/agents"), "/home?workspace=acme");
30+ assert.equal(routeFor("/acme/-/agents/skills"), "/home?workspace=acme");
31+ assert.equal(routeFor("/acme/-/agents/skills/pdf"), "/home?workspace=acme");
32+ assert.equal(routeFor("/acme/-/customize"), "/home?workspace=acme");
33+ assert.equal(routeFor("/acme/-/customize/plugins/pdf%40anthropic-agent-skills"), "/home?workspace=acme");
34+ assert.equal(routeFor("/acme/-/marketplace/integrations"), "/home?workspace=acme");
3435 });
3536
36−test("the Marketplace, integrations and the old skill library open Customize", () => {
37− assert.equal(routeFor("/acme/-/marketplace/integrations"), "/customize?workspace=acme");
38− assert.equal(routeFor("/acme/-/agents/skills"), "/customize?workspace=acme");
39− assert.equal(routeFor("/acme/-/agents/skills/pdf"), null, "a skill's own page is the site's");
37+test("the integrations directory stays on the site", () => {
38+ assert.equal(routeFor("/acme/-/integrations"), null);
39+ assert.equal(routeFor("/acme/-/integrations/github"), null);
4040 });
4141
4242 test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => {
+6−14
1111 /** Where the app's OAuth sign-in comes back to. */
1212 export const OAUTH_CALLBACK_PATH = "/oauth/callback";
1313
14−/** Customize's tabs, each a screen of the app's Customize. */
15−const CUSTOMIZE_TABS = new Set(["skills", "connectors", "plugins"]);
14+/**
15+ * Pages a workspace no longer has: Agents (with the skill library),
16+ * Customize and the Marketplace. An old link to one opens Home.
17+ */
18+const RETIRED = new Set(["agents", "customize", "marketplace"]);
1619
1720 /** Path segments a workspace or account can never be named (the site's own pages). */
1821 const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]);
4245 return null;
4346 }
4447 if (third === "overview" && !fourth) return `/code?${workspace}`;
45− // Customize's tabs open the app's Customize, on the same tab; its other
46− // pages (a plugin, a connector, requests) open on the site. The
47− // Marketplace, the integrations settings and the old skill library were
48− // Customize before it had the name.
49− if (third === "customize") {
50− if (!fourth) return `/customize?${workspace}`;
51− if (CUSTOMIZE_TABS.has(fourth) && !fifth) return `/customize/${fourth}?${workspace}`;
52− return null;
53− }
54− if (third === "marketplace" || third === "integrations" || (third === "agents" && fourth === "skills" && !fifth)) {
55− return `/customize?${workspace}`;
56− }
48+ if (third && RETIRED.has(third)) return `/home?${workspace}`;
5749 return null;
5850 }
5951
+1−2
2727 WebBrowser.maybeCompleteAuthSession();
2828
2929 /**
30− * What the app asks for: what Chat, Code and Customize show and do. The
30+ * What the app asks for: what Chat and Code show and do. The
3131 * person can untick any of these on the approval page. `chat:*` opens
3232 * Chat's routes and live sockets on the site, and `notifications:*` the
3333 * phone's registration for pushes (`/-/notify`), and nothing else there.
4747 "workflows:read",
4848 "notifications:read",
4949 "notifications:write",
50− "agents:run",
5150 "memory:read",
5251 "artifacts:read",
5352 ];
+1−1
7070 if (Platform.OS === "android") {
7171 await Notifications.setNotificationChannelAsync(MESSAGES_CHANNEL, {
7272 name: "Messages",
73− description: "Messages to you, mentions, replies, and agents waiting on you.",
73+ description: "Messages to you, mentions and replies.",
7474 importance: Notifications.AndroidImportance.HIGH,
7575 vibrationPattern: [0, 120, 80, 120],
7676 lightColor: "#b6a8ff",
+1−1
7474 });
7575 const me = await client.api<Me>("/user");
7676 if ((me.kind ?? "user") !== "user") {
77− throw new ApiError("forbidden", "Sign in as yourself: a workspace's or an agent's token cannot use the app.");
77+ throw new ApiError("forbidden", "Sign in as yourself: a workspace's token cannot use the app.");
7878 }
7979 const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null);
8080 const workspaces = me.workspaces ?? [];