mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(mobile): serve the tasks screen from the page, with its seams (OTA phase C, C2.1 + C2.5) (#21694)
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)
`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.
Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.
The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)
Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.
Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.
The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.
No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)
The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.
Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.
The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.
The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.
A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)
The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.
A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.
Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.
Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)
The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.
One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.
No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)
`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`react-native-webview` is a native component with no browser counterpart:
importing it runs a codegen lookup that throws, and the route manifest imports
every route, so one such import takes the whole page down rather than one
diagram.
The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.
Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.
Registered in the overrides, whose gate fails on an unlisted `.web.*` file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)
The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.
The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.
The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.
The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.
The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)
`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.
Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.
The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)
Four from round 1.
The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.
The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.
`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.
`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.
No parity hash moved: none of these files is in the refactor-parity source
set.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(config): use endsWith for the clipboard module check
The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)
Five from round 2, two of them real.
The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.
The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.
Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -713,7 +713,9 @@ jobs:
|
||||
config/scripts/mobile-web-app-web-overrides.test.mjs \
|
||||
config/scripts/mobile-web-app-render.test.mjs \
|
||||
config/scripts/mobile-web-app-drawer-render.test.mjs \
|
||||
config/scripts/mobile-web-app-agent-history-render.test.mjs
|
||||
config/scripts/mobile-web-app-agent-history-render.test.mjs \
|
||||
config/scripts/mobile-web-app-tasks-render.test.mjs \
|
||||
config/scripts/mobile-web-app-tasks-external-links.test.mjs
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
|
||||
@@ -338,6 +338,44 @@ export function routeChunkNames(metafile, routes, renamed) {
|
||||
const isScriptOutput = (path) => path.endsWith('.js')
|
||||
|
||||
// appDir is a seam for the tests, which bundle a scratch route tree; production always uses mobile/app.
|
||||
/**
|
||||
* Every source module one page route reaches, as the builder itself resolves them.
|
||||
*
|
||||
* One definition of "what a page contains", read from `metafile.inputs` — the modules the route
|
||||
* pulls in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a
|
||||
* browser must download. Both entry points are needed: `app/h/_layout.tsx` wraps every route under
|
||||
* it, and its imports are part of the page as surely as the route module's.
|
||||
*
|
||||
* `splitting: false` and a per-name output are required for a two-entry build; with the defaults
|
||||
* esbuild fails on two outputs claiming `dist/entry.js`.
|
||||
*
|
||||
* Note for anyone comparing this with a parity pin: `c1-page-closure.ts`, and the closures C2.6,
|
||||
* C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are
|
||||
* not the same computation, and a divergence between them is a finding rather than noise.
|
||||
*/
|
||||
export async function mobileWebAppRouteClosure(routeModule) {
|
||||
const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES)
|
||||
const result = await esbuild.build({
|
||||
...base,
|
||||
// Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a
|
||||
// `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure
|
||||
// the native switch no browser ever loads.
|
||||
entryPoints: ['app/h/_layout', routeModule.replace(/\.tsx?$/, '')],
|
||||
splitting: false,
|
||||
entryNames: '[name]',
|
||||
plugins: base.plugins.filter((plugin) => plugin.name !== ROUTE_MANIFEST_PLUGIN_NAME),
|
||||
write: false,
|
||||
metafile: true,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
const inputs = Object.keys(result.metafile.inputs)
|
||||
return {
|
||||
modules: inputs,
|
||||
/** Everything outside `node_modules`: this repository's own source, which a census reads. */
|
||||
local: inputs.filter((input) => !input.includes('node_modules'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function bundleMobileWebApp({ appDir = defaultAppDir } = {}) {
|
||||
const routes = await collectMobileWebAppRoutes(appDir)
|
||||
await assertRoutesCarryNoSynchronousExports(routes)
|
||||
|
||||
@@ -90,7 +90,11 @@ describe('the page routes the manifest declares', () => {
|
||||
const keys = await collectMobileWebAppRouteKeys(appDir)
|
||||
expect(resolveMobileWebPageRoutes(keys)).toEqual([
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
|
||||
{
|
||||
pathname: '/h/[hostId]/tasks',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
@@ -112,7 +116,11 @@ describe('the page routes the manifest declares', () => {
|
||||
const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
|
||||
expect(manifest.routes).toEqual([
|
||||
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] },
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
|
||||
{
|
||||
pathname: '/h/[hostId]/tasks',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
||||
}
|
||||
])
|
||||
// The routes are derived from the same tree the script is built from, so the assets
|
||||
// already decide them and the id has no reason to carry them as well.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Nothing the tasks page reaches opens a URL through react-native, or a clipboard through the
|
||||
* browser's.
|
||||
*
|
||||
* Inside the shell's WebView react-native-web's `Linking.openURL` calls
|
||||
* `window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
|
||||
* `createWebViewWith`, Android false from `onCreateWindow` — and resolves whether or not anything
|
||||
* opened. So a call site left on that path reports success into a tap that did nothing, which is
|
||||
* the one failure the `externalLink` grant exists to remove.
|
||||
*
|
||||
* The rule, not the twelve call sites it happens to have today: a module entering this closure
|
||||
* later is held to it without anyone remembering to add it here.
|
||||
*/
|
||||
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'
|
||||
|
||||
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
|
||||
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
|
||||
|
||||
/** The seam, as the web build resolves it: `.web.ts` wins under the builder's resolveExtensions,
|
||||
* and it is the one module in this closure allowed to reach react-native's `Linking`. */
|
||||
const SEAM = 'src/platform/external-link.web.ts'
|
||||
|
||||
/** Whether a module reaches react-native's own `Linking`, by name or through a namespace import. */
|
||||
function reachesReactNativeLinking(source) {
|
||||
const named = /import\s*\{[^}]*\bLinking\b[^}]*\}\s*from\s*'react-native'/s
|
||||
const namespace = /import\s*\*\s*as\s*(\w+)\s*from\s*'react-native'/
|
||||
const asNamespace = namespace.exec(source)
|
||||
return (
|
||||
named.test(source) || (asNamespace !== null && source.includes(`${asNamespace[1]}.Linking`))
|
||||
)
|
||||
}
|
||||
|
||||
describeClosure(
|
||||
'the tasks page closure',
|
||||
() => {
|
||||
it('opens every external URL through the platform seam', async () => {
|
||||
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
|
||||
const offenders = closure.local
|
||||
.filter((file) => file !== SEAM)
|
||||
.filter((file) => {
|
||||
try {
|
||||
// Which module the name comes from, not which text a call site writes: the tasks tree
|
||||
// still calls `Linking.openURL`, and that `Linking` is the barrel's seam-backed export.
|
||||
return reachesReactNativeLinking(readFileSync(join(mobileDir, file), 'utf8'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(offenders.sort()).toEqual([])
|
||||
})
|
||||
|
||||
it('contains the seam, so the rule above is not vacuous', async () => {
|
||||
// Without this an empty offender list would also be what a closure that reaches no link code
|
||||
// at all produces, and the census would pass against a page that opens nothing.
|
||||
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
|
||||
expect(closure.local).toContain(SEAM)
|
||||
expect(closure.local.length).toBeGreaterThan(400)
|
||||
})
|
||||
},
|
||||
180_000
|
||||
)
|
||||
|
||||
/**
|
||||
* Nothing the tasks page reaches writes the clipboard through the browser's own.
|
||||
*
|
||||
* `expo-clipboard` resolves to `ExpoClipboard.web.js`, which is `navigator.clipboard`: it needs a
|
||||
* secure context, and the iOS shell serves the page from a custom scheme while Android serves
|
||||
* `https`, so that path works on one platform and silently not on the other. The verb exists so
|
||||
* neither has to be guessed at a call site.
|
||||
*
|
||||
* Asserted as the module's absence from the closure rather than as a count of importers: a new
|
||||
* import anywhere in the tree puts the file back, whoever writes it and whatever they name it.
|
||||
*/
|
||||
describeClosure(
|
||||
'the clipboard the tasks page reaches',
|
||||
() => {
|
||||
it("does not carry expo-clipboard's web module at all", async () => {
|
||||
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
|
||||
const browserClipboard = closure.modules.filter((file) =>
|
||||
file.endsWith('ExpoClipboard.web.js')
|
||||
)
|
||||
expect(browserClipboard).toEqual([])
|
||||
})
|
||||
|
||||
it('carries the seam that replaced it, so the absence above is not vacuous', async () => {
|
||||
// An empty list is also what a closure reaching no clipboard code at all would produce.
|
||||
const closure = await mobileWebAppRouteClosure('app/h/[hostId]/tasks.tsx')
|
||||
expect(closure.local).toContain('src/platform/clipboard.web.ts')
|
||||
})
|
||||
},
|
||||
180_000
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installShellDouble,
|
||||
readBridgeFaultGrant,
|
||||
readBridgeProtocolVersion,
|
||||
readShellCsp
|
||||
} from './mobile-web-app-render-harness.mjs'
|
||||
|
||||
/**
|
||||
* The tasks page route in a real browser: its own file, as C1.10 split the harness for.
|
||||
*
|
||||
* What only a browser answers for this route: that every module in its closure evaluates under
|
||||
* React Native Web, that the provider param the shell names reaches the screen, and that its chunk
|
||||
* arrives over the wire.
|
||||
*
|
||||
* It does not cover the three seams this series added. The closing note below says why, and where
|
||||
* each is proved instead.
|
||||
*/
|
||||
|
||||
const HOST_ROUTE = '/h/render-check-host'
|
||||
const TASKS_ROUTE = `${HOST_ROUTE}/tasks`
|
||||
/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */
|
||||
const PAGE_ROUTE_PATTERNS = ['/h/[hostId]', '/h/[hostId]/tasks']
|
||||
const SHELL_SESSION_ID = 'render-check-session'
|
||||
const SHELL_BUILD_ID = 'render-check-build'
|
||||
const SHELL_HOST = {
|
||||
id: 'render-check-host',
|
||||
name: 'Render Check Host',
|
||||
endpoint: 'ws://render-check',
|
||||
lastConnected: 1
|
||||
}
|
||||
const UNMATCHED = 'Unmatched Route'
|
||||
const ROUTE_KEY = './h/[hostId]/tasks.tsx'
|
||||
/** Exactly what the route declares in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own grant. */
|
||||
const TASKS_GRANTS = ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeRender = bundles ? describe : describe.skip
|
||||
|
||||
let scratch
|
||||
let server
|
||||
let browser
|
||||
let origin
|
||||
let routeChunks = {}
|
||||
let cspHeader = null
|
||||
let bridgeVersion = null
|
||||
let faultGrant = null
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!bundles) {
|
||||
return
|
||||
}
|
||||
cspHeader = await readShellCsp()
|
||||
bridgeVersion = await readBridgeProtocolVersion()
|
||||
faultGrant = await readBridgeFaultGrant()
|
||||
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-tasks-'))
|
||||
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
|
||||
routeChunks = built.routeChunks
|
||||
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
|
||||
server = served.server
|
||||
origin = served.origin
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
server?.close()
|
||||
if (scratch) {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
/** A page carrying every signal these cases read: uncaught errors, console errors, script paths. */
|
||||
async function openPage({ shellRoute, shellGrants, shellPageRoutes = null } = {}) {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
// At document start, where the native shell installs the real channel: the entry reads it while
|
||||
// its own script runs, so a channel added after `load` would already be too late.
|
||||
await page.addInitScript(installShellDouble, {
|
||||
version: bridgeVersion,
|
||||
sessionId: SHELL_SESSION_ID,
|
||||
buildId: SHELL_BUILD_ID,
|
||||
route: shellRoute,
|
||||
host: SHELL_HOST,
|
||||
storage: {},
|
||||
faultGrant,
|
||||
// The harness falls back to the fault grant alone, which is the ungranted page.
|
||||
grants: shellGrants ?? [faultGrant],
|
||||
pageRoutes: shellPageRoutes
|
||||
})
|
||||
const errors = []
|
||||
const scripts = []
|
||||
let reportUncaught = () => {}
|
||||
const uncaught = new Promise((resolve) => {
|
||||
reportUncaught = resolve
|
||||
})
|
||||
page.on('pageerror', (error) => {
|
||||
errors.push(`${error.name}: ${error.message}`)
|
||||
reportUncaught(error)
|
||||
})
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
errors.push(`console.error: ${message.text()}`)
|
||||
}
|
||||
})
|
||||
page.on('response', (response) => {
|
||||
const path = new URL(response.url()).pathname
|
||||
if (response.status() === 200 && path.endsWith('.js')) {
|
||||
scripts.push(path)
|
||||
}
|
||||
})
|
||||
return { page, errors, scripts, uncaught }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
|
||||
* every screen is deferred behind `import()`, so `mounted` lands while the chunk is still arriving.
|
||||
*/
|
||||
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
|
||||
const named = (cause, what) =>
|
||||
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
|
||||
const race = async (wait) =>
|
||||
Promise.race([
|
||||
wait.then(
|
||||
() => null,
|
||||
(error) => error
|
||||
),
|
||||
uncaught
|
||||
])
|
||||
const cause = await race(
|
||||
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
|
||||
timeout: 30_000,
|
||||
polling: 250
|
||||
})
|
||||
)
|
||||
if (cause) {
|
||||
const state = await page.evaluate(
|
||||
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
|
||||
)
|
||||
throw named(cause, `never mounted (entry ${state})`)
|
||||
}
|
||||
const paintCause = await race(
|
||||
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
|
||||
timeout: 30_000,
|
||||
polling: 250
|
||||
})
|
||||
)
|
||||
if (paintCause) {
|
||||
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
|
||||
}
|
||||
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
|
||||
errors.push(`page fault: ${fault}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the document at `/`, the one path the shell serves, and lets the page route itself. */
|
||||
async function openRoute(route, awaitText, options = {}) {
|
||||
const opened = await openPage({ shellRoute: { pathname: route }, ...options })
|
||||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||||
await waitForRoute(opened, route, awaitText)
|
||||
return opened
|
||||
}
|
||||
|
||||
describeRender('the tasks route in a real browser', () => {
|
||||
/**
|
||||
* Every module in this route's closure imports and evaluates under React Native Web. How many
|
||||
* that is, and which, is pinned by `mobile-web-app-tasks-external-links.test.mjs`; repeating a
|
||||
* count here would be a second number to keep in step with the first.
|
||||
*
|
||||
* The unit tests cannot say this: they mock react-native, safe-area, svg, lucide and the icon
|
||||
* assets away, because react-native is Flow source vitest will not parse. Import-time breakage
|
||||
* in any of those modules has no other test.
|
||||
*/
|
||||
it('mounts the tasks screen rather than the unmatched route', async () => {
|
||||
const opened = await openRoute(TASKS_ROUTE, 'Tasks', {
|
||||
shellGrants: [faultGrant, ...TASKS_GRANTS],
|
||||
shellPageRoutes: PAGE_ROUTE_PATTERNS
|
||||
})
|
||||
const text = await opened.page.evaluate(() => document.body.innerText)
|
||||
expect(text).toContain('Tasks')
|
||||
expect(text).not.toContain(UNMATCHED)
|
||||
expect(opened.errors).toEqual([])
|
||||
await opened.page.close()
|
||||
}, 60_000)
|
||||
|
||||
it('carries the provider the shell named into the url the screen reads', async () => {
|
||||
// `taskSource` is the one page route with a query param, and it crosses in
|
||||
// `init.route.params`. Without this the param is only assumed to survive the handshake.
|
||||
const opened = await openPage({
|
||||
shellRoute: { pathname: TASKS_ROUTE, params: { taskSource: 'linear' } },
|
||||
shellGrants: [faultGrant, ...TASKS_GRANTS],
|
||||
shellPageRoutes: PAGE_ROUTE_PATTERNS
|
||||
})
|
||||
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
|
||||
await waitForRoute(opened, TASKS_ROUTE, 'Tasks')
|
||||
const url = await opened.page.evaluate(() => location.pathname + location.search)
|
||||
expect(url).toBe(`${TASKS_ROUTE}?taskSource=linear`)
|
||||
expect(opened.errors).toEqual([])
|
||||
await opened.page.close()
|
||||
}, 60_000)
|
||||
|
||||
it("fetches this route's own chunk on a client-side navigation", async () => {
|
||||
const opened = await openRoute(HOST_ROUTE, SHELL_HOST.name, {
|
||||
shellGrants: [faultGrant, ...TASKS_GRANTS],
|
||||
shellPageRoutes: PAGE_ROUTE_PATTERNS
|
||||
})
|
||||
const loadedForFirstRoute = [...opened.scripts]
|
||||
await opened.page.evaluate((to) => {
|
||||
history.pushState(null, '', to)
|
||||
dispatchEvent(new PopStateEvent('popstate'))
|
||||
}, TASKS_ROUTE)
|
||||
await waitForRoute(opened, TASKS_ROUTE, 'Tasks')
|
||||
const chunk = routeChunks[ROUTE_KEY]
|
||||
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
|
||||
// Named by the builder rather than guessed from the bytes: this is what says the route came
|
||||
// over the wire now and not out of what the first route had already loaded.
|
||||
expect(opened.scripts.filter((path) => !loadedForFirstRoute.includes(path))).toContain(
|
||||
`/assets/${chunk}`
|
||||
)
|
||||
expect(loadedForFirstRoute).not.toContain(`/assets/${chunk}`)
|
||||
await opened.page.close()
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
/**
|
||||
* What this file deliberately does not claim.
|
||||
*
|
||||
* The three seams this series added — the barrel's `Linking`, the router handoff and the clipboard
|
||||
* verb — are each reached from a control that only renders once the screen has provider data, and
|
||||
* the shell double answers no provider RPC. A case that posted those frames onto the channel
|
||||
* itself would prove the double and the transport, which the bridge suites already prove, and
|
||||
* would read as a tap that it never performed.
|
||||
*
|
||||
* Where each is proved instead: the barrel's export and the router's, by the source census in
|
||||
* `mobile/src/tasks/mobile-tasks-external-link.test.ts`; the closure having no react-native
|
||||
* `Linking` left in it, by `mobile-web-app-tasks-external-links.test.mjs`; the verb end to end,
|
||||
* by the host and port-pair suites. A tap-level proof needs provider replies lifted from the
|
||||
* recorded corpus, the way the agent-history check lifts its session list, and belongs with the
|
||||
* device proof rather than here.
|
||||
*/
|
||||
@@ -20,5 +20,13 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
// 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.
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }
|
||||
{ pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] },
|
||||
// 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
|
||||
// in its items, checks and drawers; `native.clipboard.write` for the two copy actions in its
|
||||
// comment review. Grants are scoped per route, so naming fewer here serves fewer.
|
||||
{
|
||||
pathname: '/h/[hostId]/tasks',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,77 +1,49 @@
|
||||
import { useMobileTasksRouteAndItemState } from '../../../src/tasks/use-mobile-tasks-route-and-item-state'
|
||||
import { useMobileTasksWorkspaceAndProjectState } from '../../../src/tasks/use-mobile-tasks-workspace-and-project-state'
|
||||
import { useMobileTasksProjectProjection } from '../../../src/tasks/use-mobile-tasks-project-projection'
|
||||
import { useMobileTasksProjectRepositoryResolution } from '../../../src/tasks/use-mobile-tasks-project-repository-resolution'
|
||||
import { useMobileTasksClientSettingsActions } from '../../../src/tasks/use-mobile-tasks-client-settings-actions'
|
||||
import { useMobileTasksRuntimeHydration } from '../../../src/tasks/use-mobile-tasks-runtime-hydration'
|
||||
import { useMobileTasksProviderLoadActions } from '../../../src/tasks/use-mobile-tasks-provider-load-actions'
|
||||
import { useMobileTasksTaskListLoading } from '../../../src/tasks/use-mobile-tasks-task-list-loading'
|
||||
import { useMobileTasksTaskPaginationActions } from '../../../src/tasks/use-mobile-tasks-task-pagination-actions'
|
||||
import { useMobileTasksProjectLoadingActions } from '../../../src/tasks/use-mobile-tasks-project-loading-actions'
|
||||
import { useMobileTasksListAndDetailEffects } from '../../../src/tasks/use-mobile-tasks-list-and-detail-effects'
|
||||
import { useMobileTasksItemDetailMetadataEffects } from '../../../src/tasks/use-mobile-tasks-item-detail-metadata-effects'
|
||||
import { useMobileTasksItemDetailLoading } from '../../../src/tasks/use-mobile-tasks-item-detail-loading'
|
||||
import { useMobileTasksProjectDetailLoading } from '../../../src/tasks/use-mobile-tasks-project-detail-loading'
|
||||
import { useMobileTasksProjectMetadataLoading } from '../../../src/tasks/use-mobile-tasks-project-metadata-loading'
|
||||
import { useMobileTasksWorkspaceCreateProjection } from '../../../src/tasks/use-mobile-tasks-workspace-create-projection'
|
||||
import { useMobileTasksWorkspaceSourceEffects } from '../../../src/tasks/use-mobile-tasks-workspace-source-effects'
|
||||
import { useMobileTasksWorkspaceSparseActions } from '../../../src/tasks/use-mobile-tasks-workspace-sparse-actions'
|
||||
import { useMobileTasksWorkspaceSshState } from '../../../src/tasks/use-mobile-tasks-workspace-ssh-state'
|
||||
import { useMobileTasksWorkspaceCreateActions } from '../../../src/tasks/use-mobile-tasks-workspace-create-actions'
|
||||
import { useMobileTasksProjectWorkspaceCommentActions } from '../../../src/tasks/use-mobile-tasks-project-workspace-comment-actions'
|
||||
import { useMobileTasksProjectThreadReplyActions } from '../../../src/tasks/use-mobile-tasks-project-thread-reply-actions'
|
||||
import { useMobileTasksProjectMetadataActions } from '../../../src/tasks/use-mobile-tasks-project-metadata-actions'
|
||||
import { useMobileTasksProjectReviewCheckActions } from '../../../src/tasks/use-mobile-tasks-project-review-check-actions'
|
||||
import { useMobileTasksProjectFileMergeActions } from '../../../src/tasks/use-mobile-tasks-project-file-merge-actions'
|
||||
import { useMobileTasksGitlabGithubStatusActions } from '../../../src/tasks/use-mobile-tasks-gitlab-github-status-actions'
|
||||
import { useMobileTasksHostedMetadataActions } from '../../../src/tasks/use-mobile-tasks-hosted-metadata-actions'
|
||||
import { useMobileTasksHostedCommentReviewActions } from '../../../src/tasks/use-mobile-tasks-hosted-comment-review-actions'
|
||||
import { useMobileTasksGithubCheckFileActions } from '../../../src/tasks/use-mobile-tasks-github-check-file-actions'
|
||||
import { useMobileTasksGithubReplyMergeActions } from '../../../src/tasks/use-mobile-tasks-github-reply-merge-actions'
|
||||
import { useMobileTasksLinearItemActions } from '../../../src/tasks/use-mobile-tasks-linear-item-actions'
|
||||
import { useMobileTasksTaskCreateActions } from '../../../src/tasks/use-mobile-tasks-task-create-actions'
|
||||
import { useMobileTasksDetailCommentRenderers } from '../../../src/tasks/use-mobile-tasks-detail-comment-renderers'
|
||||
import { useMobileTasksPickerProjection } from '../../../src/tasks/use-mobile-tasks-picker-projection'
|
||||
import { useMobileTasksProviderViewProjection } from '../../../src/tasks/use-mobile-tasks-provider-view-projection'
|
||||
import { useMobileTasksConnectionPresentation } from '../../../src/tasks/use-mobile-tasks-connection-presentation'
|
||||
import { MobileTasksLegacySurface } from '../../../src/tasks/MobileTasksLegacySurface'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { BridgeInitRouteSchema } from '../../../src/mobile-web-shell/bridge/bridge-envelope'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { useMobileWebShellEnabled } from '../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
|
||||
import { firstParam } from '../../../src/source-control/mobile-source-control-screen-state'
|
||||
import { MobileTasksScreen } from '../../../src/tasks/MobileTasksScreen'
|
||||
|
||||
export default function MobileTasksScreen() {
|
||||
const stage1 = useMobileTasksRouteAndItemState()
|
||||
const stage2 = useMobileTasksWorkspaceAndProjectState(stage1)
|
||||
const stage3 = useMobileTasksProjectProjection(stage2)
|
||||
const stage4 = useMobileTasksProjectRepositoryResolution(stage3)
|
||||
const stage5 = useMobileTasksClientSettingsActions(stage4)
|
||||
const stage6 = useMobileTasksRuntimeHydration(stage5)
|
||||
const stage7 = useMobileTasksProviderLoadActions(stage6)
|
||||
const stage8 = useMobileTasksTaskListLoading(stage7)
|
||||
const stage9 = useMobileTasksTaskPaginationActions(stage8)
|
||||
const stage10 = useMobileTasksProjectLoadingActions(stage9)
|
||||
const stage11 = useMobileTasksListAndDetailEffects(stage10)
|
||||
const stage12 = useMobileTasksItemDetailMetadataEffects(stage11)
|
||||
const stage13 = useMobileTasksItemDetailLoading(stage12)
|
||||
const stage14 = useMobileTasksProjectDetailLoading(stage13)
|
||||
const stage15 = useMobileTasksProjectMetadataLoading(stage14)
|
||||
const stage16 = useMobileTasksWorkspaceCreateProjection(stage15)
|
||||
const stage17 = useMobileTasksWorkspaceSourceEffects(stage16)
|
||||
const stage18 = useMobileTasksWorkspaceSparseActions(stage17)
|
||||
const stage19 = useMobileTasksWorkspaceSshState(stage18)
|
||||
const stage20 = useMobileTasksWorkspaceCreateActions(stage19)
|
||||
const stage21 = useMobileTasksProjectWorkspaceCommentActions(stage20)
|
||||
const stage22 = useMobileTasksProjectThreadReplyActions(stage21)
|
||||
const stage23 = useMobileTasksProjectMetadataActions(stage22)
|
||||
const stage24 = useMobileTasksProjectReviewCheckActions(stage23)
|
||||
const stage25 = useMobileTasksProjectFileMergeActions(stage24)
|
||||
const stage26 = useMobileTasksGitlabGithubStatusActions(stage25)
|
||||
const stage27 = useMobileTasksHostedMetadataActions(stage26)
|
||||
const stage28 = useMobileTasksHostedCommentReviewActions(stage27)
|
||||
const stage29 = useMobileTasksGithubCheckFileActions(stage28)
|
||||
const stage30 = useMobileTasksGithubReplyMergeActions(stage29)
|
||||
const stage31 = useMobileTasksLinearItemActions(stage30)
|
||||
const stage32 = useMobileTasksTaskCreateActions(stage31)
|
||||
const stage33 = useMobileTasksDetailCommentRenderers(stage32)
|
||||
const stage34 = useMobileTasksPickerProjection(stage33)
|
||||
const stage35 = useMobileTasksProviderViewProjection(stage34)
|
||||
const stage36 = useMobileTasksConnectionPresentation(stage35)
|
||||
return MobileTasksLegacySurface({ model: stage36 })
|
||||
/**
|
||||
* The shell's switch for this route, in `index.tsx`'s shape.
|
||||
*
|
||||
* The page is opened from the native home screen, so the pathname and the provider param are what
|
||||
* the shell tells it; `taskSource` rides in `init.route.params`, which the page folds back into
|
||||
* its own URL before the first render.
|
||||
*/
|
||||
export default function MobileTasksRoute() {
|
||||
// Through `firstParam`, as the agent-history switch does: expo-router hands back an array for a
|
||||
// repeated query key, and a bare read builds `/h/host-a%2Chost-b/tasks` out of one.
|
||||
const params = useLocalSearchParams<{
|
||||
hostId?: string | string[]
|
||||
taskSource?: string | string[]
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const taskSource = firstParam(params.taskSource)
|
||||
const enabled = useMobileWebShellEnabled()
|
||||
const native = <MobileTasksScreen />
|
||||
|
||||
if (enabled !== true || !hostId) {
|
||||
return native
|
||||
}
|
||||
const route = {
|
||||
pathname: `/h/${encodeURIComponent(hostId)}/tasks`,
|
||||
// Omitted rather than empty: an absent provider lets the page pick its own default, where
|
||||
// `taskSource=` is a provider named nothing.
|
||||
...(taskSource === '' ? {} : { params: { taskSource } })
|
||||
}
|
||||
if (!BridgeInitRouteSchema.safeParse(route).success) {
|
||||
return native
|
||||
}
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
// Keyed for the reason every shell route is: a host holds the grants its session opened
|
||||
// with, so a host id change must be a remount rather than a prop update.
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MobileTasksScreen } from '../../../src/tasks/MobileTasksScreen'
|
||||
|
||||
/**
|
||||
* Web sibling for the tasks screen, in `index.web.tsx`'s shape.
|
||||
*
|
||||
* This page is what the shell renders for this route, so there is no shell to mount here and no
|
||||
* flag to read: the switch already happened natively. Its native file reaches
|
||||
* `OrcaMobileWebShellView`, whose module calls `requireNativeViewManager` at import and throws in
|
||||
* a browser, and one throwing route module takes the whole bundle down because the manifest
|
||||
* imports them all.
|
||||
*/
|
||||
export default function MobileTasksRoute() {
|
||||
return <MobileTasksScreen />
|
||||
}
|
||||
@@ -23,7 +23,10 @@ vi.mock('react-native', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
router: { replace: vi.fn() }
|
||||
router: { replace: vi.fn() },
|
||||
// `ProtocolBlockScreen` reaches the router through the navigation handoff now, and the handoff's
|
||||
// native form is this hook. Its web form is what posts the target to the shell.
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), dismissTo: vi.fn() })
|
||||
}))
|
||||
|
||||
// Why: mock only client acquisition; the gate must exercise the real
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
|
||||
import { MobileSelectableText } from './MobileSelectableText'
|
||||
import {
|
||||
@@ -10,14 +11,7 @@ import {
|
||||
type ComponentType,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import {
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text as NativeText,
|
||||
View,
|
||||
type TextProps
|
||||
} from 'react-native'
|
||||
import { Pressable, ScrollView, Text as NativeText, View, type TextProps } from 'react-native'
|
||||
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
||||
import { styles } from './mobile-markdown-styles'
|
||||
import {
|
||||
@@ -65,7 +59,9 @@ function MarkdownText(props: TextProps): React.JSX.Element {
|
||||
function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
|
||||
const route = routeMarkdownHref(href)
|
||||
if (route.kind === 'web') {
|
||||
void Linking.openURL(route.url).catch(() => {})
|
||||
// The seam, not react-native's `Linking`: this module is in the tasks page closure, and inside
|
||||
// the shell's WebView `openURL` resolves without opening anything.
|
||||
openExternalLink(route.url)
|
||||
return
|
||||
}
|
||||
if (route.kind === 'file' && onOpenFile) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export function NewWorktreeFormSheet(props: {
|
||||
creating: boolean
|
||||
canCreate: boolean
|
||||
onClose: () => void
|
||||
onOpenExternalUrl: (url: string) => Promise<unknown>
|
||||
onOpenExternalUrl: (url: string) => void
|
||||
onOpenProject: () => void
|
||||
onOpenRunTarget: () => void
|
||||
onOpenSource: () => void
|
||||
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
openExternalUrl: (url: string) => Promise<unknown>
|
||||
openExternalUrl: (url: string) => void
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
onRouteVisibleChange: (visible: boolean) => void
|
||||
onCreated: (worktreeId: string, name: string, warning?: string) => void
|
||||
|
||||
@@ -20,7 +20,10 @@ vi.mock('react-native', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
router: { replace: vi.fn() }
|
||||
router: { replace: vi.fn() },
|
||||
// `ProtocolBlockScreen` reaches the router through the navigation handoff now, and the handoff's
|
||||
// native form is this hook. Its web form is what posts the target to the shell.
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), dismissTo: vi.fn() })
|
||||
}))
|
||||
|
||||
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Linking, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { router } from 'expo-router'
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { useRouteHandoff } from '../navigation/route-handoff'
|
||||
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { CompatVerdict } from '../transport/protocol-compat'
|
||||
import type { MobileWebBundleCompatVerdict } from '../transport/mobile-web-bundle-compat'
|
||||
@@ -65,6 +66,7 @@ function blockBody(verdict: BlockedVerdict, remedy: BlockRemedy, storeName: stri
|
||||
}
|
||||
|
||||
export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
const router = useRouteHandoff()
|
||||
const remedy = blockRemedy(verdict)
|
||||
// Why: Android APKs ship through GitHub Releases until a Play Store listing exists.
|
||||
const mobileUpdateTarget =
|
||||
@@ -95,7 +97,9 @@ export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
void Linking.openURL(primaryAction.url)
|
||||
// The seam: this screen is in the tasks page closure, where react-native's `openURL`
|
||||
// calls a `window.open` both shells refuse and resolves anyway.
|
||||
openExternalLink(primaryAction.url)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
|
||||
@@ -104,8 +108,9 @@ export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
// Why: route back to the host list so the user can pair a
|
||||
// different host instead of getting trapped on this screen.
|
||||
// The handoff, not expo-router's singleton: `/` is the phone's home screen and the
|
||||
// page does not carry it, so inside the shell a singleton replace renders the root
|
||||
// route in the WebView rather than leaving it. This posts the target to the shell.
|
||||
router.replace('/')
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
composer: MobileComposerSource
|
||||
label: string
|
||||
disabled?: boolean
|
||||
onOpenExternalUrl: (url: string) => Promise<unknown>
|
||||
onOpenExternalUrl: (url: string) => void
|
||||
// Why: only the active form view may focus this field. While the source drawer
|
||||
// is open/closing this stays non-focusable so the drawer's dismiss (which
|
||||
// restores native focus back here) can't re-fire onFocus and reopen the drawer.
|
||||
@@ -76,7 +76,11 @@ export function SmartWorkspaceSourceField({
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Open selected source"
|
||||
hitSlop={6}
|
||||
onPress={() => selection.url && void onOpenExternalUrl(selection.url).catch(() => {})}
|
||||
onPress={() => {
|
||||
if (selection.url) {
|
||||
onOpenExternalUrl(selection.url)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={15} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
@@ -23,7 +23,7 @@ export type NewWorktreeModalProps = {
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
openExternalUrl: (url: string) => Promise<unknown>
|
||||
openExternalUrl: (url: string) => void
|
||||
onCreated: (worktreeId: string, name: string, warning?: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WebView } from 'react-native-webview'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated'
|
||||
|
||||
type Props = {
|
||||
export type MermaidDiagramProps = {
|
||||
source: string
|
||||
base: number
|
||||
}
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
// memo: both props are primitives; without it every mounted diagram re-renders
|
||||
// per frame during pinch-to-zoom (textScale updates), marshalling the full HTML
|
||||
// string across the Fabric boundary each time.
|
||||
export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Props) {
|
||||
export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: MermaidDiagramProps) {
|
||||
const [height, setHeight] = useState(0)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const html = useMemo(() => buildHtml(source), [source])
|
||||
@@ -65,7 +65,7 @@ export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Pro
|
||||
)
|
||||
})
|
||||
|
||||
function MermaidFallback({ source, base }: Props) {
|
||||
function MermaidFallback({ source, base }: MermaidDiagramProps) {
|
||||
return (
|
||||
<View style={styles.frame}>
|
||||
<View style={styles.label}>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { memo } from 'react'
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
// The native component's own prop type, so a change to it fails here rather than drifting.
|
||||
import type { MermaidDiagramProps } from './MermaidDiagram'
|
||||
|
||||
/**
|
||||
* Web sibling: the labelled source box, which is what the native component already falls back to.
|
||||
*
|
||||
* The native one renders the diagram inside a sandboxed `WebView`, and `react-native-webview` is a
|
||||
* native component with no browser counterpart — importing it runs a codegen lookup that throws,
|
||||
* and the route manifest imports every route, so one such import takes the whole page down rather
|
||||
* than one diagram.
|
||||
*
|
||||
* A real web renderer is reachable — mermaid is a browser library and the engine bundle is already
|
||||
* vendored — but it is a different shape from the native path, not a smaller one: no WebView to
|
||||
* sandbox untrusted source in, so the escaping the native `buildHtml` does for `</script>` and the
|
||||
* line separators would have to be replaced by whatever the DOM path needs. That is its own change
|
||||
* with its own proof, so this series ships the degradation the component already defines and says
|
||||
* so, rather than a second renderer nobody has tested against hostile diagram source.
|
||||
*/
|
||||
export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: MermaidDiagramProps) {
|
||||
return (
|
||||
<View style={styles.frame}>
|
||||
<View style={styles.label}>
|
||||
<Text style={styles.labelText}>mermaid</Text>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.fallbackScroll}>
|
||||
<Text style={[styles.fallbackText, { fontSize: base - 1 }]}>{source}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
// The native component's own fallback styles, so the degradation looks like the state that
|
||||
// component already renders rather than a second design.
|
||||
const styles = StyleSheet.create({
|
||||
frame: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.row,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
label: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
labelText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 11,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
fallbackScroll: { padding: spacing.sm },
|
||||
fallbackText: { color: colors.textPrimary, fontFamily: typography.monoFamily }
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The way out of the protocol-block screen, which inside the page must not be expo-router's.
|
||||
*
|
||||
* This screen is in the tasks page closure and already renders on the live `/h/[hostId]` page. Its
|
||||
* "Back to hosts" targets `/`, the phone's home screen, which the page does not carry: taken on
|
||||
* the singleton it renders the root route inside the shell's WebView instead of leaving it. The
|
||||
* handoff posts that target to the shell, which opens the native screen over the still-mounted
|
||||
* page.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCREEN = join(import.meta.dirname, 'ProtocolBlockScreen.tsx')
|
||||
|
||||
describe('the protocol-block screen leaving for the host list', () => {
|
||||
it('does not reach expo-router directly, whose router is the app singleton', () => {
|
||||
// A singleton import is the one shape the handoff cannot intercept: it is not a hook, so the
|
||||
// page's own bridge client is never consulted and the target never reaches the shell.
|
||||
expect(readFileSync(SCREEN, 'utf8')).not.toMatch(/from 'expo-router'/)
|
||||
})
|
||||
|
||||
it('reaches the navigation handoff instead', () => {
|
||||
expect(readFileSync(SCREEN, 'utf8')).toContain("from '../navigation/route-handoff'")
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Linking, Pressable, Text, View } from 'react-native'
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Check, Moon } from 'lucide-react-native'
|
||||
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
|
||||
import { ActionSheetContent } from '../components/ActionSheetModal'
|
||||
@@ -215,7 +216,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={state.worktrees}
|
||||
openExternalUrl={(url) => Linking.openURL(url)}
|
||||
// The seam, not react-native's `Linking`: this screen is in the tasks page closure, and
|
||||
// inside the shell's WebView `openURL` resolves without opening anything.
|
||||
openExternalUrl={openExternalLink}
|
||||
onVisibleChange={(visible) => {
|
||||
state.newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
|
||||
@@ -16,8 +16,9 @@ const FLAG_HOOK = 'src/mobile-web-shell/use-mobile-web-shell-enabled.ts'
|
||||
const ROUTE = 'app/h/[hostId]/web.tsx'
|
||||
const HOST_ROUTE = 'app/h/[hostId]/index.tsx'
|
||||
const AGENT_HISTORY_ROUTE = 'app/h/[hostId]/agent-history/[worktreeId].tsx'
|
||||
const TASKS_ROUTE = 'app/h/[hostId]/tasks.tsx'
|
||||
/** One entry per screen the flag can switch to the page, which is what a review reads. */
|
||||
const SWITCHED_ROUTES = [HOST_ROUTE, AGENT_HISTORY_ROUTE]
|
||||
const SWITCHED_ROUTES = [HOST_ROUTE, AGENT_HISTORY_ROUTE, TASKS_ROUTE]
|
||||
const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx'
|
||||
/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files,
|
||||
* but it is where the native view lives and so the easiest place for a second reader to hide. */
|
||||
@@ -58,6 +59,7 @@ describe('who touches the hybrid shell flag', () => {
|
||||
expect(paths).toContain(ROUTE)
|
||||
expect(paths).toContain(HOST_ROUTE)
|
||||
expect(paths).toContain(AGENT_HISTORY_ROUTE)
|
||||
expect(paths).toContain(TASKS_ROUTE)
|
||||
expect(paths).toContain(DEVELOPER_ROW)
|
||||
expect(paths).toContain(SHELL_VIEW)
|
||||
const trees = Object.keys(TREES)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** The native form of the clipboard seam: the app's own `expo-clipboard`, and what it answers. */
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClipboardWriter } from './clipboard'
|
||||
|
||||
const clipboard = vi.hoisted(() => ({ setStringAsync: vi.fn(() => Promise.resolve(true)) }))
|
||||
|
||||
vi.mock('expo-clipboard', () => clipboard)
|
||||
|
||||
import { useClipboardWriter } from './clipboard'
|
||||
|
||||
/** The hook as a screen holds it; `react-test-renderer` is what every other seam test here uses. */
|
||||
function mountWriter(): ClipboardWriter {
|
||||
const held: { writer: ClipboardWriter | null } = { writer: null }
|
||||
function Screen(): null {
|
||||
held.writer = useClipboardWriter()
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
create(<Screen />)
|
||||
})
|
||||
const writer = held.writer
|
||||
if (writer === null) {
|
||||
throw new Error('nothing mounted')
|
||||
}
|
||||
return writer
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clipboard.setStringAsync.mockReset()
|
||||
clipboard.setStringAsync.mockImplementation(() => Promise.resolve(true))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('writing the clipboard on a phone', () => {
|
||||
it('hands the text to the app unchanged', async () => {
|
||||
const writer = mountWriter()
|
||||
await expect(writer.writeText('copied')).resolves.toBeUndefined()
|
||||
expect(clipboard.setStringAsync.mock.calls).toEqual([['copied']])
|
||||
})
|
||||
|
||||
it('rejects when the pasteboard refused it, rather than reporting a copy', async () => {
|
||||
// `setStringAsync` answers whether the write landed, and a caller showing "Copied" over a
|
||||
// write that did not is the failure this seam exists to avoid.
|
||||
clipboard.setStringAsync.mockImplementation(() => Promise.resolve(false))
|
||||
const writer = mountWriter()
|
||||
await expect(writer.writeText('copied')).rejects.toThrow(/did not accept/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from 'react'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
|
||||
/**
|
||||
* Writing text to the device clipboard, which is one call on a phone and a request to the shell
|
||||
* on the web.
|
||||
*
|
||||
* A hook rather than a function because the web sibling needs the page's bridge client, which is
|
||||
* React context. Rejecting is how it reports failure: every caller is an async handler with a
|
||||
* `catch` that puts the message on screen, so a write that did not land says so rather than
|
||||
* silently claiming to have copied.
|
||||
*/
|
||||
export type ClipboardWriter = { writeText: (value: string) => Promise<void> }
|
||||
|
||||
export function useClipboardWriter(): ClipboardWriter {
|
||||
return useMemo(
|
||||
() => ({
|
||||
writeText: async (value) => {
|
||||
// `setStringAsync` answers whether the pasteboard took it, and a caller showing "Copied"
|
||||
// over a write that did not land is the failure this seam exists to avoid.
|
||||
if (!(await Clipboard.setStringAsync(value))) {
|
||||
throw new Error('the clipboard did not accept this text')
|
||||
}
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The web form of the clipboard seam: the page asks the shell, and hears what it answered.
|
||||
*
|
||||
* Driven through the real port pair rather than a mocked `useNativeVerbs`, so what this reads is
|
||||
* the request leaving the page and the shell's reply coming back — the same path a tap takes.
|
||||
*/
|
||||
import type { ReactElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The provider module re-exports the screen hooks, and reaching the real ones imports the Expo
|
||||
// runtime this test does not have. Nothing below calls one.
|
||||
vi.mock('../transport/host-client-hooks', () => ({
|
||||
useDisconnectHostClient: () => () => {},
|
||||
useForceReconnect: () => () => Promise.resolve(),
|
||||
useForgetHostClient: () => () => {},
|
||||
useHostClient: () => ({ client: null, clientId: null, state: 'disconnected' }),
|
||||
usePrimeHosts: () => () => {},
|
||||
useRefreshHostClient: () => () => {}
|
||||
}))
|
||||
|
||||
import { RpcClientProvider } from '../transport/client-context.web'
|
||||
import {
|
||||
createFakeBridgePortPair,
|
||||
type BridgePortPair
|
||||
} from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
|
||||
import { useClipboardWriter } from './clipboard.web'
|
||||
import type { ClipboardWriter } from './clipboard'
|
||||
|
||||
const held: { writer: ClipboardWriter | null } = { writer: null }
|
||||
|
||||
function Screen(): null {
|
||||
held.writer = useClipboardWriter()
|
||||
return null
|
||||
}
|
||||
|
||||
function render(pair: BridgePortPair): ReactElement {
|
||||
return (
|
||||
<RpcClientProvider client={pair.client}>
|
||||
<Screen />
|
||||
</RpcClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(pair: BridgePortPair): Promise<ClipboardWriter> {
|
||||
await pair.flush()
|
||||
act(() => {
|
||||
create(render(pair))
|
||||
})
|
||||
const writer = held.writer
|
||||
if (writer === null) {
|
||||
throw new Error('nothing mounted')
|
||||
}
|
||||
return writer
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
held.writer = null
|
||||
})
|
||||
|
||||
describe('writing the clipboard from inside the shell', () => {
|
||||
it('asks the shell and resolves when the pasteboard took it', async () => {
|
||||
const pair = createFakeBridgePortPair()
|
||||
const writer = await mount(pair)
|
||||
const written = writer.writeText('copied from the page')
|
||||
await pair.flush()
|
||||
await expect(written).resolves.toBeUndefined()
|
||||
// The whole point of the verb: it never reached the desktop.
|
||||
expect(pair.rpc.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects when the shell says the pasteboard refused it', async () => {
|
||||
const pair = createFakeBridgePortPair({
|
||||
serveNativeVerb: () => Promise.resolve({ written: false })
|
||||
})
|
||||
const writer = await mount(pair)
|
||||
const written = writer.writeText('copied from the page').catch((error: unknown) => error)
|
||||
await pair.flush()
|
||||
expect(String(await written)).toMatch(/did not accept/)
|
||||
})
|
||||
|
||||
it('rejects on a route that was not granted the verb, without sending a frame', async () => {
|
||||
const pair = createFakeBridgePortPair({ routeGrants: ['navigate', 'storage'] })
|
||||
const writer = await mount(pair)
|
||||
const before = pair.toShell.length
|
||||
const written = writer.writeText('copied from the page').catch((error: unknown) => error)
|
||||
await pair.flush()
|
||||
expect(String(await written)).toMatch(/did not grant/)
|
||||
// A rejection after a round trip and one that never left look the same to an `await`; only the
|
||||
// first would have put a request on the wire.
|
||||
expect(pair.toShell).toHaveLength(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useNativeVerbs } from '../mobile-web-shell/bridge/use-native-verbs'
|
||||
import type { ClipboardWriter } from './clipboard'
|
||||
|
||||
/**
|
||||
* Web sibling: the page has no clipboard of its own worth using, so the shell writes for it.
|
||||
*
|
||||
* `expo-clipboard` resolves to `navigator.clipboard` on the web, which needs a secure context —
|
||||
* and the iOS shell serves the page from a custom scheme while Android serves `https`, so that
|
||||
* path would work on one platform and not the other with no way to tell from here. The verb goes
|
||||
* to the shell instead, where the pasteboard is the device's.
|
||||
*
|
||||
* A route that did not declare `native.clipboard.write` is not granted it, and the call rejects
|
||||
* before a frame is sent; the callers' own `catch` puts that on screen.
|
||||
*/
|
||||
export function useClipboardWriter(): ClipboardWriter {
|
||||
const verbs = useNativeVerbs()
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
writeText: async (value) => {
|
||||
if (!(await verbs.writeClipboardText(value))) {
|
||||
throw new Error('the clipboard did not accept this text')
|
||||
}
|
||||
}
|
||||
}),
|
||||
[verbs]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMobileTasksRouteAndItemState } from './use-mobile-tasks-route-and-item-state'
|
||||
import { useMobileTasksWorkspaceAndProjectState } from './use-mobile-tasks-workspace-and-project-state'
|
||||
import { useMobileTasksProjectProjection } from './use-mobile-tasks-project-projection'
|
||||
import { useMobileTasksProjectRepositoryResolution } from './use-mobile-tasks-project-repository-resolution'
|
||||
import { useMobileTasksClientSettingsActions } from './use-mobile-tasks-client-settings-actions'
|
||||
import { useMobileTasksRuntimeHydration } from './use-mobile-tasks-runtime-hydration'
|
||||
import { useMobileTasksProviderLoadActions } from './use-mobile-tasks-provider-load-actions'
|
||||
import { useMobileTasksTaskListLoading } from './use-mobile-tasks-task-list-loading'
|
||||
import { useMobileTasksTaskPaginationActions } from './use-mobile-tasks-task-pagination-actions'
|
||||
import { useMobileTasksProjectLoadingActions } from './use-mobile-tasks-project-loading-actions'
|
||||
import { useMobileTasksListAndDetailEffects } from './use-mobile-tasks-list-and-detail-effects'
|
||||
import { useMobileTasksItemDetailMetadataEffects } from './use-mobile-tasks-item-detail-metadata-effects'
|
||||
import { useMobileTasksItemDetailLoading } from './use-mobile-tasks-item-detail-loading'
|
||||
import { useMobileTasksProjectDetailLoading } from './use-mobile-tasks-project-detail-loading'
|
||||
import { useMobileTasksProjectMetadataLoading } from './use-mobile-tasks-project-metadata-loading'
|
||||
import { useMobileTasksWorkspaceCreateProjection } from './use-mobile-tasks-workspace-create-projection'
|
||||
import { useMobileTasksWorkspaceSourceEffects } from './use-mobile-tasks-workspace-source-effects'
|
||||
import { useMobileTasksWorkspaceSparseActions } from './use-mobile-tasks-workspace-sparse-actions'
|
||||
import { useMobileTasksWorkspaceSshState } from './use-mobile-tasks-workspace-ssh-state'
|
||||
import { useMobileTasksWorkspaceCreateActions } from './use-mobile-tasks-workspace-create-actions'
|
||||
import { useMobileTasksProjectWorkspaceCommentActions } from './use-mobile-tasks-project-workspace-comment-actions'
|
||||
import { useMobileTasksProjectThreadReplyActions } from './use-mobile-tasks-project-thread-reply-actions'
|
||||
import { useMobileTasksProjectMetadataActions } from './use-mobile-tasks-project-metadata-actions'
|
||||
import { useMobileTasksProjectReviewCheckActions } from './use-mobile-tasks-project-review-check-actions'
|
||||
import { useMobileTasksProjectFileMergeActions } from './use-mobile-tasks-project-file-merge-actions'
|
||||
import { useMobileTasksGitlabGithubStatusActions } from './use-mobile-tasks-gitlab-github-status-actions'
|
||||
import { useMobileTasksHostedMetadataActions } from './use-mobile-tasks-hosted-metadata-actions'
|
||||
import { useMobileTasksHostedCommentReviewActions } from './use-mobile-tasks-hosted-comment-review-actions'
|
||||
import { useMobileTasksGithubCheckFileActions } from './use-mobile-tasks-github-check-file-actions'
|
||||
import { useMobileTasksGithubReplyMergeActions } from './use-mobile-tasks-github-reply-merge-actions'
|
||||
import { useMobileTasksLinearItemActions } from './use-mobile-tasks-linear-item-actions'
|
||||
import { useMobileTasksTaskCreateActions } from './use-mobile-tasks-task-create-actions'
|
||||
import { useMobileTasksDetailCommentRenderers } from './use-mobile-tasks-detail-comment-renderers'
|
||||
import { useMobileTasksPickerProjection } from './use-mobile-tasks-picker-projection'
|
||||
import { useMobileTasksProviderViewProjection } from './use-mobile-tasks-provider-view-projection'
|
||||
import { useMobileTasksConnectionPresentation } from './use-mobile-tasks-connection-presentation'
|
||||
import { MobileTasksLegacySurface } from './MobileTasksLegacySurface'
|
||||
|
||||
export function MobileTasksScreen() {
|
||||
const stage1 = useMobileTasksRouteAndItemState()
|
||||
const stage2 = useMobileTasksWorkspaceAndProjectState(stage1)
|
||||
const stage3 = useMobileTasksProjectProjection(stage2)
|
||||
const stage4 = useMobileTasksProjectRepositoryResolution(stage3)
|
||||
const stage5 = useMobileTasksClientSettingsActions(stage4)
|
||||
const stage6 = useMobileTasksRuntimeHydration(stage5)
|
||||
const stage7 = useMobileTasksProviderLoadActions(stage6)
|
||||
const stage8 = useMobileTasksTaskListLoading(stage7)
|
||||
const stage9 = useMobileTasksTaskPaginationActions(stage8)
|
||||
const stage10 = useMobileTasksProjectLoadingActions(stage9)
|
||||
const stage11 = useMobileTasksListAndDetailEffects(stage10)
|
||||
const stage12 = useMobileTasksItemDetailMetadataEffects(stage11)
|
||||
const stage13 = useMobileTasksItemDetailLoading(stage12)
|
||||
const stage14 = useMobileTasksProjectDetailLoading(stage13)
|
||||
const stage15 = useMobileTasksProjectMetadataLoading(stage14)
|
||||
const stage16 = useMobileTasksWorkspaceCreateProjection(stage15)
|
||||
const stage17 = useMobileTasksWorkspaceSourceEffects(stage16)
|
||||
const stage18 = useMobileTasksWorkspaceSparseActions(stage17)
|
||||
const stage19 = useMobileTasksWorkspaceSshState(stage18)
|
||||
const stage20 = useMobileTasksWorkspaceCreateActions(stage19)
|
||||
const stage21 = useMobileTasksProjectWorkspaceCommentActions(stage20)
|
||||
const stage22 = useMobileTasksProjectThreadReplyActions(stage21)
|
||||
const stage23 = useMobileTasksProjectMetadataActions(stage22)
|
||||
const stage24 = useMobileTasksProjectReviewCheckActions(stage23)
|
||||
const stage25 = useMobileTasksProjectFileMergeActions(stage24)
|
||||
const stage26 = useMobileTasksGitlabGithubStatusActions(stage25)
|
||||
const stage27 = useMobileTasksHostedMetadataActions(stage26)
|
||||
const stage28 = useMobileTasksHostedCommentReviewActions(stage27)
|
||||
const stage29 = useMobileTasksGithubCheckFileActions(stage28)
|
||||
const stage30 = useMobileTasksGithubReplyMergeActions(stage29)
|
||||
const stage31 = useMobileTasksLinearItemActions(stage30)
|
||||
const stage32 = useMobileTasksTaskCreateActions(stage31)
|
||||
const stage33 = useMobileTasksDetailCommentRenderers(stage32)
|
||||
const stage34 = useMobileTasksPickerProjection(stage33)
|
||||
const stage35 = useMobileTasksProviderViewProjection(stage34)
|
||||
const stage36 = useMobileTasksConnectionPresentation(stage35)
|
||||
return MobileTasksLegacySurface({ model: stage36 })
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
export type { ReactNode } from 'react'
|
||||
export {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Linking,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -12,9 +12,30 @@ export {
|
||||
TextInput,
|
||||
View
|
||||
} from 'react-native'
|
||||
/**
|
||||
* `Linking` as this tree uses it: one method, routed through the platform seam.
|
||||
*
|
||||
* Not react-native's. Inside the shell's WebView react-native-web's `openURL` calls
|
||||
* `window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
|
||||
* `createWebViewWith`, Android false from `onCreateWindow` — and resolves regardless, so every
|
||||
* call site would report success into a tap that opened nothing. The seam posts `externalLink` to
|
||||
* the shell on the web and is `Linking.openURL` unchanged on a phone.
|
||||
*
|
||||
* Typed `void` on purpose: the seam names its own failures and never rejects, so a `.catch` here
|
||||
* would be a handler for a rejection that cannot arrive, and this makes that a compile error.
|
||||
*/
|
||||
export const Linking: { openURL: (url: string) => void } = { openURL: openExternalLink }
|
||||
export { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
export * as Clipboard from 'expo-clipboard'
|
||||
export { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
export { useLocalSearchParams } from 'expo-router'
|
||||
/**
|
||||
* The router as this tree uses it: expo-router's on a phone, and the handoff inside the page.
|
||||
*
|
||||
* The page is one document standing in for one screen, so a route it does not render goes back to
|
||||
* the app that does, and its Back goes to the native stack the shell pushed it onto — the
|
||||
* document has the single history entry the entry wrote, so expo-router's `back()` moves nothing.
|
||||
* `useRouteHandoff` is router-shaped, so no call site changes.
|
||||
*/
|
||||
export { useRouteHandoff as useRouter } from '../navigation/route-handoff'
|
||||
export {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
|
||||
@@ -9,7 +9,7 @@ type FunctionDefinition = {
|
||||
sourceFile: ts.SourceFile
|
||||
}
|
||||
|
||||
const TASKS_ROUTE = '../../app/h/[hostId]/tasks.tsx'
|
||||
const TASKS_ROUTE = './MobileTasksScreen.tsx'
|
||||
const FOUNDATION_SOURCE = 'mobile-tasks-legacy-foundation.tsx'
|
||||
const LEGACY_STYLE_SOURCE = 'mobile-tasks-legacy-styles.ts'
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Every external link the tasks tree opens goes through the platform seam.
|
||||
*
|
||||
* The tree reaches `Linking` through one barrel, so the swap is one export rather than nine call
|
||||
* sites. Asserted on the source because importing the barrel pulls react-native's Flow entry into
|
||||
* the test environment; what matters here is which module the name comes from, which is a fact
|
||||
* about the text.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const BARREL = join(import.meta.dirname, 'mobile-tasks-dependencies.ts')
|
||||
|
||||
function reExportBlock(source: string, from: string): string {
|
||||
const pattern = new RegExp(String.raw`export \{([^}]*)\} from '${from}'`, 's')
|
||||
return pattern.exec(source)?.[1] ?? ''
|
||||
}
|
||||
|
||||
describe('the Linking the tasks tree uses', () => {
|
||||
it('does not come from react-native, whose web build opens nothing inside the shell', () => {
|
||||
// react-native-web's `Linking.openURL` calls `window.open`, and both shells refuse it: iOS
|
||||
// returns nil from `createWebViewWith`, Android false from `onCreateWindow`. It resolves
|
||||
// anyway, so the native path would report success into a tap that did nothing.
|
||||
const source = readFileSync(BARREL, 'utf8')
|
||||
expect(reExportBlock(source, 'react-native')).not.toContain('Linking')
|
||||
})
|
||||
|
||||
it('comes from the platform seam, so the page hands the URL to the shell', () => {
|
||||
expect(readFileSync(BARREL, 'utf8')).toContain("from '../platform/external-link'")
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The tasks header's Back, which inside the page had nowhere to go.
|
||||
*
|
||||
* The document holds the one history entry the entry wrote with `replaceState`, so expo-router's
|
||||
* `back()` moves nothing; the stack that has somewhere to go is the native one the shell pushed
|
||||
* the page onto. `useRouteHandoff` is what posts `navigate-back` for it, and the tree reaches the
|
||||
* router through the same barrel it reached `Linking` through.
|
||||
*/
|
||||
describe('the router the tasks tree uses', () => {
|
||||
it('does not come from expo-router, whose back() moves nothing inside the page', () => {
|
||||
const source = readFileSync(BARREL, 'utf8')
|
||||
expect(reExportBlock(source, 'expo-router')).not.toContain('useRouter')
|
||||
})
|
||||
|
||||
it('comes from the navigation handoff, which hands Back to the shell', () => {
|
||||
expect(readFileSync(BARREL, 'utf8')).toContain("from '../navigation/route-handoff'")
|
||||
})
|
||||
})
|
||||
@@ -80,18 +80,30 @@ const hash = (parts: string[] | string): string =>
|
||||
// The pullfrog pass on the same round moved both once more, again by comment text alone: the
|
||||
// GitHub search `SAFETY:` line now separates the two members the schema requires (`items`,
|
||||
// `labels`) from the eight it only types. No statement, type or call changed; counts hold.
|
||||
const SCREEN_RPC_SCREEN_HOOKS = 'be9bb8e21c3a8c0912e8b9256a7c8e5c9ca08ebb776d57cf4fff1101fb060095'
|
||||
// C2.1 swaps the workspace-creation push onto `hostNewWorktreeSessionRoute`, which already built
|
||||
// this href with both segments encoded. Three of the family move and nothing else does: the hook
|
||||
// list, because the handler's statements changed shape; the statement hash, for the same reason;
|
||||
// and `semantics`, which is a pure deletion of two lines — the `URLSearchParams` construction and
|
||||
// the raw `/h/${hostId}/session/...` template it fed. No RPC call, method literal or JSX host
|
||||
// signature changed, and the render and style hashes did not move.
|
||||
|
||||
// C2.1 swaps the two clipboard writes onto the platform seam, so the comment-review hook gains one
|
||||
// hook call and one statement. Two of the family move: the hook list and the statement hash, each
|
||||
// by one entry. `semantics` does not — no RPC call, method literal or JSX host signature changed —
|
||||
// and the render and style hashes hold.
|
||||
|
||||
const SCREEN_RPC_SCREEN_HOOKS = '0f66df2141117dfec2f8a0adb3f598312e6fda8e80833a365a645796f5ab48c3'
|
||||
const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f'
|
||||
const SCREEN_RPC_STATEMENTS = '5fb5ffb187b4b62bdd84e4ad49aaf0d481d943e09e8c4f37433a9eb2ca40c533'
|
||||
const SCREEN_RPC_STATEMENTS = 'dd8f33cb3cf96f5c39abac397cb77e35f59079291033a1866ead462b041ab979'
|
||||
const MAIN_REBASED_DECLARATIONS = '920a1b66445d10e2a64fbdbe9d7138a4ebe21bbccde1b9ac9c89267cecc584b9'
|
||||
const SCREEN_RPC_SEMANTICS = '763f4ffc60b8b335eaab4a51820dc782be430ada879564888a5d28929c9e938b'
|
||||
const SCREEN_RPC_SEMANTICS = 'bb15f6a382612c827c88dee7ef92ca5f1e2aa7bcc36fbb5611232cc0f1f600ff'
|
||||
const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a'
|
||||
const SCREEN_RPC_RENDER_TREE = '46d5a3ce9d71a8281a1e7b17411fb1dd963a4f392a5d095bc126b6a7cff4b92d'
|
||||
|
||||
describe('Mobile Tasks refactor parity', () => {
|
||||
it('preserves recursively flattened hook and dependency order', () => {
|
||||
const screenHooks = readFlattenedMobileTasksHookSignatures('MobileTasksScreen')
|
||||
expect(screenHooks).toHaveLength(350)
|
||||
expect(screenHooks).toHaveLength(351)
|
||||
expect(hash(screenHooks)).toBe(SCREEN_RPC_SCREEN_HOOKS)
|
||||
|
||||
const diffHooks = readFlattenedMobileTasksHookSignatures('GitHubPrFileDiff')
|
||||
@@ -101,7 +113,7 @@ describe('Mobile Tasks refactor parity', () => {
|
||||
|
||||
it('preserves every screen statement in execution order', () => {
|
||||
const statements = readFlattenedMobileTasksCoreStatements()
|
||||
expect(statements).toHaveLength(417)
|
||||
expect(statements).toHaveLength(418)
|
||||
expect(hash(statements)).toBe(SCREEN_RPC_STATEMENTS)
|
||||
})
|
||||
|
||||
@@ -113,7 +125,7 @@ describe('Mobile Tasks refactor parity', () => {
|
||||
|
||||
it('preserves RPC calls, runtime strings, and JSX host signatures', () => {
|
||||
const semantics = readMobileTasksSemanticSource()
|
||||
expect(semantics.split('\n')).toHaveLength(3_274)
|
||||
expect(semantics.split('\n')).toHaveLength(3_272)
|
||||
expect(hash(semantics)).toBe(SCREEN_RPC_SEMANTICS)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The href the tasks screen sends a phone to after it creates a workspace.
|
||||
*
|
||||
* The module under test is a hook with a dozen collaborators, so the property is pinned where it
|
||||
* is decided: this file asserts that no module in the tasks tree builds that href itself. A host
|
||||
* id carrying `/`, `#`, `?` or whitespace reaches the wire as a route the bridge refuses
|
||||
* (`BRIDGE_ROUTE_HREF_PATTERN`), the handoff falls through to the local router, and expo-router's
|
||||
* Unmatched paints over the page — the C1.2 class.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
|
||||
|
||||
const TASKS_DIR = join(import.meta.dirname, '.')
|
||||
|
||||
function tasksSources(): string[] {
|
||||
return readdirSync(TASKS_DIR, { recursive: true, encoding: 'utf8' })
|
||||
.filter(
|
||||
(name) => /\.tsx?$/.test(name) && !name.endsWith('.test.ts') && !name.endsWith('.test.tsx')
|
||||
)
|
||||
.map((name) => join(TASKS_DIR, name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a source builds a `/h/...` route by hand with any segment left raw.
|
||||
*
|
||||
* Every interpolation in such a template, not just the first: checking only the leading one lets
|
||||
* `` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` through, and a worktree id
|
||||
* carrying `/`, `#`, `?` or whitespace breaks the href exactly as a host id does.
|
||||
*/
|
||||
function hasRawHostTemplate(source: string): boolean {
|
||||
return [...source.matchAll(/`\/h\/[^`]*`/g)].some((match) =>
|
||||
[...match[0].matchAll(/\$\{([^}]*)\}/g)].some(
|
||||
(interpolation) => !interpolation[1].trimStart().startsWith('encodeURIComponent(')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('a session href built under the tasks tree', () => {
|
||||
it('is built by the shared route helper, never interpolated raw', () => {
|
||||
const offenders = tasksSources().filter((file) =>
|
||||
hasRawHostTemplate(readFileSync(file, 'utf8'))
|
||||
)
|
||||
expect(offenders.map((file) => file.slice(TASKS_DIR.length + 1))).toEqual([])
|
||||
})
|
||||
|
||||
it('encodes both segments, which is what the raw interpolation did not', () => {
|
||||
expect(hostNewWorktreeSessionRoute('relay/one#50%', 'wt/1', 'Fix login', 'no terminal')).toBe(
|
||||
'/h/relay%2Fone%2350%25/session/wt%2F1?name=Fix+login&created=1&warning=no+terminal'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,11 +3,15 @@ import { join } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
const TASKS_DIRECTORY = __dirname
|
||||
const TASKS_ROUTE = '../../app/h/[hostId]/tasks.tsx'
|
||||
const SOURCE_PATTERN = /^(?:MobileTasks.*\.tsx|mobile-tasks-.*\.tsx?|use-mobile-tasks-.*\.tsx?)$/
|
||||
|
||||
/**
|
||||
* The screen moved out of `app/h/[hostId]/tasks.tsx` into this directory when the route became the
|
||||
* shell's flag switch, so it arrives through the pattern below rather than as a listed path. The
|
||||
* route file that remains declares no hook and no statement of its own and is covered by the flag
|
||||
* census, not by this family.
|
||||
*/
|
||||
export const MOBILE_TASKS_SOURCE_FILES = [
|
||||
TASKS_ROUTE,
|
||||
...readdirSync(TASKS_DIRECTORY)
|
||||
.filter(
|
||||
(name) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useClipboardWriter } from '../platform/clipboard'
|
||||
import type { HostedMetadataActionsModel } from './use-mobile-tasks-hosted-metadata-actions'
|
||||
import {
|
||||
Clipboard,
|
||||
buildGitHubCheckSummary,
|
||||
scheduleMobileTaskCopyFeedbackReset,
|
||||
useCallback
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
} from './mobile-task-item-state-operations'
|
||||
|
||||
export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataActionsModel) {
|
||||
// The seam, not `expo-clipboard`: inside the shell the page's own clipboard needs a secure
|
||||
// context, which the iOS custom scheme is not and Android's https is, so that path would work on
|
||||
// one platform and silently not on the other.
|
||||
const clipboard = useClipboardWriter()
|
||||
const {
|
||||
client,
|
||||
copiedLinkResetTimerRef,
|
||||
@@ -119,25 +123,31 @@ export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataAc
|
||||
[client, itemCommentDraft, mutatingStatus]
|
||||
)
|
||||
|
||||
const copyTaskLink = useCallback(async (key: string, url: string): Promise<void> => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(url)
|
||||
setCopiedLinkKey(key)
|
||||
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to copy link')
|
||||
}
|
||||
}, [])
|
||||
const copyTaskLink = useCallback(
|
||||
async (key: string, url: string): Promise<void> => {
|
||||
try {
|
||||
await clipboard.writeText(url)
|
||||
setCopiedLinkKey(key)
|
||||
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to copy link')
|
||||
}
|
||||
},
|
||||
[clipboard]
|
||||
)
|
||||
|
||||
const copyTextToClipboard = useCallback(async (key: string, value: string): Promise<void> => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(value)
|
||||
setCopiedLinkKey(key)
|
||||
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to copy text')
|
||||
}
|
||||
}, [])
|
||||
const copyTextToClipboard = useCallback(
|
||||
async (key: string, value: string): Promise<void> => {
|
||||
try {
|
||||
await clipboard.writeText(value)
|
||||
setCopiedLinkKey(key)
|
||||
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to copy text')
|
||||
}
|
||||
},
|
||||
[clipboard]
|
||||
)
|
||||
|
||||
const requestGitHubReviewers = useCallback(
|
||||
async (item: Extract<TaskItem, { provider: 'github' }>, logins?: string[]): Promise<void> => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
|
||||
import { settingsRead } from '../transport/settings-read-operations'
|
||||
import type { WorkspaceSshStateModel } from './use-mobile-tasks-workspace-ssh-state'
|
||||
import {
|
||||
@@ -263,13 +264,15 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
setActionItem(null)
|
||||
setWorkspaceCreateDraft(null)
|
||||
setSetupPrompt(null)
|
||||
const name = result.worktree.displayName ?? item.title
|
||||
const queryParams = new URLSearchParams({ name, created: '1' })
|
||||
if (result.warning) {
|
||||
queryParams.set('warning', result.warning)
|
||||
}
|
||||
// The shared builder, not a template: it encodes the host id, which this did not, and a
|
||||
// host id carrying `/`, `#` or whitespace reaches the wire as an href the bridge refuses.
|
||||
router.push(
|
||||
`/h/${hostId}/session/${encodeURIComponent(result.worktree.id)}?${queryParams.toString()}`
|
||||
hostNewWorktreeSessionRoute(
|
||||
hostId,
|
||||
result.worktree.id,
|
||||
result.worktree.displayName ?? item.title,
|
||||
result.warning
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create workspace')
|
||||
|
||||
@@ -36,6 +36,18 @@
|
||||
{
|
||||
"file": "src/platform/external-link.web.ts",
|
||||
"reason": "The page runs inside the shell's WebView, where react-native-web's Linking.openURL calls window.open(url, '_blank', 'noopener') and resolves whether or not anything opened; only a tel: URL assigns window.location, and none of the three allowed schemes is one. Both shells refuse window.open outright: iOS sets javaScriptCanOpenWindowsAutomatically = false and returns nil from WKUIDelegate's createWebViewWith, and Android sets javaScriptCanOpenWindowsAutomatically = false, setSupportMultipleWindows(false) and returns false from onCreateWindow. So the native path reports success into a tap that did nothing. This one posts the externalLink notify instead, after the same scheme check the frame enforces, and names its refusal rather than throwing inside a tap handler."
|
||||
},
|
||||
{
|
||||
"file": "src/platform/clipboard.web.ts",
|
||||
"reason": "expo-clipboard resolves to navigator.clipboard on the web, which needs a secure context; the iOS shell serves the page from the custom scheme orca-mobile-web://<session>/ while Android serves https, so that path would work on one platform and silently not on the other. This one asks the shell through the native.clipboard.write verb, where the pasteboard is the device's, and rejects when the route was not granted it so the caller's own catch puts that on screen."
|
||||
},
|
||||
{
|
||||
"file": "src/components/pr-sidebar/MermaidDiagram.web.tsx",
|
||||
"reason": "The native component renders the diagram inside a sandboxed WebView, and react-native-webview is a native component with no browser counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every route, so one such import takes the whole page down rather than one diagram. This one renders the labelled source box the native component already falls back to on a parse or render error. A real browser renderer is reachable — mermaid is a browser library and the engine bundle is vendored — but it is a different shape rather than a smaller one: with no WebView to sandbox untrusted diagram source in, the escaping buildHtml does for </script> and the line separators has to be replaced by whatever the DOM path needs, which is its own change with its own proof."
|
||||
},
|
||||
{
|
||||
"file": "app/h/[hostId]/tasks.web.tsx",
|
||||
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and one throwing route module takes the whole bundle down because the manifest imports them all."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user