The phone app no longer has g1t's own agents, to match the site. The Agents tab is gone from the bar, and Customize (skills, connectors and plugins) is gone with its row in More. New message finds and messages people only, and its search and heading say so. A card an agent posted earlier still shows with its title, state, details and links, but its other buttons are no longer drawn, since chat no longer answers them; an old agent's messages and direct messages still show, under the name chat gives them, marked Deleted agent. The app stops asking for agents:run when it signs in. An old g1t:// link to Agents, the skill library, Customize or the Marketplace opens Home, while the integrations directory opens on the site. Notifications, the sign-in screen and the access-token error no longer mention agents waiting on you or agent tokens, and the sign-in screen speaks of reviewing what you and your coding agents make. Pull requests still show the coding agent they were made with.
23 files+80−3800/23 viewed
| 1 | 1 | # g1t for Android and iOS | |
| 2 | 2 | ||
| 3 | 3 | 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 | |
| 5 | 5 | own OAuth. Android is the first target. What it does for people is in the | |
| 6 | 6 | guide, [The phone app](../docs/src/content/docs/guides/mobile.md). | |
| 7 | 7 | ||
| ⋯ | |||
| 19 | 19 | (app)/ signed in: the tabs | |
| 20 | 20 | chat/ the list, a conversation, a thread, new, browse | |
| 21 | 21 | 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 | |
| 25 | 24 | +native-intent.tsx g1t:// links to screens (lib/links.ts) | |
| 26 | 25 | components/ the building blocks, themed (ui.tsx); chat/ and Code's | |
| 27 | 26 | lib/ | |
| ⋯ | |||
| 34 | 33 | live.ts the site's live sockets, with tickets, kept open | |
| 35 | 34 | feed.tsx counts, banners and presence while the app is open | |
| 36 | 35 | push.ts notifications on the phone | |
| 37 | − | customize.ts Customize's listings, from @g1t/contracts' catalogs | |
| 38 | 36 | theme/ packages/theme/tokens.css's colours, for React Native | |
| 39 | 37 | ``` | |
| 40 | 38 | ||
| 41 | 39 | Shared types and catalogs come from `@g1t/contracts` (by subpath, such as | |
| 42 | 40 | `@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. | |
| 45 | 41 | ||
| 46 | 42 | ## How it talks to g1t | |
| 47 | 43 | ||
| ⋯ | |||
| 260 | 256 | - **The brand's faces**: TTF copies of Hanken Grotesk, Bricolage | |
| 261 | 257 | Grotesque and IBM Plex Mono (packages/theme ships woff2), loaded with | |
| 262 | 258 | `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. | |
| 265 | 259 | - **The store listing**: screenshots, a privacy policy URL, and Play's | |
| 266 | 260 | data safety form (the app keeps the server and credentials in the | |
| 267 | 261 | keystore, registers a push token with the g1t it is pointed at, and | |
| 1 | 1 | import * as Notifications from "expo-notifications"; | |
| 2 | 2 | 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"; | |
| 4 | 4 | import type { LucideIcon } from "lucide-react-native"; | |
| 5 | 5 | import { type ComponentType, useEffect, useRef } from "react"; | |
| 6 | 6 | import { StyleSheet, View } from "react-native"; | |
| ⋯ | |||
| 15 | 15 | ||
| 16 | 16 | /** | |
| 17 | 17 | * 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. | |
| 21 | 21 | */ | |
| 22 | 22 | export default function AppLayout() { | |
| 23 | 23 | return ( | |
| ⋯ | |||
| 73 | 73 | }} | |
| 74 | 74 | /> | |
| 75 | 75 | <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} /> }} /> | |
| 77 | 76 | <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 }} /> | |
| 79 | 77 | <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} /> | |
| 80 | 78 | </Tabs> | |
| 81 | 79 | ); | |
| 1 | − | import { TabStack } from "@/components/stack"; | |
| 2 | − | ||
| 3 | − | export default TabStack; |
| 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 | − | } |
| 91 | 91 | search ? ( | |
| 92 | 92 | <Empty title="Nothing matches" /> | |
| 93 | 93 | ) : ( | |
| 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")} />} /> | |
| 95 | 95 | ) | |
| 96 | 96 | } | |
| 97 | 97 | ListFooterComponent={ | |
| ⋯ | |||
| 151 | 151 | </View> | |
| 152 | 152 | <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}> | |
| 153 | 153 | <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" : " "))} | |
| 155 | 155 | </Text> | |
| 156 | 156 | {entry.mentions > 0 ? <Badge label={`@ ${entry.mentions}`} tone="accent" /> : loud ? <View style={[styles.dot, { backgroundColor: colors.accent }]} /> : null} | |
| 157 | 157 | </View> | |
| 1 | − | import type { MemberProfile } from "@g1t/contracts"; | |
| 2 | 1 | import { useQuery } from "@tanstack/react-query"; | |
| 3 | 2 | import { Stack, router } from "expo-router"; | |
| 4 | 3 | import { Check, Hash, Search } from "lucide-react-native"; | |
| ⋯ | |||
| 7 | 6 | ||
| 8 | 7 | import { PromptSheet } from "@/components/chat/sheet"; | |
| 9 | 8 | import { Avatar, Button, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 10 | − | import { useChatSidebar, useStartConversation } from "@/lib/chat"; | |
| 9 | + | import { useStartConversation } from "@/lib/chat"; | |
| 11 | 10 | import { useSignedIn } from "@/lib/session"; | |
| 12 | 11 | import { radius, space, useColors } from "@/theme"; | |
| 13 | 12 | ||
| 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 }; | |
| 15 | 14 | ||
| 16 | 15 | /** | |
| 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`). | |
| 21 | 19 | */ | |
| 22 | 20 | export default function NewConversation() { | |
| 23 | 21 | const { client, workspace, me, server } = useSignedIn(); | |
| 24 | 22 | const slug = workspace?.slug ?? ""; | |
| 25 | 23 | const colors = useColors(); | |
| 26 | − | const sidebar = useChatSidebar(); | |
| 27 | 24 | const start = useStartConversation(); | |
| 28 | 25 | const [search, setSearch] = useState(""); | |
| 29 | 26 | const [picked, setPicked] = useState<Person[]>([]); | |
| ⋯ | |||
| 38 | 35 | }); | |
| 39 | 36 | ||
| 40 | 37 | 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); | |
| 43 | 38 | const people: Person[] = (members.data ?? []) | |
| 44 | 39 | .filter((member) => member.username.toLowerCase() !== me.username.toLowerCase()) | |
| 45 | 40 | .map((member) => ({ | |
| ⋯ | |||
| 47 | 42 | name: member.name || member.display_username || member.username, | |
| 48 | 43 | handle: member.display_username ?? member.username, | |
| 49 | 44 | avatar: member.avatar ?? null, | |
| 50 | − | agent: false, | |
| 51 | − | detail: null, | |
| 52 | 45 | })); | |
| 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 | − | })); | |
| 61 | 46 | 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]); | |
| 64 | 49 | ||
| 65 | 50 | const toggle = (person: Person) => | |
| 66 | 51 | setPicked((now) => (now.some((one) => one.key === person.key) ? now.filter((one) => one.key !== person.key) : [...now, person])); | |
| ⋯ | |||
| 85 | 70 | autoFocus | |
| 86 | 71 | value={search} | |
| 87 | 72 | onChangeText={setSearch} | |
| 88 | − | placeholder="Find a person or an agent" | |
| 73 | + | placeholder="Find a person" | |
| 89 | 74 | placeholderTextColor={colors.faint} | |
| 90 | 75 | style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }} | |
| 91 | 76 | autoCorrect={false} | |
| ⋯ | |||
| 107 | 92 | ListHeaderComponent={ | |
| 108 | 93 | <> | |
| 109 | 94 | <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" /> | |
| 111 | 96 | </> | |
| 112 | 97 | } | |
| 113 | 98 | renderItem={({ item }) => { | |
| 114 | 99 | const on = picked.some((one) => one.key === item.key); | |
| 115 | 100 | return ( | |
| 116 | 101 | <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} /> | |
| 118 | 103 | <View style={{ flex: 1 }}> | |
| 119 | 104 | <Text style={{ fontWeight: "600" }} numberOfLines={1}> | |
| 120 | 105 | {item.name} | |
| 121 | 106 | </Text> | |
| 122 | 107 | <Text size="small" tone="muted" numberOfLines={1}> | |
| 123 | − | {item.agent ? (item.detail ?? "Agent") : `@${item.handle}`} | |
| 108 | + | @{item.handle} | |
| 124 | 109 | </Text> | |
| 125 | 110 | </View> | |
| 126 | 111 | <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}> | |
| 21 | 21 | return ( | |
| 22 | 22 | <Screen> | |
| 23 | 23 | <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.`} /> | |
| 25 | 25 | </Screen> | |
| 26 | 26 | ); | |
| 27 | 27 | } |
| 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 | − | } |
| 1 | − | import { TabStack } from "@/components/stack"; | |
| 2 | − | ||
| 3 | − | export default TabStack; |
| 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 | − | } |
| 1 | 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | 2 | import Constants from "expo-constants"; | |
| 3 | − | import { Stack, router } from "expo-router"; | |
| 3 | + | import { Stack } from "expo-router"; | |
| 4 | 4 | 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"; | |
| 6 | 6 | import { Linking, View } from "react-native"; | |
| 7 | 7 | ||
| 8 | 8 | import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| ⋯ | |||
| 11 | 11 | import { useSignedIn } from "@/lib/session"; | |
| 12 | 12 | import { space, useColors } from "@/theme"; | |
| 13 | 13 | ||
| 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. */ | |
| 15 | 15 | export default function More() { | |
| 16 | 16 | const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn(); | |
| 17 | 17 | const colors = useColors(); | |
| ⋯ | |||
| 25 | 25 | unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." }, | |
| 26 | 26 | off: { label: "Off", detail: `${new URL(server.site).host} sends no notifications to phones. Chat still updates while the app is open.` }, | |
| 27 | 27 | 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." }, | |
| 30 | 30 | }[push.data ?? "undetermined"]; | |
| 31 | 31 | ||
| 32 | 32 | return ( | |
| ⋯ | |||
| 43 | 43 | </View> | |
| 44 | 44 | ||
| 45 | 45 | <View> | |
| 46 | − | <Row leading={<Blocks size={22} color={colors.accent} />} title="Customize" subtitle="Skills, connectors and plugins for your agents" onPress={() => router.push("/customize")} /> | |
| 47 | 46 | <Row | |
| 48 | 47 | leading={<Bell size={22} color={colors.accent} />} | |
| 49 | 48 | title="Notifications on this phone" | |
| 50 | 50 | <View style={{ gap: space.md }}> | |
| 51 | 51 | <G1tMark size={44} /> | |
| 52 | 52 | <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> | |
| 54 | 54 | </View> | |
| 55 | 55 | ||
| 56 | 56 | {mode === "oauth" ? ( |
| 39 | 39 | return ( | |
| 40 | 40 | <Animated.View pointerEvents="box-none" style={[styles.wrap, { top: insets.top + space.sm, transform: [{ translateY: offset }] }]}> | |
| 41 | 41 | <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} /> | |
| 48 | 43 | <View style={{ flex: 1 }}> | |
| 49 | 44 | <Text style={{ fontWeight: "700" }} numberOfLines={1}> | |
| 50 | 45 | {banner.title} |
| 1 | − | import type { CardAction, ChatMessage } from "@g1t/contracts"; | |
| 1 | + | import type { ChatMessage } from "@g1t/contracts"; | |
| 2 | 2 | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | 3 | import * as Clipboard from "expo-clipboard"; | |
| 4 | 4 | import { router, useFocusEffect } from "expo-router"; | |
| ⋯ | |||
| 88 | 88 | useEffect(() => { | |
| 89 | 89 | if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest); | |
| 90 | 90 | }, [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 | − | ); | |
| 100 | 91 | ||
| 101 | 92 | const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]); | |
| 102 | 93 | const onRetry = useCallback((message: Shown) => send(message.body, message), [send]); | |
| ⋯ | |||
| 137 | 128 | onLongPress={setPicked} | |
| 138 | 129 | onReact={onReact} | |
| 139 | 130 | onRetry={onRetry} | |
| 140 | − | onCardAction={onCardAction} | |
| 141 | 131 | /> | |
| 142 | 132 | ) | |
| 143 | 133 | } | |
| 1 | − | import type { CardAction, ChatMessage, MessageCard } from "@g1t/contracts"; | |
| 1 | + | import type { ChatMessage, MessageCard } from "@g1t/contracts"; | |
| 2 | 2 | import * as Haptics from "expo-haptics"; | |
| 3 | 3 | import { router } from "expo-router"; | |
| 4 | 4 | import * as WebBrowser from "expo-web-browser"; | |
| 5 | 5 | 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"; | |
| 8 | 8 | ||
| 9 | 9 | import { Markdown } from "@/components/chat/markdown"; | |
| 10 | − | import { PromptSheet } from "@/components/chat/sheet"; | |
| 11 | 10 | import { Avatar, Badge, Text } from "@/components/ui"; | |
| 12 | 11 | import type { Shown } from "@/lib/chat"; | |
| 13 | 12 | import { routeFor } from "@/lib/links"; | |
| ⋯ | |||
| 25 | 24 | onLongPress: (message: Shown) => void; | |
| 26 | 25 | onReact: (message: ChatMessage, emoji: string, add: boolean) => void; | |
| 27 | 26 | onRetry: (message: Shown) => void; | |
| 28 | − | onCardAction: (message: ChatMessage, action: CardAction, input?: string | null) => Promise<string | null>; | |
| 29 | 27 | }; | |
| 30 | 28 | ||
| 31 | 29 | /** | |
| 32 | 30 | * 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. | |
| 35 | 34 | */ | |
| 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) { | |
| 37 | 36 | const colors = useColors(); | |
| 38 | 37 | const { server } = useSignedIn(); | |
| 39 | 38 | const { author } = message; | |
| ⋯ | |||
| 65 | 64 | <Text style={{ fontWeight: "700" }} numberOfLines={1}> | |
| 66 | 65 | {author.display_name} | |
| 67 | 66 | </Text> | |
| 68 | − | {agent ? <Badge label={author.title ?? "Agent"} tone="accent" /> : null} | |
| 67 | + | {agent ? <Badge label="Deleted agent" tone="neutral" /> : null} | |
| 69 | 68 | <Text size="small" tone="faint"> | |
| 70 | 69 | {clock(message.created_at)} | |
| 71 | 70 | </Text> | |
| ⋯ | |||
| 77 | 76 | (edited) | |
| 78 | 77 | </Text> | |
| 79 | 78 | ) : 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} | |
| 81 | 80 | {message.reactions && message.reactions.length > 0 ? ( | |
| 82 | 81 | <View style={styles.reactions}> | |
| 83 | 82 | {message.reactions.map((reaction) => ( | |
| ⋯ | |||
| 130 | 129 | }); | |
| 131 | 130 | ||
| 132 | 131 | /** | |
| 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. | |
| 137 | 135 | */ | |
| 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 }) { | |
| 149 | 137 | 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); | |
| 153 | 139 | ||
| 154 | 140 | const open = (href: string) => { | |
| 155 | 141 | const route = href.startsWith("/") ? routeFor(href) : null; | |
| 156 | 142 | if (route) router.push(route as never); | |
| 157 | 143 | 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); | |
| 185 | 144 | }; | |
| 186 | 145 | ||
| 187 | 146 | return ( | |
| ⋯ | |||
| 212 | 171 | </View> | |
| 213 | 172 | ) : null} | |
| 214 | 173 | </Pressable> | |
| 215 | − | {card.actions?.length ? ( | |
| 174 | + | {links.length ? ( | |
| 216 | 175 | <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 | + | ))} | |
| 244 | 188 | </View> | |
| 245 | − | ) : null} | |
| 246 | − | {said ? ( | |
| 247 | − | <Text size="small" tone="muted"> | |
| 248 | − | {said} | |
| 249 | − | </Text> | |
| 250 | 189 | ) : 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 | − | /> | |
| 266 | 190 | </View> | |
| 267 | 191 | ); | |
| 268 | 192 | } | |
| 239 | 239 | onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }), | |
| 240 | 240 | }); | |
| 241 | 241 | ||
| 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 | − | ||
| 248 | 242 | const preferences = useMutation({ | |
| 249 | 243 | mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }), | |
| 250 | 244 | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| ⋯ | |||
| 255 | 249 | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 256 | 250 | }); | |
| 257 | 251 | ||
| 258 | − | return { react, edit, remove, cardAction, preferences, leave }; | |
| 252 | + | return { react, edit, remove, preferences, leave }; | |
| 259 | 253 | } | |
| 260 | 254 | ||
| 261 | 255 | /** Marks a conversation read up to a message; the sidebar's counts follow. */ | |
| ⋯ | |||
| 271 | 265 | const queries = useQueryClient(); | |
| 272 | 266 | const slug = workspace?.slug ?? ""; | |
| 273 | 267 | 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>`. */ | |
| 275 | 269 | dm: useMutation({ | |
| 276 | 270 | mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }), | |
| 277 | 271 | onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 9 | 9 | * fields the app shows are named. | |
| 10 | 10 | */ | |
| 11 | 11 | ||
| 12 | + | /** `agent` only on what g1t's own agents wrote before they were retired; shown with an Agent badge. */ | |
| 12 | 13 | export type ApiUser = { id: string; username: string; display_username?: string; kind?: "user" | "workspace" | "agent" | "system" }; | |
| 13 | 14 | ||
| 14 | 15 | export type ApiRepo = { | |
| ⋯ | |||
| 45 | 46 | issue: number | null; | |
| 46 | 47 | title: string; | |
| 47 | 48 | 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; | |
| 49 | 51 | status: "draft" | "open" | "merged" | "closed"; | |
| 50 | 52 | branch: string | null; | |
| 51 | 53 | base: string; | |
| 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 }; |
| 25 | 25 | assert.equal(routeFor("/acme/rocket/issues/new"), null); | |
| 26 | 26 | }); | |
| 27 | 27 | ||
| 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"); | |
| 34 | 35 | }); | |
| 35 | 36 | ||
| 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); | |
| 40 | 40 | }); | |
| 41 | 41 | ||
| 42 | 42 | test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => { |
| 11 | 11 | /** Where the app's OAuth sign-in comes back to. */ | |
| 12 | 12 | export const OAUTH_CALLBACK_PATH = "/oauth/callback"; | |
| 13 | 13 | ||
| 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"]); | |
| 16 | 19 | ||
| 17 | 20 | /** Path segments a workspace or account can never be named (the site's own pages). */ | |
| 18 | 21 | const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]); | |
| ⋯ | |||
| 42 | 45 | return null; | |
| 43 | 46 | } | |
| 44 | 47 | 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}`; | |
| 57 | 49 | return null; | |
| 58 | 50 | } | |
| 59 | 51 | ||
| 27 | 27 | WebBrowser.maybeCompleteAuthSession(); | |
| 28 | 28 | ||
| 29 | 29 | /** | |
| 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 | |
| 31 | 31 | * person can untick any of these on the approval page. `chat:*` opens | |
| 32 | 32 | * Chat's routes and live sockets on the site, and `notifications:*` the | |
| 33 | 33 | * phone's registration for pushes (`/-/notify`), and nothing else there. | |
| ⋯ | |||
| 47 | 47 | "workflows:read", | |
| 48 | 48 | "notifications:read", | |
| 49 | 49 | "notifications:write", | |
| 50 | − | "agents:run", | |
| 51 | 50 | "memory:read", | |
| 52 | 51 | "artifacts:read", | |
| 53 | 52 | ]; | |
| 70 | 70 | if (Platform.OS === "android") { | |
| 71 | 71 | await Notifications.setNotificationChannelAsync(MESSAGES_CHANNEL, { | |
| 72 | 72 | name: "Messages", | |
| 73 | − | description: "Messages to you, mentions, replies, and agents waiting on you.", | |
| 73 | + | description: "Messages to you, mentions and replies.", | |
| 74 | 74 | importance: Notifications.AndroidImportance.HIGH, | |
| 75 | 75 | vibrationPattern: [0, 120, 80, 120], | |
| 76 | 76 | lightColor: "#b6a8ff", |
| 74 | 74 | }); | |
| 75 | 75 | const me = await client.api<Me>("/user"); | |
| 76 | 76 | 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."); | |
| 78 | 78 | } | |
| 79 | 79 | const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null); | |
| 80 | 80 | const workspaces = me.workspaces ?? []; |