mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(mobile): declare externalLink on the two page routes that reach the protocol wall (OTA phase C follow-up) (#22113)
* test(mobile): hold every page route to the externalLink call site it reaches The grant call-site census carried an exact allowance for the two routes that reach the shared protocol wall's `openExternalLink` without declaring `externalLink`. Removing it makes the census enforce the declaration instead of recording the gap; it now names both routes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): declare externalLink on the worktree list and agent history Both routes render the shared layout's `HostProtocolGate`, whose `ProtocolBlockScreen` opens its Update Orca link through `openExternalLink`, and neither declared the grant: the tap posted a notify the shell refuses, with nothing on screen. The census measures one call site in each closure, `src/components/ProtocolBlockScreen.tsx`. Repinned by measurement, with the manifest change named: the route-list pin, and the handed-off hop census, which goes 23 rows to 19. The four rows that leave are these two routes into the explorer and its preview — all four now declare the same four grants, so a tapped file stays in the document instead of costing a native frame and a second bridge session. A new case asserts that coverage, so the four absences are load-bearing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -40,7 +40,7 @@ const SHELL_HOST = {
|
||||
}
|
||||
/** The manifest's own pairs, as the shell would send them. */
|
||||
const PAGE_ROUTE_GRANTS = [
|
||||
{ pathname: HOST_PATTERN, grants: ['navigate', 'storage', 'haptics'] },
|
||||
{ pathname: HOST_PATTERN, grants: ['navigate', 'storage', 'externalLink', 'haptics'] },
|
||||
{ pathname: FILES_PATTERN, grants: ['navigate', 'storage', 'externalLink', 'haptics'] },
|
||||
{
|
||||
pathname: TASKS_PATTERN,
|
||||
|
||||
@@ -41,25 +41,6 @@ function closureOf(pathname) {
|
||||
return held
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a page route reaches a seam it does not declare, recorded rather than exempted.
|
||||
*
|
||||
* `app/h/_layout.tsx` wraps every `/h` route in `HostProtocolGate`, whose `ProtocolBlockScreen`
|
||||
* offers an Update Orca link through `openExternalLink`. Six routes declare `externalLink` and two
|
||||
* do not, so on those two the wall's link posts a notify the shell refuses — a dead tap with
|
||||
* nothing on screen. Pre-existing on main and not C7.7's to change: widening two other routes'
|
||||
* grants is a capability decision, and this lane reports rather than fixes it.
|
||||
*
|
||||
* Exact, so it reds in both directions: adding the grant to either route empties an entry here and
|
||||
* a new gap anywhere adds one.
|
||||
*/
|
||||
const KNOWN_UNDECLARED = new Map([
|
||||
[
|
||||
'externalLink',
|
||||
['/h/[hostId] needs externalLink', '/h/[hostId]/agent-history/[worktreeId] needs externalLink']
|
||||
]
|
||||
])
|
||||
|
||||
describe('the call-site reader', () => {
|
||||
const navigate = PAGE_GRANT_CALL_SITES[0]
|
||||
const storage = PAGE_GRANT_CALL_SITES[1]
|
||||
@@ -150,10 +131,10 @@ describeClosure(
|
||||
*/
|
||||
it.each(PAGE_GRANT_CALL_SITES.map((row) => [row.grants.join(' + '), row]))(
|
||||
'declares %s on every registered route whose own call sites reach it',
|
||||
async (name, row) => {
|
||||
async (_name, row) => {
|
||||
expect(
|
||||
await grantsMissingForRow(mobileDir, MOBILE_WEB_PAGE_ROUTES, closureOf, row)
|
||||
).toEqual(KNOWN_UNDECLARED.get(name) ?? [])
|
||||
).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -44,8 +44,14 @@ function sameRoute(pushed, declared) {
|
||||
* What is NOT here is the point of the census. `files/[worktreeId] -> files/preview/[worktreeId]`
|
||||
* is absent because the preview declares no more than the explorer, so that hop stays in the
|
||||
* document — which is C3.1's pairwise pin, now a consequence of the rule rather than a rule of its
|
||||
* own. The two `-> tasks` entries and the four `-> files/*` entries are the hops the sidebar and
|
||||
* the rows make into a route that asks for more than their opener holds.
|
||||
* own. Absent for the same reason, and measured rather than reasoned: the four hops the worktree
|
||||
* list and the history screen make into the explorer and its preview, which left this list when
|
||||
* those two declared the `externalLink` their own protocol wall reaches and took it from 23 rows
|
||||
* to 19. The four now declare the same four grants, so a tapped file costs no native frame and no
|
||||
* second bridge session.
|
||||
*
|
||||
* What remains beside the session rows is twelve: four openers holding no `native.clipboard.write`
|
||||
* into the three routes that ask for it — tasks, the hub and review.
|
||||
*
|
||||
* Absent for the same reason, and the reason C4 registered its two routes in one PR:
|
||||
* `source-control ⇄ review` in both directions. The hub's rows push review and review replaces
|
||||
@@ -53,21 +59,17 @@ function sameRoute(pushed, declared) {
|
||||
* 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 fourteen grants are a strict
|
||||
* The seven C7 rows are the same rule with the arrows all one way: every one `X -> session`, one
|
||||
* from each other page route. The session screen's fourteen 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]',
|
||||
@@ -133,6 +135,33 @@ describe('in-page hops between page routes', () => {
|
||||
expect(preview.grants.filter((grant) => !explorer.grants.includes(grant))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the file hops local from the two routes whose rows open them', () => {
|
||||
// The other half of the four rows that left the list above. Asserted as coverage rather than as
|
||||
// their absence: an unregistered route is absent too, and a worktree row opening a file is the
|
||||
// hop a phone actually makes.
|
||||
const grantsOf = (pathname) => {
|
||||
const route = MOBILE_WEB_PAGE_ROUTES.find((entry) => entry.pathname === pathname)
|
||||
if (!route) {
|
||||
throw new Error(`${pathname} is not registered`)
|
||||
}
|
||||
return route.grants
|
||||
}
|
||||
const explorer = grantsOf('/h/[hostId]/files/[worktreeId]')
|
||||
const preview = grantsOf('/h/[hostId]/files/preview/[worktreeId]')
|
||||
expect(explorer.length, 'the explorer declares something to cover').toBeGreaterThan(0)
|
||||
for (const opener of ['/h/[hostId]', '/h/[hostId]/agent-history/[worktreeId]']) {
|
||||
const held = grantsOf(opener)
|
||||
expect(
|
||||
explorer.filter((grant) => !held.includes(grant)),
|
||||
opener
|
||||
).toEqual([])
|
||||
expect(
|
||||
preview.filter((grant) => !held.includes(grant)),
|
||||
opener
|
||||
).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
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// The worktree list. `navigate` because every row opens a session screen that is still native.
|
||||
// `storage` because its pins and its last-visited repo are the app's, not the document's.
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] },
|
||||
// `externalLink` for the one opener the census finds in this closure: `app/h/_layout.tsx` wraps
|
||||
// every `/h` route in `HostProtocolGate`, so without the grant the wall's Update Orca tap posts a
|
||||
// notify the shell refuses, with nothing on screen to say why.
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'externalLink', 'haptics'] },
|
||||
// Agent session history. `navigate` because a resumed session opens the session screen, which is
|
||||
// native, and because the list above now reaches this one without leaving the page. `storage`
|
||||
// because the host layout above every page route reads the app's own sidebar width.
|
||||
// `externalLink` for that same layout's wall, which is this route's only opener too.
|
||||
{
|
||||
pathname: '/h/[hostId]/agent-history/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'haptics']
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
},
|
||||
// Tasks. `navigate` for the session screens its rows open and for the Back that pops the native
|
||||
// stack; `storage` for the shared components it renders; `externalLink` for the provider links
|
||||
@@ -40,20 +44,16 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// The file explorer. `navigate` because its Back pops the native stack. `storage` for the shared
|
||||
// components the host layout renders above it.
|
||||
//
|
||||
// `externalLink` is transitive, not its own: a row opens the preview, and because that is a page
|
||||
// route and this list covers what it declares, the handoff keeps that push inside this document.
|
||||
// `externalLink` is the layout wall's here, the single opener the census finds in this closure:
|
||||
// nothing the explorer itself renders opens a URL. It is also inherited, and that is what makes
|
||||
// the hop below cheap: a row opens the preview, and because that is a page route and this list
|
||||
// covers what it declares, the handoff keeps that push inside this document.
|
||||
// Grants are resolved once, from the route the shell opened (`grantsForRoute` on
|
||||
// `session.routePathname`), so a preview reached that way runs under *this* route's grants for
|
||||
// the life of the session. Covering the preview is therefore what buys the cheap in-document hop,
|
||||
// not what makes it correct: a target this list did not cover would be handed to the shell and
|
||||
// reopened under its own grants instead. The census beside it reads that relation off this list.
|
||||
//
|
||||
// Nothing the explorer itself renders opens a URL. The two openers in its own closure are the
|
||||
// shared layout's — the protocol wall, and the New Workspace source field the sidebar renders on
|
||||
// a wide layout — and every `/h` route reaches both, `/h/[hostId]` included, which declares no
|
||||
// `externalLink`. That tablet tap stays dead on all of them: a pre-existing gap this route
|
||||
// neither widens nor fixes.
|
||||
//
|
||||
// The sidebar `HostScreen` the layout renders on a wide layout pushes to `/h/<id>/tasks` from
|
||||
// every page route, and no other route declares the `native.clipboard.write` that one asks for.
|
||||
// The handoff gives that hop to the shell rather than keeping it here, which is why this list
|
||||
@@ -62,11 +62,10 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
pathname: '/h/[hostId]/files/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
},
|
||||
// The file preview. Same three. `externalLink` is this route's own rather than inherited: a
|
||||
// Markdown preview renders links and `MobileMarkdown` opens them through the platform seam, which
|
||||
// is a consumer inside the domain rather than the shared wall. The explorer declares the same
|
||||
// list only because it can become this route in-page, so the two happen to be equal today and
|
||||
// the reasons are not.
|
||||
// The file preview. Same three. `externalLink` has a consumer inside the domain as well as the
|
||||
// shared wall every `/h` route carries: a Markdown preview renders links and `MobileMarkdown`
|
||||
// opens them through the platform seam. That second site is what the census finds here and not in
|
||||
// the explorer, which declares the same list for the wall and for the hop into this route.
|
||||
{
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
|
||||
@@ -46,10 +46,10 @@ async function withScratch(run) {
|
||||
* the route tree resolves to, and what the built manifest actually carries.
|
||||
*/
|
||||
const EXPECTED_PAGE_ROUTES = [
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'haptics'] },
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'externalLink', 'haptics'] },
|
||||
{
|
||||
pathname: '/h/[hostId]/agent-history/[worktreeId]',
|
||||
grants: ['navigate', 'storage', 'haptics']
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics']
|
||||
},
|
||||
{
|
||||
pathname: '/h/[hostId]/tasks',
|
||||
|
||||
Reference in New Issue
Block a user