Skip to content

feat(mobile): add Shipyard for iPhone - #209

Merged
sean-brydon merged 3 commits into
feat/core-packagefrom
feat/ios-app
Nov 1, 2026
Merged

sean-brydon merged 3 commits into
feat/core-packagefrom
feat/ios-app

Conversation

@pumfleet

@pumfleet pumfleet commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Last of the phone app's PRs. Stacked on #206 (packages/core), so its diff shows only the app; it retargets to main once #206 merges. It uses the box API from #207 and the relay from #208, and still works with older boxes.

mobile/: Expo SDK 58, expo-router, iOS first (Android later from the same code)

  • Pair: scan the existing Settings → Phone QR code in the app, paste its link, or open berth://pair#…. Each box is asked who it is before it's kept.
  • Needs you: every box's agents, waiting first, plus runs at a gate; a badge on the tab.
  • Session: Chat (the conversation, from feat(box): let the phone app read conversations, follow events and get notified #207) or Screen; answer buttons (a permission reads as Allow / Always allow / Deny, keeping the option's number); Enter, Stop and arrow keys; a prompt.
  • Runs: approve, reject, pick an attempt, cancel.
  • Boxes: notifications Off / Needs you / Needs you and done (registers with the relay, then each box), Forget, Pair another box.
  • Live: a version 2 box's event stream while the app is open; older boxes are read every 4 s like the web app. Push covers the background, and a tap opens the session or run, also from a cold start.
  • Storage: tokens in the keychain (AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY).
  • ATS: NSAllowsLocalNetworking plus an exception for ts.net. No NSAllowsArbitraryLoads; raw tailnet addresses aren't subject to ATS.
  • Demo (acme data, no box), for App Review.
  • Shared code comes from @berth/core/* (tsconfig paths, Metro watchFolders, Jest moduleNameMapper).

Desktop: Settings → Phone gets a Phone apps group (the phones the boxes notify, with which boxes, what they hear and when they last got one) with Remove. The pairing steps mention the app, and a note suggests turning ntfy off when a phone uses the app. New spec e2e/phone-apps.spec.ts.

Docs: phone guide section and changelog. The guide links to the relay section added in #207.

Checks:

  • mobile: pnpm typecheck, pnpm test (12), and pnpm export (Metro bundles the iOS app, which proves packages/core resolves).
  • A new mobile CI job runs the same three.
  • The app's full checks and build pass; the new e2e spec passed 3/3.
  • The demo was driven in Chromium at iPhone size through a temporary web export (not committed).

Not verified, needs Apple setup:

  • An EAS development build on a device.
  • ATS for tailnet addresses, short names and *.ts.net.
  • Streaming the event feed over Tailscale on iOS.
  • A real APNs notification through the deployed relay.

Prerequisites:

  • An Apple Developer org account, App ID dev.berth.phone and an APNs key (the same key goes to the relay).
  • An EAS account.
  • A privacy policy URL and review notes for App Review.

Merging after #207/#208: each PR adds its own changelog bullet, CI job and AGENTS.md line in the same places, so expect small additive conflicts.

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

pumfleet and others added 3 commits October 29, 2026 16:10
An Expo app (iOS first, Android later from the same code) that pairs
with boxes from the desktop app's existing QR code and shows which
agents need you across them: a session's conversation or its screen,
the answers it offers, keys and a prompt to send; runs, their gates
and cancelling them. Boxes at the phone API's version 2 stream their
events and send notifications through the push relay; older boxes
are read every few seconds. Tokens stay in the keychain, and the
demo works without a box. Session state, screen and transcript
reading come from packages/core, shared with the desktop app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fail

Reading the notification that opened the app throws where
notifications aren't supported, which stopped the rest of start-up;
it's now caught, as is reading the notification setting. The demo's
boxes are current boxes with a conversation to read, and say they're
made up instead of "Connecting".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings → Phone shows each Shipyard phone app the boxes notify through
the push relay, with which boxes, what it hears and when it last got one,
and Remove stops them on every box. The pairing steps mention the iPhone
app, and ntfy is described as what the web app and Android use. The phone
guide and changelog cover the iPhone app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
berth Ready Ready Preview Oct 29, 2026 9:22pm UTC
berth-docs Ready Ready Preview Oct 29, 2026 9:22pm UTC

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

43 issues found across 44 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="mobile/src/components/transcript.tsx">

<violation number="1" location="mobile/src/components/transcript.tsx:41">
P3: The command line ignores the `error` flag that core sets on a failed command. Color the command line with `c.bad` when `item.error` is true, as the notice case does.</violation>
</file>

<file name="mobile/src/lib/theme.ts">

<violation number="1" location="mobile/src/lib/theme.ts:20">
P3: `idle` (#8a8d92) fails text contrast on the light background at about 3:1. `Pill` uses it as the label colour for idle sessions, so that text is hard to read in light mode. Consider a darker light-mode idle such as #6b6e73 (about 4.7:1 on `bg`), which keeps the dot visible too.</violation>
</file>

<file name="docs/guides/phone.mdx">

<violation number="1" location="docs/guides/phone.mdx:38">
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.</violation>

<violation number="2" location="docs/guides/phone.mdx:43">
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.</violation>

<violation number="3" location="docs/guides/phone.mdx:44">
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.</violation>

<violation number="4" location="docs/guides/phone.mdx:45">
P2: `(below)` points to nothing: this page never describes Shipyard's push relay. Either add a short section on how the relay works, or drop "(below)" and name what the relay sends.</violation>
</file>

<file name="mobile/src/app/(tabs)/runs.tsx">

<violation number="1" location="mobile/src/app/(tabs)/runs.tsx:46">
P2: This empty state shows "No runs on your boxes" before data loads and when every box is unreachable. Guard it with a loading check and surface offline boxes, as `index.tsx` does with `loading` and the `offline` Note list.</violation>
</file>

<file name="mobile/src/app/(tabs)/index.tsx">

<violation number="1" location="mobile/src/app/(tabs)/index.tsx:81">
P2: When a box is unreachable, this empty state still says "No agents are running on your boxes." Those agents are unknown, not absent. Skip the empty state when any box is offline, for example by adding `&& !offline.length`.</violation>
</file>

<file name="mobile/AGENTS.md">

<violation number="1" location="mobile/AGENTS.md:10">
P3: This bullet points at files that don't exist on this branch: `docs/reference/phone-api.mdx`, `phonev2.go`, and `push/`. Agents following `mobile/AGENTS.md` will hit dead references. Confirm they land with #207/#208 before merge, or drop the references until they do.</violation>
</file>

<file name="mobile/src/components/cards.tsx">

<violation number="1" location="mobile/src/components/cards.tsx:15">
P2: `whereOf` puts the session's `title` ahead of the worktree name. A worktree session with no `worktree_title` then shows its prompt-derived title as the headline, and the worktree name disappears. The web phone UI (`phoneui/app.js`) skips `s.title` here, so the two should match. Drop `s.title` from the chain. The `?? ""` on `loc` is also dead, since `loc` is always a string.</violation>
</file>

<file name="mobile/src/app/s/[box]/[session].tsx">

<violation number="1" location="mobile/src/app/s/[box]/[session].tsx:27">
P2: Use the dynamic route param as returned instead of decoding it again; a percent-containing session or run ID can be altered or throw, so the screen then targets the wrong ID. Remove the corresponding extra decode from `RunScreen` too.</violation>

<violation number="2" location="mobile/src/app/s/[box]/[session].tsx:65">
P3: The screen poll keeps running every 2 seconds after the session has ended, even though the screen only shows "This session has ended." Skip the poll when `ended` is true.</violation>

<violation number="3" location="mobile/src/app/s/[box]/[session].tsx:164">
P2: Answer chips have no in-flight guard, so a double tap sends the digit twice. Disable the chips while an answer is in flight, for example by tracking an `answering` state the way `send` uses `sending`.</violation>
</file>

<file name="mobile/src/lib/storage.ts">

<violation number="1" location="mobile/src/lib/storage.ts:39">
P2: `loadBoxes` returns `[]` for any read error, including one bad token among several. Callers then persist that empty view, which drops the other boxes from the stored list and orphans their tokens. Catch errors per box so the other boxes still load, and don't let an empty result overwrite the persisted list.</violation>
</file>

<file name="mobile/src/lib/store.ts">

<violation number="1" location="mobile/src/lib/store.ts:127">
P2: `refresh` writes both `sessions` and `runs` even when only one was re-read. The value for the field not re-read is captured at call time, so a concurrent refresh can revert it when it finishes. Write only the fields that were actually fetched.</violation>

<violation number="2" location="mobile/src/lib/store.ts:217">
P2: Reserve each box's setup before awaiting its refresh. A boxes-list change can start a second `sync` while this one is pending, creating a duplicate feed whose handle is then overwritten and never stopped.</violation>

<violation number="3" location="mobile/src/lib/store.ts:218">
P2: Avoid serializing feed setup behind each box's network refresh. An unreachable box can consume the 15-second request timeout and delay live updates for every box after it in the list.</violation>
</file>

<file name="mobile/src/lib/push.ts">

<violation number="1" location="mobile/src/lib/push.ts:25">
P1: `installId` races on first use. With several boxes, `setPush` calls it in parallel, so each box gets a different id and only the last one is stored. Later "off" and "forget" calls then use the stored id and miss the other boxes' registrations, so those boxes keep sending. Create the id once and reuse the same promise.</violation>

<violation number="2" location="mobile/src/lib/push.ts:69">
P2: Fetch or await box info before treating a missing push capability as unsupported. While info is still loading, this silently skips registration or unregistration, yet `setPush` records the preference as successful and does not retry when info arrives.</violation>
</file>

<file name="mobile/src/lib/links.ts">

<violation number="1" location="mobile/src/lib/links.ts:20">
P2: `routeFor` falls back to the only paired box even when the notice names a different box. A notice from an unpaired box opens a session on the paired box, which can be a same-named session of another box. The single-box fallback should apply only when `d.box` is absent; a named but unpaired box should return "/".</violation>

<violation number="2" location="mobile/src/lib/links.ts:32">
P3: `noticeKey` does not resolve the box the way `routeFor` does. For a notice with no box name it yields "undefined/<session>", so the notice is never quieted while its session is open. Resolve the box with the same single-box fallback, or return `undefined` when there is no box.</violation>
</file>

<file name="mobile/src/app/_layout.tsx">

<violation number="1" location="mobile/src/app/_layout.tsx:29">
P3: The handler suppresses notices only for the open session. A run notice is never suppressed because the run screen never calls `setShowing`. Either set `showing` in `r/[box]/[run].tsx` or drop the run branch from `noticeKey`.</violation>

<violation number="2" location="mobile/src/app/_layout.tsx:84">
P2: Backgrounding the app while `startFeeds`' initial `sync()` is still awaiting `refresh` leaves polls and streams running after `stop()`. Each foreground then adds another set. The stop has to cancel pending work, for example with a `stopped` flag checked after each await in `sync`.</violation>

<violation number="3" location="mobile/src/app/_layout.tsx:89">
P2: Push registration relies on a fixed 3-second delay for box info to arrive. A slow box gets skipped with no error. Register when each box's info arrives, for example by subscribing to the store, instead of on a timer.</violation>
</file>

<file name="mobile/src/lib/choices.ts">

<violation number="1" location="mobile/src/lib/choices.ts:11">
P2: `answerChoices` shows numbered-option chips for an idle agent without checking whether it sits at its own prompt. A numbered list in the agent's last message would then offer chips that type digits into its prompt. Check `screenAt(...) === "prompt"` first, as `askIn` in `packages/core/src/screen.ts` does.</violation>
</file>

<file name="mobile/src/lib/types.ts">

<violation number="1" location="mobile/src/lib/types.ts:45">
P2: `RunGate` leaves out `approve_label` and `reject_label`, which the box sends for a publish gate. The run screen hardcodes "Approve" and "Reject", so a failed-publish gate shows the wrong actions. Add both fields and render them as the button titles.</violation>

<violation number="2" location="mobile/src/lib/types.ts:70">
P2: These types duplicate `@berth/plugin`'s run types and have already drifted. `RunStatus` lacks "paused", `RunCandidate.agent` is required although the box treats it as optional, and `RunStep` lacks `path`. Import the run types from `@berth/plugin` instead of redefining them, and drop the `| string` widening on `status`.</violation>

<violation number="3" location="mobile/src/lib/types.ts:85">
P3: `runDone` repeats the terminal-status check already written as `terminal` in app/src/lib/orchestrate-core.ts. Move one shared predicate into packages/core and use it from both apps, so the two lists cannot drift.</violation>
</file>

<file name="mobile/src/app/r/[box]/[run].tsx">

<violation number="1" location="mobile/src/app/r/[box]/[run].tsx:38">
P3: The 5-second poll keeps running after the run is finished. Start the interval only while `runDone(run)` is false, since the status- and cursor-driven reads already keep finished runs current.</violation>

<violation number="2" location="mobile/src/app/r/[box]/[run].tsx:77">
P2: Once a run has loaded, a failed refresh sets `error` but nothing renders it, so the screen shows stale data with no indication. Render `error` alongside the loaded run as well.</violation>

<violation number="3" location="mobile/src/app/r/[box]/[run].tsx:99">
P2: Disable every gate action while any decision is in flight. Approve remains tappable during Reject (and Reject all remains tappable during a candidate pick), allowing conflicting decisions to race against the same gate.</violation>
</file>

<file name="app/src/views/settings/phone-section.tsx">

<violation number="1" location="app/src/views/settings/phone-section.tsx:258">
P2: This aggregation can hide a box's push failure: a record with `last_error` is discarded whenever another box has a newer `last_ok`, so the grouped row appears healthy. Preserve errors when selecting the representative status, or report failures per box.</violation>

<violation number="2" location="app/src/views/settings/phone-section.tsx:286">
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 `Remove ${...}` pattern used in box-routes.tsx.</violation>
</file>

<file name="mobile/src/app/pair.tsx">

<violation number="1" location="mobile/src/app/pair.tsx:32">
P2: After a failed pair, the scanner re-arms while the same code is still in view, so it retries the same boxes repeatedly and flickers the error. Keep the scanner locked until the user changes the code, for example by pausing scans or requiring a new code value.</violation>

<violation number="2" location="mobile/src/app/pair.tsx:54">
P2: If the keychain write fails, `pair()` never clears `busy` or `scanned`, and the error is never shown. Wrap the store call so a failure resets the screen state and shows the message.</violation>
</file>

<file name="mobile/src/app/(tabs)/boxes.tsx">

<violation number="1" location="mobile/src/app/(tabs)/boxes.tsx:46">
P2: A box that hasn't answered yet has no `info`, so `can(..., "push")` is false and it gets the "runs an older berthd" note. That tells the user to update a box that may be current but offline. Only report boxes whose info has loaded.</violation>

<violation number="2" location="mobile/src/app/(tabs)/boxes.tsx:68">
P2: Forgetting a box, or turning notifications Off, can leave that box still registered to this phone. The unregister is skipped when `info` is unknown, and any failure is swallowed, so the UI shows success. Surface the failure, and have `unregisterBox` attempt the DELETE even when `info` is unknown.</violation>
</file>

<file name="mobile/src/lib/events.ts">

<violation number="1" location="mobile/src/lib/events.ts:46">
P3: The reconnect/watchdog loop duplicates the desktop `followEvents`. AGENTS.md says code both apps need belongs in `packages/core`. Move the loop there, parameterised by the line parser, so the two apps share one implementation.</violation>

<violation number="2" location="mobile/src/lib/events.ts:67">
P2: A 401 is not transient, but the catch block still schedules a retry every 10s for as long as the app is open. Stop the stream on 401 instead of retrying, and keep the "isn't paired" message as the final state.</violation>

<violation number="3" location="mobile/src/lib/events.ts:84">
P3: `attempt++` passes a 0-based index to `reconnectDelay`, which expects 1-based. The first two retries both wait 500ms, so the backoff runs one step behind. Use `++attempt`, as the desktop `followEvents` does.</violation>
</file>

<file name="mobile/src/lib/mock.ts">

<violation number="1" location="mobile/src/lib/mock.ts:83">
P2: The demo lists `r_demo2` as the running loop, but its detail request returns `r_demo1`'s refunds gate. Return the matching demo run by ID.</violation>

<violation number="2" location="mobile/src/lib/mock.ts:90">
P2: The demo reports every write as successful without changing mocked state. A picked gate remains waiting and sent prompts never appear; update demo state before acknowledging success.</violation>
</file>

<file name="mobile/eas.json">

<violation number="1" location="mobile/eas.json:6">
P2: `preview` is documented as the TestFlight build, but `distribution: "internal"` produces an ad hoc build that TestFlight cannot accept. Set this profile to `store` or describe it as internal distribution instead.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread mobile/src/lib/push.ts
Comment on lines +25 to +32
export async function installId(): Promise<string> {
let id = await read(INSTALL);
if (!id) {
id = Array.from(getRandomBytes(16), (b) => b.toString(16).padStart(2, "0")).join("");
await write(INSTALL, id);
}
return id;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: installId races on first use. With several boxes, setPush calls it in parallel, so each box gets a different id and only the last one is stored. Later "off" and "forget" calls then use the stored id and miss the other boxes' registrations, so those boxes keep sending. Create the id once and reuse the same promise.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/lib/push.ts, line 25:

<comment>`installId` races on first use. With several boxes, `setPush` calls it in parallel, so each box gets a different id and only the last one is stored. Later "off" and "forget" calls then use the stored id and miss the other boxes' registrations, so those boxes keep sending. Create the id once and reuse the same promise.</comment>

<file context>
@@ -0,0 +1,104 @@
+const RELAY = extra.relay ?? "https://push.berthd.app";
+
+// installId names this install to every box: 32 hex characters, made once.
+export async function installId(): Promise<string> {
+  let id = await read(INSTALL);
+  if (!id) {
</file context>
Suggested change
export async function installId(): Promise<string> {
let id = await read(INSTALL);
if (!id) {
id = Array.from(getRandomBytes(16), (b) => b.toString(16).padStart(2, "0")).join("");
await write(INSTALL, id);
}
return id;
}
let install: Promise<string> | undefined;
export function installId(): Promise<string> {
install ??= (async () => {
let id = await read(INSTALL);
if (!id) {
id = Array.from(getRandomBytes(16), (b) => b.toString(16).padStart(2, "0")).join("");
await write(INSTALL, id);
}
return id;
})();
return install;
}

Comment thread docs/guides/phone.mdx
steps.
- **Notifications** come without ntfy: in Boxes, choose Needs you (or Needs
you and done). Each box then notifies the phone through Shipyard's push
relay (below), and a notification opens its agent or run. Settings → Phone

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: (below) points to nothing: this page never describes Shipyard's push relay. Either add a short section on how the relay works, or drop "(below)" and name what the relay sends.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 45:

<comment>`(below)` points to nothing: this page never describes Shipyard's push relay. Either add a short section on how the relay works, or drop "(below)" and name what the relay sends.</comment>

<file context>
@@ -27,6 +27,27 @@ the ntfy app. Boxes then push "Claude Code needs you · shop/checkout on devl"
+  steps.
+- **Notifications** come without ntfy: in Boxes, choose Needs you (or Needs
+  you and done). Each box then notifies the phone through Shipyard's push
+  relay (below), and a notification opens its agent or run. Settings → Phone
+  on the laptop lists the phones that get them, and removes one.
+
</file context>
Suggested change
relay (below), and a notification opens its agent or run. Settings → Phone
relay, and a notification opens its agent or run. Settings → Phone

]
: null,
)}
{!all.length && <Empty>No runs on your boxes. Start one from Shipyard on your laptop.</Empty>}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This empty state shows "No runs on your boxes" before data loads and when every box is unreachable. Guard it with a loading check and surface offline boxes, as index.tsx does with loading and the offline Note list.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/app/(tabs)/runs.tsx, line 46:

<comment>This empty state shows "No runs on your boxes" before data loads and when every box is unreachable. Guard it with a loading check and surface offline boxes, as `index.tsx` does with `loading` and the `offline` Note list.</comment>

<file context>
@@ -0,0 +1,51 @@
+            ]
+          : null,
+      )}
+      {!all.length && <Empty>No runs on your boxes. Start one from Shipyard on your laptop.</Empty>}
+    </ScrollView>
+  );
</file context>

)),
];
})}
{!agents.length && !gated.length && !loading && <Empty>No agents are running on your boxes.</Empty>}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a box is unreachable, this empty state still says "No agents are running on your boxes." Those agents are unknown, not absent. Skip the empty state when any box is offline, for example by adding && !offline.length.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/app/(tabs)/index.tsx, line 81:

<comment>When a box is unreachable, this empty state still says "No agents are running on your boxes." Those agents are unknown, not absent. Skip the empty state when any box is offline, for example by adding `&& !offline.length`.</comment>

<file context>
@@ -0,0 +1,92 @@
+          )),
+        ];
+      })}
+      {!agents.length && !gated.length && !loading && <Empty>No agents are running on your boxes.</Empty>}
+      {offline.map((b) => (
+        <Note key={b.id} bad>{`Can't reach ${b.name}: ${data[b.id]?.error}`}</Note>
</file context>
Suggested change
{!agents.length && !gated.length && !loading && <Empty>No agents are running on your boxes.</Empty>}
{!agents.length && !gated.length && !loading && !offline.length && <Empty>No agents are running on your boxes.</Empty>}

// and its project.
export function whereOf(s: PhoneSession): { worktree: string; project: string } {
const [loc, wt] = String(s.location ?? "").split("/");
return { worktree: s.worktree_title || s.title || wt || loc || s.name, project: loc ?? "" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: whereOf puts the session's title ahead of the worktree name. A worktree session with no worktree_title then shows its prompt-derived title as the headline, and the worktree name disappears. The web phone UI (phoneui/app.js) skips s.title here, so the two should match. Drop s.title from the chain. The ?? "" on loc is also dead, since loc is always a string.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/components/cards.tsx, line 15:

<comment>`whereOf` puts the session's `title` ahead of the worktree name. A worktree session with no `worktree_title` then shows its prompt-derived title as the headline, and the worktree name disappears. The web phone UI (`phoneui/app.js`) skips `s.title` here, so the two should match. Drop `s.title` from the chain. The `?? ""` on `loc` is also dead, since `loc` is always a string.</comment>

<file context>
@@ -0,0 +1,71 @@
+// and its project.
+export function whereOf(s: PhoneSession): { worktree: string; project: string } {
+  const [loc, wt] = String(s.location ?? "").split("/");
+  return { worktree: s.worktree_title || s.title || wt || loc || s.name, project: loc ?? "" };
+}
+
</file context>
Suggested change
return { worktree: s.worktree_title || s.title || wt || loc || s.name, project: loc ?? "" };
return { worktree: s.worktree_title || wt || loc || s.name, project: loc };

Comment thread mobile/src/lib/types.ts
error?: string;
}

export const runDone = (r: Pick<Run, "status">) => ["succeeded", "failed", "cancelled", "interrupted"].includes(r.status);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: runDone repeats the terminal-status check already written as terminal in app/src/lib/orchestrate-core.ts. Move one shared predicate into packages/core and use it from both apps, so the two lists cannot drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/lib/types.ts, line 85:

<comment>`runDone` repeats the terminal-status check already written as `terminal` in app/src/lib/orchestrate-core.ts. Move one shared predicate into packages/core and use it from both apps, so the two lists cannot drift.</comment>

<file context>
@@ -0,0 +1,85 @@
+  error?: string;
+}
+
+export const runDone = (r: Pick<Run, "status">) => ["succeeded", "failed", "cancelled", "interrupted"].includes(r.status);
</file context>

}, [box, runId]);
useEffect(() => {
read();
const t = setInterval(read, 5000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The 5-second poll keeps running after the run is finished. Start the interval only while runDone(run) is false, since the status- and cursor-driven reads already keep finished runs current.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/app/r/[box]/[run].tsx, line 38:

<comment>The 5-second poll keeps running after the run is finished. Start the interval only while `runDone(run)` is false, since the status- and cursor-driven reads already keep finished runs current.</comment>

<file context>
@@ -0,0 +1,127 @@
+  }, [box, runId]);
+  useEffect(() => {
+    read();
+    const t = setInterval(read, 5000);
+    return () => clearInterval(t);
+  }, [read, summary?.status, summary?.cursor]);
</file context>

)
}
>
<Button

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 Remove ${...} pattern used in box-routes.tsx.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/views/settings/phone-section.tsx, line 286:

<comment>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 `Remove ${...}` pattern used in box-routes.tsx.</comment>

<file context>
@@ -238,3 +241,61 @@ function Notifications({ boxes, phone, onChange }: { boxes: string[]; phone: Rec
+              )
+            }
+          >
+            <Button
+              size="xs"
+              variant="ghost"
</file context>

Comment thread mobile/src/lib/events.ts
// follow keeps a stream open to one box, reconnecting with backoff, and
// starting over when the box goes quiet past STREAM_SILENCE_MS (a dropped
// connection that never said so).
export function follow(box: Box, hooks: FollowHooks, fetchImpl: typeof fetch = fetch): Follow {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The reconnect/watchdog loop duplicates the desktop followEvents. AGENTS.md says code both apps need belongs in packages/core. Move the loop there, parameterised by the line parser, so the two apps share one implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/lib/events.ts, line 46:

<comment>The reconnect/watchdog loop duplicates the desktop `followEvents`. AGENTS.md says code both apps need belongs in `packages/core`. Move the loop there, parameterised by the line parser, so the two apps share one implementation.</comment>

<file context>
@@ -0,0 +1,113 @@
+// follow keeps a stream open to one box, reconnecting with backoff, and
+// starting over when the box goes quiet past STREAM_SILENCE_MS (a dropped
+// connection that never said so).
+export function follow(box: Box, hooks: FollowHooks, fetchImpl: typeof fetch = fetch): Follow {
+  let stopped = false;
+  let ctrl: AbortController | undefined;
</file context>

Comment thread mobile/src/lib/events.ts
clearTimeout(watchdog);
if (stopped) return;
hooks.onState(false, (err as Error).message);
retry = setTimeout(run, reconnectDelay(attempt++));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: attempt++ passes a 0-based index to reconnectDelay, which expects 1-based. The first two retries both wait 500ms, so the backoff runs one step behind. Use ++attempt, as the desktop followEvents does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/src/lib/events.ts, line 84:

<comment>`attempt++` passes a 0-based index to `reconnectDelay`, which expects 1-based. The first two retries both wait 500ms, so the backoff runs one step behind. Use `++attempt`, as the desktop `followEvents` does.</comment>

<file context>
@@ -0,0 +1,113 @@
+      clearTimeout(watchdog);
+      if (stopped) return;
+      hooks.onState(false, (err as Error).message);
+      retry = setTimeout(run, reconnectDelay(attempt++));
+    }
+  };
</file context>
Suggested change
retry = setTimeout(run, reconnectDelay(attempt++));
retry = setTimeout(run, reconnectDelay(++attempt));

@sean-brydon
sean-brydon merged commit 30ce5fa into feat/core-package Nov 1, 2026
10 checks passed
@linear-code

linear-code Bot commented Nov 1, 2026

Copy link
Copy Markdown

ENG-7523

This branch was successfully deployed

2 active deployments
Preview – berth-docs — 749ce020 Deployed Oct 29, 2026 by vercel[bot]
Preview – berth — 749ce020 Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants