feat(mobile): register the session page route (OTA phase C, C7.7)

One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this
route's own closure rather than carried from design §1. Measured here:

  navigate                 7 handoff sites
  externalLink             6 openers
  haptics                 24 trigger sites
  native.clipboard.write   6 sites
  native.clipboard.read    3 sites
  native.media.*           2 sites, one seam (`useMediaPicker`)
  screencastBinary         1 site (`MobileBrowserPane.tsx`)
  storage                 10 exact keys and 2 workspace-scoped, the previous commit's

`pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is
no second table to edit. The design's list was exactly right; the counts are what say so.

The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/
[worktreeId]`, one from each other page route, and none goes the other way: the session's ten
grants are a strict superset of every other route's, so every hop into it is handed to the shell
and every one of its own targets stays in the document. That second half is asserted as grant
coverage rather than as the absence of seven rows — absent is also what an unregistered route
looks like, which is the shape C4 already had to correct once.

Two censuses gained the route and one is new:
- The haptics seam census, whose route-module map moves to
  `mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a
  second copy that stops growing when the first one does.
- `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`:
  `MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and
  `QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is
  no native chrome behind them, so a bare Pressable is absent from the accessibility tree.
- `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the
  way the haptics census derives its token. C6 could not write it: the pane is mounted by a route
  rather than registered as one, so there was no route to pin the grant against (C6 ruling 3).

The derivation census gains C6's half measured against this route rather than against a module
closure read on its own, which is the other half of C6 ruling 3. The composed row for the session
route's own families waits on C7.8's table, and on C4.5's split before it.

Numbers, both ends measured on this tree, never summed:
- Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is
  `MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`.
- Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence
  is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split.
- Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side.

`mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and
`resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached
"no output". It tries the sibling first now, which is what the build actually chunked.

Without the manifest entry these red on this tree: `pins every hop the handoff must take away from
the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a
module for`, `reaches the built manifest`, `covers every page route and finds a control in each`,
both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure
asks for it`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-21 06:13:53 -04:00
parent 57370a36c6
commit 185914430d
12 changed files with 319 additions and 20 deletions
@@ -17,6 +17,10 @@ import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
PAGE_ROUTE_MODULES,
pageRouteModulesCoverTheManifest
} from './mobile-web-app-page-route-modules.mjs'
import {
HAPTICS_KINDS_MODULE,
HAPTICS_NATIVE,
@@ -33,16 +37,8 @@ const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.
const read = (file) => readFileSync(join(mobileDir, file), 'utf8')
/** The route module behind each declared page route, which is what a closure is read from. */
const ROUTE_MODULES = new Map([
['/h/[hostId]', 'app/h/[hostId]/index.tsx'],
['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'],
['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'],
['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'],
['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'],
['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'],
['/h/[hostId]/review/[worktreeId]', 'app/h/[hostId]/review/[worktreeId].tsx']
])
/** The route module behind each declared page route, shared with the screencast-lane census. */
const ROUTE_MODULES = PAGE_ROUTE_MODULES
const HAPTICS_GRANT = 'haptics'
@@ -269,10 +265,9 @@ describeClosure(
})
it('covers every declared page route, so a new one cannot be missed by this file', () => {
// The map above is a hand list of route modules; this is what holds it to the declarations.
expect([...ROUTE_MODULES.keys()].sort()).toEqual(
MOBILE_WEB_PAGE_ROUTES.map((route) => route.pathname).sort()
)
// The shared map is a hand list of route modules; this is what holds it to the declarations.
const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES)
expect(mapped).toEqual(declared)
})
/**
@@ -143,6 +143,32 @@ describeClosure('the browser pane closure', () => {
await expectClosureFamilies(closure.local, [C6_PIN_TABLE])
}, 60_000)
it('adds exactly those families to the session route, which is the route that mounts it', async () => {
// C6 ruling 3: a composed table is pinned against a route, and the pane had none — it is
// mounted by `MobileSessionActiveContent`, not registered. C7.7 registers that route, so the
// half is measured here against the page the shell actually serves rather than against a
// module closure read on its own. The difference matters: the session route reaches the whole
// of `src/session` around the pane, and a family the pane shares with the screen it sits in
// would be invisible in the module reading and present here.
const scenarios = JSON.parse(read('mobile/rpc-foundation/pilot-scenarios.json')).scenarios
const [layout, route] = await Promise.all([
mobileWebAppModuleClosure(['app/h/_layout']),
mobileWebAppRouteClosure('app/h/[hostId]/session/[worktreeId].tsx')
])
const layoutFamilies = pageClosureFamilies(layout.local, scenarios)
const routeFamilies = pageClosureFamilies(route.local, scenarios)
// The pane's four are in the route's set, and they are not the layout's, so the route is what
// brings them. Asserted as containment rather than as a difference: the session route reaches
// far more than the pane, and C7.8 is what pins its whole set.
for (const family of pinnedFamilyNames(read(C6_PIN_TABLE))) {
expect(routeFamilies, family).toContain(family)
expect(layoutFamilies, family).not.toContain(family)
}
// And the layout is the C1 control it is everywhere else, so the line above is a real
// difference rather than a set that happens to contain everything.
expect(layoutFamilies).toEqual(pinnedFamilyNames(read(C1_TABLE)).sort())
}, 300_000)
it('adds exactly those families to a page, and no other', async () => {
// The pin is a half: alone it would also pass if the pane dragged in a family C1 already pins
// and the table happened to list it. This reads the difference the pane makes to the layout.
@@ -0,0 +1,34 @@
/**
* The route module behind each declared page route, which is what a closure is read from.
*
* `MOBILE_WEB_PAGE_ROUTES` names URL patterns and the bundler walks files, so something has to
* join the two. Shared rather than restated in each census for the reason
* `mobile-web-app-external-link-seam.mjs` is: a second copy is a list that stops growing when the
* first one does, and every census over it goes quietly green on a route nobody added.
*
* Extensionless is deliberate on neither side: the `.tsx` is named because that is the file on
* disk, and the builder's own `resolveExtensions` picks the `.web.tsx` sibling ahead of it exactly
* as it would for the page.
*
* `pageRouteModulesCoverTheManifest` is the guard that holds this map to the manifest; every
* census that reads it asserts that too, so a route registered without a row here is a route no
* closure census reads.
*/
export const PAGE_ROUTE_MODULES = new Map([
['/h/[hostId]', 'app/h/[hostId]/index.tsx'],
['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'],
['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'],
['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'],
['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'],
['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'],
['/h/[hostId]/review/[worktreeId]', 'app/h/[hostId]/review/[worktreeId].tsx'],
['/h/[hostId]/session/[worktreeId]', 'app/h/[hostId]/session/[worktreeId].tsx']
])
/** The map's pathnames and the manifest's, each sorted, for a caller to compare. */
export function pageRouteModulesCoverTheManifest(routes) {
return {
mapped: [...PAGE_ROUTE_MODULES.keys()].sort(),
declared: routes.map((route) => route.pathname).sort()
}
}
@@ -6,6 +6,20 @@ import { collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
/**
* The file the build actually put in a chunk for this route, which is not always the one named.
*
* `resolveExtensions` puts `.web.tsx` ahead of `.tsx`, so a route with a sibling is bundled as the
* sibling and the named path appears in no output at all. Until C7.7 the session route had none
* and the lookup below was exact; the first route with a sibling to be asked for reached "no
* output" instead — a route that is served on the page reading as one the bundle never built.
*/
function chunkOwnerPaths(routeModule) {
const named = resolve(mobileDir, routeModule)
const sibling = named.replace(/\.(tsx?)$/, '.web.$1')
return sibling === named ? [named] : [sibling, named]
}
/**
* What a browser must download before one page route can paint, and what it may defer.
*
@@ -26,12 +40,14 @@ export async function mobileWebAppRouteChunkClosure(routeModule) {
metafile: true,
write: false
})
const routePath = resolve(mobileDir, routeModule)
const routePaths = chunkOwnerPaths(routeModule)
const owner = Object.entries(metafile.outputs).find(([, output]) =>
Object.keys(output.inputs ?? {}).some((input) => resolve(mobileDir, input) === routePath)
Object.keys(output.inputs ?? {}).some((input) => routePaths.includes(resolve(mobileDir, input)))
)
if (!owner) {
throw new Error(`[mobile-web-app-route-chunk-closure] ${routeModule} reached no output`)
throw new Error(
`[mobile-web-app-route-chunk-closure] ${routeModule} reached no output (tried ${routePaths.join(', ')})`
)
}
const reached = entryStaticClosure(metafile, owner[0])
const inputsOf = (outputs) =>
@@ -0,0 +1,102 @@
/**
* Which page routes mount the browser pane, and the grant the pane needs from each of them.
*
* Natively the socket carries the screencast's binary frames and the app is both halves of that
* path, so there is nothing to negotiate. In the page the frames come through a shell that may
* predate the encoder, and the pane asks first: `use-browser-binary-screencast-grant.web.ts` reads
* `init.grants.native`, and a route that did not declare `screencastBinary` subscribes without
* `wantsBinary` — a live pane on a stream no frame arrives on, with nothing on screen to say why.
*
* C6 could not write this census: the pane is mounted by a route rather than registered as one, so
* there was no route to pin the grant against (C6 ruling 3 deferred it to C7). The session route is
* that route, and this is the general rule rather than an entry for it — the haptics seam census's
* shape, against the other grant a shared component brings into a closure.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
PAGE_ROUTE_MODULES,
pageRouteModulesCoverTheManifest
} from './mobile-web-app-page-route-modules.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
/** The seam as the web build resolves it, and the native sibling the page must never reach. */
const SEAM = 'src/browser/use-browser-binary-screencast-grant.web.ts'
const NATIVE = 'src/browser/use-browser-binary-screencast-grant.ts'
/** Where the grant token is declared, so this file reads it rather than spelling it again. */
const GRANT_MODULE = 'src/mobile-web-shell/bridge/bridge-screencast-grant.ts'
/** The token, parsed off its own declaration: a second spelling is one that can drift. */
function screencastGrantToken() {
const source = readFileSync(join(mobileDir, GRANT_MODULE), 'utf8')
const declared = /BRIDGE_SCREENCAST_BINARY_GRANT = '([^']+)'/.exec(source)
if (declared === null) {
throw new Error(`${GRANT_MODULE} no longer declares the grant this census reads`)
}
return declared[1]
}
/** The modules that call the hook, which is the pane and whatever else grows one. */
function screencastGrantCallers(closure) {
return closure.local.filter((file) => {
if (!/\.tsx?$/.test(file) || file === SEAM || file === NATIVE) {
return false
}
return /\buseBrowserBinaryScreencastGrant\s*\(/.test(
readFileSync(join(mobileDir, file), 'utf8')
)
})
}
describe('the grant token this census is written against', () => {
it('is the one the shell declares', () => {
expect(screencastGrantToken()).toBe('screencastBinary')
})
})
describeClosure(
'the routes that mount the browser pane',
() => {
it('declares the screencast lane on exactly the routes whose closure asks for it', async () => {
const asking = []
for (const [route, mod] of PAGE_ROUTE_MODULES) {
const closure = await mobileWebAppRouteClosure(mod)
if (screencastGrantCallers(closure).length > 0) {
asking.push(route)
}
}
// One route today, and the precondition an assertion about a derived set needs: an empty
// list is also what a walk that read nothing produces.
expect(asking).toEqual(['/h/[hostId]/session/[worktreeId]'])
const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) =>
route.grants.includes(screencastGrantToken())
).map((route) => route.pathname)
expect([...declared].sort()).toEqual([...asking].sort())
})
it('reaches the seam through its web sibling, and the caller is the pane', async () => {
const closure = await mobileWebAppRouteClosure(
PAGE_ROUTE_MODULES.get('/h/[hostId]/session/[worktreeId]')
)
expect(closure.local).toContain(SEAM)
expect(closure.local).not.toContain(NATIVE)
expect(screencastGrantCallers(closure)).toEqual(['src/browser/MobileBrowserPane.tsx'])
// The pane is mounted by the session's content row rather than by a route of its own, which
// is the whole reason this grant had no route to be pinned against until now.
expect(closure.local).toContain('src/session/MobileSessionActiveContent.tsx')
})
it('covers every declared page route, so a new one cannot be missed by this file', () => {
const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES)
expect(mapped).toEqual(declared)
})
},
240_000
)
@@ -164,8 +164,21 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/'
* The module list on the merge, recorded at the base in the docstring above, which is where every
* part of it is accounted for: the document's own modules replacing the factory that carried them,
* mermaid's three, and the three bridge modules #21908 and C2.9 pin on main.
*
* C7.7 moves it by one, measured at both ends on this tree rather than summed: 4,328 at
* origin/main `23207bfde2` and 4,329 here, with local modules 978 -> 979. The +1 is the route body
* becoming a component — the walk now enters through `app/h/[hostId]/session/[worktreeId].web.tsx`
* instead of the native file and reaches `src/session/MobileSessionRouteScreen.tsx` under it, so
* the route file is one input either way and the component is the one that is new. Both are read
* out of the list below by name rather than inferred from the total.
*/
const SESSION_ROUTE_MODULES = 4328
const SESSION_ROUTE_MODULES = 4329
/** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */
const ROUTE_ENTRY = [
'app/h/[hostId]/session/[worktreeId].web.tsx',
'src/session/MobileSessionRouteScreen.tsx'
]
const artifactModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PAGE_ENGINE))
const packageModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PACKAGE))
@@ -203,6 +216,18 @@ describeClosure(
expect(local).not.toContain('src/terminal/terminal-webview-document-script.generated.ts')
}, 300_000)
it('enters through the web sibling and the route body, not the switch', async () => {
const { local } = await mobileWebAppRouteClosure(SESSION_ROUTE)
for (const entry of ROUTE_ENTRY) {
expect(local, `${entry} is not in the closure`).toContain(entry)
}
// The switch itself is what the shell renders natively, and it reaches
// `MobileWebShellScreen`, whose module calls `requireNativeViewManager` at import. A closure
// that carried it would be a bundle that throws when the manifest imports this route.
expect(local).not.toContain('app/h/[hostId]/session/[worktreeId].tsx')
expect(local).not.toContain('src/mobile-web-shell/MobileWebShellScreen.tsx')
}, 300_000)
it('reaches the engine as one deferred module and never as part of the download', async () => {
const { modules } = await mobileWebAppRouteClosure(SESSION_ROUTE)
// The engine is here, as the one artifact the loader imports.
@@ -52,24 +52,37 @@ function sameRoute(pushed, declared) {
* back, and the two declare the same five grants, so both hops stay in the document. Either one
* landing alone would have put a handoff — a new native screen and a new bridge session — between
* a changed-file row and its diff.
*
* The seven C7 rows are the same rule with the arrows all one way: 16 -> 23, every new entry
* `X -> session`, one from each other page route. The session screen's ten grants are a strict
* superset of every other route's, so nothing can reach it under the grants it was opened with —
* and nothing it pushes to leaves, because its own seven targets each declare a subset. A row in
* the other direction would mean a route had grown a grant the session lacks.
*/
const HANDED_OFF = [
'/h/[hostId] -> /h/[hostId]/files/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/files/preview/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/tasks',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/files/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/files/preview/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/tasks',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/tasks',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/tasks'
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/tasks',
'/h/[hostId]/review/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/source-control/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/tasks -> /h/[hostId]/session/[worktreeId]'
]
describe('in-page hops between page routes', () => {
@@ -120,6 +133,33 @@ describe('in-page hops between page routes', () => {
expect(preview.grants.filter((grant) => !explorer.grants.includes(grant))).toEqual([])
})
it('keeps every hop out of the session local, which is the other half of its seven rows', () => {
// Asserted as grant coverage rather than as the absence of seven rows: absent is also what an
// unregistered route looks like, and a `session -> tasks` handoff would read the same either
// way. Every target the session pushes to declares a subset of what it holds, so a tapped row
// stays in this document instead of costing a native frame and a second bridge session.
const session = MOBILE_WEB_PAGE_ROUTES.find(
(route) => route.pathname === '/h/[hostId]/session/[worktreeId]'
)
if (!session) {
throw new Error('the manifest lost the session route this census is written against')
}
const uncovered = MOBILE_WEB_PAGE_ROUTES.filter(
(target) => target.pathname !== session.pathname
)
.filter((target) => target.grants.some((grant) => !session.grants.includes(grant)))
.map((target) => target.pathname)
expect(uncovered).toEqual([])
// And the superset is strict, so the line above is not two equal lists.
expect(session.grants.length).toBeGreaterThan(
Math.max(
...MOBILE_WEB_PAGE_ROUTES.map((route) => route.grants.length).filter(
(length) => length !== session.grants.length
)
)
)
})
it('keeps the hub and review local to each other, in both directions', () => {
// The pair C4 registered together. Asserted as equality of the two grant lists rather than as
// the absence of two rows above: absent is also what an unregistered route looks like, and the
+43
View File
@@ -96,5 +96,48 @@ export const MOBILE_WEB_PAGE_ROUTES = [
{
pathname: '/h/[hostId]/review/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
},
// The session screen: terminal and chat. Ten grants, every one of them read off a call site in
// this route's own closure rather than carried from the design, and it is the only route that
// asks for the media verbs or the screencast lane.
//
// `navigate` for the Back that pops the native stack and for the seven handoff sites its panels
// push from; `storage` for the ten exact keys and two workspace-scoped ones its screens read,
// which is what `page-storage-keys.ts` now lists; `externalLink` for the six openers it reaches —
// a terminal link tap whose open mode is the phone's browser, the Markdown and file readers, and
// the PR segment it docks; `haptics` for twenty-four trigger sites, which is the most of any
// route. `native.clipboard.write` has six call sites (the quick-command row, the sheets, the diff
// note, the Markdown actions, the accessory selection, the PR conflict list) and
// `native.clipboard.read` three (the accessory selection, the terminal's paste, the attachment
// probe): this screen is the heaviest clipboard user in the app and the first route to need the
// read as well as the write.
//
// The three media verbs are one seam, `useMediaPicker`, reached from the image attachment and the
// chat's image upload. They are declared together because `canPickMedia` is
// `pick && read && release` — a picked image is a handle, then chunks, so a route holding fewer
// than all three can start a pick it cannot finish.
//
// `screencastBinary` is C6's, and this is the route C6 ruling 3 deferred it to: the browser pane
// is mounted by `MobileSessionActiveContent`, and `use-browser-binary-screencast-grant.web.ts`
// asks the shell through the grants `init` carried. Without it the pane subscribes without
// `wantsBinary` against a shell that would have encoded the frames.
//
// Dictation is not here. The vendored `ExpoTwoWayAudioModule.web.ts` answers permission denied,
// so the page degrades to the error the screen already has (ruling 4); the verbs that would
// replace it are C7.10's PR D and are not on main at this commit.
{
pathname: '/h/[hostId]/session/[worktreeId]',
grants: [
'navigate',
'storage',
'externalLink',
'haptics',
'screencastBinary',
'native.clipboard.write',
'native.clipboard.read',
'native.media.pick',
'native.media.read',
'native.media.release'
]
}
]
@@ -70,6 +70,21 @@ const EXPECTED_PAGE_ROUTES = [
{
pathname: '/h/[hostId]/review/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
},
{
pathname: '/h/[hostId]/session/[worktreeId]',
grants: [
'navigate',
'storage',
'externalLink',
'haptics',
'screencastBinary',
'native.clipboard.write',
'native.clipboard.read',
'native.media.pick',
'native.media.read',
'native.media.release'
]
}
]
@@ -62,7 +62,8 @@ const PAGE_SERVED_SCREENS = [
{
pathname: '/h/[hostId]/review/[worktreeId]',
screen: 'src/components/MobileDiffReviewHeader.tsx'
}
},
{ pathname: '/h/[hostId]/session/[worktreeId]', screen: 'src/session/MobileSessionHeader.tsx' }
]
/** The rule reads whole trees, so a Back added beside a screen is ruled as well as the screen's. */
@@ -65,6 +65,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={requestLeaveSession}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back to worktrees"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
@@ -160,6 +160,7 @@ export function QuickCommandsSheet({
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
onPress={() => setView(view === 'agent' ? 'editor' : 'list')}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={18} color={colors.textSecondary} />