mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* feat(mobile): give the page a haptics notify and the grant that gates it `native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly the five `src/platform/haptics.ts` has, and the single token `haptics` joins `BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb because nothing is owed back: a reply would spend a slot in the same 64-deep in-flight window a forwarded request does, and there are 90 call sites in this app, some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30). The arm's fields live in their own module because `bridge-envelope.ts` is at its line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays in the envelope, so the fields are spread in beside it rather than reading it back through an import cycle. The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other device-local notify does: the host is the protocol's side of the bridge and a static import of the app's haptics would put `react-native` and `expo-haptics` in its graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping — `haptics.ts`'s own functions, its `Platform.OS` split and its Android `HapticFeedbackConstants` untouched. The dispatch branch rides along with the union rather than waiting for the page side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over that union, so the shell does not compile without it. That is the totality working, and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is. Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin and the missing-row type error all failed against the tree before this. Control on the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): post the page's haptics over the notify instead of doing nothing `haptics.web.ts` stops being five no-ops. Each of the five posts its own kind through the notify seam the entry publishes — the same shape `publishExternalLinkOpener` has, and for the same reason: every caller is a plain function inside a row's press handler that no provider wraps. `notifyHaptics` joins the page client beside the other gated notifies and answers whether the frame left, which nothing reads: a tap that did not buzz is what the page did before this, and a warning per refusal would be one per row of a scrolling list. Measured off the frame the client posted rather than a written copy of its shape, which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection / success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`, and a twelve-row scroll 888 bytes across twelve frames. The `web-overrides.json` reason now says what the file does instead of what it declines to do. Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a function`, and the six client cases on `notifyHaptics is not a function`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): grant haptics on every page route, with a census that derives the list All five declared page routes carry the `haptics` grant, and the list is a measurement rather than a hand choice: `WorktreeListRow` is in every page closure and calls the seam, so a route without the grant is a page whose taps stop buzzing with nothing on screen to say why. Grants are resolved once from the route the shell opened and held for the session, so the declaration is the only place to fix it. `mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one: it reads the kinds off the tuple that declares them, finds every exported `trigger…` function in a haptics module, and reports the kind each one posts. The posting call is found through the binding `publishHapticsNotifier` assigns rather than a local spelled `post`, because a rename would otherwise turn every posting site into a non-posting one and leave this green on a page with no haptics at all. The census proper holds each route's closure to the `.web.ts` sibling, asserts at least one importer so the grant is not idle, and derives the granted-route list from the closures. The control is the design's: the same walk over the native sibling finds the same five functions and no posting site, so "all five post" is a number rather than an empty scan. Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post reds 1 of 23. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record what the haptics notify costs a page closure One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it arrives through `page-route-policy.ts` reading the grant token rather than through the seam, whose import of the kind type is erased; its only dependency is `zod`, which the envelope already put in every closure, so the module total moved by the same one. Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336. Pinned structurally rather than as a total, because an absolute closure count is main's to move and a number that drifts for unrelated reasons is one nobody reads. The call sites this replaces, measured over product modules: `triggerError` 43, `triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10, `triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus `page-haptics.ts` itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry the haptics grant into the shell's two grant pins `bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over and reaching the real module pulls in an Expo runtime this test does not have, which failed the whole suite at import. Which expo member each kind reaches stays in `page-haptics.test.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): map each haptic kind to a named import, not a namespace index The changed-code gate refuses a computed reference into an imported namespace, in both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()` is a call nothing can follow. Each function is a named import instead, which also keeps the second compile-time direction — a row naming something `haptics.ts` does not export is now an import error rather than a `keyof` mismatch. The third direction moves with it, from a namespace read in the test to the census that already reads both files' text: `hapticsImportedNames` names what the shell's mapping takes from the app's haptics, and the census holds that to the five the native file exports. So a haptic added there with no kind of its own still fails, and now it fails where the other two siblings' names are already compared. The test's two `as` assertions become one annotated hoisted type, the shape `MobileWebShellScreen.test.tsx` uses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the true reason the haptics grant is one token `GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>` with lowercase segments, which is why it rejected `native.media.readChunk` and rejects `navigate-back`, not a dotted name as such. So four comments claiming a route declaring the notify's own name would have its bundle refused were false, and they are gone: the grant is a token because the notify table's grants are tokens, a notify not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from the verb table alone. Also folded, with the false claim: `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so a token every page route declares couples the whole set to a shell that carries it — against one without it, no page route is served at all and the phone renders five native screens. Stated in the function's docstring and beside the census's derived list, and pinned: the same declaration under a grant this build does not implement comes back empty, with the token-free route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS` reds that case. `%#` consumes no argument, so the web seam's five cases were titled with the whole function body; the kind is the first element now and `%s` names it. One 110-char comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two still over it there are main's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
120 lines
5.9 KiB
TypeScript
120 lines
5.9 KiB
TypeScript
// Route A web entry: mounts the phone's h/[hostId] route tree on react-native-web.
|
|
// Dark: built by `build:mobile-web:app` into out/mobile-web-app, shipped by nothing until C1.
|
|
import { useEffect, type PropsWithChildren } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import { ExpoRoot } from 'expo-router'
|
|
import type { BridgeRpcClient } from '../src/mobile-web-shell/bridge/bridge-rpc-client'
|
|
import {
|
|
bootstrapShellPage,
|
|
createShellPageClient,
|
|
stampPageMountState,
|
|
type PageMountTarget
|
|
} from '../src/mobile-web-shell/bridge/page-bootstrap'
|
|
import { publishPageStorage } from '../src/mobile-web-shell/bridge/page-async-storage'
|
|
import { PageFaultBoundary } from '../src/mobile-web-shell/bridge/page-fault-boundary'
|
|
import { publishPageHostProfile } from '../src/mobile-web-shell/bridge/page-host-profile'
|
|
import { publishExternalLinkOpener } from '../src/platform/external-link.web'
|
|
import { publishHapticsNotifier } from '../src/platform/haptics.web'
|
|
// Named with its extension: this entry is the web build's and the provider it needs is the web
|
|
// sibling's, which takes the page's client. The screens below still import `./client-context`
|
|
// and reach the same module, because the builder resolves both specifiers to the same file.
|
|
import { RpcClientProvider } from '../src/transport/client-context.web'
|
|
// Body replaced at build time: esbuild has no require.context, so the builder synthesizes one.
|
|
import routeContext from './route-manifest'
|
|
|
|
// The route tree starts at app/h, below the native root layout that owns the provider, so the
|
|
// page supplies it here through ExpoRoot's own wrapper rather than mounting the native shell.
|
|
// No suspense boundary: expo-router wraps every screen in its own, which is what catches the
|
|
// route chunks the manifest defers. A chunk that never arrives is a rejection rather than a wait,
|
|
// and that is the boundary below's, not suspense's.
|
|
// A factory because the client is not in scope until `init` lands, and ExpoRoot takes a component.
|
|
function createRootProviders(client: BridgeRpcClient, target: PageMountTarget) {
|
|
return function RootProviders({ children }: PropsWithChildren) {
|
|
// Effects run child-first, so 'mounted' lands only after the router tree below this wrapper
|
|
// has committed. The tree is rendered once, with a ready client, so there is one such commit.
|
|
useEffect(() => {
|
|
stampPageMountState(target, 'mounted')
|
|
}, [])
|
|
return <RpcClientProvider client={client}>{children}</RpcClientProvider>
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The whole page for a shell that opened it and then named no screen.
|
|
*
|
|
* Built as elements rather than markup, and outside React: the route tree is exactly what cannot
|
|
* be mounted here, and a panel that needed it would be a second way to fail. The copy names the
|
|
* one thing that fixes it, because nothing on this device will.
|
|
*/
|
|
function renderShellTooOldPanel(container: HTMLElement): void {
|
|
const panel = document.createElement('div')
|
|
panel.setAttribute('role', 'alert')
|
|
panel.style.cssText =
|
|
'font:16px/1.5 system-ui,-apple-system,sans-serif;color:#e6e6e6;background:#141414;' +
|
|
'min-height:100vh;display:flex;flex-direction:column;align-items:center;' +
|
|
'justify-content:center;gap:8px;padding:24px;text-align:center'
|
|
const title = document.createElement('div')
|
|
title.style.cssText = 'font-weight:600'
|
|
title.textContent = 'Update Orca to open this workspace'
|
|
const body = document.createElement('div')
|
|
body.style.cssText = 'color:#9a9a9a;font-size:14px'
|
|
body.textContent = 'This version of the app cannot open the workspace it downloaded.'
|
|
panel.append(title, body)
|
|
container.replaceChildren(panel)
|
|
}
|
|
|
|
const container = document.getElementById('root')
|
|
if (!container) {
|
|
throw new Error('[orca-mobile-web-app] #root missing')
|
|
}
|
|
const target = document.documentElement
|
|
stampPageMountState(target, 'started')
|
|
|
|
bootstrapShellPage({
|
|
target,
|
|
client: createShellPageClient(),
|
|
replaceUrl: (href) => {
|
|
history.replaceState(null, '', href)
|
|
},
|
|
mount: (client, session) => {
|
|
// Before the first render, because both are read from effects that run on it: the host store is
|
|
// a plain async function with no provider above it, and the first list paints its pins.
|
|
publishPageHostProfile(session.host)
|
|
// Same reason, and the same shape: the seam is a plain function in render trees the provider
|
|
// does not wrap, so the client's notify is published rather than read from context.
|
|
publishExternalLinkOpener((url) => client.notifyExternalLink(url))
|
|
// The same shape again, and for the same reason: every haptic on this page is played from a
|
|
// plain function inside a row's press handler, which no provider wraps.
|
|
publishHapticsNotifier((kind) => client.notifyHaptics(kind))
|
|
// Scoped to the host `init` named: with none, no key is writable, which is the right answer
|
|
// for a shell too old to say whose list this is.
|
|
publishPageStorage(
|
|
session.storage,
|
|
(key, value) => client.notifyStorageWrite(key, value),
|
|
session.host?.id ?? ''
|
|
)
|
|
createRoot(container).render(
|
|
// Above `ExpoRoot`, not inside its wrapper: a route this bundle cannot resolve or import
|
|
// throws where the router renders it, and a boundary below the router never sees that.
|
|
<PageFaultBoundary
|
|
onFault={(error) => {
|
|
client.notifyPageFault(error)
|
|
}}
|
|
>
|
|
<ExpoRoot
|
|
context={routeContext}
|
|
// The same URL the line above just wrote, handed over rather than left to be read:
|
|
// ExpoRoot snapshots `window.location.href` when its module is imported, which is before
|
|
// any frame has crossed the bridge, so what it captured on its own is the `/` the shell
|
|
// serves.
|
|
location={new URL(window.location.href)}
|
|
wrapper={createRootProviders(client, target)}
|
|
/>
|
|
</PageFaultBoundary>
|
|
)
|
|
},
|
|
refuseUnroutedShell: () => {
|
|
renderShellTooOldPanel(container)
|
|
}
|
|
})
|