Repository navigation
feat(mobile): add Shipyard for iPhone - #209
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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
| 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; | ||
| } |
There was a problem hiding this comment.
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>
| 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; | |
| } |
| 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 |
There was a problem hiding this comment.
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>
| 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>} |
There was a problem hiding this comment.
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>} |
There was a problem hiding this comment.
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>
| {!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 ?? "" }; |
There was a problem hiding this comment.
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>
| return { worktree: s.worktree_title || s.title || wt || loc || s.name, project: loc ?? "" }; | |
| return { worktree: s.worktree_title || wt || loc || s.name, project: loc }; |
| error?: string; | ||
| } | ||
|
|
||
| export const runDone = (r: Pick<Run, "status">) => ["succeeded", "failed", "cancelled", "interrupted"].includes(r.status); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>
| // 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 { |
There was a problem hiding this comment.
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>
| clearTimeout(watchdog); | ||
| if (stopped) return; | ||
| hooks.onState(false, (err as Error).message); | ||
| retry = setTimeout(run, reconnectDelay(attempt++)); |
There was a problem hiding this comment.
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>
| retry = setTimeout(run, reconnectDelay(attempt++)); | |
| retry = setTimeout(run, reconnectDelay(++attempt)); |
Last of the phone app's PRs. Stacked on #206 (
packages/core), so its diff shows only the app; it retargets tomainonce #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)berth://pair#…. Each box is asked who it is before it's kept.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY).NSAllowsLocalNetworkingplus an exception forts.net. NoNSAllowsArbitraryLoads; raw tailnet addresses aren't subject to ATS.@berth/core/*(tsconfig paths, MetrowatchFolders, JestmoduleNameMapper).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), andpnpm export(Metro bundles the iOS app, which provespackages/coreresolves).mobileCI job runs the same three.Not verified, needs Apple setup:
*.ts.net.Prerequisites:
dev.berth.phoneand an APNs key (the same key goes to the relay).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