Skip to content

Commit

The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server.

Chat reads the sidebar and messages, posts and marks read through the site's own chat routes, live over the channel's socket with a ticket. The site takes those only from a token with "Use the website as you", which OAuth tokens are not, so with the app's sign-in Chat says it needs another one; the README sets out the two ways the server can close that before a release. Code lists a workspace's repositories, their pull requests and issues, a pull request's checks, conversation and diff, and approves or asks for changes; an issue takes comments. g1t:// links open the screens the app has, as on the desktop, and the rest open on the site. Customize replaces the Marketplace on the phone: Skills, Connectors and Plugins, from the catalogs g1t ships with. Its three parts are defined in @g1t/contracts (customize.ts) so the site can move onto the same model, and contracts exports oauth, customize and skills as subpaths so the app bundles only what it uses. eas.json has development, preview and production profiles; APP_VARIANT gives each its own package (sh.g1t.app, .dev, .preview), so they install side by side. Metro pins React to the app's 19.2.3, since the site's newer React is hoisted to the root and React Native needs its own. The icons are drawn from the mark (npm run icons). The phone app guide is new, and the desktop guide and README point to it.

Claude-Session:
https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
Claudeagent and Claudeagentcommitted Parentca6b57dBrowse files
61 files+3546−30/61 viewed
+2−1
4242 | Slides, designs and dashboards as artifacts | Coming |
4343 | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming |
4444 | Desktop app for Windows, macOS and Linux (Tauri): the site in a window of its own, notifications from the system, a badge, g1t:// links, updates that install themselves | Preview |
45−| A runner inside the desktop app, and a phone app (Expo) | Coming |
45+| A runner inside the desktop app | Coming |
46+| A phone app for Android and iOS (Expo, in `apps/mobile`): Chat, Code and Customize, signed in with OAuth; Android first | Coming |
4647
4748 ### Agents
4849
+1−0
8282 { label: 'Home', slug: 'guides/home' },
8383 { label: 'Notifications', slug: 'guides/notifications' },
8484 { label: 'The desktop app', slug: 'guides/desktop' },
85+ { label: 'The phone app', slug: 'guides/mobile' },
8586 { label: 'Search and Explore', slug: 'guides/search' },
8687 { label: 'Status and incidents', slug: 'guides/status' },
8788 ],
+1−1
113113 page is open, the window shows the web view's own error until it is
114114 back; starting without a network shows the app's own page, with a retry.
115115 - **Notarization on macOS**, so the first open needs no right-click.
116−- **A phone app.**
116+- **A phone app.** It is being built: see [the phone app](/guides/mobile/).
117117
118118 ## For operators: releasing the app
119119
+109−0
1+---
2+title: The phone app
3+description: g1t on Android and iOS, built with Expo. Chat, Code and Customize on your phone, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it.
4+---
5+
6+The phone app is g1t on Android and iOS, with its own screens for what
7+you do away from a desk: answering in Chat, reviewing what agents made in
8+Code, and seeing the skills, connectors and plugins your workspace can
9+use in Customize. Everything else opens on the site, in your phone's
10+browser.
11+
12+It is not in Google Play or the App Store yet. Android comes first. Until
13+then, it is built from `apps/mobile` in
14+[g1t's repository](https://g1t.sh/flagon-io/g1t), as
15+[For operators](#for-operators-building-and-releasing) says.
16+
17+| Tab | What is on it |
18+| --- | --- |
19+| **Chat** | The channels you are in and your direct messages, with unread counts. A conversation shows its messages and agents' cards, live while it is open, and you write back from the box at the bottom. |
20+| **Code** | The workspace's repositories, each with its pull requests and issues, open or closed. A pull request shows its description, checks, conversation and changed files, and you can approve it or ask for changes. An issue shows the pull requests made for it, and you can comment. |
21+| **Customize** | **Skills** (what agents know how to do), **Connectors** (the tools they can reach, the Marketplace's integrations today) and **Plugins** (what adds pages and tools, the Marketplace's extensions today). Each opens where it is set up on the site. |
22+| **You** | Your workspaces, to switch between them, how you signed in, and **Sign out**. |
23+
24+## Signing in
25+
26+**Sign in with g1t** opens g1t.sh in your phone's browser, where you sign
27+in as usual (with your password, GitHub, two-factor) and approve the app.
28+The browser sends you back to the app, signed in. The app never sees your
29+password. This is [OAuth](/guides/authentication/#signing-in-with-oauth):
30+the app is listed as **g1t for Android** or **g1t for iOS** in
31+[Settings → Connected applications](https://g1t.sh/settings/applications),
32+where **Change access** narrows what it can do and **Sign out** ends it at
33+once.
34+
35+The approval page lists what the app asks for: reading your account,
36+workspaces, repositories, checks and workflows, reading and writing
37+issues, pull requests and notifications, recalling memory, reading
38+artifacts, and putting agents to work. Untick what you would rather it
39+could not do.
40+
41+**Use an access token** signs in with a
42+[personal access token](/guides/authentication/#access-tokens) you paste
43+in instead. The token stays on your phone, in its keystore.
44+
45+### Chat and the app's sign-in
46+
47+Chat is served by the site, and the site takes only a browser's session
48+or a token with **Use the website as you** turned on. The app's OAuth
49+sign-in is neither yet, so with it Code and Customize work and Chat says
50+it needs another sign-in. Until the app's sign-in opens Chat, sign in
51+with **Use an access token** and a token with
52+[Use the website as you](/guides/authentication/#use-a-token-on-the-website)
53+turned on. Keep that token as safe as your password: on the website it can
54+do anything you can.
55+
56+## Links
57+
58+A `g1t://` link opens the app, as it opens [the desktop app](/guides/desktop/#links):
59+`g1t://acme/-/chat/dm/chn_1` is that conversation. The app shows the
60+pages it has screens for:
61+
62+| Link | Opens |
63+| --- | --- |
64+| `g1t://<workspace>/-/chat`, `…/-/chat/<channel>`, `…/-/chat/dm/<id>` | Chat, or the conversation, in that workspace |
65+| `g1t://<owner>/<repo>` | The repository |
66+| `g1t://<owner>/<repo>/pull/<number>`, `…/issues/<number>` | The pull request or issue |
67+| `g1t://<workspace>/-/marketplace…`, `…/-/agents/skills` | Customize |
68+
69+Any other page opens on the site, in the browser.
70+
71+## Your own g1t
72+
73+If you [run g1t yourself](/guides/self-hosting/), choose **Server** on the
74+sign-in screen and enter your site's address, such as
75+`https://g1t.example.com`. The app takes the API to be `api.` in front of
76+it (`https://api.g1t.example.com`); if yours is elsewhere, enter it under
77+**API**. **Use g1t.sh** goes back.
78+
79+## What's not there yet
80+
81+- **Store releases.** Google Play first, then the App Store.
82+- **Notifications on your phone.** g1t sends browser and desktop
83+ notifications today; push to phones needs g1t to send through Firebase
84+ and Apple, which is coming.
85+- **Chat with the app's own sign-in**, as [above](#chat-and-the-apps-sign-in).
86+- **Home, Notifications, Agents and Artifacts** as tabs of their own.
87+ Their pages open on the site meanwhile.
88+- **Formatting.** Messages, descriptions and comments show their Markdown
89+ as written.
90+- **Threads.** A message's replies are counted, and read on the site.
91+- **https links.** `g1t.sh` addresses open the site in the browser, not
92+ the app, until g1t.sh publishes the files Android and iOS check first.
93+
94+## For operators: building and releasing
95+
96+The app lives in `apps/mobile`: [Expo](https://expo.dev) with Expo
97+Router, in TypeScript, sharing types with the site through
98+`@g1t/contracts`. It builds with EAS Build. `apps/mobile/README.md` has
99+the commands for running it on a phone and the first-time setup.
100+
101+| Build | Command | What it makes |
102+| --- | --- | --- |
103+| Development | `eas build --profile development --platform android` | An APK of `g1t (dev)` (`sh.g1t.app.dev`) that loads its code from your computer. |
104+| Preview | `npm run build:android:apk -w @g1t/mobile` | An APK of `g1t (beta)` (`sh.g1t.app.preview`) to install on testers' phones. |
105+| Production | `npm run build:android -w @g1t/mobile` | An Android App Bundle of `g1t` (`sh.g1t.app`) for Google Play. |
106+
107+The three install side by side. `eas submit --platform android` sends
108+the latest production build to Google Play's internal testing track as a
109+draft.
+42−0
1+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2+
3+# dependencies
4+node_modules/
5+
6+# Expo
7+.expo/
8+dist/
9+web-build/
10+expo-env.d.ts
11+
12+# Native
13+.kotlin/
14+*.orig.*
15+*.jks
16+*.p8
17+*.p12
18+*.key
19+*.mobileprovision
20+
21+# Metro
22+.metro-health-check*
23+
24+# debug
25+npm-debug.*
26+yarn-debug.*
27+yarn-error.*
28+
29+# macOS
30+.DS_Store
31+*.pem
32+
33+# local env files
34+.env*.local
35+
36+# typescript
37+*.tsbuildinfo
38+
39+
40+# generated native folders
41+/ios
42+/android
+160−0
1+# g1t for Android and iOS
2+
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
5+own OAuth. Android is the first target. What it does for people is in the
6+guide, [The phone app](../docs/src/content/docs/guides/mobile.md).
7+
8+## Layout
9+
10+```text
11+app.config.ts name, ids and icons per build variant (APP_VARIANT)
12+eas.json EAS Build profiles: development, preview, production
13+metro.config.js one React for the app in the monorepo
14+scripts/icons.mjs draws the icons from packages/theme/mark.svg
15+src/
16+ app/ the screens (Expo Router: one file, one route)
17+ sign-in.tsx Sign in with g1t, an access token, or another server
18+ oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback)
19+ (app)/ signed in: the tabs
20+ chat/ conversations, and one conversation, live
21+ code/ repositories, pull requests, issues, diffs
22+ customize/ Skills, Connectors, Plugins
23+ you/ workspaces, how you signed in, sign out
24+ +native-intent.tsx g1t:// links to screens (lib/links.ts)
25+ components/ the building blocks, themed (ui.tsx), and Code's
26+ lib/
27+ server.ts which g1t: site and API addresses
28+ oauth.ts OAuth 2.1 with PKCE against the server's metadata
29+ credentials.ts the server and credentials, in the phone's keystore
30+ client.ts every request: refresh, errors, the site or the API
31+ session.tsx who is signed in, to which workspace
32+ chat.ts, code.ts the data for Chat and Code (TanStack Query)
33+ customize.ts Customize's listings, from @g1t/contracts' catalogs
34+ theme/ packages/theme/tokens.css's colours, for React Native
35+```
36+
37+Shared types and catalogs come from `@g1t/contracts` (by subpath, such as
38+`@g1t/contracts/oauth`, so the app does not bundle every module).
39+Customize's three parts are defined there too (`customize.ts`), so the
40+site can move the Marketplace onto the same model.
41+
42+## How it talks to g1t
43+
44+| What | Where | How it is signed in |
45+| --- | --- | --- |
46+| Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` |
47+| Code | `{api}/repos…` REST | the same |
48+| Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, which the site takes only from a token with **Use the website as you**; the socket gets a ticket from `{site}/-/live/ticket` first |
49+| Sign-in | `{api}/.well-known/oauth-authorization-server`, `{site}/oauth/authorize`, `{api}/oauth/token` | OAuth 2.1, PKCE (S256), public client |
50+
51+The OAuth client is not registered anywhere: its id encodes its name and
52+redirect (`encodeOAuthClient`), so `g1t for Android` with
53+`g1t://oauth/callback` is the same client on every phone. Access tokens
54+last 30 days and are refreshed a minute before they expire; a refresh
55+token works once, so `lib/client.ts` shares one refresh between
56+requests.
57+
58+### Open: Chat with the app's OAuth sign-in
59+
60+OAuth tokens are made without the website permission
61+(`services/identity/src/oauth.rs` → `issue_oauth_tokens`), and Chat is
62+only on the site, so **an OAuth sign-in reaches Code but not Chat**. The
63+app says so on Chat and offers an access-token sign-in, which works today
64+with a token that has **Use the website as you**. The server needs one of
65+these before the app ships:
66+
67+1. **Chat in the REST API** (recommended): `GET/POST /workspaces/:ws/chat…`
68+ in `apps/api` with `chat:read` and `chat:write` scopes, calling the chat
69+ service as the site does, and a socket ticket the API can mint. It is
70+ what the API and MCP will want anyway; the app moves its three calls
71+ in `lib/chat.ts` over.
72+2. **Let the site take OAuth tokens for Chat's routes alone**:
73+ `website-token.ts` accepts a token without `website` on
74+ `/<ws>/-/chat/api`, `/<ws>/-/chat/live` and `/-/live/ticket` when it
75+ has new `chat:*` scopes. Smaller, but it widens what the site accepts.
76+
77+Turning `website` on for every OAuth token is not one of them: it would
78+let any approved application use the whole site as the person.
79+
80+## Run it
81+
82+You need Node 22 (the repository's), and for Android, Android Studio with
83+an emulator or a phone with USB debugging.
84+
85+```sh
86+npm install # at the repository's root: one workspace
87+cd apps/mobile
88+npx expo run:android # builds a development app and starts Metro
89+```
90+
91+`expo run:android` makes the native project (`android/`, ignored by git)
92+and installs `g1t (dev)`. After the first build, `npx expo start` and
93+the installed app are enough until a native module changes.
94+
95+Expo Go works for screens that need nothing native beyond what Go has,
96+and sign-in comes back to Go's own `exp://` address (allowed as a
97+redirect). `g1t://` links need a development build.
98+
99+Checks, which CI runs from the root (`npm run typecheck`, `npm test`):
100+
101+```sh
102+npm run typecheck -w @g1t/mobile
103+npm test -w @g1t/mobile # lib/*.test.ts under Node
104+```
105+
106+## Set up EAS and ship Android
107+
108+Once, by someone with access to the Expo account and Google Play:
109+
110+1. `npm install -g eas-cli`, then `eas login`.
111+2. In `apps/mobile`, `eas init`. It makes the EAS project and prints its
112+ id: put it in `EAS_PROJECT` in `app.config.ts` (the config is code, so
113+ `eas init` cannot write it there). Set `EAS_OWNER` in your shell if the
114+ project belongs to an organization.
115+3. `eas build --profile preview --platform android`. EAS makes and keeps
116+ the upload keystore the first time; say yes. The build ends with a link
117+ and a QR code to install the APK.
118+4. In the [Google Play Console](https://play.google.com/console), create
119+ the app with the package `sh.g1t.app`, and turn on Play App Signing.
120+5. `npm run build:android` makes the production App Bundle. Upload the
121+ first one by hand on Play's **Internal testing** track: Google requires
122+ that before its API accepts uploads.
123+6. For `eas submit`, make a Google Cloud service account with access to
124+ the app in the Play Console, download its JSON key, and add it to the
125+ EAS project's credentials (`eas credentials`). From then on,
126+ `eas submit --platform android --latest` sends a build to internal
127+ testing as a draft (`eas.json`'s `submit.production`).
128+
129+iOS is the same with `--platform ios`, an Apple Developer account and the
130+bundle id `sh.g1t.app`; EAS makes the certificates.
131+
132+### Before the first store release
133+
134+- **Chat with OAuth**, above.
135+- **Push notifications**: `expo-notifications`, and in `services/notify`
136+ a subscription kind for Expo push tokens (or FCM and APNs) beside Web
137+ Push, registered from the app after sign-in.
138+- **App Links**: `https://g1t.sh/.well-known/assetlinks.json` naming
139+ `sh.g1t.app` and Play's signing certificate's SHA-256, then an
140+ `intentFilters` entry with `autoVerify` in `app.config.ts`, so g1t.sh
141+ links open the app. iOS needs `apple-app-site-association` the same way.
142+- **The brand's faces**: TTF copies of Hanken Grotesk, Bricolage
143+ Grotesque and IBM Plex Mono (packages/theme ships woff2), loaded with
144+ `expo-font`.
145+- **Agents' faces**: `components/ui.tsx`'s `Avatar` draws a letter for an
146+ agent; the site's `AgentFace` should be shared and drawn here.
147+- **The store listing**: screenshots, a privacy policy URL, and Play's
148+ data safety form (the app keeps the server and credentials in the
149+ keystore, and sends nothing anywhere but the g1t it is pointed at).
150+
151+## Notes
152+
153+- **React is pinned.** The site uses a newer React than React Native 0.86
154+ allows, so the app has its own `react` (19.2.3) and `metro.config.js`
155+ resolves every `react` import to it. `expo-doctor` reports the root's
156+ copy as a duplicate; that is expected here, and the bundle has one.
157+- **Icons** are drawn from the mark: `npm run icons -w @g1t/mobile`.
158+- **Versions**: `version` in `app.config.ts` is what people see;
159+ EAS counts build numbers itself (`appVersionSource: remote`). Over-the-air
160+ updates go to builds of the same `version` (`runtimeVersion`).
+79−0
1+import type { ConfigContext, ExpoConfig } from "expo/config";
2+
3+/**
4+ * The phone app's configuration. `APP_VARIANT` (set by eas.json's
5+ * profiles) picks an identity, so a development build, a preview build
6+ * and the store build install side by side on one phone:
7+ *
8+ * | Variant | Android package / iOS bundle | Name |
9+ * | ------------- | ---------------------------- | ----------- |
10+ * | `development` | `sh.g1t.app.dev` | g1t (dev) |
11+ * | `preview` | `sh.g1t.app.preview` | g1t (beta) |
12+ * | production | `sh.g1t.app` | g1t |
13+ *
14+ * The EAS project is set once `eas init` has made one (README.md); until
15+ * then, builds and updates are not linked to one.
16+ */
17+
18+/** The EAS project's id, from `eas init`. `EAS_PROJECT_ID` overrides it. */
19+const EAS_PROJECT: string | undefined = undefined;
20+
21+const variant = process.env.APP_VARIANT;
22+const suffix = variant === "development" ? ".dev" : variant === "preview" ? ".preview" : "";
23+const name = variant === "development" ? "g1t (dev)" : variant === "preview" ? "g1t (beta)" : "g1t";
24+const id = `sh.g1t.app${suffix}`;
25+const projectId = process.env.EAS_PROJECT_ID ?? EAS_PROJECT;
26+
27+/** g1t's background, dark as the site is by default (packages/theme/tokens.css `--g1t-bg`). */
28+const BACKGROUND = "#0f0f11";
29+
30+export default ({ config }: ConfigContext): ExpoConfig => ({
31+ ...config,
32+ name,
33+ slug: "g1t",
34+ owner: process.env.EAS_OWNER,
35+ version: "0.1.0",
36+ orientation: "portrait",
37+ icon: "./assets/images/icon.png",
38+ // g1t:// links, as on the desktop, and where sign-in comes back to.
39+ scheme: "g1t",
40+ userInterfaceStyle: "automatic",
41+ backgroundColor: BACKGROUND,
42+ ios: {
43+ bundleIdentifier: id,
44+ supportsTablet: true,
45+ config: { usesNonExemptEncryption: false },
46+ },
47+ android: {
48+ package: id,
49+ adaptiveIcon: {
50+ backgroundColor: BACKGROUND,
51+ foregroundImage: "./assets/images/android-icon-foreground.png",
52+ monochromeImage: "./assets/images/android-icon-monochrome.png",
53+ },
54+ predictiveBackGestureEnabled: true,
55+ softwareKeyboardLayoutMode: "resize",
56+ },
57+ plugins: [
58+ "expo-router",
59+ "expo-secure-store",
60+ "expo-web-browser",
61+ [
62+ "expo-splash-screen",
63+ {
64+ backgroundColor: BACKGROUND,
65+ image: "./assets/images/splash-icon.png",
66+ imageWidth: 96,
67+ },
68+ ],
69+ ],
70+ experiments: {
71+ reactCompiler: true,
72+ },
73+ runtimeVersion: { policy: "appVersion" },
74+ ...(projectId ? { updates: { url: `https://u.expo.dev/${projectId}` } } : {}),
75+ extra: {
76+ ...config.extra,
77+ ...(projectId ? { eas: { projectId } } : {}),
78+ },
79+});
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+36−0
1+{
2+ "cli": {
3+ "version": ">= 16.0.0",
4+ "appVersionSource": "remote"
5+ },
6+ "build": {
7+ "base": {
8+ "node": "22.22.0"
9+ },
10+ "development": {
11+ "extends": "base",
12+ "developmentClient": true,
13+ "distribution": "internal",
14+ "env": { "APP_VARIANT": "development" },
15+ "android": { "buildType": "apk" }
16+ },
17+ "preview": {
18+ "extends": "base",
19+ "distribution": "internal",
20+ "channel": "preview",
21+ "env": { "APP_VARIANT": "preview" },
22+ "android": { "buildType": "apk" }
23+ },
24+ "production": {
25+ "extends": "base",
26+ "channel": "production",
27+ "autoIncrement": true,
28+ "android": { "buildType": "app-bundle" }
29+ }
30+ },
31+ "submit": {
32+ "production": {
33+ "android": { "track": "internal", "releaseStatus": "draft" }
34+ }
35+ }
36+}
+20−0
1+// Metro in the g1t monorepo. Expo finds the workspace root and
2+// @g1t/contracts by itself; what it needs told is that React comes from
3+// this app alone. The site pins a newer React at the root, and React
4+// Native must run with the exact React it was built for (19.2.3 with
5+// Expo SDK 57), so `react` and its subpaths resolve from here wherever
6+// they are imported, including from packages hoisted to the root.
7+const path = require("node:path");
8+const { getDefaultConfig } = require("expo/metro-config");
9+
10+const config = getDefaultConfig(__dirname);
11+const here = path.join(__dirname, "package.json");
12+
13+const resolveRequest = config.resolver.resolveRequest;
14+config.resolver.resolveRequest = (context, moduleName, platform) => {
15+ const pinned = moduleName === "react" || moduleName.startsWith("react/");
16+ const from = pinned ? { ...context, originModulePath: here } : context;
17+ return resolveRequest ? resolveRequest(from, moduleName, platform) : context.resolveRequest(from, moduleName, platform);
18+};
19+
20+module.exports = config;
+52−0
1+{
2+ "name": "@g1t/mobile",
3+ "private": true,
4+ "version": "0.1.0",
5+ "description": "The g1t phone app (Expo): Chat and Code first, signed in with OAuth, for Android and iOS.",
6+ "main": "expo-router/entry",
7+ "scripts": {
8+ "start": "expo start",
9+ "android": "expo run:android",
10+ "ios": "expo run:ios",
11+ "prebuild": "expo prebuild --clean",
12+ "typecheck": "tsc --noEmit",
13+ "test": "node --test \"src/lib/*.test.ts\"",
14+ "doctor": "expo-doctor",
15+ "icons": "node scripts/icons.mjs",
16+ "build:android": "eas build --platform android --profile production",
17+ "build:android:apk": "eas build --platform android --profile preview",
18+ "build:ios": "eas build --platform ios --profile production"
19+ },
20+ "dependencies": {
21+ "@g1t/contracts": "*",
22+ "@tanstack/react-query": "^5.104.1",
23+ "expo": "~57.0.27",
24+ "expo-auth-session": "~57.0.14",
25+ "expo-clipboard": "~57.0.2",
26+ "expo-constants": "~57.0.21",
27+ "expo-crypto": "~57.0.3",
28+ "expo-font": "~57.0.4",
29+ "expo-image": "~57.0.5",
30+ "expo-linking": "~57.0.12",
31+ "expo-router": "~57.0.25",
32+ "expo-secure-store": "~57.0.4",
33+ "expo-splash-screen": "~57.0.9",
34+ "expo-status-bar": "~57.0.1",
35+ "expo-system-ui": "~57.0.4",
36+ "expo-web-browser": "~57.0.3",
37+ "lucide-react-native": "^1.55.0",
38+ "react": "19.2.3",
39+ "react-native": "0.86.3",
40+ "react-native-gesture-handler": "~2.32.0",
41+ "react-native-reanimated": "4.5.1",
42+ "react-native-safe-area-context": "~5.7.0",
43+ "react-native-screens": "~4.26.0",
44+ "react-native-svg": "15.15.4",
45+ "react-native-worklets": "0.10.1"
46+ },
47+ "devDependencies": {
48+ "@types/react": "~19.2.2",
49+ "sharp": "^0.35.5",
50+ "typescript": "~6.0.3"
51+ }
52+}
+32−0
1+// Draws the app's icons from the g1t mark (packages/theme/mark.svg):
2+// `npm run icons -w @g1t/mobile` after the mark or the background changes.
3+//
4+// - icon.png: the store and iOS icon, the mark on g1t's background.
5+// - android-icon-foreground.png: the adaptive icon's mark, inside the
6+// middle 66% Android keeps whatever the launcher's mask.
7+// - android-icon-monochrome.png: the same in white, for themed icons.
8+// - splash-icon.png: the mark alone, on the splash screen's background.
9+import { readFile } from "node:fs/promises";
10+import { fileURLToPath } from "node:url";
11+import sharp from "sharp";
12+
13+const here = (path) => fileURLToPath(new URL(path, import.meta.url));
14+const mark = await readFile(here("../../../packages/theme/mark.svg"), "utf8");
15+const BACKGROUND = "#0f0f11";
16+
17+/** The mark at `scale` of a `size` square, recoloured, on `background` or nothing. */
18+async function draw(file, { size, scale, color, background }) {
19+ const inner = Math.round(size * scale);
20+ const svg = Buffer.from(mark.replace(/#b6a8ff/g, color ?? "#b6a8ff").replace("<svg ", `<svg width="${inner}" height="${inner}" `));
21+ const glyph = await sharp(svg, { density: 72 * (inner / 80) }).resize(inner, inner).png().toBuffer();
22+ await sharp({ create: { width: size, height: size, channels: 4, background: background ?? { r: 0, g: 0, b: 0, alpha: 0 } } })
23+ .composite([{ input: glyph, gravity: "centre" }])
24+ .png()
25+ .toFile(here(`../assets/images/${file}`));
26+}
27+
28+await draw("icon.png", { size: 1024, scale: 0.62, background: BACKGROUND });
29+await draw("android-icon-foreground.png", { size: 1024, scale: 0.46 });
30+await draw("android-icon-monochrome.png", { size: 1024, scale: 0.46, color: "#ffffff" });
31+await draw("splash-icon.png", { size: 512, scale: 0.9 });
32+console.log("Drew icon.png, android-icon-foreground.png, android-icon-monochrome.png and splash-icon.png.");
+38−0
1+import { Tabs, useGlobalSearchParams } from "expo-router";
2+import { Blocks, CircleUser, Code2, MessagesSquare } from "lucide-react-native";
3+import { useEffect } from "react";
4+
5+import { useSignedIn } from "@/lib/session";
6+import { useColors } from "@/theme";
7+
8+/**
9+ * The signed-in app: Chat, Code, Customize and You, as the site's phone
10+ * layout has its bottom bar (apps/web/app/components/mobile.tsx). Home,
11+ * Notifications, Agents and Artifacts join as they get screens.
12+ */
13+export default function AppLayout() {
14+ const colors = useColors();
15+ const { workspace, selectWorkspace } = useSignedIn();
16+ // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it.
17+ const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>();
18+ useEffect(() => {
19+ if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked);
20+ }, [linked, workspace?.slug, selectWorkspace]);
21+
22+ return (
23+ <Tabs
24+ screenOptions={{
25+ headerShown: false,
26+ tabBarActiveTintColor: colors.accent,
27+ tabBarInactiveTintColor: colors.muted,
28+ tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.line },
29+ }}
30+ >
31+ <Tabs.Screen name="index" options={{ href: null }} />
32+ <Tabs.Screen name="chat" options={{ title: "Chat", tabBarIcon: ({ color, size }) => <MessagesSquare color={color} size={size} /> }} />
33+ <Tabs.Screen name="code" options={{ title: "Code", tabBarIcon: ({ color, size }) => <Code2 color={color} size={size} /> }} />
34+ <Tabs.Screen name="customize" options={{ title: "Customize", tabBarIcon: ({ color, size }) => <Blocks color={color} size={size} /> }} />
35+ <Tabs.Screen name="you" options={{ title: "You", tabBarIcon: ({ color, size }) => <CircleUser color={color} size={size} /> }} />
36+ </Tabs>
37+ );
38+}
+158−0
1+import type { ChatMessage } from "@g1t/contracts";
2+import { Stack, useLocalSearchParams } from "expo-router";
3+import * as WebBrowser from "expo-web-browser";
4+import { SendHorizontal } from "lucide-react-native";
5+import { useEffect, useMemo, useState } from "react";
6+import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
7+import { useSafeAreaInsets } from "react-native-safe-area-context";
8+
9+import { Avatar, Badge, ErrorView, Loading, Screen, Text } from "@/components/ui";
10+import { markRead, useChannelLive, useChatSidebar, useMessages, usePostMessage } from "@/lib/chat";
11+import { siteUrl } from "@/lib/server";
12+import { useSignedIn } from "@/lib/session";
13+import { clock } from "@/lib/time";
14+import { radius, space, useColors } from "@/theme";
15+
16+/**
17+ * A conversation: its messages, newest at the bottom, live while open, and
18+ * a box to write in. `channel` is a channel's id, or a channel's name when
19+ * a link named it (`/acme/-/chat/general`).
20+ */
21+export default function Conversation() {
22+ const params = useLocalSearchParams<{ channel: string; title?: string }>();
23+ const { client, workspace, server } = useSignedIn();
24+ const sidebar = useChatSidebar();
25+ const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel);
26+ // A link by name waits for the sidebar to say which channel it is.
27+ const channelId = entry?.channel.id ?? (params.channel.includes("_") ? params.channel : "");
28+ const title = params.title ?? (entry ? (entry.channel.kind === "dm" ? entry.title : `#${entry.channel.name}`) : "Chat");
29+
30+ const messages = useMessages(channelId);
31+ const live = useChannelLive(channelId);
32+ const post = usePostMessage(channelId);
33+ const [draft, setDraft] = useState("");
34+ const colors = useColors();
35+ const insets = useSafeAreaInsets();
36+
37+ // Newest first, for a list drawn from the bottom up.
38+ const newestFirst = useMemo(
39+ () => (messages.data?.pages ?? []).flatMap((page) => [...page.messages].reverse()).filter((message) => !message.deleted_at),
40+ [messages.data],
41+ );
42+ const newest = newestFirst[0]?.id;
43+ useEffect(() => {
44+ if (workspace && channelId && newest) void markRead(client, workspace.slug, channelId, newest);
45+ }, [client, workspace, channelId, newest]);
46+
47+ const send = () => {
48+ const body = draft.trim();
49+ if (!body || post.isPending) return;
50+ post.mutate(body, { onSuccess: () => setDraft("") });
51+ };
52+
53+ return (
54+ <Screen>
55+ <Stack.Screen options={{ title, headerRight: () => <LiveDot status={live} /> }} />
56+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={90}>
57+ {!channelId && sidebar.isPending ? (
58+ <Loading />
59+ ) : messages.isPending ? (
60+ <Loading />
61+ ) : messages.error ? (
62+ <ErrorView error={messages.error} retry={() => messages.refetch()} />
63+ ) : (
64+ <FlatList
65+ inverted
66+ data={newestFirst}
67+ keyExtractor={(message) => message.id}
68+ renderItem={({ item }) => <Message message={item} site={server.site} />}
69+ onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
70+ onEndReachedThreshold={0.5}
71+ ListFooterComponent={messages.isFetchingNextPage ? <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} /> : null}
72+ contentContainerStyle={{ paddingVertical: space.md }}
73+ />
74+ )}
75+ <View style={[styles.composer, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}>
76+ <TextInput
77+ value={draft}
78+ onChangeText={setDraft}
79+ placeholder={`Message ${title}`}
80+ placeholderTextColor={colors.faint}
81+ multiline
82+ style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
83+ />
84+ <Pressable accessibilityRole="button" accessibilityLabel="Send" onPress={send} disabled={!draft.trim() || post.isPending} style={styles.send}>
85+ {post.isPending ? <ActivityIndicator color={colors.accent} /> : <SendHorizontal color={draft.trim() ? colors.accent : colors.faint} size={22} />}
86+ </Pressable>
87+ </View>
88+ {post.error ? (
89+ <Text tone="danger" size="small" style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}>
90+ {post.error.message}
91+ </Text>
92+ ) : null}
93+ </KeyboardAvoidingView>
94+ </Screen>
95+ );
96+}
97+
98+function Message({ message, site }: { message: ChatMessage; site: string }) {
99+ const colors = useColors();
100+ const { author } = message;
101+ const card = message.kind === "card" ? message.card : null;
102+ return (
103+ <View style={styles.message}>
104+ <Avatar name={author.display_name} uri={author.avatar ? `${site}/avatars/${author.avatar}` : null} agent={author.kind === "agent"} />
105+ <View style={{ flex: 1, gap: 2 }}>
106+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "baseline" }}>
107+ <Text style={{ fontWeight: "600" }}>{author.display_name}</Text>
108+ <Text size="small" tone="faint">
109+ {clock(message.created_at)}
110+ {message.edited_at ? " · edited" : ""}
111+ </Text>
112+ </View>
113+ {message.body ? <Text selectable>{message.body}</Text> : null}
114+ {card ? (
115+ <Pressable
116+ disabled={!card.href}
117+ onPress={() => card.href && WebBrowser.openBrowserAsync(siteUrl({ site, api: "" }, card.href))}
118+ style={[styles.card, { borderColor: colors.lineStrong, backgroundColor: colors.surface }]}
119+ >
120+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
121+ <Text style={{ flex: 1, fontWeight: "600" }}>{card.title}</Text>
122+ {card.state ? <Badge label={card.state} /> : null}
123+ </View>
124+ {card.detail ? (
125+ <Text size="small" tone="muted">
126+ {card.detail}
127+ </Text>
128+ ) : null}
129+ {card.body ? (
130+ <Text size="small" numberOfLines={6}>
131+ {card.body}
132+ </Text>
133+ ) : null}
134+ </Pressable>
135+ ) : null}
136+ {message.reply_count > 0 ? (
137+ <Text size="small" tone="accent">
138+ {message.reply_count === 1 ? "1 reply" : `${message.reply_count} replies`}
139+ </Text>
140+ ) : null}
141+ </View>
142+ </View>
143+ );
144+}
145+
146+function LiveDot({ status }: { status: "connecting" | "live" | "offline" }) {
147+ const colors = useColors();
148+ const color = status === "live" ? colors.success : status === "connecting" ? colors.warn : colors.faint;
149+ return <View accessibilityLabel={status === "live" ? "Live" : status === "connecting" ? "Connecting" : "Offline"} style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />;
150+}
151+
152+const styles = StyleSheet.create({
153+ message: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.sm },
154+ card: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.xs, marginTop: space.xs },
155+ composer: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth },
156+ input: { flex: 1, minHeight: 40, maxHeight: 140, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 15 },
157+ send: { width: 40, height: 40, alignItems: "center", justifyContent: "center" },
158+});
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+81−0
1+import type { ChatSidebarEntry } from "@g1t/contracts";
2+import { Stack, router } from "expo-router";
3+import { Hash, Lock, MessagesSquare } from "lucide-react-native";
4+import { RefreshControl, SectionList, View } from "react-native";
5+
6+import { Avatar, Badge, Empty, ErrorView, Loading, Row, Screen, SectionHeader } from "@/components/ui";
7+import { useChatSidebar } from "@/lib/chat";
8+import { useSignedIn } from "@/lib/session";
9+import { ago } from "@/lib/time";
10+import { useColors } from "@/theme";
11+
12+/** Chat: the workspace's channels you are in, then your direct messages. */
13+export default function ChatList() {
14+ const { workspace, server } = useSignedIn();
15+ const sidebar = useChatSidebar();
16+ const colors = useColors();
17+
18+ const title = workspace?.name ?? workspace?.slug ?? "Chat";
19+ if (!workspace) {
20+ return (
21+ <Screen>
22+ <Stack.Screen options={{ title: "Chat" }} />
23+ <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
24+ </Screen>
25+ );
26+ }
27+
28+ const entries = sidebar.data?.entries ?? [];
29+ const sections = [
30+ { title: "Channels", data: entries.filter((entry) => entry.channel.kind === "channel" && !entry.channel.archived_at) },
31+ { title: "Direct messages", data: entries.filter((entry) => entry.channel.kind === "dm") },
32+ ].filter((section) => section.data.length > 0);
33+
34+ return (
35+ <Screen>
36+ <Stack.Screen options={{ title }} />
37+ {sidebar.isPending ? (
38+ <Loading />
39+ ) : sidebar.error ? (
40+ <ErrorView error={sidebar.error} retry={() => sidebar.refetch()} />
41+ ) : (
42+ <SectionList
43+ sections={sections}
44+ keyExtractor={(entry) => entry.channel.id}
45+ renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
46+ refreshControl={<RefreshControl refreshing={sidebar.isRefetching} onRefresh={() => sidebar.refetch()} tintColor={colors.accent} />}
47+ ListEmptyComponent={<Empty icon={MessagesSquare} title="No conversations yet" body="Join a channel or message someone on g1t." />}
48+ renderItem={({ item }) => <Conversation entry={item} site={server.site} />}
49+ stickySectionHeadersEnabled={false}
50+ />
51+ )}
52+ </Screen>
53+ );
54+}
55+
56+function Conversation({ entry, site }: { entry: ChatSidebarEntry; site: string }) {
57+ const colors = useColors();
58+ const { channel } = entry;
59+ const dm = channel.kind === "dm";
60+ const other = entry.others[0];
61+ const leading = dm && other ? (
62+ <Avatar name={other.display_name} uri={other.avatar ? `${site}/avatars/${other.avatar}` : null} agent={other.kind === "agent"} />
63+ ) : (
64+ <View style={{ width: 32, alignItems: "center" }}>
65+ {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />}
66+ </View>
67+ );
68+ const unread = entry.unread > 0 && !entry.muted;
69+ return (
70+ <Row
71+ leading={leading}
72+ title={dm ? entry.title : channel.name ?? entry.title}
73+ subtitle={channel.topic ?? (channel.last_message_at ? ago(channel.last_message_at) : undefined)}
74+ trailing={
75+ entry.mentions > 0 ? <Badge label={`@${entry.mentions}`} tone="accent" /> : unread ? <Badge label={String(entry.unread)} tone="neutral" /> : null
76+ }
77+ chevron={false}
78+ onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id, title: dm ? entry.title : `#${channel.name}` } })}
79+ />
80+ );
81+}
+100−0
1+import { Stack, router, useLocalSearchParams } from "expo-router";
2+import { CircleDot, GitPullRequest } from "lucide-react-native";
3+import { useState } from "react";
4+import { FlatList, RefreshControl, View } from "react-native";
5+
6+import { Checks, IssueIcon, PullIcon } from "@/components/code";
7+import { Segments } from "@/components/segments";
8+import { Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
9+import { type ApiIssue, type ApiPull, useIssues, usePulls, useRepo } from "@/lib/code";
10+import { ago } from "@/lib/time";
11+import { space, useColors } from "@/theme";
12+
13+type Tab = "pulls" | "issues";
14+type State = "open" | "closed";
15+
16+/** A repository: its pull requests and issues. */
17+export default function Repository() {
18+ const { owner, repo } = useLocalSearchParams<{ owner: string; repo: string }>();
19+ const [tab, setTab] = useState<Tab>("pulls");
20+ const [state, setState] = useState<State>("open");
21+ const details = useRepo(owner, repo);
22+ const pulls = usePulls(owner, repo, state);
23+ const issues = useIssues(owner, repo, state);
24+ const list = tab === "pulls" ? pulls : issues;
25+ const colors = useColors();
26+
27+ return (
28+ <Screen>
29+ <Stack.Screen options={{ title: repo }} />
30+ <View style={{ padding: space.lg, gap: space.md }}>
31+ <Text size="small" tone="muted">
32+ {owner}/{repo}
33+ {details.data?.description ? ` · ${details.data.description}` : ""}
34+ </Text>
35+ <Segments
36+ value={tab}
37+ onChange={setTab}
38+ options={[
39+ { value: "pulls", label: "Pull requests" },
40+ { value: "issues", label: "Issues" },
41+ ]}
42+ />
43+ <Segments
44+ value={state}
45+ onChange={setState}
46+ options={[
47+ { value: "open", label: "Open" },
48+ { value: "closed", label: "Closed" },
49+ ]}
50+ />
51+ </View>
52+ {list.isPending ? (
53+ <Loading />
54+ ) : list.error ? (
55+ <ErrorView error={list.error} retry={() => list.refetch()} />
56+ ) : tab === "pulls" ? (
57+ <FlatList
58+ data={pulls.data}
59+ keyExtractor={(pull) => pull.id}
60+ refreshControl={<RefreshControl refreshing={pulls.isRefetching} onRefresh={() => pulls.refetch()} tintColor={colors.accent} />}
61+ ListEmptyComponent={<Empty icon={GitPullRequest} title={`No ${state} pull requests`} />}
62+ renderItem={({ item }) => <PullRow pull={item} owner={owner} repo={repo} />}
63+ />
64+ ) : (
65+ <FlatList
66+ data={issues.data}
67+ keyExtractor={(issue) => issue.id}
68+ refreshControl={<RefreshControl refreshing={issues.isRefetching} onRefresh={() => issues.refetch()} tintColor={colors.accent} />}
69+ ListEmptyComponent={<Empty icon={CircleDot} title={`No ${state} issues`} />}
70+ renderItem={({ item }) => <IssueRow issue={item} owner={owner} repo={repo} />}
71+ />
72+ )}
73+ </Screen>
74+ );
75+}
76+
77+function PullRow({ pull, owner, repo }: { pull: ApiPull; owner: string; repo: string }) {
78+ const by = pull.author.display_username ?? pull.author.username;
79+ return (
80+ <Row
81+ leading={<PullIcon pull={pull} />}
82+ title={pull.title}
83+ subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""} · ${ago(pull.updated_at)}`}
84+ trailing={<Checks status={pull.check_status} />}
85+ onPress={() => router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo, number: String(pull.number) } })}
86+ />
87+ );
88+}
89+
90+function IssueRow({ issue, owner, repo }: { issue: ApiIssue; owner: string; repo: string }) {
91+ const by = issue.author.display_username ?? issue.author.username;
92+ return (
93+ <Row
94+ leading={<IssueIcon issue={issue} />}
95+ title={issue.title}
96+ subtitle={`#${issue.number} · ${by} · ${ago(issue.updated_at)}${issue.comment_count ? ` · ${issue.comment_count} comments` : ""}`}
97+ onPress={() => router.push({ pathname: "/code/[owner]/[repo]/issues/[number]", params: { owner, repo, number: String(issue.number) } })}
98+ />
99+ );
100+}
+86−0
1+import { Stack, router, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { ExternalLink, Send } from "lucide-react-native";
4+import { useState } from "react";
5+import { View } from "react-native";
6+
7+import { Body, IssueIcon, PullIcon, Timeline } from "@/components/code";
8+import { Button, ErrorView, Field, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
9+import { useComment, useIssue } from "@/lib/code";
10+import { siteUrl } from "@/lib/server";
11+import { useSignedIn } from "@/lib/session";
12+import { ago } from "@/lib/time";
13+import { space } from "@/theme";
14+
15+/** An issue: what it asks for, the pull requests made for it, and its conversation. */
16+export default function Issue() {
17+ const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>();
18+ const number = Number(params.number);
19+ const { server } = useSignedIn();
20+ const issue = useIssue(params.owner, params.repo, number);
21+ const comment = useComment(params.owner, params.repo, number);
22+ const [draft, setDraft] = useState("");
23+
24+ if (issue.isPending || issue.error) {
25+ return (
26+ <Screen>
27+ <Stack.Screen options={{ title: `#${number}` }} />
28+ {issue.error ? <ErrorView error={issue.error} retry={() => issue.refetch()} /> : <Loading />}
29+ </Screen>
30+ );
31+ }
32+
33+ const { issue: item, pulls, comments } = issue.data;
34+ const by = item.author.display_username ?? item.author.username;
35+
36+ return (
37+ <Screen scroll>
38+ <Stack.Screen options={{ title: `#${number}` }} />
39+ <View style={{ gap: space.sm }}>
40+ <Text size="heading">{item.title}</Text>
41+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
42+ <IssueIcon issue={item} />
43+ <Text size="small" tone="muted">
44+ {item.state === "open" ? "Open" : "Closed"} · {by} · {ago(item.created_at)}
45+ {item.labels.length ? ` · ${item.labels.join(", ")}` : ""}
46+ </Text>
47+ </View>
48+ </View>
49+ <Body text={item.body} />
50+ {pulls.length ? (
51+ <View style={{ marginHorizontal: -space.lg }}>
52+ <SectionHeader title="Pull requests" />
53+ {pulls.map((pull) => (
54+ <Row
55+ key={pull.id}
56+ leading={<PullIcon pull={pull} />}
57+ title={pull.title}
58+ subtitle={`#${pull.number} · ${pull.status}`}
59+ onPress={() =>
60+ router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner: params.owner, repo: params.repo, number: String(pull.number) } })
61+ }
62+ />
63+ ))}
64+ </View>
65+ ) : null}
66+ <Timeline comments={comments} />
67+ <View style={{ gap: space.sm }}>
68+ <Field label="Comment" placeholder="Add to the conversation" value={draft} onChangeText={setDraft} multiline />
69+ <Button
70+ title="Comment"
71+ icon={Send}
72+ busy={comment.isPending}
73+ disabled={!draft.trim()}
74+ onPress={() => comment.mutate(draft.trim(), { onSuccess: () => setDraft("") })}
75+ />
76+ {comment.error ? <Text tone="danger">{comment.error.message}</Text> : null}
77+ </View>
78+ <Button
79+ title="Open on g1t"
80+ variant="secondary"
81+ icon={ExternalLink}
82+ onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/issues/${number}`))}
83+ />
84+ </Screen>
85+ );
86+}
+121−0
1+import { Stack, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native";
4+import { useState } from "react";
5+import { View } from "react-native";
6+
7+import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code";
8+import { Segments } from "@/components/segments";
9+import { Button, ErrorView, Field, Loading, Screen, Text } from "@/components/ui";
10+import { usePull, usePullChanges, useReview } from "@/lib/code";
11+import { siteUrl } from "@/lib/server";
12+import { useSignedIn } from "@/lib/session";
13+import { ago } from "@/lib/time";
14+import { space } from "@/theme";
15+
16+/**
17+ * A pull request: what it is for, its checks, the conversation, and what
18+ * it changes; approve it or ask for changes from here. Merging stays on
19+ * the site for now, where its rules and the merge queue are shown.
20+ */
21+export default function PullRequest() {
22+ const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>();
23+ const number = Number(params.number);
24+ const { server } = useSignedIn();
25+ const [tab, setTab] = useState<"conversation" | "files">("conversation");
26+ const pull = usePull(params.owner, params.repo, number);
27+ const changes = usePullChanges(params.owner, params.repo, number, tab === "files");
28+ const review = useReview(params.owner, params.repo, number);
29+ const [note, setNote] = useState("");
30+
31+ const open = () => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/pull/${number}`));
32+
33+ if (pull.isPending) {
34+ return (
35+ <Screen>
36+ <Stack.Screen options={{ title: `#${number}` }} />
37+ <Loading />
38+ </Screen>
39+ );
40+ }
41+ if (pull.error) {
42+ return (
43+ <Screen>
44+ <Stack.Screen options={{ title: `#${number}` }} />
45+ <ErrorView error={pull.error} retry={() => pull.refetch()} />
46+ </Screen>
47+ );
48+ }
49+
50+ const { pull: pr, comments } = pull.data;
51+ const by = pr.author.display_username ?? pr.author.username;
52+ const reviewable = pr.status === "open";
53+
54+ return (
55+ <Screen scroll>
56+ <Stack.Screen options={{ title: `#${number}` }} />
57+ <View style={{ gap: space.sm }}>
58+ <Text size="heading">{pr.title}</Text>
59+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center", flexWrap: "wrap" }}>
60+ <StateBadge pull={pr} />
61+ <Checks status={pr.check_status} />
62+ <Text size="small" tone="muted">
63+ {by}
64+ {pr.agent ? ` with ${pr.agent}` : ""} · {pr.branch ?? "a fork"} → {pr.base} · {ago(pr.updated_at)}
65+ </Text>
66+ </View>
67+ </View>
68+
69+ <Segments
70+ value={tab}
71+ onChange={setTab}
72+ options={[
73+ { value: "conversation", label: "Conversation" },
74+ { value: "files", label: `Files · ${pr.files.length}` },
75+ ]}
76+ />
77+
78+ {tab === "conversation" ? (
79+ <>
80+ <Body text={pr.body} />
81+ <Timeline comments={comments} />
82+ {reviewable ? (
83+ <View style={{ gap: space.md }}>
84+ <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline />
85+ <View style={{ flexDirection: "row", gap: space.sm }}>
86+ <View style={{ flex: 1 }}>
87+ <Button
88+ title="Approve"
89+ icon={Check}
90+ busy={review.isPending && review.variables?.verdict === "approve"}
91+ disabled={review.isPending}
92+ onPress={() => review.mutate({ verdict: "approve", body: note.trim() }, { onSuccess: () => setNote("") })}
93+ />
94+ </View>
95+ <View style={{ flex: 1 }}>
96+ <Button
97+ title="Request changes"
98+ variant="secondary"
99+ icon={MessageSquareWarning}
100+ busy={review.isPending && review.variables?.verdict === "request_changes"}
101+ disabled={review.isPending || !note.trim()}
102+ onPress={() => review.mutate({ verdict: "request_changes", body: note.trim() }, { onSuccess: () => setNote("") })}
103+ />
104+ </View>
105+ </View>
106+ {review.error ? <Text tone="danger">{review.error.message}</Text> : null}
107+ </View>
108+ ) : null}
109+ </>
110+ ) : changes.isPending ? (
111+ <Loading />
112+ ) : changes.error ? (
113+ <ErrorView error={changes.error} retry={() => changes.refetch()} />
114+ ) : (
115+ <Diff changes={changes.data} />
116+ )}
117+
118+ <Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={open} />
119+ </Screen>
120+ );
121+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+58−0
1+import { hasCodeAccess } from "@g1t/contracts";
2+import { Stack, router } from "expo-router";
3+import { BookLock, BookMarked, Code2 } from "lucide-react-native";
4+import { useState } from "react";
5+import { FlatList, RefreshControl, View } from "react-native";
6+
7+import { Badge, Empty, ErrorView, Field, Loading, Row, Screen } from "@/components/ui";
8+import { useRepos } from "@/lib/code";
9+import { useSignedIn } from "@/lib/session";
10+import { ago } from "@/lib/time";
11+import { space, useColors } from "@/theme";
12+
13+/** Code: the workspace's repositories you can see, newest first. */
14+export default function Repos() {
15+ const { workspace } = useSignedIn();
16+ const [search, setSearch] = useState("");
17+ const repos = useRepos(search.trim());
18+ const colors = useColors();
19+
20+ if (workspace && !hasCodeAccess(workspace)) {
21+ return (
22+ <Screen>
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.`} />
25+ </Screen>
26+ );
27+ }
28+
29+ return (
30+ <Screen>
31+ <Stack.Screen options={{ title: "Code" }} />
32+ <View style={{ padding: space.lg, paddingBottom: space.sm }}>
33+ <Field label="Find a repository" placeholder="Name or description" value={search} onChangeText={setSearch} returnKeyType="search" />
34+ </View>
35+ {repos.isPending ? (
36+ <Loading />
37+ ) : repos.error ? (
38+ <ErrorView error={repos.error} retry={() => repos.refetch()} />
39+ ) : (
40+ <FlatList
41+ data={repos.data}
42+ keyExtractor={(repo) => repo.id}
43+ refreshControl={<RefreshControl refreshing={repos.isRefetching} onRefresh={() => repos.refetch()} tintColor={colors.accent} />}
44+ ListEmptyComponent={<Empty icon={BookMarked} title={search ? "Nothing matches" : "No repositories yet"} />}
45+ renderItem={({ item }) => (
46+ <Row
47+ leading={item.is_private ? <BookLock size={20} color={colors.muted} /> : <BookMarked size={20} color={colors.muted} />}
48+ title={`${item.namespace}/${item.name}`}
49+ subtitle={item.description ?? `Created ${ago(item.created_at)}`}
50+ trailing={item.archived_at ? <Badge label="Archived" tone="warn" /> : null}
51+ onPress={() => router.push({ pathname: "/code/[owner]/[repo]", params: { owner: item.namespace, repo: item.name } })}
52+ />
53+ )}
54+ />
55+ )}
56+ </Screen>
57+ );
58+}
+47−0
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+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+43−0
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+}
+6−0
1+import { Redirect } from "expo-router";
2+
3+/** The app opens on Chat. */
4+export default function Index() {
5+ return <Redirect href="/chat" />;
6+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+70−0
1+import Constants from "expo-constants";
2+import { Stack } from "expo-router";
3+import * as WebBrowser from "expo-web-browser";
4+import { Check, ExternalLink, LogOut } from "lucide-react-native";
5+import { View } from "react-native";
6+
7+import { Avatar, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
8+import { isHosted, siteUrl } from "@/lib/server";
9+import { useSignedIn } from "@/lib/session";
10+import { space, useColors } from "@/theme";
11+
12+/** You: who is signed in, the workspace the app shows, and signing out. */
13+export default function You() {
14+ const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
15+ const colors = useColors();
16+ const name = me.display_username ?? me.username;
17+ const oauth = client.credentials.kind === "oauth";
18+
19+ return (
20+ <Screen scroll style={{ paddingHorizontal: 0 }}>
21+ <Stack.Screen options={{ title: "You" }} />
22+ <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}>
23+ <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} />
24+ <View style={{ flex: 1 }}>
25+ <Text size="heading">{name}</Text>
26+ <Text size="small" tone="muted">
27+ {isHosted(server) ? "g1t.sh" : new URL(server.site).host}
28+ </Text>
29+ </View>
30+ </View>
31+
32+ <View>
33+ <SectionHeader title="Workspaces" />
34+ {(me.workspaces ?? []).map((one) => (
35+ <Row
36+ key={one.slug}
37+ leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />}
38+ title={one.name ?? one.slug}
39+ subtitle={`${one.slug} · ${one.role}`}
40+ trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null}
41+ chevron={false}
42+ onPress={() => selectWorkspace(one.slug)}
43+ />
44+ ))}
45+ </View>
46+
47+ <View>
48+ <SectionHeader title="Signed in" />
49+ <Row
50+ title={oauth ? "With g1t" : "With an access token"}
51+ subtitle={
52+ oauth
53+ ? "End or narrow the app's access in Settings → Connected applications."
54+ : "The token you pasted. Delete it in Settings → Access tokens to end it."
55+ }
56+ trailing={<ExternalLink size={16} color={colors.faint} />}
57+ chevron={false}
58+ onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))}
59+ />
60+ </View>
61+
62+ <View style={{ paddingHorizontal: space.lg, gap: space.md }}>
63+ <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} />
64+ <Text size="small" tone="faint" style={{ textAlign: "center" }}>
65+ g1t {Constants.expoConfig?.version ?? ""}
66+ </Text>
67+ </View>
68+ </Screen>
69+ );
70+}
+24−0
1+import { routeFor, sitePathOf } from "@/lib/links";
2+
3+/**
4+ * Where a link that opens the app goes. `g1t://` links name a site path,
5+ * as on the desktop; the app shows the ones it has screens for and opens
6+ * the rest on the site (app/open.tsx). Android App Links and iOS
7+ * Universal Links for https://g1t.sh come through here too once the site
8+ * publishes assetlinks.json and apple-app-site-association.
9+ */
10+export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
11+ try {
12+ const sitePath = sitePathOf(path) ?? sitePathOfWeb(path) ?? path;
13+ const route = routeFor(sitePath);
14+ return route ?? `/open?path=${encodeURIComponent(sitePath)}`;
15+ } catch {
16+ return "/";
17+ }
18+}
19+
20+function sitePathOfWeb(link: string): string | null {
21+ if (!/^https?:\/\//i.test(link)) return null;
22+ const url = new URL(link);
23+ return url.pathname + url.search;
24+}
+22−0
1+import { Link, Stack } from "expo-router";
2+import { CircleHelp } from "lucide-react-native";
3+
4+import { Empty, Screen, Text } from "@/components/ui";
5+
6+export default function NotFound() {
7+ return (
8+ <Screen safe>
9+ <Stack.Screen options={{ headerShown: false }} />
10+ <Empty
11+ icon={CircleHelp}
12+ title="Nothing here"
13+ body="The app has no screen for this link."
14+ action={
15+ <Link href="/" replace>
16+ <Text tone="accent">Go to Chat</Text>
17+ </Link>
18+ }
19+ />
20+ </Screen>
21+ );
22+}
+68−0
1+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router";
3+import * as SplashScreen from "expo-splash-screen";
4+import { StatusBar } from "expo-status-bar";
5+import { useEffect, useState } from "react";
6+import { useColorScheme } from "react-native";
7+
8+import { ApiError } from "@/lib/client";
9+import { SessionProvider, useSession } from "@/lib/session";
10+import { useColors } from "@/theme";
11+
12+void SplashScreen.preventAutoHideAsync();
13+
14+export default function RootLayout() {
15+ const [queries] = useState(
16+ () =>
17+ new QueryClient({
18+ defaultOptions: {
19+ queries: {
20+ staleTime: 30_000,
21+ // Signed out, not found and refused are answers, not hiccups.
22+ retry: (count, error) => !(error instanceof ApiError && error.code !== "offline") && count < 2,
23+ },
24+ },
25+ }),
26+ );
27+ return (
28+ <QueryClientProvider client={queries}>
29+ <SessionProvider>
30+ <Navigation />
31+ </SessionProvider>
32+ </QueryClientProvider>
33+ );
34+}
35+
36+function Navigation() {
37+ const { state } = useSession();
38+ const scheme = useColorScheme();
39+ const colors = useColors();
40+ const base = scheme === "light" ? DefaultTheme : DarkTheme;
41+ const theme = {
42+ ...base,
43+ colors: { ...base.colors, primary: colors.accent, background: colors.bg, card: colors.surface, text: colors.fg, border: colors.line },
44+ };
45+
46+ useEffect(() => {
47+ if (state.status !== "loading") void SplashScreen.hideAsync();
48+ }, [state.status]);
49+
50+ if (state.status === "loading") return null;
51+ const signedIn = state.status === "signed-in";
52+
53+ return (
54+ <ThemeProvider value={theme}>
55+ <StatusBar style={scheme === "light" ? "dark" : "light"} />
56+ <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
57+ <Stack.Protected guard={signedIn}>
58+ <Stack.Screen name="(app)" />
59+ </Stack.Protected>
60+ <Stack.Protected guard={!signedIn}>
61+ <Stack.Screen name="sign-in" />
62+ </Stack.Protected>
63+ <Stack.Screen name="oauth/callback" />
64+ <Stack.Screen name="open" />
65+ </Stack>
66+ </ThemeProvider>
67+ );
68+}
+22−0
1+import { Redirect } from "expo-router";
2+
3+import { Loading, Screen } from "@/components/ui";
4+import { useSession } from "@/lib/session";
5+
6+/**
7+ * Where the site sends the person back after they approve the app
8+ * (`g1t://oauth/callback`). The sign-in screen's browser session reads
9+ * the code from the address itself (lib/oauth.ts); this screen is only
10+ * what shows if the link opens the app on its own, and it moves on.
11+ */
12+export default function OAuthCallback() {
13+ const { state } = useSession();
14+ if (state.status === "loading") {
15+ return (
16+ <Screen>
17+ <Loading />
18+ </Screen>
19+ );
20+ }
21+ return <Redirect href={state.status === "signed-in" ? "/chat" : "/sign-in"} />;
22+}
+32−0
1+import { router, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { useEffect } from "react";
4+
5+import { Loading, Screen } from "@/components/ui";
6+import { useSession } from "@/lib/session";
7+import { siteUrl } from "@/lib/server";
8+
9+/**
10+ * A link to a page the app has no screen for: it opens on the site, in
11+ * the system's browser, and the app goes back to where it was.
12+ */
13+export default function Open() {
14+ const { path } = useLocalSearchParams<{ path?: string }>();
15+ const { state } = useSession();
16+
17+ useEffect(() => {
18+ const server = state.server;
19+ if (!server) return;
20+ const safe = path && path.startsWith("/") ? path : "/";
21+ void WebBrowser.openBrowserAsync(siteUrl(server, safe)).finally(() => {
22+ if (router.canGoBack()) router.back();
23+ else router.replace("/");
24+ });
25+ }, [path, state.server]);
26+
27+ return (
28+ <Screen>
29+ <Loading />
30+ </Screen>
31+ );
32+}
+124−0
1+import { KeyRound, LogIn, Server as ServerIcon } from "lucide-react-native";
2+import { useState } from "react";
3+import { KeyboardAvoidingView, Platform, View } from "react-native";
4+
5+import { Button, Field, Screen, Text } from "@/components/ui";
6+import { G1tMark } from "@/components/mark";
7+import { SignInCancelled } from "@/lib/oauth";
8+import { HOSTED, isHosted, parseServer } from "@/lib/server";
9+import { useSession } from "@/lib/session";
10+import { space } from "@/theme";
11+
12+/**
13+ * Signing in. The usual way is **Sign in with g1t**: the site opens in the
14+ * system's browser, the person approves the app, and it comes back signed
15+ * in (lib/oauth.ts). **Use an access token** takes a personal token
16+ * instead. **Server** points the app at a self-hosted g1t.
17+ */
18+export default function SignIn() {
19+ const { state, signIn, signInWithToken, changeServer } = useSession();
20+ const server = state.server ?? HOSTED;
21+ const [mode, setMode] = useState<"oauth" | "token" | "server">("oauth");
22+ const [busy, setBusy] = useState(false);
23+ const [error, setError] = useState<string | null>(null);
24+ const [token, setToken] = useState("");
25+ const [site, setSite] = useState(server.site);
26+ const [api, setApi] = useState(isHosted(server) ? "" : server.api);
27+
28+ const run = async (work: () => Promise<void>) => {
29+ setBusy(true);
30+ setError(null);
31+ try {
32+ await work();
33+ } catch (failure) {
34+ if (!(failure instanceof SignInCancelled)) setError(failure instanceof Error ? failure.message : "Sign-in failed.");
35+ } finally {
36+ setBusy(false);
37+ }
38+ };
39+
40+ return (
41+ <Screen safe>
42+ <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={{ flex: 1 }}>
43+ <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}>
44+ <View style={{ gap: space.md }}>
45+ <G1tMark size={44} />
46+ <Text size="title">Sign in to g1t</Text>
47+ <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text>
48+ </View>
49+
50+ {mode === "oauth" ? (
51+ <View style={{ gap: space.md }}>
52+ <Button title="Sign in with g1t" icon={LogIn} busy={busy} onPress={() => run(signIn)} />
53+ <Text size="small" tone="faint">
54+ {new URL(server.site).host} opens in your browser to approve the app. Your password never reaches it.
55+ </Text>
56+ </View>
57+ ) : null}
58+
59+ {mode === "token" ? (
60+ <View style={{ gap: space.md }}>
61+ <Field
62+ label="Access token"
63+ placeholder="g1t_…"
64+ value={token}
65+ onChangeText={setToken}
66+ secureTextEntry
67+ hint="Make one in Settings → Access tokens. For Chat, turn on “Use the website as you”."
68+ />
69+ <Button title="Sign in" busy={busy} disabled={!token.trim()} onPress={() => run(() => signInWithToken(token))} />
70+ </View>
71+ ) : null}
72+
73+ {mode === "server" ? (
74+ <View style={{ gap: space.md }}>
75+ <Field label="Site" placeholder="g1t.example.com" value={site} onChangeText={setSite} keyboardType="url" />
76+ <Field
77+ label="API (optional)"
78+ placeholder={`api.${site ? site.replace(/^https?:\/\//, "") : "g1t.example.com"}`}
79+ value={api}
80+ onChangeText={setApi}
81+ keyboardType="url"
82+ hint="Left empty, the API is api. in front of the site's address."
83+ />
84+ <Button
85+ title="Use this server"
86+ busy={busy}
87+ onPress={() =>
88+ run(async () => {
89+ const next = parseServer(site, api);
90+ if (!next) throw new Error("That is not a web address. It looks like https://g1t.example.com.");
91+ await changeServer(next);
92+ setMode("oauth");
93+ })
94+ }
95+ />
96+ <Button
97+ title="Use g1t.sh"
98+ variant="secondary"
99+ onPress={() => {
100+ setSite(HOSTED.site);
101+ setApi("");
102+ void run(async () => {
103+ await changeServer(HOSTED);
104+ setMode("oauth");
105+ });
106+ }}
107+ />
108+ </View>
109+ ) : null}
110+
111+ {error ? <Text tone="danger">{error}</Text> : null}
112+
113+ <View style={{ gap: space.sm }}>
114+ {mode !== "oauth" ? <Button title="Sign in with g1t instead" variant="secondary" icon={LogIn} onPress={() => setMode("oauth")} /> : null}
115+ {mode !== "token" ? <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} /> : null}
116+ {mode !== "server" ? (
117+ <Button title={isHosted(server) ? "Server: g1t.sh" : `Server: ${new URL(server.site).host}`} variant="secondary" icon={ServerIcon} onPress={() => setMode("server")} />
118+ ) : null}
119+ </View>
120+ </View>
121+ </KeyboardAvoidingView>
122+ </Screen>
123+ );
124+}
+150−0
1+import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native";
2+import { ScrollView, StyleSheet, View } from "react-native";
3+
4+import { Badge, Text } from "@/components/ui";
5+import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code";
6+import { ago } from "@/lib/time";
7+import { fonts, radius, space, useColors } from "@/theme";
8+
9+/** A pull request's state, as an icon in its colour. */
10+export function PullIcon({ pull }: { pull: Pick<ApiPull, "status"> }) {
11+ const colors = useColors();
12+ if (pull.status === "merged") return <GitMerge size={20} color={colors.merged} />;
13+ if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />;
14+ if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />;
15+ return <GitPullRequest size={20} color={colors.success} />;
16+}
17+
18+export function IssueIcon({ issue }: { issue: Pick<ApiIssue, "state" | "reason"> }) {
19+ const colors = useColors();
20+ if (issue.state === "open") return <CircleDot size={20} color={colors.success} />;
21+ return issue.reason === "not_planned" ? <CircleX size={20} color={colors.muted} /> : <CircleCheck size={20} color={colors.merged} />;
22+}
23+
24+export function Checks({ status }: { status: CheckStatus }) {
25+ const colors = useColors();
26+ if (status === "passed") return <CircleCheck size={16} color={colors.success} accessibilityLabel="Checks passed" />;
27+ if (status === "failed" || status === "errored") return <CircleX size={16} color={colors.danger} accessibilityLabel="Checks failed" />;
28+ if (status === "queued" || status === "running") return <Loader size={16} color={colors.warn} accessibilityLabel="Checks running" />;
29+ return null;
30+}
31+
32+export function StateBadge({ pull }: { pull: Pick<ApiPull, "status"> }) {
33+ const tone = pull.status === "merged" ? "merged" : pull.status === "closed" ? "danger" : pull.status === "open" ? "success" : "neutral";
34+ return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />;
35+}
36+
37+/** Markdown, shown as it was written until the app renders it. */
38+export function Body({ text }: { text: string | null }) {
39+ const colors = useColors();
40+ if (!text?.trim()) {
41+ return (
42+ <Text tone="faint" style={{ fontStyle: "italic" }}>
43+ No description.
44+ </Text>
45+ );
46+ }
47+ return (
48+ <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}>
49+ <Text selectable>{text}</Text>
50+ </View>
51+ );
52+}
53+
54+/** The conversation under an issue or pull request: comments, reviews and what happened. */
55+export function Timeline({ comments }: { comments: ApiComment[] }) {
56+ const colors = useColors();
57+ if (comments.length === 0) return null;
58+ return (
59+ <View style={{ gap: space.md }}>
60+ {comments.map((comment) =>
61+ comment.kind === "event" ? (
62+ <Text key={comment.id} size="small" tone="muted">
63+ {comment.author.display_username ?? comment.author.username} {comment.body} · {ago(comment.created_at)}
64+ </Text>
65+ ) : (
66+ <View key={comment.id} style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface, gap: space.xs }]}>
67+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
68+ <Text style={{ fontWeight: "600" }}>{comment.author.display_username ?? comment.author.username}</Text>
69+ {comment.author.kind === "agent" ? <Badge label="Agent" tone="accent" /> : null}
70+ {comment.verdict ? <Badge label={comment.verdict.replace(/_/g, " ")} tone={comment.verdict === "approve" ? "success" : "warn"} /> : null}
71+ <Text size="small" tone="faint">
72+ {ago(comment.created_at)}
73+ </Text>
74+ </View>
75+ {comment.path ? (
76+ <Text size="mono" tone="muted">
77+ {comment.path}
78+ {comment.line ? `:${comment.line}` : ""}
79+ </Text>
80+ ) : null}
81+ <Text selectable>{comment.body}</Text>
82+ </View>
83+ ),
84+ )}
85+ </View>
86+ );
87+}
88+
89+/** What a pull request changes, file by file, as unified hunks. */
90+export function Diff({ changes }: { changes: ApiChanges }) {
91+ const colors = useColors();
92+ return (
93+ <View style={{ gap: space.lg }}>
94+ {changes.files.map((file) => (
95+ <View key={file.path} style={[styles.file, { borderColor: colors.line }]}>
96+ <View style={[styles.fileHeader, { backgroundColor: colors.raised, borderBottomColor: colors.line }]}>
97+ <Text size="mono" style={{ flex: 1 }} numberOfLines={1}>
98+ {file.path}
99+ </Text>
100+ <Text size="mono" tone="success">
101+ +{file.additions}
102+ </Text>
103+ <Text size="mono" tone="danger">
104+ −{file.deletions}
105+ </Text>
106+ </View>
107+ {file.binary ? (
108+ <Text tone="muted" size="small" style={{ padding: space.md }}>
109+ A binary file.
110+ </Text>
111+ ) : (
112+ <ScrollView horizontal>
113+ <View>
114+ {(file.hunks ?? []).flatMap((hunk, index) =>
115+ hunk.lines.map((line, at) => (
116+ <Text
117+ key={`${index}-${at}`}
118+ style={{
119+ fontFamily: fonts.mono,
120+ fontSize: 12,
121+ lineHeight: 18,
122+ paddingHorizontal: space.sm,
123+ color: line.kind === "add" ? colors.success : line.kind === "delete" ? colors.danger : colors.fgSoft,
124+ backgroundColor: line.kind === "add" ? colors.successLow : "transparent",
125+ }}
126+ >
127+ {line.kind === "add" ? "+ " : line.kind === "delete" ? "− " : " "}
128+ {line.text}
129+ </Text>
130+ )),
131+ )}
132+ </View>
133+ </ScrollView>
134+ )}
135+ </View>
136+ ))}
137+ {changes.truncated ? (
138+ <Text tone="muted" size="small">
139+ Too much changed to show here. Open it on g1t for the rest.
140+ </Text>
141+ ) : null}
142+ </View>
143+ );
144+}
145+
146+const styles = StyleSheet.create({
147+ box: { borderWidth: 1, borderRadius: radius.md, padding: space.md },
148+ file: { borderWidth: 1, borderRadius: radius.md, overflow: "hidden" },
149+ fileHeader: { flexDirection: "row", gap: space.sm, padding: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
150+});
+34−0
1+import Svg, { Rect } from "react-native-svg";
2+
3+import { useColors } from "@/theme";
4+
5+/**
6+ * The g1t mark, as packages/theme/mark.svg draws it: pixels on an 80 by
7+ * 80 grid, in the accent.
8+ */
9+const PIXELS: [number, number][] = [
10+ [36, 6],
11+ [26, 16],
12+ [36, 16],
13+ [16, 26],
14+ [36, 26],
15+ [36, 36],
16+ [36, 46],
17+ [36, 56],
18+ [16, 66],
19+ [26, 66],
20+ [36, 66],
21+ [46, 66],
22+ [56, 66],
23+];
24+
25+export function G1tMark({ size = 32 }: { size?: number }) {
26+ const colors = useColors();
27+ return (
28+ <Svg width={size} height={size} viewBox="0 0 80 80" accessibilityLabel="g1t">
29+ {PIXELS.map(([x, y]) => (
30+ <Rect key={`${x}-${y}`} x={x} y={y} width={8} height={8} fill={colors.accent} />
31+ ))}
32+ </Svg>
33+ );
34+}
+34−0
1+import { Pressable, StyleSheet, View } from "react-native";
2+
3+import { Text } from "@/components/ui";
4+import { radius, space, useColors } from "@/theme";
5+
6+/** A row of choices, one picked: the site's tabs, on a phone. */
7+export function Segments<T extends string>({ options, value, onChange }: { options: { value: T; label: string }[]; value: T; onChange: (value: T) => void }) {
8+ const colors = useColors();
9+ return (
10+ <View style={[styles.row, { backgroundColor: colors.raised }]}>
11+ {options.map((option) => {
12+ const on = option.value === value;
13+ return (
14+ <Pressable
15+ key={option.value}
16+ accessibilityRole="tab"
17+ accessibilityState={{ selected: on }}
18+ onPress={() => onChange(option.value)}
19+ style={[styles.option, on && { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
20+ >
21+ <Text size="small" style={{ fontWeight: "600", color: on ? colors.fg : colors.muted }}>
22+ {option.label}
23+ </Text>
24+ </Pressable>
25+ );
26+ })}
27+ </View>
28+ );
29+}
30+
31+const styles = StyleSheet.create({
32+ row: { flexDirection: "row", borderRadius: radius.md, padding: 3, gap: 3 },
33+ option: { flex: 1, alignItems: "center", paddingVertical: space.sm, borderRadius: radius.sm, borderWidth: 1, borderColor: "transparent" },
34+});
+19−0
1+import { Stack } from "expo-router";
2+
3+import { useColors } from "@/theme";
4+
5+/** A tab's stack of screens, with g1t's header. */
6+export function TabStack() {
7+ const colors = useColors();
8+ return (
9+ <Stack
10+ screenOptions={{
11+ headerStyle: { backgroundColor: colors.surface },
12+ headerTintColor: colors.fg,
13+ headerTitleStyle: { color: colors.fg },
14+ headerShadowVisible: false,
15+ contentStyle: { backgroundColor: colors.bg },
16+ }}
17+ />
18+ );
19+}
+269−0
1+import { Image } from "expo-image";
2+import type { LucideIcon } from "lucide-react-native";
3+import { ChevronRight } from "lucide-react-native";
4+import type { ReactNode } from "react";
5+import {
6+ ActivityIndicator,
7+ Pressable,
8+ type PressableProps,
9+ ScrollView,
10+ StyleSheet,
11+ Text as NativeText,
12+ type TextProps,
13+ TextInput,
14+ type TextInputProps,
15+ View,
16+ type ViewProps,
17+} from "react-native";
18+import { SafeAreaView } from "react-native-safe-area-context";
19+
20+import { ApiError } from "@/lib/client";
21+import { fonts, radius, space, useColors } from "@/theme";
22+
23+/**
24+ * The app's building blocks, themed with g1t's tokens. As on the site
25+ * (apps/web/app/components/ui), a screen is built from these, and a look
26+ * changes through a component's variant, not a style at the call site.
27+ */
28+
29+type Tone = "default" | "muted" | "faint" | "accent" | "danger" | "success";
30+type Size = "title" | "heading" | "body" | "small" | "mono";
31+
32+const SIZES: Record<Size, { fontSize: number; lineHeight: number; fontWeight?: "600" | "700" }> = {
33+ title: { fontSize: 26, lineHeight: 32, fontWeight: "700" },
34+ heading: { fontSize: 17, lineHeight: 22, fontWeight: "600" },
35+ body: { fontSize: 15, lineHeight: 21 },
36+ small: { fontSize: 13, lineHeight: 18 },
37+ mono: { fontSize: 12, lineHeight: 17 },
38+};
39+
40+export function Text({ size = "body", tone = "default", style, ...props }: TextProps & { size?: Size; tone?: Tone }) {
41+ const colors = useColors();
42+ const color = { default: colors.fg, muted: colors.muted, faint: colors.faint, accent: colors.accent, danger: colors.danger, success: colors.success }[tone];
43+ return <NativeText style={[SIZES[size], size === "mono" && { fontFamily: fonts.mono }, { color }, style]} {...props} />;
44+}
45+
46+/** A screen's background, inside the safe area when it has no header above it. */
47+export function Screen({ children, scroll, safe, style, ...props }: ViewProps & { scroll?: boolean; safe?: boolean }) {
48+ const colors = useColors();
49+ const body = scroll ? (
50+ <ScrollView contentContainerStyle={[styles.padded, style]} keyboardShouldPersistTaps="handled">
51+ {children}
52+ </ScrollView>
53+ ) : (
54+ <View style={[{ flex: 1 }, style]} {...props}>
55+ {children}
56+ </View>
57+ );
58+ return safe ? (
59+ <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }}>{body}</SafeAreaView>
60+ ) : (
61+ <View style={{ flex: 1, backgroundColor: colors.bg }}>{body}</View>
62+ );
63+}
64+
65+export function Button({
66+ title,
67+ variant = "primary",
68+ busy,
69+ disabled,
70+ icon: Icon,
71+ ...props
72+}: PressableProps & { title: string; variant?: "primary" | "secondary" | "danger"; busy?: boolean; icon?: LucideIcon }) {
73+ const colors = useColors();
74+ const look = {
75+ primary: { bg: colors.accent, fg: colors.onAccent, border: colors.accent },
76+ secondary: { bg: colors.surface, fg: colors.fg, border: colors.lineStrong },
77+ danger: { bg: colors.surface, fg: colors.danger, border: colors.lineStrong },
78+ }[variant];
79+ const off = disabled || busy;
80+ return (
81+ <Pressable
82+ accessibilityRole="button"
83+ disabled={off}
84+ style={({ pressed }) => [styles.button, { backgroundColor: look.bg, borderColor: look.border, opacity: off ? 0.55 : pressed ? 0.8 : 1 }]}
85+ {...props}
86+ >
87+ {busy ? <ActivityIndicator color={look.fg} /> : Icon ? <Icon size={18} color={look.fg} /> : null}
88+ <Text style={{ color: look.fg, fontWeight: "600" }}>{title}</Text>
89+ </Pressable>
90+ );
91+}
92+
93+export function Field({ label, hint, ...props }: TextInputProps & { label: string; hint?: string }) {
94+ const colors = useColors();
95+ return (
96+ <View style={{ gap: space.xs }}>
97+ <Text size="small" tone="muted">
98+ {label}
99+ </Text>
100+ <TextInput
101+ placeholderTextColor={colors.faint}
102+ autoCapitalize="none"
103+ autoCorrect={false}
104+ style={[styles.input, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
105+ {...props}
106+ />
107+ {hint ? (
108+ <Text size="small" tone="faint">
109+ {hint}
110+ </Text>
111+ ) : null}
112+ </View>
113+ );
114+}
115+
116+/** A row in a list: what it is, a line under it, and what is at its end. */
117+export function Row({
118+ title,
119+ subtitle,
120+ leading,
121+ trailing,
122+ chevron = true,
123+ onPress,
124+}: {
125+ title: ReactNode;
126+ subtitle?: ReactNode;
127+ leading?: ReactNode;
128+ trailing?: ReactNode;
129+ chevron?: boolean;
130+ onPress?: () => void;
131+}) {
132+ const colors = useColors();
133+ return (
134+ <Pressable
135+ onPress={onPress}
136+ disabled={!onPress}
137+ style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
138+ >
139+ {leading}
140+ <View style={{ flex: 1, gap: 2 }}>
141+ {typeof title === "string" ? (
142+ <Text numberOfLines={1} style={{ fontWeight: "600" }}>
143+ {title}
144+ </Text>
145+ ) : (
146+ title
147+ )}
148+ {typeof subtitle === "string" ? (
149+ <Text size="small" tone="muted" numberOfLines={2}>
150+ {subtitle}
151+ </Text>
152+ ) : (
153+ subtitle
154+ )}
155+ </View>
156+ {trailing}
157+ {onPress && chevron ? <ChevronRight size={18} color={colors.faint} /> : null}
158+ </Pressable>
159+ );
160+}
161+
162+export function SectionHeader({ title }: { title: string }) {
163+ const colors = useColors();
164+ return (
165+ <View style={[styles.sectionHeader, { backgroundColor: colors.bg }]}>
166+ <Text size="small" tone="muted" style={{ fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.6 }}>
167+ {title}
168+ </Text>
169+ </View>
170+ );
171+}
172+
173+type BadgeTone = "neutral" | "accent" | "success" | "danger" | "merged" | "warn";
174+
175+export function Badge({ label, tone = "neutral" }: { label: string; tone?: BadgeTone }) {
176+ const colors = useColors();
177+ const fg = { neutral: colors.muted, accent: colors.accent, success: colors.success, danger: colors.danger, merged: colors.merged, warn: colors.warn }[tone];
178+ return (
179+ <View style={[styles.badge, { borderColor: fg }]}>
180+ <Text size="small" style={{ color: fg, fontWeight: "600" }}>
181+ {label}
182+ </Text>
183+ </View>
184+ );
185+}
186+
187+/**
188+ * A person's or an agent's picture: the uploaded one when there is one,
189+ * else their initial. Agents carry a marker, as everywhere on g1t. The
190+ * site draws an agent's own bot face (components/agent-face.tsx); bringing
191+ * that here is to come.
192+ */
193+export function Avatar({ name, uri, agent, size = 32 }: { name: string; uri?: string | null; agent?: boolean; size?: number }) {
194+ const colors = useColors();
195+ const corner = agent ? radius.sm : size / 2;
196+ return (
197+ <View accessibilityLabel={agent ? `${name}, agent` : name} style={{ width: size, height: size }}>
198+ {uri ? (
199+ <Image source={{ uri }} style={{ width: size, height: size, borderRadius: corner }} />
200+ ) : (
201+ <View style={{ width: size, height: size, borderRadius: corner, backgroundColor: colors.accentLow, alignItems: "center", justifyContent: "center" }}>
202+ <Text style={{ color: colors.accent, fontWeight: "700", fontSize: size * 0.42, lineHeight: size * 0.5 }}>{name.slice(0, 1).toUpperCase()}</Text>
203+ </View>
204+ )}
205+ {agent ? <View style={[styles.agentMarker, { backgroundColor: colors.accent, borderColor: colors.bg }]} /> : null}
206+ </View>
207+ );
208+}
209+
210+export function Loading() {
211+ const colors = useColors();
212+ return (
213+ <View style={styles.center}>
214+ <ActivityIndicator color={colors.accent} />
215+ </View>
216+ );
217+}
218+
219+export function Empty({ icon: Icon, title, body, action }: { icon?: LucideIcon; title: string; body?: string; action?: ReactNode }) {
220+ const colors = useColors();
221+ return (
222+ <View style={[styles.center, { gap: space.sm, padding: space.xl }]}>
223+ {Icon ? <Icon size={28} color={colors.faint} /> : null}
224+ <Text size="heading" style={{ textAlign: "center" }}>
225+ {title}
226+ </Text>
227+ {body ? (
228+ <Text tone="muted" style={{ textAlign: "center" }}>
229+ {body}
230+ </Text>
231+ ) : null}
232+ {action}
233+ </View>
234+ );
235+}
236+
237+/** What went wrong, in words, with a way to try again. */
238+export function ErrorView({ error, retry }: { error: unknown; retry?: () => void }) {
239+ const message = error instanceof Error ? error.message : "Something went wrong.";
240+ const title = error instanceof ApiError && error.code === "website_refused" ? "Chat needs another sign-in" : "Could not load this";
241+ return <Empty title={title} body={message} action={retry ? <Button title="Try again" variant="secondary" onPress={retry} /> : null} />;
242+}
243+
244+const styles = StyleSheet.create({
245+ padded: { padding: space.lg, gap: space.lg },
246+ button: {
247+ minHeight: 46,
248+ borderRadius: radius.md,
249+ borderWidth: 1,
250+ paddingHorizontal: space.lg,
251+ flexDirection: "row",
252+ alignItems: "center",
253+ justifyContent: "center",
254+ gap: space.sm,
255+ },
256+ input: { minHeight: 46, borderRadius: radius.md, borderWidth: 1, paddingHorizontal: space.md, fontSize: 15 },
257+ row: {
258+ flexDirection: "row",
259+ alignItems: "center",
260+ gap: space.md,
261+ paddingHorizontal: space.lg,
262+ paddingVertical: space.md,
263+ borderBottomWidth: StyleSheet.hairlineWidth,
264+ },
265+ sectionHeader: { paddingHorizontal: space.lg, paddingTop: space.lg, paddingBottom: space.sm },
266+ badge: { borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 },
267+ agentMarker: { position: "absolute", right: -2, bottom: -2, width: 10, height: 10, borderRadius: 3, borderWidth: 2 },
268+ center: { flex: 1, alignItems: "center", justifyContent: "center" },
269+});
+167−0
1+import type { ChatLiveEvent, ChatMessage, ChatSidebar, MessagePage } from "@g1t/contracts";
2+import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3+import { useEffect, useState } from "react";
4+
5+import type { Client } from "./client";
6+import { socketUrl } from "./server";
7+import { useSignedIn } from "./session";
8+
9+/**
10+ * Chat, through the site's routes, as the site's own Chat pages use them
11+ * (apps/web/app/routes/workspace/chat/api.ts):
12+ *
13+ * - `GET /<workspace>/-/chat/api`: the sidebar.
14+ * - `GET /<workspace>/-/chat/api?channel=<id>&before=<id>`: a page of messages.
15+ * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: post, read, react.
16+ * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens, live.
17+ * A socket cannot carry a header, so it asks `GET /-/live/ticket` for a
18+ * ticket first (apps/web/app/lib/socket-ticket.ts).
19+ */
20+
21+const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`;
22+
23+export function useChatSidebar() {
24+ const { client, workspace } = useSignedIn();
25+ const slug = workspace?.slug ?? "";
26+ return useQuery({
27+ queryKey: ["chat", slug, "sidebar"],
28+ queryFn: () => client.site<ChatSidebar>(chatPath(slug)),
29+ enabled: !!slug,
30+ });
31+}
32+
33+export function useMessages(channelId: string, thread?: string | null) {
34+ const { client, workspace } = useSignedIn();
35+ const slug = workspace?.slug ?? "";
36+ return useInfiniteQuery({
37+ queryKey: ["chat", slug, "messages", channelId, thread ?? null],
38+ initialPageParam: null as string | null,
39+ queryFn: ({ pageParam }) => {
40+ const query = new URLSearchParams({ channel: channelId, limit: "60" });
41+ if (thread) query.set("thread", thread);
42+ if (pageParam) query.set("before", pageParam);
43+ return client.site<MessagePage>(`${chatPath(slug)}?${query}`);
44+ },
45+ getNextPageParam: (page) => page.older,
46+ enabled: !!slug && !!channelId,
47+ });
48+}
49+
50+/** Sends a message; it appears through the live socket, or on the next read. */
51+export function usePostMessage(channelId: string, thread?: string | null) {
52+ const { client, workspace } = useSignedIn();
53+ const queries = useQueryClient();
54+ const slug = workspace?.slug ?? "";
55+ return useMutation({
56+ mutationFn: (body: string) =>
57+ client.site<ChatMessage>(chatPath(slug), {
58+ method: "POST",
59+ body: JSON.stringify({ intent: "post", channel_id: channelId, body, thread_root: thread ?? null }),
60+ }),
61+ onSuccess: (message) => addMessage(queries, slug, message),
62+ });
63+}
64+
65+/** Marks a channel read up to a message. */
66+export function markRead(client: Client, workspace: string, channelId: string, messageId: string) {
67+ return client
68+ .site(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent: "read", channel_id: channelId, id: messageId }) })
69+ .catch(() => undefined);
70+}
71+
72+type Pages = InfiniteData<MessagePage, string | null>;
73+
74+/** Puts a message into the newest page of its channel's cache, once. */
75+function addMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) {
76+ queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) => {
77+ if (!data) return data;
78+ if (data.pages.some((page) => page.messages.some((one) => one.id === message.id))) return data;
79+ const [newest, ...rest] = data.pages;
80+ return { ...data, pages: [{ ...newest, messages: [...newest.messages, message] }, ...rest] };
81+ });
82+ void queries.invalidateQueries({ queryKey: ["chat", workspace, "sidebar"] });
83+}
84+
85+function replaceMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) {
86+ queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) =>
87+ data
88+ ? { ...data, pages: data.pages.map((page) => ({ ...page, messages: page.messages.map((one) => (one.id === message.id ? message : one)) })) }
89+ : data,
90+ );
91+}
92+
93+export type LiveStatus = "connecting" | "live" | "offline";
94+
95+/**
96+ * Keeps a channel's messages live while its screen is open: opens the
97+ * channel's socket with a ticket, applies what it says to the cache, and
98+ * opens it again (with a new ticket) when it drops.
99+ */
100+export function useChannelLive(channelId: string): LiveStatus {
101+ const { client, workspace } = useSignedIn();
102+ const queries = useQueryClient();
103+ const slug = workspace?.slug ?? "";
104+ const [status, setStatus] = useState<LiveStatus>("connecting");
105+
106+ useEffect(() => {
107+ if (!slug || !channelId) return;
108+ let socket: WebSocket | null = null;
109+ let closed = false;
110+ let retry: ReturnType<typeof setTimeout> | null = null;
111+ let attempts = 0;
112+
113+ const open = async () => {
114+ setStatus("connecting");
115+ const path = `/${encodeURIComponent(slug)}/-/chat/live`;
116+ try {
117+ const token = await client.token();
118+ const response = await fetch(`${client.server.site}/-/live/ticket?path=${encodeURIComponent(path)}`, {
119+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
120+ });
121+ if (!response.ok) throw new Error(`ticket ${response.status}`);
122+ const { ticket } = (await response.json()) as { ticket: string };
123+ if (closed) return;
124+ const query = new URLSearchParams({ channel: channelId, ticket });
125+ socket = new WebSocket(`${socketUrl(client.server, path)}?${query}`);
126+ } catch {
127+ return again();
128+ }
129+ socket.onopen = () => {
130+ attempts = 0;
131+ setStatus("live");
132+ };
133+ socket.onmessage = (event) => {
134+ let live: ChatLiveEvent;
135+ try {
136+ live = JSON.parse(String(event.data)) as ChatLiveEvent;
137+ } catch {
138+ return;
139+ }
140+ if (live.type === "message.created") addMessage(queries, slug, live.message);
141+ else if (live.type === "message.updated") replaceMessage(queries, slug, live.message);
142+ else if (live.type === "message.deleted" || live.type.startsWith("reaction.")) {
143+ void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] });
144+ }
145+ };
146+ socket.onclose = () => {
147+ if (!closed) again();
148+ };
149+ };
150+
151+ const again = () => {
152+ setStatus("offline");
153+ if (closed) return;
154+ attempts += 1;
155+ retry = setTimeout(open, Math.min(30_000, 1_000 * 2 ** Math.min(attempts, 5)));
156+ };
157+
158+ void open();
159+ return () => {
160+ closed = true;
161+ if (retry) clearTimeout(retry);
162+ socket?.close();
163+ };
164+ }, [client, queries, slug, channelId]);
165+
166+ return status;
167+}
+180−0
1+import { type Credentials, saveCredentials } from "./credentials";
2+import { SignedOut, refresh } from "./oauth";
3+import type { Server } from "./server";
4+
5+/**
6+ * Every request the app makes, with its credentials: `api` for the REST
7+ * API (api.g1t.sh), `site` for the site's own JSON routes, which Chat
8+ * lives behind (`/<workspace>/-/chat/api`). Both take
9+ * `Authorization: Bearer g1t_…`.
10+ *
11+ * An OAuth access token is refreshed a minute before it expires, and on a
12+ * 401 once; requests that need a refresh at the same time share it,
13+ * because a refresh token works once.
14+ */
15+
16+export type ErrorCode =
17+ /** Signed out: the session is over and the app returns to sign-in. */
18+ | "signed_out"
19+ /**
20+ * The site refused the token: an OAuth token, or a personal one without
21+ * "Use the website as you". The API still works; Chat does not.
22+ */
23+ | "website_refused"
24+ | "not_found"
25+ | "forbidden"
26+ | "offline"
27+ | "failed";
28+
29+export class ApiError extends Error {
30+ constructor(
31+ readonly code: ErrorCode,
32+ message: string,
33+ readonly status = 0,
34+ ) {
35+ super(message);
36+ }
37+}
38+
39+export type Session = {
40+ server: Server;
41+ credentials: Credentials;
42+ /** Told when credentials change (a refresh) or end (signed out). */
43+ onCredentials: (next: Credentials | null) => void;
44+};
45+
46+const REFRESH_EARLY_MS = 60_000;
47+
48+export class Client {
49+ private refreshing: Promise<Credentials> | null = null;
50+
51+ constructor(private session: Session) {}
52+
53+ get server(): Server {
54+ return this.session.server;
55+ }
56+
57+ get credentials(): Credentials {
58+ return this.session.credentials;
59+ }
60+
61+ /** A REST API call: `client.api<Repo[]>("/repos")`. */
62+ api<T>(path: string, init?: RequestInit): Promise<T> {
63+ return this.json<T>(this.session.server.api + path, init, "api");
64+ }
65+
66+ /**
67+ * One of the site's JSON routes. They answer `{ ok, value }` or
68+ * `{ ok: false, error }`, the services' own shape, which this unwraps.
69+ */
70+ async site<T>(path: string, init?: RequestInit): Promise<T> {
71+ const answer = await this.json<{ ok: true; value: T } | { ok: false; error: { code: string; message: string } }>(
72+ this.session.server.site + path,
73+ init,
74+ "site",
75+ );
76+ if (!answer.ok) {
77+ const code: ErrorCode = answer.error.code === "not_found" ? "not_found" : answer.error.code === "forbidden" ? "forbidden" : "failed";
78+ throw new ApiError(code, answer.error.message);
79+ }
80+ return answer.value;
81+ }
82+
83+ /** The current access token, refreshed first when it is about to expire. */
84+ async token(): Promise<string> {
85+ const credentials = this.session.credentials;
86+ if (credentials.kind === "oauth" && credentials.expiresAt - REFRESH_EARLY_MS < Date.now()) {
87+ return (await this.refreshOnce()).accessToken;
88+ }
89+ return credentials.accessToken;
90+ }
91+
92+ private refreshOnce(): Promise<Credentials> {
93+ const credentials = this.session.credentials;
94+ if (credentials.kind !== "oauth") return Promise.resolve(credentials);
95+ this.refreshing ??= refresh(this.session.server, credentials)
96+ .then(async (next) => {
97+ this.session = { ...this.session, credentials: next };
98+ await saveCredentials(next);
99+ this.session.onCredentials(next);
100+ return next;
101+ })
102+ .catch((error) => {
103+ if (error instanceof SignedOut) this.session.onCredentials(null);
104+ throw error instanceof SignedOut ? new ApiError("signed_out", error.message, 401) : error;
105+ })
106+ .finally(() => {
107+ this.refreshing = null;
108+ });
109+ return this.refreshing;
110+ }
111+
112+ private async json<T>(url: string, init: RequestInit | undefined, target: "api" | "site", retried = false): Promise<T> {
113+ const token = await this.token();
114+ let response: Response;
115+ try {
116+ response = await fetch(url, {
117+ ...init,
118+ headers: {
119+ accept: "application/json",
120+ ...(init?.body ? { "content-type": "application/json" } : {}),
121+ ...init?.headers,
122+ authorization: `Bearer ${token}`,
123+ },
124+ });
125+ } catch {
126+ throw new ApiError("offline", "Could not reach g1t. Check your connection.");
127+ }
128+ if (response.status === 401) {
129+ // An expired or revoked OAuth token gets one refresh; the site refusing
130+ // a token it will never take is not that.
131+ const refusedBySite = target === "site" && (await this.siteRefusesToken(response));
132+ if (refusedBySite) {
133+ throw new ApiError(
134+ "website_refused",
135+ this.session.credentials.kind === "oauth"
136+ ? "Chat is not open to the app's sign-in on this g1t yet. Sign in with an access token that can use the website, from You → Sign in another way."
137+ : "This access token cannot use the website. Turn on “Use the website as you” for it in Settings → Access tokens.",
138+ 401,
139+ );
140+ }
141+ if (!retried && this.session.credentials.kind === "oauth") {
142+ await this.refreshOnce();
143+ return this.json<T>(url, init, target, true);
144+ }
145+ this.session.onCredentials(null);
146+ throw new ApiError("signed_out", "You were signed out. Sign in again.", 401);
147+ }
148+ if (response.status === 404) throw new ApiError("not_found", "Not found.", 404);
149+ if (response.status === 403) throw new ApiError("forbidden", await messageOf(response, "You do not have access to this."), 403);
150+ if (!response.ok) throw new ApiError("failed", await messageOf(response, `g1t answered ${response.status}.`), response.status);
151+ return (await response.json()) as T;
152+ }
153+
154+ /**
155+ * Whether a 401 from the site is it refusing the token itself (its
156+ * `WWW-Authenticate` names `invalid_token`; apps/web lib/website-token.ts).
157+ * The token may still be good for the API: check before signing out.
158+ */
159+ private async siteRefusesToken(response: Response): Promise<boolean> {
160+ if (!/invalid_token/.test(response.headers.get("www-authenticate") ?? "")) return false;
161+ try {
162+ const check = await fetch(`${this.session.server.api}/user`, {
163+ headers: { authorization: `Bearer ${this.session.credentials.accessToken}` },
164+ });
165+ return check.ok;
166+ } catch {
167+ return false;
168+ }
169+ }
170+}
171+
172+async function messageOf(response: Response, fallback: string): Promise<string> {
173+ try {
174+ const body = (await response.clone().json()) as { message?: string; error?: { message?: string }; error_description?: string };
175+ return body.message ?? body.error?.message ?? body.error_description ?? fallback;
176+ } catch {
177+ const text = await response.text().catch(() => "");
178+ return text.trim().slice(0, 300) || fallback;
179+ }
180+}
+167−0
1+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
3+import { useSignedIn } from "./session";
4+
5+/**
6+ * Code, through the REST API (docs.g1t.sh/reference/api/). The API
7+ * answers in snake_case, so these are its shapes, not the camelCase ones
8+ * in @g1t/contracts that the services use among themselves. Only the
9+ * fields the app shows are named.
10+ */
11+
12+export type ApiUser = { id: string; username: string; display_username?: string; kind?: "user" | "workspace" | "agent" | "system" };
13+
14+export type ApiRepo = {
15+ id: string;
16+ namespace: string;
17+ name: string;
18+ description: string | null;
19+ is_private: boolean;
20+ default_branch: string;
21+ created_at: string;
22+ archived_at?: string | null;
23+};
24+
25+export type ApiIssue = {
26+ id: string;
27+ number: number;
28+ title: string;
29+ body: string;
30+ labels: string[];
31+ state: "open" | "closed";
32+ reason: "completed" | "not_planned" | null;
33+ author: ApiUser;
34+ created_at: string;
35+ updated_at: string;
36+ comment_count: number;
37+ pull_count: number;
38+};
39+
40+export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored" | null;
41+
42+export type ApiPull = {
43+ id: string;
44+ number: number;
45+ issue: number | null;
46+ title: string;
47+ body: string | null;
48+ agent: string;
49+ status: "draft" | "open" | "merged" | "closed";
50+ branch: string | null;
51+ base: string;
52+ check_status: CheckStatus;
53+ files: { path: string; additions: number; deletions: number }[];
54+ reviewers: string[];
55+ labels: string[];
56+ author: ApiUser;
57+ created_at: string;
58+ updated_at: string;
59+ merged_at: string | null;
60+};
61+
62+export type ApiComment = {
63+ id: string;
64+ kind: "comment" | "event";
65+ author: ApiUser;
66+ body: string;
67+ path: string | null;
68+ line: number | null;
69+ verdict: string | null;
70+ created_at: string;
71+};
72+
73+export type ApiChanges = {
74+ base: string;
75+ head: string;
76+ truncated: boolean;
77+ files: {
78+ path: string;
79+ status: "added" | "modified" | "deleted";
80+ additions: number;
81+ deletions: number;
82+ binary: boolean;
83+ hunks?: { lines: { kind: "context" | "add" | "delete"; old: number | null; new: number | null; text: string }[] }[];
84+ }[];
85+};
86+
87+const repoPath = (owner: string, name: string) => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
88+
89+/** Repositories you can see, newest first (at most 50), in the workspace chosen. */
90+export function useRepos(search: string) {
91+ const { client, workspace } = useSignedIn();
92+ return useQuery({
93+ queryKey: ["code", "repos", search],
94+ queryFn: () => client.api<ApiRepo[]>(`/repos${search ? `?q=${encodeURIComponent(search)}` : ""}`),
95+ select: (repos) => (workspace ? repos.filter((repo) => repo.namespace.toLowerCase() === workspace.slug.toLowerCase()) : repos),
96+ });
97+}
98+
99+export function useRepo(owner: string, name: string) {
100+ const { client } = useSignedIn();
101+ return useQuery({ queryKey: ["code", "repo", owner, name], queryFn: () => client.api<ApiRepo>(repoPath(owner, name)) });
102+}
103+
104+export function usePulls(owner: string, name: string, state: "open" | "closed" | "all") {
105+ const { client } = useSignedIn();
106+ return useQuery({
107+ queryKey: ["code", "pulls", owner, name, state],
108+ queryFn: () => client.api<ApiPull[]>(`${repoPath(owner, name)}/pulls?state=${state}`),
109+ });
110+}
111+
112+export function useIssues(owner: string, name: string, state: "open" | "closed" | "all") {
113+ const { client } = useSignedIn();
114+ return useQuery({
115+ queryKey: ["code", "issues", owner, name, state],
116+ queryFn: () => client.api<ApiIssue[]>(`${repoPath(owner, name)}/issues?state=${state}`),
117+ });
118+}
119+
120+export function usePull(owner: string, name: string, number: number) {
121+ const { client } = useSignedIn();
122+ return useQuery({
123+ queryKey: ["code", "pull", owner, name, number],
124+ queryFn: () =>
125+ client.api<{ pull: ApiPull; issue: ApiIssue | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`),
126+ });
127+}
128+
129+export function usePullChanges(owner: string, name: string, number: number, enabled: boolean) {
130+ const { client } = useSignedIn();
131+ return useQuery({
132+ queryKey: ["code", "pull-changes", owner, name, number],
133+ queryFn: () => client.api<ApiChanges>(`${repoPath(owner, name)}/pulls/${number}/changes`),
134+ enabled,
135+ });
136+}
137+
138+export function useIssue(owner: string, name: string, number: number) {
139+ const { client } = useSignedIn();
140+ return useQuery({
141+ queryKey: ["code", "issue", owner, name, number],
142+ queryFn: () =>
143+ client.api<{ issue: ApiIssue; pulls: ApiPull[]; comments: ApiComment[] }>(`${repoPath(owner, name)}/issues/${number}`),
144+ });
145+}
146+
147+/** Comments on an issue (`POST /repos/{owner}/{name}/issues/{number}/comments`). */
148+export function useComment(owner: string, name: string, number: number) {
149+ const { client } = useSignedIn();
150+ const queries = useQueryClient();
151+ return useMutation({
152+ mutationFn: (body: string) =>
153+ client.api<ApiComment>(`${repoPath(owner, name)}/issues/${number}/comments`, { method: "POST", body: JSON.stringify({ body }) }),
154+ onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "issue", owner, name, number] }),
155+ });
156+}
157+
158+/** Reviews a pull request (`POST /repos/{owner}/{name}/pulls/{number}/reviews`). */
159+export function useReview(owner: string, name: string, number: number) {
160+ const { client } = useSignedIn();
161+ const queries = useQueryClient();
162+ return useMutation({
163+ mutationFn: (review: { verdict: "approve" | "request_changes"; body: string }) =>
164+ client.api<ApiComment>(`${repoPath(owner, name)}/pulls/${number}/reviews`, { method: "POST", body: JSON.stringify(review) }),
165+ onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "pull", owner, name, number] }),
166+ });
167+}
+61−0
1+import * as SecureStore from "expo-secure-store";
2+
3+import { HOSTED, type Server } from "./server";
4+
5+/**
6+ * What the app keeps between launches, in the phone's keystore (Android
7+ * Keystore, the iOS keychain): the server, and how it is signed in.
8+ *
9+ * Two ways to be signed in:
10+ * - `oauth`: the app's own sign-in (lib/oauth.ts). An access token that
11+ * lasts 30 days and a refresh token that works once and returns the
12+ * next pair, as every OAuth client of g1t's gets.
13+ * - `token`: a personal access token pasted in, for a g1t whose OAuth
14+ * tokens do not yet open Chat (see the README) or for testing. One with
15+ * "Use the website as you" turned on opens everything the app shows.
16+ */
17+export type Credentials =
18+ | {
19+ kind: "oauth";
20+ accessToken: string;
21+ refreshToken: string;
22+ /** Epoch milliseconds. */
23+ expiresAt: number;
24+ /** The scopes the person granted, or `*` for full access. */
25+ scope: string;
26+ }
27+ | { kind: "token"; accessToken: string };
28+
29+const SERVER_KEY = "g1t.server";
30+const CREDENTIALS_KEY = "g1t.credentials";
31+
32+async function readJson<T>(key: string): Promise<T | null> {
33+ try {
34+ const text = await SecureStore.getItemAsync(key);
35+ return text ? (JSON.parse(text) as T) : null;
36+ } catch {
37+ // Unreadable (a restore to another phone, a keystore reset): start over.
38+ return null;
39+ }
40+}
41+
42+export async function loadServer(): Promise<Server> {
43+ const saved = await readJson<Server>(SERVER_KEY);
44+ return saved?.site && saved?.api ? saved : HOSTED;
45+}
46+
47+export async function saveServer(server: Server): Promise<void> {
48+ await SecureStore.setItemAsync(SERVER_KEY, JSON.stringify(server));
49+}
50+
51+export async function loadCredentials(): Promise<Credentials | null> {
52+ return readJson<Credentials>(CREDENTIALS_KEY);
53+}
54+
55+export async function saveCredentials(credentials: Credentials): Promise<void> {
56+ await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials));
57+}
58+
59+export async function clearCredentials(): Promise<void> {
60+ await SecureStore.deleteItemAsync(CREDENTIALS_KEY);
61+}
+58−0
1+import { connectorPath, connectorsFor } from "@g1t/contracts/connectors";
2+import { CUSTOMIZE, type CustomizeSection, customizePath } from "@g1t/contracts/customize";
3+import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
4+import { FOUNDATIONAL_SKILLS } from "@g1t/contracts/skills";
5+
6+/**
7+ * Customize on the phone: Skills, Connectors and Plugins
8+ * (packages/contracts/src/customize.ts). What is listed comes from the
9+ * catalogs every g1t ships with; a workspace's own library, connections
10+ * and installs are read on the site for now, and added here once the API
11+ * serves them.
12+ */
13+
14+export type CustomizeItem = {
15+ id: string;
16+ name: string;
17+ description: string;
18+ /** Available, Soon, or what else a listing says about itself. */
19+ status: "available" | "soon" | "built-in";
20+ /** A group to list it under. */
21+ group: string;
22+ /** The site page that sets it up, for this workspace. */
23+ sitePath: string;
24+};
25+
26+export function customizeItems(section: CustomizeSection, workspace: string): CustomizeItem[] {
27+ const fallback = customizePath(section, workspace);
28+ if (section === "skills") {
29+ return FOUNDATIONAL_SKILLS.map((skill) => ({
30+ id: skill.id,
31+ name: skill.name,
32+ description: skill.description,
33+ status: "built-in" as const,
34+ group: "In every agent",
35+ sitePath: fallback,
36+ }));
37+ }
38+ if (section === "connectors") {
39+ return connectorsFor("workspace").map((connector) => ({
40+ id: connector.id,
41+ name: connector.name,
42+ description: connector.description,
43+ status: connector.status,
44+ group: connector.status === "available" ? "Connect now" : "Coming",
45+ sitePath: connector.href ? connectorPath(connector.href, workspace) : `${fallback}/${encodeURIComponent(connector.id)}`,
46+ }));
47+ }
48+ return FIRST_PARTY_EXTENSIONS.map((extension) => ({
49+ id: extension.id,
50+ name: extension.name,
51+ description: extension.tagline,
52+ status: extension.status,
53+ group: `${extension.publisher.name} · ${extension.category}`,
54+ sitePath: `${fallback}/${encodeURIComponent(extension.id)}`,
55+ }));
56+}
57+
58+export { CUSTOMIZE, customizePath, type CustomizeSection };
+37−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { routeFor, sitePathOf } from "./links.ts";
5+
6+test("a g1t:// link names a site path, as on the desktop", () => {
7+ assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1");
8+ assert.equal(sitePathOf("g1t:///acme/rocket/pull/3?tab=files"), "/acme/rocket/pull/3?tab=files");
9+ assert.equal(sitePathOf("https://g1t.sh/acme"), null);
10+});
11+
12+test("Chat's pages open the app's Chat", () => {
13+ assert.equal(routeFor("/acme/-/chat"), "/chat?workspace=acme");
14+ assert.equal(routeFor("/acme/-/chat/general"), "/chat/general?workspace=acme");
15+ assert.equal(routeFor("/acme/-/chat/dm/chn_1"), "/chat/chn_1?workspace=acme");
16+ assert.equal(routeFor("/acme/-/chat/browse"), null);
17+});
18+
19+test("Code's pages open the app's Code", () => {
20+ assert.equal(routeFor("/acme/rocket"), "/code/acme/rocket");
21+ assert.equal(routeFor("/acme/rocket/pull/14"), "/code/acme/rocket/pull/14");
22+ assert.equal(routeFor("/acme/rocket/issues/12"), "/code/acme/rocket/issues/12");
23+ assert.equal(routeFor("/acme/-/overview"), "/code?workspace=acme");
24+ assert.equal(routeFor("/acme/rocket/pull/14/files"), null);
25+ assert.equal(routeFor("/acme/rocket/issues/new"), null);
26+});
27+
28+test("the Marketplace, integrations and skills open Customize", () => {
29+ assert.equal(routeFor("/acme/-/marketplace/integrations"), "/customize?workspace=acme");
30+ assert.equal(routeFor("/acme/-/agents/skills"), "/customize?workspace=acme");
31+});
32+
33+test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => {
34+ assert.equal(routeFor("/oauth/callback?code=c&state=s"), "/oauth/callback?code=c&state=s");
35+ assert.equal(routeFor("/settings/tokens"), null);
36+ assert.equal(routeFor("/"), "/");
37+});
+72−0
1+/**
2+ * Links into the app. A `g1t://` link names a site path, as on the desktop
3+ * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is
4+ * the page `/acme/-/chat/dm/chn_1`. The app has screens for some of the
5+ * site's pages; `routeFor` says which screen shows a site path, and the
6+ * rest open on the site.
7+ *
8+ * Kept free of React Native imports so `npm test` runs it under Node.
9+ */
10+
11+/** Where the app's OAuth sign-in comes back to. */
12+export const OAUTH_CALLBACK_PATH = "/oauth/callback";
13+
14+/** Path segments a workspace or account can never be named (the site's own pages). */
15+const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]);
16+
17+/**
18+ * The app's screen for a site path, or null when the app has none and the
19+ * page should open on the site.
20+ */
21+export function routeFor(sitePath: string): string | null {
22+ const [pathname, query = ""] = sitePath.split("?", 2);
23+ const parts = pathname.split("/").filter(Boolean).map(decodeURIComponentSafe);
24+ const keep = query ? `?${query}` : "";
25+ if (parts.length === 0) return "/";
26+ if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep;
27+ const [owner, second, third, fourth, fifth] = parts;
28+ if (NOT_OWNERS.has(owner.toLowerCase())) return null;
29+ const workspace = `workspace=${encodeURIComponent(owner)}`;
30+
31+ // A workspace's own pages: /<workspace>/-/<page>.
32+ if (second === "-") {
33+ if (third === "chat") {
34+ if (!fourth) return `/chat?${workspace}`;
35+ if (fourth === "dm" && fifth) return `/chat/${encodeURIComponent(fifth)}?${workspace}`;
36+ if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`;
37+ return null;
38+ }
39+ if (third === "overview" && !fourth) return `/code?${workspace}`;
40+ if (third === "marketplace" || third === "integrations" || (third === "agents" && fourth === "skills")) {
41+ return `/customize?${workspace}`;
42+ }
43+ return null;
44+ }
45+
46+ // A repository's pages: /<owner>/<repo>[/pull/<n> | /issues/<n>].
47+ if (!second) return null;
48+ const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`;
49+ if (!third) return repo;
50+ if (third === "pull" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/pull/${fourth}`;
51+ if (third === "issues" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/issues/${fourth}`;
52+ return null;
53+}
54+
55+/**
56+ * The site path a `g1t://` link names, or null for any other link.
57+ * `g1t://acme/-/chat?x` is `/acme/-/chat?x`.
58+ */
59+export function sitePathOf(link: string): string | null {
60+ const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link);
61+ if (!match) return null;
62+ const path = `/${match[1].replace(/^\/+/, "")}`;
63+ return path + (match[2] ?? "");
64+}
65+
66+function decodeURIComponentSafe(part: string): string {
67+ try {
68+ return decodeURIComponent(part);
69+ } catch {
70+ return part;
71+ }
72+}
+173−0
1+import { encodeOAuthClient } from "@g1t/contracts/oauth";
2+import * as AuthSession from "expo-auth-session";
3+import * as WebBrowser from "expo-web-browser";
4+import { Platform } from "react-native";
5+
6+import type { Credentials } from "./credentials";
7+import { OAUTH_CALLBACK_PATH } from "./links";
8+import type { Server } from "./server";
9+
10+/**
11+ * Signing in with g1t's OAuth 2.1, as any public client does
12+ * (docs.g1t.sh/guides/authentication/#signing-in-with-oauth): the
13+ * authorization code flow with PKCE (S256), no client secret, and a client
14+ * id that encodes the app's name and where it is sent back, so nothing is
15+ * registered first.
16+ *
17+ * The person approves on the site, in the system's browser, which keeps
18+ * their g1t session (and passkeys, password managers, two-factor) out of
19+ * the app. The site sends them back to `g1t://oauth/callback`.
20+ *
21+ * The app appears in Settings → Connected applications as "g1t for
22+ * Android" or "g1t for iOS", where its access can be narrowed or ended.
23+ */
24+
25+// Lets the browser hand its result back when the app was in the background.
26+WebBrowser.maybeCompleteAuthSession();
27+
28+/**
29+ * What the app asks for: what Chat, Code and Customize show and do. The
30+ * person can untick any of these on the approval page. Chat has no scope:
31+ * see the README's "Chat and OAuth tokens".
32+ */
33+export const SCOPES = [
34+ "account:read",
35+ "workspace:read",
36+ "repo:read",
37+ "code:read",
38+ "issues:read",
39+ "issues:write",
40+ "pull_requests:read",
41+ "pull_requests:write",
42+ "checks:read",
43+ "workflows:read",
44+ "notifications:read",
45+ "notifications:write",
46+ "agents:run",
47+ "memory:read",
48+ "artifacts:read",
49+];
50+
51+/** The name shown on the approval page and in Connected applications. */
52+const CLIENT_NAME = Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android";
53+
54+/**
55+ * Where the site sends the person back: `g1t://oauth/callback` in a build
56+ * of the app, and Expo Go's own address while developing in it.
57+ */
58+export function redirectUri(): string {
59+ return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) });
60+}
61+
62+export function clientId(redirect: string): string {
63+ const id = encodeOAuthClient({ name: CLIENT_NAME, redirectUris: [redirect] });
64+ if (!id) throw new Error(`g1t does not accept ${redirect} as a place to send you back.`);
65+ return id;
66+}
67+
68+type Metadata = {
69+ issuer: string;
70+ authorization_endpoint: string;
71+ token_endpoint: string;
72+ revocation_endpoint?: string;
73+};
74+
75+/** The server's OAuth metadata (RFC 8414), from its API. */
76+async function metadata(server: Server): Promise<Metadata> {
77+ const response = await fetch(`${server.api}/.well-known/oauth-authorization-server`, {
78+ headers: { accept: "application/json" },
79+ });
80+ if (!response.ok) throw new Error(`${server.api} did not say how to sign in (${response.status}).`);
81+ return (await response.json()) as Metadata;
82+}
83+
84+function discovery(meta: Metadata): AuthSession.DiscoveryDocument {
85+ return {
86+ authorizationEndpoint: meta.authorization_endpoint,
87+ tokenEndpoint: meta.token_endpoint,
88+ revocationEndpoint: meta.revocation_endpoint,
89+ };
90+}
91+
92+export class SignInCancelled extends Error {
93+ constructor() {
94+ super("Sign-in was cancelled.");
95+ }
96+}
97+
98+/**
99+ * Opens the site's approval page and, once the person approves, trades the
100+ * code for tokens. Throws {@link SignInCancelled} when they close the page
101+ * or deny.
102+ */
103+export async function signInWithOAuth(server: Server): Promise<Credentials> {
104+ const meta = await metadata(server);
105+ const redirect = redirectUri();
106+ const id = clientId(redirect);
107+ const request = new AuthSession.AuthRequest({
108+ clientId: id,
109+ redirectUri: redirect,
110+ scopes: SCOPES,
111+ usePKCE: true,
112+ codeChallengeMethod: AuthSession.CodeChallengeMethod.S256,
113+ });
114+ const result = await request.promptAsync(discovery(meta));
115+ if (result.type === "cancel" || result.type === "dismiss") throw new SignInCancelled();
116+ if (result.type !== "success") {
117+ const denied = result.type === "error" && result.params.error === "access_denied";
118+ if (denied) throw new SignInCancelled();
119+ throw new Error(result.type === "error" ? (result.params.error_description ?? result.params.error ?? "Sign-in failed.") : "Sign-in failed.");
120+ }
121+ // RFC 9207: the code must come from the server the app asked.
122+ if (result.params.iss && result.params.iss.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) {
123+ throw new Error("The sign-in came back from a different server than the one asked. Try again.");
124+ }
125+ const tokens = await AuthSession.exchangeCodeAsync(
126+ {
127+ clientId: id,
128+ code: result.params.code,
129+ redirectUri: redirect,
130+ extraParams: { code_verifier: request.codeVerifier ?? "" },
131+ },
132+ discovery(meta),
133+ );
134+ return fromTokenResponse(tokens);
135+}
136+
137+function fromTokenResponse(tokens: AuthSession.TokenResponse): Credentials {
138+ if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token.");
139+ return {
140+ kind: "oauth",
141+ accessToken: tokens.accessToken,
142+ refreshToken: tokens.refreshToken,
143+ expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000,
144+ scope: tokens.scope ?? "",
145+ };
146+}
147+
148+/** Raised when the refresh token no longer works: the person must sign in again. */
149+export class SignedOut extends Error {
150+ constructor(message = "You were signed out. Sign in again.") {
151+ super(message);
152+ }
153+}
154+
155+/**
156+ * The next pair of tokens. A refresh token works once: the old access
157+ * token stops working, so callers share one refresh at a time
158+ * (lib/client.ts).
159+ */
160+export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> {
161+ const meta = await metadata(server);
162+ try {
163+ const tokens = await AuthSession.refreshAsync(
164+ { clientId: clientId(redirectUri()), refreshToken: credentials.refreshToken },
165+ discovery(meta),
166+ );
167+ return fromTokenResponse(tokens);
168+ } catch (error) {
169+ // invalid_grant: used, expired or revoked in Connected applications.
170+ if (error instanceof AuthSession.TokenError) throw new SignedOut();
171+ throw error;
172+ }
173+}
+32−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { HOSTED, apiFor, parseOrigin, parseServer, socketUrl } from "./server.ts";
5+
6+test("an origin is all that is kept", () => {
7+ assert.equal(parseOrigin("https://g1t.sh/acme/-/chat?x#y"), "https://g1t.sh");
8+ assert.equal(parseOrigin(" g1t.example.com "), "https://g1t.example.com");
9+ assert.equal(parseOrigin("http://192.168.1.20:5173/login"), "http://192.168.1.20:5173");
10+ assert.equal(parseOrigin("ftp://g1t.sh"), null);
11+ assert.equal(parseOrigin("not a url at all"), null);
12+ assert.equal(parseOrigin(""), null);
13+});
14+
15+test("hosted g1t's API is api.g1t.sh, and a self-hosted one's is api. on its host", () => {
16+ assert.equal(apiFor("https://g1t.sh"), "https://api.g1t.sh");
17+ assert.equal(apiFor("https://g1t.example.com"), "https://api.g1t.example.com");
18+ assert.deepEqual(parseServer("g1t.sh"), HOSTED);
19+});
20+
21+test("an API given by hand wins", () => {
22+ assert.deepEqual(parseServer("http://localhost:5173", "http://localhost:8789/"), {
23+ site: "http://localhost:5173",
24+ api: "http://localhost:8789",
25+ });
26+ assert.equal(parseServer("g1t.sh", "javascript:alert(1)"), null);
27+});
28+
29+test("sockets follow the site's scheme", () => {
30+ assert.equal(socketUrl(HOSTED, "/acme/-/chat/live?channel=chn_1"), "wss://g1t.sh/acme/-/chat/live?channel=chn_1");
31+ assert.equal(socketUrl({ site: "http://localhost:5173", api: "" }, "/-/live"), "ws://localhost:5173/-/live");
32+});
+76−0
1+/**
2+ * Which g1t the app talks to. Hosted g1t is the site `https://g1t.sh` and
3+ * the API `https://api.g1t.sh`; a self-hosted g1t sets its own (SITE_URL
4+ * and API_URL, apps/api/src/addresses.rs), so the app keeps both.
5+ *
6+ * The site is where people sign in and approve the app, and where Chat's
7+ * routes are; the API is the REST API and the OAuth issuer.
8+ *
9+ * Kept free of React Native imports so `npm test` runs it under Node.
10+ */
11+
12+export type Server = {
13+ /** An origin, no trailing slash: `https://g1t.sh`. */
14+ site: string;
15+ /** An origin, no trailing slash: `https://api.g1t.sh`. */
16+ api: string;
17+};
18+
19+export const HOSTED: Server = { site: "https://g1t.sh", api: "https://api.g1t.sh" };
20+
21+/**
22+ * An origin as typed: http or https with a host, `https://` when no scheme
23+ * is given; only the origin is kept. Null when it is not a web address.
24+ * Plain http is accepted for a g1t on this network, as in development.
25+ */
26+export function parseOrigin(text: string): string | null {
27+ const trimmed = text.trim();
28+ if (!trimmed) return null;
29+ const withScheme = trimmed.includes("://") ? trimmed : `https://${trimmed}`;
30+ let url: URL;
31+ try {
32+ url = new URL(withScheme);
33+ } catch {
34+ return null;
35+ }
36+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
37+ if (!url.hostname) return null;
38+ return `${url.protocol}//${url.host}`;
39+}
40+
41+/**
42+ * The API's address for a site, when only the site was given: hosted
43+ * g1t's own, or `api.` in front of a self-hosted site's host, which is
44+ * how deploy/self-host names it.
45+ */
46+export function apiFor(site: string): string {
47+ if (site === HOSTED.site) return HOSTED.api;
48+ const url = new URL(site);
49+ return `${url.protocol}//api.${url.host}`;
50+}
51+
52+/** A server from what was typed: the site, and the API if it was given. */
53+export function parseServer(siteText: string, apiText?: string): Server | null {
54+ const site = parseOrigin(siteText);
55+ if (!site) return null;
56+ if (apiText && apiText.trim()) {
57+ const api = parseOrigin(apiText);
58+ if (!api) return null;
59+ return { site, api };
60+ }
61+ return { site, api: apiFor(site) };
62+}
63+
64+export function isHosted(server: Server): boolean {
65+ return server.site === HOSTED.site && server.api === HOSTED.api;
66+}
67+
68+/** The site's address for a path, such as a page to open in the browser. */
69+export function siteUrl(server: Server, path: string): string {
70+ return server.site + (path.startsWith("/") ? path : `/${path}`);
71+}
72+
73+/** The socket address for one of the site's live paths. */
74+export function socketUrl(server: Server, path: string): string {
75+ return siteUrl(server, path).replace(/^http/, "ws");
76+}
+161−0
1+import type { Membership, User } from "@g1t/contracts";
2+import { useQueryClient } from "@tanstack/react-query";
3+import * as SecureStore from "expo-secure-store";
4+import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5+
6+import { ApiError, Client } from "./client";
7+import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials";
8+import { signInWithOAuth } from "./oauth";
9+import type { Server } from "./server";
10+
11+/**
12+ * Who is signed in, to which g1t, and in which workspace. Everything that
13+ * talks to g1t reads its {@link Client} from here.
14+ */
15+
16+/** `GET /user`: the person, their workspaces, and what the token may do. */
17+export type Me = User & { token?: { token_id: string; scopes?: string[] } };
18+
19+type State =
20+ | { status: "loading"; server: Server | null }
21+ | { status: "signed-out"; server: Server }
22+ | { status: "signed-in"; server: Server; client: Client; me: Me; workspace: Membership | null };
23+
24+type SignedInState = Extract<State, { status: "signed-in" }>;
25+
26+type SessionApi = {
27+ state: State;
28+ /**
29+ * The last signed-in state, kept through signing out: the signed-in
30+ * screens render once more as they leave, and read it then.
31+ */
32+ lastSignedIn: SignedInState | null;
33+ /** Opens the site to approve the app, then signs in. */
34+ signIn: () => Promise<void>;
35+ /** Signs in with a personal access token (`g1t_…`). */
36+ signInWithToken: (token: string) => Promise<void>;
37+ signOut: () => Promise<void>;
38+ /** Points the app at another g1t; signs out of the one before. */
39+ changeServer: (server: Server) => Promise<void>;
40+ /** Switches workspace by slug. */
41+ selectWorkspace: (slug: string) => Promise<void>;
42+};
43+
44+const WORKSPACE_KEY = "g1t.workspace";
45+
46+const SessionContext = createContext<SessionApi | null>(null);
47+
48+export function SessionProvider({ children }: { children: ReactNode }) {
49+ const [state, setState] = useState<State>({ status: "loading", server: null });
50+ const queries = useQueryClient();
51+ const serverRef = useRef<Server | null>(null);
52+ const lastSignedIn = useRef<SignedInState | null>(null);
53+ if (state.status === "signed-in") lastSignedIn.current = state;
54+
55+ const signedOut = useCallback(
56+ async (server: Server) => {
57+ await clearCredentials();
58+ queries.clear();
59+ setState({ status: "signed-out", server });
60+ },
61+ [queries],
62+ );
63+
64+ /** Makes a client for credentials, reads who they are, and signs in. */
65+ const start = useCallback(
66+ async (server: Server, credentials: Credentials) => {
67+ const client = new Client({
68+ server,
69+ credentials,
70+ onCredentials: (next) => {
71+ if (next === null) void signedOut(server);
72+ },
73+ });
74+ const me = await client.api<Me>("/user");
75+ if ((me.kind ?? "user") !== "user") {
76+ throw new ApiError("forbidden", "Sign in as yourself: a workspace's or an agent's token cannot use the app.");
77+ }
78+ const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null);
79+ const workspaces = me.workspaces ?? [];
80+ const workspace = workspaces.find((one) => one.slug === saved) ?? workspaces[0] ?? null;
81+ setState({ status: "signed-in", server, client, me, workspace });
82+ },
83+ [signedOut],
84+ );
85+
86+ useEffect(() => {
87+ void (async () => {
88+ const server = await loadServer();
89+ serverRef.current = server;
90+ const credentials = await loadCredentials();
91+ if (!credentials) return setState({ status: "signed-out", server });
92+ try {
93+ await start(server, credentials);
94+ } catch (error) {
95+ // Offline at launch keeps the credentials for next time; anything
96+ // else (revoked, expired) signs out.
97+ if (error instanceof ApiError && error.code === "offline") {
98+ setState({ status: "signed-out", server });
99+ return;
100+ }
101+ await signedOut(server);
102+ }
103+ })();
104+ }, [start, signedOut]);
105+
106+ const api = useMemo<SessionApi>(() => {
107+ const current = () => serverRef.current ?? state.server;
108+ return {
109+ state,
110+ lastSignedIn: lastSignedIn.current,
111+ signIn: async () => {
112+ const server = current();
113+ if (!server) return;
114+ const credentials = await signInWithOAuth(server);
115+ await saveCredentials(credentials);
116+ await start(server, credentials);
117+ },
118+ signInWithToken: async (token) => {
119+ const server = current();
120+ if (!server) return;
121+ const trimmed = token.trim();
122+ if (!trimmed.startsWith("g1t_")) throw new ApiError("failed", "An access token starts with g1t_.");
123+ const credentials: Credentials = { kind: "token", accessToken: trimmed };
124+ await start(server, credentials);
125+ await saveCredentials(credentials);
126+ },
127+ signOut: async () => {
128+ const server = current();
129+ if (server) await signedOut(server);
130+ },
131+ changeServer: async (server) => {
132+ await saveServer(server);
133+ serverRef.current = server;
134+ await signedOut(server);
135+ },
136+ selectWorkspace: async (slug) => {
137+ if (state.status !== "signed-in") return;
138+ const workspace = state.me.workspaces?.find((one) => one.slug.toLowerCase() === slug.toLowerCase());
139+ if (!workspace || workspace.slug === state.workspace?.slug) return;
140+ await SecureStore.setItemAsync(WORKSPACE_KEY, workspace.slug);
141+ setState({ ...state, workspace });
142+ },
143+ };
144+ }, [state, start, signedOut]);
145+
146+ return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
147+}
148+
149+export function useSession(): SessionApi {
150+ const session = useContext(SessionContext);
151+ if (!session) throw new Error("useSession is used outside SessionProvider.");
152+ return session;
153+}
154+
155+/** The signed-in session, for screens that only show when signed in. */
156+export function useSignedIn() {
157+ const { state, lastSignedIn, ...rest } = useSession();
158+ const signedIn = state.status === "signed-in" ? state : lastSignedIn;
159+ if (!signedIn) throw new Error("This screen needs a signed-in session.");
160+ return { ...signedIn, ...rest };
161+}
+20−0
1+/** "now", "5m", "3h", "2d", or a date, for lists. */
2+export function ago(iso: string, now = Date.now()): string {
3+ const then = Date.parse(iso);
4+ if (Number.isNaN(then)) return "";
5+ const seconds = Math.max(0, Math.round((now - then) / 1000));
6+ if (seconds < 60) return "now";
7+ const minutes = Math.round(seconds / 60);
8+ if (minutes < 60) return `${minutes}m`;
9+ const hours = Math.round(minutes / 60);
10+ if (hours < 24) return `${hours}h`;
11+ const days = Math.round(hours / 24);
12+ if (days < 7) return `${days}d`;
13+ return new Date(then).toLocaleDateString(undefined, { month: "short", day: "numeric" });
14+}
15+
16+/** A message's time of day: "14:05". */
17+export function clock(iso: string): string {
18+ const date = new Date(iso);
19+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
20+}
+10−0
1+import { useColorScheme } from "react-native";
2+
3+import { dark, light, type Palette } from "./tokens";
4+
5+export { fonts, radius, space, type Palette } from "./tokens";
6+
7+/** The palette for the phone's light or dark setting; dark when it has none. */
8+export function useColors(): Palette {
9+ return useColorScheme() === "light" ? light : dark;
10+}
+85−0
1+/**
2+ * g1t's colours, as packages/theme/tokens.css names them, for React
3+ * Native, which cannot read CSS variables. Keep the two in step: a colour
4+ * changed there changes here.
5+ *
6+ * Dark is g1t's default, as on the site; light follows the phone's setting.
7+ */
8+
9+export type Palette = {
10+ bg: string;
11+ surface: string;
12+ raised: string;
13+ line: string;
14+ lineStrong: string;
15+ fg: string;
16+ fgSoft: string;
17+ muted: string;
18+ faint: string;
19+ accent: string;
20+ accentDim: string;
21+ accentLow: string;
22+ /** Text on an accent-filled button. */
23+ onAccent: string;
24+ success: string;
25+ successLow: string;
26+ info: string;
27+ merged: string;
28+ warn: string;
29+ danger: string;
30+};
31+
32+export const dark: Palette = {
33+ bg: "#0f0f11",
34+ surface: "#161618",
35+ raised: "#1e1e21",
36+ line: "#28282c",
37+ lineStrong: "#38383e",
38+ fg: "#ededef",
39+ fgSoft: "#dcdce0",
40+ muted: "#a0a0a8",
41+ faint: "#86868e",
42+ accent: "#b6a8ff",
43+ accentDim: "#8577e0",
44+ accentLow: "#221e38",
45+ onAccent: "#0f0f11",
46+ success: "#86efc4",
47+ successLow: "#10302a",
48+ info: "#8ab4ff",
49+ merged: "#a678f5",
50+ warn: "#ffbd8c",
51+ danger: "#ff8394",
52+};
53+
54+export const light: Palette = {
55+ bg: "#fbfbfa",
56+ surface: "#ffffff",
57+ raised: "#f1f1ef",
58+ line: "#e4e4e1",
59+ lineStrong: "#d2d2ce",
60+ fg: "#18181b",
61+ fgSoft: "#2e2e33",
62+ muted: "#5c5c66",
63+ faint: "#686871",
64+ accent: "#6b52c8",
65+ accentDim: "#9584dc",
66+ accentLow: "#ece8fb",
67+ onAccent: "#ffffff",
68+ success: "#0d7a52",
69+ successLow: "#e2f4ec",
70+ info: "#2160cc",
71+ merged: "#7c3fd1",
72+ warn: "#a84f00",
73+ danger: "#c42a43",
74+};
75+
76+export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 } as const;
77+
78+export const radius = { sm: 6, md: 10, lg: 14, full: 999 } as const;
79+
80+/**
81+ * The site's faces (Hanken Grotesk, Bricolage Grotesque, IBM Plex Mono)
82+ * ship as woff2, which phones cannot load; until TTF copies are bundled
83+ * with expo-font, the system's faces stand in.
84+ */
85+export const fonts = { mono: "monospace" } as const;
+14−0
1+{
2+ "extends": "expo/tsconfig.base",
3+ "compilerOptions": {
4+ "strict": true,
5+ "skipLibCheck": true,
6+ "paths": {
7+ "@/*": ["./src/*"],
8+ "@/assets/*": ["./assets/*"]
9+ }
10+ },
11+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
12+ // Node runs the tests on the files as they are (`npm test`).
13+ "exclude": ["node_modules", "src/**/*.test.ts"]
14+}
+0−0

Binary or large file; its contents are not shown.

+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./skills": "./src/skills.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+15−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { CUSTOMIZE, CUSTOMIZE_SECTIONS, customizePath, sectionOfListing } from "./customize.ts";
5+
6+test("every Marketplace listing has a place in Customize", () => {
7+ assert.equal(sectionOfListing("integration"), "connectors");
8+ assert.equal(sectionOfListing("extension"), "plugins");
9+ for (const section of CUSTOMIZE_SECTIONS) assert.equal(CUSTOMIZE[section].id, section);
10+});
11+
12+test("a section's site path names its workspace", () => {
13+ assert.equal(customizePath("connectors", "acme"), "/acme/-/marketplace/integrations");
14+ assert.equal(customizePath("skills", "a b"), "/a%20b/-/agents/skills");
15+});
+64−0
1+/**
2+ * Customize: one place for what shapes how a workspace's agents work, in
3+ * three parts. It is the Marketplace's successor, and what the phone app
4+ * shows today; the site moves to it in its own change.
5+ *
6+ * - **Skills**: what agents know how to do, in the open SKILL.md format
7+ * (./skill-library.ts): the foundational skills every agent has, and the
8+ * workspace's library.
9+ * - **Connectors**: what agents can reach: the tools a team already uses
10+ * (./connectors.ts). Today's Marketplace integrations.
11+ * - **Plugins**: what adds pages, cards, tools and agent roles to the
12+ * workspace (./marketplace.ts's `ExtensionManifest`). Today's
13+ * Marketplace extensions.
14+ *
15+ * A Marketplace listing (`integration:<id>`, `extension:<id>`) keeps its
16+ * reference; {@link sectionOfListing} says which part shows it. No imports
17+ * beyond types, so it runs anywhere: services, the site, the phone.
18+ */
19+
20+import type { ListingKind } from "./marketplace";
21+
22+export type CustomizeSection = "skills" | "connectors" | "plugins";
23+
24+export const CUSTOMIZE_SECTIONS: readonly CustomizeSection[] = ["skills", "connectors", "plugins"];
25+
26+export type CustomizeSectionInfo = {
27+ id: CustomizeSection;
28+ title: string;
29+ /** One line, for the section's row. */
30+ description: string;
31+ /** Where the site manages it today, with `:workspace` to fill in. */
32+ sitePath: string;
33+};
34+
35+export const CUSTOMIZE: Record<CustomizeSection, CustomizeSectionInfo> = {
36+ skills: {
37+ id: "skills",
38+ title: "Skills",
39+ description: "What agents know how to do: the skills every agent has, and your workspace's library.",
40+ sitePath: "/:workspace/-/agents/skills",
41+ },
42+ connectors: {
43+ id: "connectors",
44+ title: "Connectors",
45+ description: "The tools your team already uses, connected so agents can work with their data.",
46+ sitePath: "/:workspace/-/marketplace/integrations",
47+ },
48+ plugins: {
49+ id: "plugins",
50+ title: "Plugins",
51+ description: "Pages, cards, tools and agent roles for what your team does besides code.",
52+ sitePath: "/:workspace/-/marketplace/extensions",
53+ },
54+};
55+
56+/** The part of Customize that shows a Marketplace listing of this kind. */
57+export function sectionOfListing(kind: ListingKind): CustomizeSection {
58+ return kind === "integration" ? "connectors" : "plugins";
59+}
60+
61+/** A section's site path with its workspace filled in. */
62+export function customizePath(section: CustomizeSection, workspace: string): string {
63+ return CUSTOMIZE[section].sitePath.replace(/:workspace\b/g, encodeURIComponent(workspace));
64+}
+1−0
1515 export * from "./compute";
1616 export * from "./connectors";
1717 export * from "./context";
18+export * from "./customize";
1819 export * from "./d1";
1920 export * from "./datasets";
2021 export * from "./docs";