Repository navigation
feat(mobile): add Shipyard for iPhone #209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { expect, mockOnly, test } from "./fixtures"; | ||
|
|
||
| // Settings › Phone › Phone apps: the Shipyard phone apps a box notifies | ||
| // through the push relay, and removing one. | ||
|
|
||
| test("a phone that gets notifications is listed with its box, and Remove stops them", async ({ app }) => { | ||
| mockOnly("turning phone access on writes to a box"); | ||
| await app.open(); | ||
| const phone = await app.openSettings("phone"); | ||
| const apps = phone.locator("section").filter({ has: app.page.getByRole("heading", { name: "Phone apps", exact: true }) }); | ||
| await expect(apps).toContainText("No phone gets notifications yet"); | ||
|
|
||
| await phone.getByRole("switch", { name: "Phone access on devl" }).click(); | ||
| await expect(apps).toContainText("Acme iPhone"); | ||
| await expect(apps).toContainText("devl · needs you"); | ||
|
|
||
| await apps.getByRole("button", { name: "Remove" }).click(); | ||
| await expect(apps).toContainText("No phone gets notifications yet"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { CopyIcon, EllipsisIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react"; | ||
| import { BellIcon, CopyIcon, EllipsisIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react"; | ||
| import QRCode from "qrcode"; | ||
| import { useCallback, useEffect, useMemo, useState } from "react"; | ||
|
|
||
|
|
@@ -7,16 +7,17 @@ import { Input } from "@/components/ui/input"; | |
| import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu"; | ||
| import { Switch } from "@/components/ui/switch"; | ||
| import { toastManager } from "@/components/ui/toast"; | ||
| import { errorMessage } from "@/lib/format"; | ||
| import { ago, errorMessage } from "@/lib/format"; | ||
| import { BoxError } from "@/components/upgrade-box"; | ||
| import { newNtfyTopic, type PairedBox, pairingLink, phoneApi, type PhoneStatus } from "@/lib/phone"; | ||
| import { newNtfyTopic, type PairedBox, pairingLink, phoneApi, type PhonePush, type PhoneStatus } from "@/lib/phone"; | ||
| import { NONE, useStore } from "@/lib/store"; | ||
| import { cn } from "@/lib/utils"; | ||
| import { ConfirmDialog } from "@/views/settings/confirm"; | ||
| import { Code, SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"; | ||
|
|
||
| // Settings → Phone: turn on each box's phone app, pair a phone with one QR | ||
| // code, and send "needs you" notifications through ntfy. | ||
| // code, see the Shipyard phone apps each box notifies, and send "needs | ||
| // you" notifications through ntfy. | ||
| export function PhoneSection() { | ||
| const client = useStore((s) => s.client); | ||
| const boxes = useStore((s) => s.status?.boxes ?? NONE); | ||
|
|
@@ -112,6 +113,8 @@ export function PhoneSection() { | |
| <PairCode link={link} count={paired.length} /> | ||
| </SettingsGroup> | ||
|
|
||
| <PhoneApps boxes={paired.map((p) => p.name)} phone={phone} onChange={change} /> | ||
|
|
||
| <Notifications boxes={paired.map((p) => p.name)} phone={phone} onChange={change} /> | ||
|
|
||
| <ConfirmDialog | ||
|
|
@@ -153,7 +156,7 @@ function PairCode({ link, count }: { link?: string; count: number }) { | |
| </p> | ||
| <ol className="list-decimal space-y-1 pl-4 text-muted-foreground"> | ||
| <li>Your phone needs the Tailscale app, signed in to the same tailnet as the boxes.</li> | ||
| <li>Scan the code, then add the page to your home screen (Share → Add to Home Screen).</li> | ||
| <li>Scan the code in the Shipyard app on iPhone. Or scan it with the camera, then add the page to your home screen (Share → Add to Home Screen).</li> | ||
| <li>Anyone with this code can answer your agents. Make a new code if it leaks.</li> | ||
| </ol> | ||
| <div> | ||
|
|
@@ -202,7 +205,7 @@ function Notifications({ boxes, phone, onChange }: { boxes: string[]; phone: Rec | |
| title="Notifications" | ||
| description={ | ||
| <> | ||
| Boxes push to an <Code>ntfy</Code> topic when an agent needs you, and the ntfy app shows it on iOS and Android. Tapping it opens that agent here. | ||
| For the web phone app and Android: boxes push to an <Code>ntfy</Code> topic when an agent needs you, and the ntfy app shows it. Tapping it opens that agent in the web app. The Shipyard app on iPhone needs none of this. | ||
| </> | ||
| } | ||
| > | ||
|
|
@@ -238,3 +241,61 @@ function Notifications({ boxes, phone, onChange }: { boxes: string[]; phone: Rec | |
| </SettingsGroup> | ||
| ); | ||
| } | ||
|
|
||
| type PhoneState = PhoneStatus | { error: string; unsupported: true }; | ||
|
|
||
| // PhoneApps lists the Shipyard phone apps the boxes notify, one row per | ||
| // phone with the boxes that notify it. A phone turns this on from its | ||
| // own Boxes tab; removing it here stops every box at once. | ||
| function PhoneApps({ boxes, phone, onChange }: { boxes: string[]; phone: Record<string, PhoneState>; onChange: (box: string, c: Parameters<typeof phoneApi.change>[2]) => Promise<void> }) { | ||
| const apps = new Map<string, { push: PhonePush; boxes: string[] }>(); | ||
| for (const b of boxes) { | ||
| const st = phone[b]; | ||
| for (const d of (st && "push" in st && st.push) || []) { | ||
| const app = apps.get(d.id); | ||
| if (app) { | ||
| app.boxes.push(b); | ||
| if ((d.last_ok ?? "") > (app.push.last_ok ?? "")) app.push = d; | ||
| } else apps.set(d.id, { push: d, boxes: [b] }); | ||
| } | ||
| } | ||
| const ntfyToo = boxes.some((b) => { | ||
| const st = phone[b]; | ||
| return st && "notify" in st && !!st.notify; | ||
| }); | ||
| return ( | ||
| <SettingsGroup title="Phone apps" description="The Shipyard app on these phones gets notifications through Shipyard's push relay: which worktree and box need you, never what an agent wrote."> | ||
| {apps.size === 0 ? ( | ||
| <div className="flex items-center gap-3 py-5 text-muted-foreground text-body"> | ||
| <BellIcon className="size-5 shrink-0" /> | ||
| No phone gets notifications yet. In the Shipyard app, open Boxes and turn notifications on. | ||
| </div> | ||
| ) : ( | ||
| [...apps.values()].map(({ push, boxes: from }) => ( | ||
| <SettingsRow | ||
| key={push.id} | ||
| label={push.name || (push.platform === "ios" ? "iPhone" : "Android phone")} | ||
| description={ | ||
| push.last_error ? ( | ||
| <span className="text-destructive">{push.last_error}</span> | ||
| ) : ( | ||
| `${from.join(", ")} · ${(push.on ?? ["waiting"]).includes("finished") ? "needs you and done" : "needs you"}${push.last_ok ? ` · last sent ${ago(push.last_ok)}` : ""}` | ||
| ) | ||
| } | ||
| > | ||
| <Button | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Every row's Remove button has the same accessible name, so screen-reader users cannot tell which phone each one removes. Add an aria-label that includes the phone's name, following the Prompt for AI agents |
||
| size="xs" | ||
| variant="ghost" | ||
| onClick={async () => { | ||
| for (const b of from) await onChange(b, { remove_push: push.id }); | ||
| }} | ||
| > | ||
| Remove | ||
| </Button> | ||
| </SettingsRow> | ||
| )) | ||
| )} | ||
| {apps.size > 0 && ntfyToo && <p className="py-3 text-muted-foreground text-meta">ntfy is on too, so these phones may get each notification twice. Turn ntfy off below if they only use the app.</p>} | ||
| </SettingsGroup> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -27,6 +27,27 @@ the ntfy app. Boxes then push "Claude Code needs you · shop/checkout on devl" | |||||
| when an agent waits for you (and, if you switch it on, when one finishes its | ||||||
| turn), and tapping it opens that agent in the phone app. | ||||||
|
|
||||||
| ## The Shipyard app for iPhone | ||||||
|
|
||||||
| The Shipyard app does what the web app does, and more: | ||||||
|
|
||||||
| - **Pair** by scanning the same code in the app (Pair), or pasting its link. | ||||||
| - **Needs you** lists every paired box's agents, waiting first, and the runs | ||||||
| waiting at a gate. Boxes with an up-to-date berthd say what changes as it | ||||||
| happens; older ones are read every few seconds. | ||||||
| - **A session** shows its conversation (**Chat**) or its terminal (**Screen**), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Chat is available only when the box advertises transcript support; otherwise the app forces the session to Screen. Qualify this description so users with older boxes know they will not see the conversation. Prompt for AI agents
Suggested change
|
||||||
| the answers it offers as buttons (a permission reads as Allow, Always allow | ||||||
| or Deny), Enter, Stop, and a box for a prompt. | ||||||
| - **Runs** shows a run's gate, to approve, reject or pick an attempt, and its | ||||||
| steps. | ||||||
| - **Notifications** come without ntfy: in Boxes, choose Needs you (or Needs | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This bullet says notifications already work without ntfy, but the 'What works where' section still calls notifications without ntfy a future step that needs HTTPS Web Push. Reconcile the two so the page does not say both. Prompt for AI agents |
||||||
| you and done). Each box then notifies the phone through Shipyard's push | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Boxes without push support cannot send these notifications; the app skips their registration and warns that older berthd versions need updating. Limit this statement to boxes with push support or mention the required update. Prompt for AI agents
Suggested change
|
||||||
| relay (below), and a notification opens its agent or run. Settings → Phone | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Suggested change
|
||||||
| on the laptop lists the phones that get them, and removes one. | ||||||
|
|
||||||
| The app keeps each box's token in the iPhone's keychain, on that phone only. | ||||||
| **Try the demo** on its Pair screen shows made-up boxes, without one. | ||||||
|
|
||||||
| ## How it works | ||||||
|
|
||||||
| Each box serves the phone app itself, on its tailnet address, port 1379. | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files | ||
|
|
||
| # dependencies | ||
| node_modules/ | ||
|
|
||
| # Expo | ||
| .expo/ | ||
| dist/ | ||
| web-build/ | ||
| expo-env.d.ts | ||
|
|
||
| # Native | ||
| .kotlin/ | ||
| *.orig.* | ||
| *.jks | ||
| *.p8 | ||
| *.p12 | ||
| *.key | ||
| *.mobileprovision | ||
|
|
||
| # Metro | ||
| .metro-health-check* | ||
|
|
||
| # debug | ||
| npm-debug.* | ||
| yarn-debug.* | ||
| yarn-error.* | ||
|
|
||
| # macOS | ||
| .DS_Store | ||
| *.pem | ||
|
|
||
| # local env files | ||
| .env*.local | ||
|
|
||
| # typescript | ||
| *.tsbuildinfo | ||
|
|
||
| # generated native folders | ||
| /ios | ||
| /android | ||
|
|
||
| # expo export (pnpm export) | ||
| .expo-export/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # Working on the phone app | ||
|
|
||
| Shipyard for iPhone (Android later, from this code): Expo SDK 58, React Native, expo-router. The repository's own `AGENTS.md` applies too. | ||
|
|
||
| - **Expo changes every SDK.** Before writing code against an Expo or React Native API, read the docs for the SDK in `package.json` (`https://docs.expo.dev/versions/v58.0.0/`, and `https://docs.expo.dev/llms.txt`), not memory. | ||
| - **Add native modules with `npx expo install <pkg>`**, which picks SDK-matched versions. A module with native code needs a new development build (`eas build --profile development`); Expo Go can't load it, nor receive remote notifications. | ||
| - **No `ios/` or `android/`.** They are generated (Continuous Native Generation). Configure native behaviour in `app.config.ts` and config plugins. | ||
| - **Routes live in `src/app/`**; everything else in `src/lib` (no React) and `src/components`. | ||
| - **Shared code is `packages/core`**, imported as `@berth/core/<file>.ts` (tsconfig paths, Metro's `watchFolders`, Jest's `moduleNameMapper`). Code both apps need goes there, by its rules (`packages/core/README.md`). | ||
| - **Boxes are the backend**: each box's phone API on its tailnet address, port 1379 (`docs/reference/phone-api.mdx`, `internal/box/phone.go`, `phonev2.go`). Notifications go through the push relay (`push/`). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This bullet points at files that don't exist on this branch: Prompt for AI agents |
||
| - **The demo** (`src/lib/mock.ts`, "Try the demo") is what App Review sees: keep it working, with acme's made-up data. | ||
|
|
||
| ## Checks | ||
|
|
||
| ```sh | ||
| CI=true pnpm install --frozen-lockfile | ||
| pnpm typecheck && pnpm test && pnpm export # export: Metro bundles the iOS app, no Xcode needed | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # Shipyard for iPhone | ||
|
|
||
| Which agents need you across your boxes, and answering them, from your phone. | ||
| It talks to each box over your tailnet, on the box's phone API, and gets | ||
| notifications through Shipyard's push relay. The guide for people is | ||
| [docs/guides/phone.mdx](../docs/guides/phone.mdx). | ||
|
|
||
| ## Running it | ||
|
|
||
| You need an iPhone with Tailscale, signed in to your boxes' tailnet, and an | ||
| [Expo](https://expo.dev) account. No Mac is needed: EAS builds in the cloud. | ||
|
|
||
| ```sh | ||
| CI=true pnpm install --frozen-lockfile | ||
| npx eas-cli@latest build --profile development --platform ios # once, and after adding a native module | ||
| pnpm start # then open the build on the phone | ||
| ``` | ||
|
|
||
| The phone reaches Metro over the tailnet: start it with | ||
| `REACT_NATIVE_PACKAGER_HOSTNAME=<this machine's tailnet address>`. | ||
|
|
||
| On a box, turn phone access on in Shipyard → Settings → Phone and scan the | ||
| code from the app. **Try the demo** shows made-up boxes without one. | ||
|
|
||
| ## What's where | ||
|
|
||
| | | | | ||
| | --- | --- | | ||
| | `src/app/` | The screens (expo-router): Needs you, Runs, Boxes, a session, a run, Pair | | ||
| | `src/lib/api.ts` | The box's phone API | | ||
| | `src/lib/events.ts` | Following a box's events as they happen | | ||
| | `src/lib/store.ts` | Boxes, their sessions and runs, conversations | | ||
| | `src/lib/push.ts` | Notifications: the relay's key, registering with boxes | | ||
| | `src/lib/mock.ts` | The demo | | ||
| | `../packages/core` | What the desktop app and this one share | | ||
|
|
||
| ## Builds | ||
|
|
||
| `eas.json` has three profiles: `development` (a development client, APNs | ||
| sandbox), `preview` (TestFlight) and `production` (the App Store, APNs | ||
| production). The push relay must hold the same team's APNs key | ||
| (`push/README.md`), with `APNS_TOPIC=dev.berth.phone`. | ||
|
|
||
| ## Checks | ||
|
|
||
| ```sh | ||
| pnpm typecheck && pnpm test && pnpm export | ||
| ``` | ||
|
|
||
| `pnpm export` bundles the iOS app with Metro (no Xcode), which is how CI | ||
| checks that imports from `packages/core` resolve. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: This aggregation can hide a box's push failure: a record with
last_erroris discarded whenever another box has a newerlast_ok, so the grouped row appears healthy. Preserve errors when selecting the representative status, or report failures per box.Prompt for AI agents