Merge branch 'mobile-agent-leftovers' into rm-agents
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 ?? []; |