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" >