diff --git a/config/scripts/mobile-web-app-haptics-seam.test.mjs b/config/scripts/mobile-web-app-haptics-seam.test.mjs
index d8f81c726c4..96c02c4b29b 100644
--- a/config/scripts/mobile-web-app-haptics-seam.test.mjs
+++ b/config/scripts/mobile-web-app-haptics-seam.test.mjs
@@ -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)
})
/**
diff --git a/config/scripts/mobile-web-app-page-closure-families.test.mjs b/config/scripts/mobile-web-app-page-closure-families.test.mjs
index a800faec9b6..ea6808ca155 100644
--- a/config/scripts/mobile-web-app-page-closure-families.test.mjs
+++ b/config/scripts/mobile-web-app-page-closure-families.test.mjs
@@ -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.
diff --git a/config/scripts/mobile-web-app-page-route-modules.mjs b/config/scripts/mobile-web-app-page-route-modules.mjs
new file mode 100644
index 00000000000..ebed987170f
--- /dev/null
+++ b/config/scripts/mobile-web-app-page-route-modules.mjs
@@ -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()
+ }
+}
diff --git a/config/scripts/mobile-web-app-route-chunk-closure.mjs b/config/scripts/mobile-web-app-route-chunk-closure.mjs
index b4cee5aaf27..c624622631a 100644
--- a/config/scripts/mobile-web-app-route-chunk-closure.mjs
+++ b/config/scripts/mobile-web-app-route-chunk-closure.mjs
@@ -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) =>
diff --git a/config/scripts/mobile-web-app-screencast-lane-grant.test.mjs b/config/scripts/mobile-web-app-screencast-lane-grant.test.mjs
new file mode 100644
index 00000000000..a568b1ae87b
--- /dev/null
+++ b/config/scripts/mobile-web-app-screencast-lane-grant.test.mjs
@@ -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
+)
diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs
index 8012021e52e..4f4826f47cd 100644
--- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs
+++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs
@@ -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.
diff --git a/config/scripts/mobile-web-page-route-hop-coverage.test.mjs b/config/scripts/mobile-web-page-route-hop-coverage.test.mjs
index bd41642232c..e6760a611f3 100644
--- a/config/scripts/mobile-web-page-route-hop-coverage.test.mjs
+++ b/config/scripts/mobile-web-page-route-hop-coverage.test.mjs
@@ -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
diff --git a/config/scripts/mobile-web-page-routes.mjs b/config/scripts/mobile-web-page-routes.mjs
index 56f70d9a4a9..4190cf60761 100644
--- a/config/scripts/mobile-web-page-routes.mjs
+++ b/config/scripts/mobile-web-page-routes.mjs
@@ -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'
+ ]
}
]
diff --git a/config/scripts/mobile-web-page-routes.test.mjs b/config/scripts/mobile-web-page-routes.test.mjs
index 9eaca6b37d1..cadd45baf38 100644
--- a/config/scripts/mobile-web-page-routes.test.mjs
+++ b/config/scripts/mobile-web-page-routes.test.mjs
@@ -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'
+ ]
}
]
diff --git a/mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts b/mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts
index 4b1850c7747..b1d63fa4a71 100644
--- a/mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts
+++ b/mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts
@@ -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. */
diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx
index 552f507a787..d74cbbcaca7 100644
--- a/mobile/src/session/MobileSessionHeader.tsx
+++ b/mobile/src/session/MobileSessionHeader.tsx
@@ -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"
>
diff --git a/mobile/src/session/QuickCommandsSheet.tsx b/mobile/src/session/QuickCommandsSheet.tsx
index 3342f122665..e828676f67b 100644
--- a/mobile/src/session/QuickCommandsSheet.tsx
+++ b/mobile/src/session/QuickCommandsSheet.tsx
@@ -160,6 +160,7 @@ export function QuickCommandsSheet({
[styles.backButton, pressed && styles.pressed]}
onPress={() => setView(view === 'agent' ? 'editor' : 'list')}
+ accessibilityRole="button"
accessibilityLabel="Back"
>