mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
debug-github-rate-limit
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5d51bcaf6e |
feat(mobile): serve any manifest route through a [...page] catch-all (OTA phase C, C8) (#21950)
* feat(mobile): add the page-route-unavailable refusal screen The catch-all route landing next has no native screen behind it, so its fallback cannot be a panel. Nothing imports this yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move firstParam out of the source-control tree Every caller is a route file under app/h/, and the import dragged mobile-git-status.ts and the screen-state module into the closure of any route that reads a param: 1989 modules (3 local) for a one-line helper, against 1 from src/navigation/route-param-reader.ts. Pure move. The three shell route suites drop their lucide-react-native mocks with it; that barrel was only ever reached through the old home. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): serve any manifest route through a [...page] catch-all Every page screen needs a route file under app/h/[hostId]/ today, so a screen the desktop registers after a store build has nowhere to mount and expo-router paints Unmatched. This adds one catch-all that hands any host-scoped pathname to the shell; the manifest still decides, through the same routeViewOf the other switches reach. Measured with expo-router's own matcher on both platforms: every route that has a file keeps it, index and the four .web.tsx siblings included; only pathnames that reached Unmatched move. The body lives under src/ because expo-router 55 reads a file's platform from the first dot of its stripped name: [...page].web.tsx under app/ parses as platform '' and registers a second route rather than overriding the first. Under src/ the stem is plain and Metro and the page builder both resolve the sibling. getRoutes shows exactly one [...page] key on each platform. Registers no manifest entry, grant, hop row or PAGE_SERVED_SCREENS row. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page refuses an unrouted host path instead of Unmatched The catch-all owns every /h/<id>/... pathname with no module, on the page as well as in the app, so the render check's unmatched case can no longer pass: measured in headless Chromium, /h/<id>/not-a-route paints the refusal with no page or console error. bridge-caps.ts records that C8 closes the C1.7 class for the host subtree, and its dot-segment note is rewritten to the measured mechanism: getStateFromPath normalizes the href through new URL(href, 'file:') in getUrlWithReactNavigationConcessions before cleanPath sees it, so /h/..?x lands on the app's root screen rather than on a host screen with hostId '..'. Refusing it stays correct. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the web-overrides allowlist in append order The catch-all entry was added with a whole-file sort, which rewrote 116 lines for one addition and buried it. The test compares sorted sets, so the order on disk is free; append order is what makes the diff readable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): leave the refusal on the encoded host route The Back href was a raw template, so a host id carrying a slash built /h/a/b — two segments, which the catch-all that rendered the refusal matches with hostId now "a". The control looped back into the screen it exists to leave. hostStackHostRoute already encodes it and is what the notification path pushes through. hostId is a string: firstParam returns one, so the undefined arm and its ?? '' were unreachable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the catch-all's fallback binding and its way out catch-all-page-route.test.tsx mocks both the shell and the refusal, so fallback={refusal} was unpinned: mutating it to null left that file at 10 passed. This drives the other half with the real shell screen and the real refusal under it, stubbing only what the session reducer settled on. Four cases, each measured against a mutant: fallback={null} reds three, push instead of replace reds two, a raw /h/${hostId} template reds the five-shape encoding case. The checking case is the presence precondition the rest need: before the flag read settles the switch returns the refusal on its own, with the same text and the same control, so an assertion on the refusal alone would pass against a screen no shell ever rendered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the route-body follower resolve every shape or fail The follower read only `export { default } from 'x'`. A route file doing `import X from 'x'; export default X` and mounting the shell without shellScreenRoute left the census at 5 passed: the body was never opened, so the file read as "not a switch" — the one answer a census must never give by default. Both shapes are followed now, and an unresolvable one is named rather than skipped. Measured against four mutants: a shellScreenRoute call dropped from the re-exported body reds the rule; an import-then-export route mounting the shell reds two cases naming the file; a default from a package specifier, a file with no default, and a re-export with no module each red the new resolution case with the reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): type the host-node lookup in the catch-all state test The tests-typecheck ratchet reds on findAllByType with a host string: react-native is mocked to strings here, which is not an ElementType. A findAll predicate on node.type is the same lookup and checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the refusal does not absorb the other shell states The refusal answers one question — this build cannot serve this route — and offline, checking and the protocol wall answer different ones that are each true for a screen only the page has. Absorbing them would tell someone with no connection that the screen does not exist. Written where fallback is bound, and driven: an offline session through the catch-all paints the connect message. Mutating the shell to return fallback for offline reds that case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the type the shell-view probe stands in for The anti-slop gate refuses a broad `object` parameter, and it is right here: the probe forwards every prop to its host node, so the type it accepts is the view's own. Type-only import, so the module's requireNativeViewManager call is still never evaluated. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send the refusal to the app root when it has no host firstParam answers an absent hostId as '', so Back called hostStackHostRoute('') and landed on /h/ — which expo-router's own matcher resolves to the h layout with no child, a press that paints nothing and leaves the dead end in place. The app root lists hosts and is where ProtocolBlockScreen sends the same gesture from the same position; the label follows the target rather than outliving it. Also through useRouteHandoff rather than useRouter, which is the same defect on the other side: the page renders this screen through the catch-all's .web.tsx sibling, and there a bare replace navigates inside the WebView to a route the page does not carry instead of leaving it. ProtocolBlockScreen already uses the seam; the router-seam censuses cover src/session, src/files and src/source-control, not src/mobile-web-shell, so nothing caught it. Costs one module in the page closure (host-stack-navigation.ts): every module the seam reaches is already in the layout's closure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): repoint C4.4's source-control web sibling at the moved reader The merge brought in a route file that imports firstParam from the source-control screen state, which this branch emptied. Git merged both sides cleanly because neither touched the other's lines; tsc is what catches it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): mock dictation's device half in the catch-all state test The merge put the audio verbs in the shell screen's closure, so this file reaches @orca/expo-two-way-audio, whose module touches the Expo global at import. Same two mocks MobileWebShellScreen.test.tsx carries for the same reason; what each verb does is bridge-audio-verbs.test.ts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the router seam under mobile-web-shell The three landed censuses walk src/session, src/files and src/source-control, which is how PageRouteUnavailableScreen shipped with a bare useRouter and nothing caught it until CodeRabbit. This tree needs a shape of its own because it holds both halves: the rule cannot be "no router" when MobileWebShellScreen and useShellStackPop are the app end the page's navigate and navigate-back notifies arrive at. Both are named with the reason, and neither has a .web.* sibling, so neither runs inside the page. Red first: with the bare useRouter put back, two of the four rules fail naming the file — + "PageRouteUnavailableScreen.tsx (useRouter)" - "PageRouteUnavailableScreen.tsx" The walk covers 72 product modules, asserted above 60, so the empty finding list is over a non-empty walk. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ac4dc6599b |
feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
47d107cf2e |
feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window `useHostStatusGates` settled the same closed gates for a host that answered `status.get` with no capabilities and for one whose status nobody could read: both paths produced an empty capability list and an `ok` verdict. A caller that walls on a missing capability cannot tell those apart, and the mobile web bundle's wall is terminal, so it must never fire for the second. `statusReadable` distinguishes them. `hostProtocolWindow` exposes the two protocol numbers the hook already read for `evaluateCompat`, as the reply's own fields, so the bundle wall can evaluate its own window without a second `status.get`. Both are additive; no existing consumer changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): add the hybrid shell flag and the generation path both loaders demand `orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same shape as the terminal autocomplete flag. `generationDirectoryPath` converts the store's `file://` uri to the absolute path the native shell view requires: both `MobileWebShellGeneration.load` implementations refuse anything without a leading slash, and `expo-file-system` only ever hands out uris. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell session as a pure reducer Every decision the route makes, as `(session, event) -> (state, effects)`: the capability wall, the lazy sweep and cache read, the manifest check, the cached build-id hit that skips paging, the offline open with no compat check, and the three recovery rules the native shell view's contract states. Pure, so the rules are table tests rather than a simulator run. Two latches sit beside the state because both outlive it: `retriedOnce` spans the delete and refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced by a `ready` under a new session id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) Wires the four Phase B and A pieces together and adds no decision of its own. `h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native `desktop-too-old` wall still applies above it. With the flag off — every store build, since the only writer is a `__DEV__` Troubleshoot toggle — the route redirects to `h/[hostId]` and the screen is never constructed, so nothing is fetched, written or swept. The runner owns only the impure edges and checks an epoch before every dispatch, so an unmount, a host change or a retry abandons work in flight and aborts a download that would otherwise hold four of the host's read slots. The native view is keyed on the session id, which is what makes the reducer's remount a rebuilt WebView with every fence reinstalled. A census test pins who touches the flag: the route reads it, the developer row reads and writes it, and the key itself lives in one module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the hoisted test doubles instead of asserting them The changed-code casting gate refuses `as` in new code, and these three were only widening an empty literal. An annotated `vi.hoisted` factory does the same job under a check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress Found on a simulator with the paired desktop stopped: the client never settles on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping `reconnecting` to "still connecting" left a phone holding a verified cached generation on `checking` forever instead of opening it, which is the one case the offline rule exists for. `connecting` and `handshaking` are the first dial and still wait; everything else is unreachable. The mapping moves next to the reducer it feeds, because it is a decision and the runner is supposed to hold none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the reachability stub instead of asserting it Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the shell session vocabulary into its own module Pure move, no behaviour: the states, events, effects and gates the reducer and its runner share now sit beside the reducer rather than inside it, so the transition rules have room to grow under the file's line budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop a shell effect result whose flow has been superseded Every restart of the flow bumps a number the effects of that run are stamped with, and a result echoes it back: a manifest read still in flight when the socket drops used to reject after the offline path had already opened the cached generation, replacing a displayed workspace with a download failure, and a status refetch arriving mid-check used to run the cache read and the download twice. The gates restart no longer clears the remount latch either; only the retry button does, so a reconnect cannot grant a second remount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the flag across modules, not just src and app The native view tree was outside the scan, so a reader added there would have passed an assertion that reads as exhaustive. Proven by adding one to the shell view module: the census fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let only the state that mounted the view hear the view A native batch reports two failures in a row, and the reducer applied both: document-load-failed started the delete-and-refetch, render-process-gone then made it terminal without a new flow, and the cache read the recovery had already asked for dragged the session back to checking behind a failure screen. A report arriving outside `ready` is from a view that is no longer on screen, so it changes nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say a host status could not be read instead of spinning on it A transient status.get failure settles the gate unreadable and nothing probes it again, so the route sat on "Checking host" for as long as anyone left it there and Try again re-read the same settled answer. It now says what happened and offers no retry, and a status that does become readable picks the flow back up on its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restart the flow on the verdict that changed, not on every gates object A reconnect cycle rebuilds the gates several times a second with the same answer in them, and each one re-swept the staging tree and flipped an offline screen to a spinner and back. Only a changed verdict restarts now, which is also why the gates effect has to depend on the host id: two hosts whose gates read identically would otherwise leave the second session in `checking`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover what only the shell runner can get wrong Three cancellations had no test: the epoch that stops a result reaching a session that is gone, the unmount cleanup that aborts the download, and the retry that does both before starting over. Each is now red under its own mutant. The download also re-checks the abort before it writes, since an abort landing between the fetch's last read and the commit would otherwise still put a generation on disk for a screen nobody is on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the shell runner's refs after the commit, not during render React can replay or discard a render, so a handle written during one can run effects for a session that never existed. The client and the host cache key stop being refs at all; the effect handle is committed in an effect above every effect that dispatches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the hybrid shell flag unreadable outside a development build Development and release share a bundle id, and the iOS data container survives an install-over, so a flag a developer toggled on would follow the store build in and mount the shell on a deep link. The release read never reaches storage. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive the route's flag read as each build kind reads it The route test exercises the real preference read, so it has to say which build it is. A store build whose container kept a development toggle redirects. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a cache read that lands mid-dial wait for the compat check A connection still being made is not a host that cannot be reached. Opening the cached generation there skips the compat check the landing connection is what makes answerable, so only `unreachable` takes the offline path now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the developer toggle only after its write lands The route reads the flag back from storage, so a switch that moved on the tap let the open button race the value that was being persisted. The switch and the button both stay put until the write settles, and a failed write keeps the previous position. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): say which build kind a test runs as without asserting on globalThis Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): undo a staged generation the abort reached before the commit The commit is the write staging cannot take back: it renames into the active slot and moves the host index. An abort landing while the bytes were being staged now removes the staged tree instead of activating it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): open the cached workspace when the link, not the bundle, cut a read short An RPC rejection can reach the reducer before the reachability change does, so the offline gate never fires and a phone holding a valid generation reads that the workspace could not be downloaded. A read that failed on the link now opens what is on disk, the same path offline takes; a verdict about the bundle, from the host or from the bytes, still fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send a hybrid shell recovery through the same gate a start takes A view failure deleted the host cache and went straight back to the manifest check on whatever gates the ready session happened to be holding. Gates that arrive while a generation is on screen are stored without restarting, so after a reconnect whose status probe failed a ready session carried statusReadable false and an empty capability list, and the recovery's manifest check walled the host as bundle-unavailable: terminal, no retry, about a host that never answered. The gate is now one verdict both entries read, and recovery passes its delete through it, so an unreadable status lands on the status-unreadable message that re-arms when a readable gate arrives, and only a readable refusal still walls. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
405b9f245a |
feat(mobile): mount ProtocolBlockScreen when protocol compat is blocked (#9780)
* feat(mobile): mount ProtocolBlockScreen when protocol compat is blocked ProtocolBlockScreen existed since PR #1440 but was never mounted: on a 'blocked' compat verdict the only output was a console.warn, so a future MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION bump would have silently shown a broken host UI instead of the update screen. Add HostProtocolGate — a choke point in app/h/_layout.tsx above every /h/[hostId] route — that consumes useHostStatusGates and replaces the blocked host's entire UI (sidebar + detail stack) with ProtocolBlockScreen. The host list and other hosts stay usable; the screen's own 'Back to hosts' escape hatch routes to '/'. Both block reasons render their respective CTAs (mobile-too-old → App Store, desktop-too-old → GitHub Releases). Compat logic stays in the src/shared mirror contract — no fork. * fix(mobile): fence incompatible host routes efficiently * fix(mobile): route Android updates to releases |
||
|
|
79551c38f5 |
feat(mobile): edit saved host endpoints (#8294)
* feat(mobile): edit saved host endpoints * fix(mobile): reject ambiguous numeric host addresses * fix(mobile): label edit host inputs * fix(mobile): make host edit save atomic and remove superseded mutators Two independent review rounds found the same class of foot-gun: a superseded mutator (updateHostEndpoint, then renameHost) left in host-store.ts after the atomic updateHostNameAndEndpoint refactor, with zero remaining callers. Either could be reintroduced by a future caller and silently regress the non-atomic name/endpoint race the atomic function was written to close, so both are removed. Also covers reconnect-rejection and endpoint-only save paths that were missing test coverage, and merges origin/main (#8789) so this lands without reverting the mobile terminal restore fix. Co-authored-by: Orca <help@stably.ai> * Simplify save-race comment and reword host-removed error message - Trims the redundant comment explaining the savingRef race guard down to one line. - Changes the "no longer saved" load-error copy to "was removed" for clearer phrasing, updating the matching test expectation. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai> |
||
|
|
8f6e44ed53 |
Show agent session history on mobile (#6786)
* Show agent session history on mobile Bring the desktop "Agent Session History" panel to Orca Mobile as a per-worktree screen: browse past agent transcript sessions across the host with scope tabs (Workspace/Project/All), search, grouping, session cards, and tap-to-read message previews. The transcript scan previously ran only over Electron IPC, so mobile could not reach it. Expose it over the runtime RPC protocol mobile already speaks (aiVault.listSessions) so the scan runs on whichever host owns the transcripts — correct for local and SSH/remote hosts. Both the desktop IPC handler and the new RPC method share one cache, so opening the desktop panel and the mobile screen never double-scan. The pure filter/group/display logic is lifted into /shared (the renderer re-exports it) so the standalone mobile package can reuse it. Mobile narrows scoped tabs client-side by cwd path-prefix because the host scan treats scope paths as a widening union. Resume-from-mobile is intentionally a follow-up. * Fix mobile agent history list rendering and RPC authorization - Authorize aiVault.listSessions in the mobile RPC allowlist so the mobile client's call is not rejected before dispatch (without this the screen could never load sessions at runtime). - Name each SectionList section's rows `data` (the field React Native reads) instead of `cards`, fixing a type error and silent empty-section rendering. * Address review feedback on agent session history - Match quoted repo:/path: search operator values so labels and paths with spaces match (e.g. path:"/Users/ada/My Project"). - Hold a scoped tab in loading until the worktree list resolves instead of firing an unscoped fetch that briefly shows unrelated host history; proceed once loaded even if the worktree is absent (no stuck spinner). - Clear cached host capabilities on disconnect/host-switch and failed status.get so a capability-gated action can't linger for a host that doesn't support it. - Cover the real OrcaRuntimeService codex-home forwarding path and the quoted-operator parser with tests. * Hide redundant mobile current worktree badges Co-authored-by: Orca <help@stably.ai> * Resume agent sessions from mobile history (#6969) Co-authored-by: Orca <help@stably.ai> * Adapt merged seams to main's lint and reply-sender hardening Co-authored-by: Orca <help@stably.ai> * Cap mobile project-scope paths to the aiVault RPC bound Co-authored-by: Orca <help@stably.ai> * Share the aiVault scopePaths bound between the RPC schema and mobile Co-authored-by: Orca <help@stably.ai> * Guard shared AI Vault inflight cleanup against concurrent key replacement The extracted cache module's .finally() cleared inflight tracking unconditionally, dropping the if (inflightKey === key) guard its sibling outer cache kept: an older scan resolving after a different-key scan replaced the tracking would null the newer scan's dedup slot, so a re-request started a duplicate transcript rescan. Mirrors the sibling guard; the regression test flushes a macrotask so a reverted guard fails fast on the call count instead of hanging. Co-authored-by: Orca <help@stably.ai> * Harden aiVault.listSessions contract and gate mobile header entry on capability - Clamp scopePaths (64) instead of rejecting, cap limit at 2000, and make executionHostId optional so mobile can omit it; restamp per caller. - Retain successful mobile terminal-create mutation ids for 60s so resume retries dedupe after transient socket drops. - Gate the session-header Agent History action on the aiVault.v1 capability (mirrors the host-list action) so old hosts never show a dead-end entry. - Fix stale contract comments (scopePaths clamp semantics; filters move includes quoted repo:/path: operator parsing). * Add subagent field to session test fixtures after #7423 merge AiVaultSession.subagent became required on main; the five fixtures added on this branch predate it. Top-level scanned sessions carry null. --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local> |
||
|
|
a6f12f6120 |
Show the git primary action on mobile Source Control (#6659)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
c9bd61376f |
feat(mobile): combine PR sidebar and checks parity (#5641)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com> |
||
|
|
21a01c7d6b |
Mobile: worktree-list sidebar for tablet/foldable layouts (#5505)
* Add worktree-list sidebar for mobile tablet/foldable layouts On wide canvases (tablet/foldable, >=700pt) the per-host worktree list now renders as a persistent left sidebar with the routed screens (terminal, source control, review, accounts, tasks) shown in a detail pane to its right — mirroring the desktop's sidebar + center layout. Phones keep the existing single-pane stack navigation unchanged. - Reuse the existing worktree-list screen as the sidebar via an `embedded` mode (props for hostId/action, mount-driven fetch since a sidebar is never the focused route, hide-sidebar control in place of the back button, and open-into-detail-pane navigation that replaces rather than stacks). - The default host route renders an empty WorkspaceDetailPlaceholder on wide layouts (the list lives in the sidebar) and the full screen on phones, so exactly one instance mounts either way. - Hide button collapses the sidebar to give the detail pane full width; an elevated reveal tab brings it back. - Detail-pane screen transitions use `animation: 'none'` while the split is active so workspaces swap instantly instead of sliding and flashing the screen beneath. - Drag the sidebar's right edge to resize (clamped 280-560pt, detail pane kept >=320pt, tap-transparent so list rows still work); width persists via AsyncStorage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address PR review feedback (#5505) - Clamp the restored/persisted sidebar width against the current window and re-clamp when the window shrinks, so a width saved on a larger device can't starve the detail pane below MIN_DETAIL_WIDTH. Extract a shared clampSidebarToWindow helper reused by load, window-resize, and drag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Polish mobile tablet sidebar Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> |
||
|
|
c4fade90e7 |
Add mobile Review Changes workflow (#5313)
* Add mobile diff review * Wrap mobile review notes prompt * Fix mobile review unreviewed navigation * Clear review completion when a refreshed diff invalidates a reviewed file mergeMobileDiffReviewState invalidates a file's reviewed flag when its diff identity changes, but left completedAt set — inconsistent with markUnreviewed. Drop completedAt on invalidation and add a regression test. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> |
||
|
|
b1973657ea |
Add mobile Tasks parity (#2452)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
afe90a616f |
Add mobile source control actions (#2193)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
f938ff185f |
feat(mobile): account switcher and rate-limit usage on mobile (#1467)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
fc578f5ea9 |
feat(mobile): Expo companion app [beta] (#1245)
Co-authored-by: Orca <help@stably.ai> |