diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 59279d4247b..622a163eb88 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -468,10 +468,10 @@ describe('the Phase C budget', () => { key ).toBeGreaterThanOrEqual(measured) } - // 1 to 11 per route, which is why four per route was a bound rather than a fit and why the + // 1 to 14 per route, which is why four per route was a bound rather than a fit and why the // envelope cannot be a line through the measurement either. expect(Math.min(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(1) - expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(11) + expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(14) }) it('sits exactly one margin over the swept tree and grants the worst route beyond it', () => { @@ -543,13 +543,13 @@ describe('the Phase C budget', () => { it('fails the build when the derived ceiling passes what the phone will accept', async () => { // The shell hands back null for a manifest over its own ceiling, so a derived ceiling above // that ships a green build no device can open. At the 42 images the tree carries, the envelope - // plus 42 plus the document crosses 256 at 27 routes, which Phase C reaches. The crossing came + // plus 42 plus the document crosses 256 at 25 routes, which Phase C reaches. The crossing came // in from 50 with the envelope: it grants the worst swept route to each one past the sweep, // where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out. expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS) - expect(assertAssetCeilingFitsShell(26, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(246) - expect(() => assertAssetCeilingFitsShell(27, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( - /257 .*256/ + expect(assertAssetCeilingFitsShell(24, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(250) + expect(() => assertAssetCeilingFitsShell(25, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( + /264 .*256/ ) }) }) diff --git a/config/scripts/mobile-web-app-page-route-modules.mjs b/config/scripts/mobile-web-app-page-route-modules.mjs index cfa89415e28..531a64a81d4 100644 --- a/config/scripts/mobile-web-app-page-route-modules.mjs +++ b/config/scripts/mobile-web-app-page-route-modules.mjs @@ -19,6 +19,7 @@ export const PAGE_ROUTE_MODULES = new Map([ ['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'], ['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'], ['/h/[hostId]/accounts', 'app/h/[hostId]/accounts.tsx'], + ['/h/[hostId]/new-workspace', 'app/h/[hostId]/new-workspace.tsx'], ['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'], ['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'], ['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'], diff --git a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs index c97636416e6..0001266f4ed 100644 --- a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs +++ b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs @@ -188,6 +188,7 @@ describe('the census rule itself', () => { 'app/h/[hostId]/agent-history/[worktreeId].tsx', 'app/h/[hostId]/tasks.tsx', 'app/h/[hostId]/accounts.tsx', + 'app/h/[hostId]/new-workspace.tsx', 'app/h/[hostId]/files/[worktreeId].tsx', 'app/h/[hostId]/files/preview/[worktreeId].tsx', 'app/h/[hostId]/source-control/[worktreeId].tsx', diff --git a/config/scripts/mobile-web-page-routes.mjs b/config/scripts/mobile-web-page-routes.mjs index e75ea674f17..0a7665e2937 100644 --- a/config/scripts/mobile-web-page-routes.mjs +++ b/config/scripts/mobile-web-page-routes.mjs @@ -59,6 +59,12 @@ export const MOBILE_WEB_PAGE_ROUTES = [ pathname: '/h/[hostId]/accounts', grants: ['navigate', 'storage', 'externalLink', 'haptics'] }, + // Create Workspace, in Home's native overlay. `navigate` for its close (Back pops the overlay) and + // for the session it opens; the rest for the shared form and the host layout. + { + pathname: '/h/[hostId]/new-workspace', + grants: ['navigate', 'storage', 'externalLink', 'haptics'] + }, // The file explorer. `navigate` because its Back pops the native stack. `storage` for the shared // components the host layout renders above it. // diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index 96854f6cd8e..c5bd541ac74 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -80,9 +80,9 @@ export function mobileWebAppBundleBudgetBytes(manifest) { * A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is * what it fails to share rather than what it weighs. Re-measured on this head by building * `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than - * fitted to. The spread it shows is 1 to 11: `pr` adds one script, `tasks` adds eleven. + * fitted to. The spread it shows is 1 to 14: `pr` adds one script, `tasks` adds fourteen. * The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the - * swept tree reads 78 scripts at 15 routes, with theme tokens in the entry. + * swept tree reads 91 scripts at 16 routes, with theme tokens in the entry. * * This table is the fence's only input, so a route added to the tree stales it and the pins beside * the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped. @@ -97,12 +97,13 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [ ['./h/[hostId]/files/preview/[worktreeId].tsx', 31], ['./h/[hostId]/history/[worktreeId].tsx', 34], ['./h/[hostId]/index.tsx', 42], - ['./h/[hostId]/pr/[worktreeId].tsx', 43], - ['./h/[hostId]/review/[worktreeId].tsx', 50], - ['./h/[hostId]/session/[worktreeId].tsx', 60], - ['./h/[hostId]/source-control/[worktreeId].tsx', 64], - ['./h/[hostId]/tasks.tsx', 75], - ['./h/_layout.tsx', 78] + ['./h/[hostId]/new-workspace.tsx', 49], + ['./h/[hostId]/pr/[worktreeId].tsx', 50], + ['./h/[hostId]/review/[worktreeId].tsx', 57], + ['./h/[hostId]/session/[worktreeId].tsx', 69], + ['./h/[hostId]/source-control/[worktreeId].tsx', 73], + ['./h/[hostId]/tasks.tsx', 87], + ['./h/_layout.tsx', 91] ] const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts) diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index ad7ee9ccee0..1631f7122b4 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -240,6 +240,16 @@ export default function RootLayout() { + diff --git a/mobile/app/_layout.web.tsx b/mobile/app/_layout.web.tsx index 7b20142574d..57d869fd515 100644 --- a/mobile/app/_layout.web.tsx +++ b/mobile/app/_layout.web.tsx @@ -1,11 +1,12 @@ import { useMemo, useSyncExternalStore } from 'react' import { StyleSheet, useWindowDimensions, View } from 'react-native' -import { Slot } from 'expo-router' +import { Slot, usePathname } from 'expo-router' import { SafeAreaFrameContext, SafeAreaInsetsContext } from 'react-native-safe-area-context' import { ZERO_SAFE_AREA_INSETS } from '../src/mobile-web-shell/bridge/bridge-safe-area-insets' import { SideSafeArea } from '../src/layout/side-safe-area' import { usePageBridgeClient } from '../src/transport/client-context.web' import { colors } from '../src/theme/mobile-theme' +import { isOverlayPageRoute } from '../src/new-workspace/overlay-page-route' /** * The page's root layout, standing where the native `_layout.tsx` does. Without one expo-router @@ -20,11 +21,12 @@ export default function PageRootLayout() { () => client.getShellSession()?.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS ) const { width, height } = useWindowDimensions() + const transparent = isOverlayPageRoute(usePathname()) const frame = useMemo(() => ({ x: 0, y: 0, width, height }), [width, height]) return ( - + @@ -38,5 +40,8 @@ const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bgBase + }, + transparent: { + backgroundColor: 'transparent' } }) diff --git a/mobile/app/h/[hostId]/new-workspace.tsx b/mobile/app/h/[hostId]/new-workspace.tsx new file mode 100644 index 00000000000..774b071631f --- /dev/null +++ b/mobile/app/h/[hostId]/new-workspace.tsx @@ -0,0 +1,12 @@ +import { Redirect, useLocalSearchParams } from 'expo-router' +import { firstParam } from '../../../src/navigation/route-param-reader' +import { newWorkspaceOverlayHref } from '../../../src/new-workspace/new-workspace-overlay-route' + +/** + * Natively this path is the overlay over the screen that asked for it; the page's file is the + * drawer that overlay hosts. + */ +export default function NewWorkspaceRoute() { + const params = useLocalSearchParams<{ hostId?: string | string[] }>() + return +} diff --git a/mobile/app/h/[hostId]/new-workspace.web.tsx b/mobile/app/h/[hostId]/new-workspace.web.tsx new file mode 100644 index 00000000000..c2d38d2ff0d --- /dev/null +++ b/mobile/app/h/[hostId]/new-workspace.web.tsx @@ -0,0 +1,6 @@ +import { NewWorkspacePageScreen } from '../../../src/new-workspace/NewWorkspacePageScreen' + +/** The page's Create Workspace route, which Home's `/new-workspace` overlay opens in its shell. */ +export default function NewWorkspaceRoute() { + return +} diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index 3e58e43a326..caa90655a91 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -16,6 +16,7 @@ import { HostStack } from '../../src/navigation/host-stack' import { HostSidebarRevealContext } from '../../src/layout/host-sidebar-reveal' import { HostAreaServingContext } from '../../src/mobile-web-shell/host-area-serving' import { usePageOwnsHostArea } from '../../src/mobile-web-shell/page-owns-host-area' +import { isOverlayPageRoute } from '../../src/new-workspace/overlay-page-route' // Keep at least this much room for the detail pane when resizing the sidebar. const MIN_DETAIL_WIDTH = 320 @@ -34,8 +35,9 @@ function clampSidebarToWindow(width: number, windowWidth: number): number { export default function HostGroupLayout() { // Wide layout = tablet/foldable canvas (see responsive-layout-metrics). const { isWideLayout, width: windowWidth } = useResponsiveLayout() - const { hostId, action } = useGlobalSearchParams<{ hostId?: string; action?: string }>() + const { hostId } = useGlobalSearchParams<{ hostId?: string }>() const pathname = usePathname() + const overlayRoute = isOverlayPageRoute(pathname) const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarWidth, setSidebarWidth] = useState(HOST_SIDEBAR_DEFAULT_WIDTH) @@ -117,14 +119,13 @@ export default function HostGroupLayout() { // changes so a fold/rotation doesn't remount the navigator and reset the // navigation stack — only the sidebar pane toggles in and out. return ( - - + + {showSidebar && sidebarOpen ? ( {/* Dedicated drag handle straddling the right border — see resizer note. */} @@ -151,6 +152,9 @@ const styles = StyleSheet.create({ flexDirection: 'row', backgroundColor: colors.bgBase }, + transparent: { + backgroundColor: 'transparent' + }, sidebar: { borderRightWidth: 1, borderRightColor: colors.borderSubtle diff --git a/mobile/app/new-workspace.tsx b/mobile/app/new-workspace.tsx new file mode 100644 index 00000000000..d78db03baec --- /dev/null +++ b/mobile/app/new-workspace.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import { useLocalSearchParams } from 'expo-router' +import { firstParam } from '../src/navigation/route-param-reader' +import { NewWorkspaceHostPicker } from '../src/new-workspace/NewWorkspaceHostPicker' +import { NewWorkspaceOverlay } from '../src/new-workspace/NewWorkspaceOverlay' +import { readOfferedHostIds } from '../src/new-workspace/new-workspace-overlay-route' + +/** Home's New Workspace, over Home: which desktop, then that desktop's Create Workspace drawer. */ +export default function NewWorkspaceOverlayRoute() { + const params = useLocalSearchParams<{ hostIds?: string | string[] }>() + const [offered] = useState(() => readOfferedHostIds(firstParam(params.hostIds))) + const [hostId, setHostId] = useState(offered.length === 1 ? offered[0] : null) + return ( + <> + {offered.length === 1 ? null : ( + + )} + {hostId === null ? null : } + + ) +} diff --git a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json index 82f504d5566..9fcff13d0a4 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json +++ b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json @@ -59,66 +59,6 @@ } } }, - "2ef4e83a1a85": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": true, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ], - "optimisticActiveWorktreeIdentity": { - "$rpc": "null" - }, - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": true, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ] - }, - "38c12b517ad0": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [], - "optimisticActiveWorktreeIdentity": "|wt-1", - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [] - }, "40f6e6f5e2cd": { "name": "worktree.activate#1", "ordinal": 7, @@ -160,6 +100,53 @@ "ordinal": 8, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.activate\",\"params\":{\"worktree\":\"id:wt-1\",\"notifyClients\":false,\"navigation\":\"caller\"}}" }, + "71653dfadc9c": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ], + "optimisticActiveWorktreeIdentity": { + "$rpc": "null" + }, + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ] + }, "9270aeb7d9c6": { "status": "pending", "startedAt": 0 @@ -223,6 +210,17 @@ } ] }, + "d5cd618d0458": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [], + "optimisticActiveWorktreeIdentity": "|wt-1", + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [] + }, "da6c9e1a507d": { "name": "lastKnownWorktrees", "ordinal": 2, @@ -328,7 +326,7 @@ "mount": "eb79a9b3682a", "toggle-pin": "eb79a9b3682a" }, - "state": "2ef4e83a1a85", + "state": "71653dfadc9c", "effects": ["ca91c555e48e", "da6c9e1a507d", "14ce9f03298e"] } }, @@ -343,7 +341,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -365,7 +363,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index 6b915ddc77a..e3867f1ac64 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -45,6 +45,19 @@ "ordinal": 4, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.rm\",\"params\":{\"worktree\":\"id:wt-1\",\"force\":true}}" }, + "1991d3406586": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [], + "optimisticActiveWorktreeIdentity": { + "$rpc": "null" + }, + "pinnedIds": [], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [] + }, "4da755eea670": { "name": "worktree.rm#1", "ordinal": 3, @@ -94,21 +107,7 @@ } ] }, - "6b0066cfa6bd": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [], - "optimisticActiveWorktreeIdentity": { - "$rpc": "null" - }, - "pinnedIds": [], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [] - }, - "6e7f22e079bb": { + "55adbae71b7b": { "confirmRemoveHost": false, "lastKnownWorktrees": [ { @@ -132,7 +131,6 @@ "$rpc": "null" }, "pinnedIds": [], - "routeActionState": {}, "serverWorkspaces": { "hosts": [], "worktrees": [] @@ -213,7 +211,7 @@ "mount": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "6b0066cfa6bd", + "state": "1991d3406586", "effects": ["8f27c443230b", "7a68c6ea2c44"] } }, @@ -226,7 +224,7 @@ "mount": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "6e7f22e079bb", + "state": "55adbae71b7b", "effects": ["8f27c443230b", "7a68c6ea2c44", "50378a292ce7", "753a3ae9bdd7"] } } diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json index 7d76855ba15..1e794dc9d1c 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json @@ -59,54 +59,6 @@ } } }, - "2ef4e83a1a85": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": true, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ], - "optimisticActiveWorktreeIdentity": { - "$rpc": "null" - }, - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": true, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ] - }, "363cbceb6083": { "name": "worktree.activate#1", "ordinal": 7, @@ -144,18 +96,6 @@ } } }, - "38c12b517ad0": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [], - "optimisticActiveWorktreeIdentity": "|wt-1", - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [] - }, "40f6e6f5e2cd": { "name": "worktree.activate#1", "ordinal": 7, @@ -270,6 +210,53 @@ } } }, + "71653dfadc9c": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ], + "optimisticActiveWorktreeIdentity": { + "$rpc": "null" + }, + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ] + }, "841394e7fa21": { "name": "worktree.activate#1", "ordinal": 7, @@ -514,6 +501,17 @@ } } }, + "d5cd618d0458": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [], + "optimisticActiveWorktreeIdentity": "|wt-1", + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [] + }, "d7a804fea94d": { "name": "worktree.activate#1", "ordinal": 7, @@ -688,7 +686,7 @@ "mount": "eb79a9b3682a", "toggle-pin": "eb79a9b3682a" }, - "state": "2ef4e83a1a85", + "state": "71653dfadc9c", "effects": ["ca91c555e48e", "da6c9e1a507d", "14ce9f03298e"] } }, @@ -703,7 +701,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -725,7 +723,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -747,7 +745,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -769,7 +767,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -791,7 +789,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -813,7 +811,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -835,7 +833,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -857,7 +855,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -879,7 +877,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -901,7 +899,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -923,7 +921,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -945,7 +943,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -967,7 +965,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -989,7 +987,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1011,7 +1009,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1033,7 +1031,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1055,7 +1053,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1077,7 +1075,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1099,7 +1097,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1121,7 +1119,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1143,7 +1141,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1165,7 +1163,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json index f88dc4ee133..c726f898dd4 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json @@ -135,14 +135,14 @@ } } }, - "2ef4e83a1a85": { + "2f6b4e46b5b5": { "confirmRemoveHost": false, "lastKnownWorktrees": [ { "branch": "feature/pin", "displayName": "marlin", "hasAttachedPty": false, - "isPinned": true, + "isPinned": false, "linkedPR": { "$rpc": "null" }, @@ -155,11 +155,8 @@ "worktreeId": "wt-1" } ], - "optimisticActiveWorktreeIdentity": { - "$rpc": "null" - }, + "optimisticActiveWorktreeIdentity": "|wt-1", "pinnedIds": ["wt-1"], - "routeActionState": {}, "serverWorkspaces": { "hosts": [], "worktrees": [] @@ -169,7 +166,7 @@ "branch": "feature/pin", "displayName": "marlin", "hasAttachedPty": false, - "isPinned": true, + "isPinned": false, "linkedPR": { "$rpc": "null" }, @@ -183,18 +180,6 @@ } ] }, - "38c12b517ad0": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [], - "optimisticActiveWorktreeIdentity": "|wt-1", - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [] - }, "40f6e6f5e2cd": { "name": "worktree.activate#1", "ordinal": 7, @@ -272,6 +257,53 @@ } } }, + "71653dfadc9c": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ], + "optimisticActiveWorktreeIdentity": { + "$rpc": "null" + }, + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ] + }, "801e7b09c9bf": { "name": "worktree.rm#1", "ordinal": 11, @@ -548,6 +580,17 @@ } } }, + "d5cd618d0458": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [], + "optimisticActiveWorktreeIdentity": "|wt-1", + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [] + }, "d989f96b300d": { "name": "worktree.rm#1", "ordinal": 11, @@ -709,52 +752,6 @@ } } }, - "fdf6088925b0": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": false, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ], - "optimisticActiveWorktreeIdentity": "|wt-1", - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": false, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ] - }, "fe2baa54af5a": { "name": "worktree.rm#1", "ordinal": 11, @@ -801,7 +798,7 @@ "mount": "eb79a9b3682a", "toggle-pin": "eb79a9b3682a" }, - "state": "2ef4e83a1a85", + "state": "71653dfadc9c", "effects": ["ca91c555e48e", "da6c9e1a507d", "14ce9f03298e"] } }, @@ -816,7 +813,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -838,7 +835,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -862,7 +859,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -884,7 +881,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -906,7 +903,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -928,7 +925,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -950,7 +947,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -972,7 +969,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -994,7 +991,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "fdf6088925b0", + "state": "2f6b4e46b5b5", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1018,7 +1015,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "fdf6088925b0", + "state": "2f6b4e46b5b5", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1042,7 +1039,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "fdf6088925b0", + "state": "2f6b4e46b5b5", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1066,7 +1063,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "fdf6088925b0", + "state": "2f6b4e46b5b5", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1090,7 +1087,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "fdf6088925b0", + "state": "2f6b4e46b5b5", "effects": [ "ca91c555e48e", "da6c9e1a507d", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json index f2600f2e91d..f8d2514b7b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json @@ -203,54 +203,6 @@ } } }, - "2ef4e83a1a85": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": true, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ], - "optimisticActiveWorktreeIdentity": { - "$rpc": "null" - }, - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [ - { - "branch": "feature/pin", - "displayName": "marlin", - "hasAttachedPty": false, - "isPinned": true, - "linkedPR": { - "$rpc": "null" - }, - "liveTerminalCount": 0, - "path": "/repos/marlin/wt-1", - "preview": "", - "repo": "marlin", - "repoId": "repo-1", - "unread": false, - "worktreeId": "wt-1" - } - ] - }, "33ef7c1e7ddd": { "name": "worktree.set#1", "ordinal": 4, @@ -287,18 +239,6 @@ } } }, - "38c12b517ad0": { - "confirmRemoveHost": false, - "lastKnownWorktrees": [], - "optimisticActiveWorktreeIdentity": "|wt-1", - "pinnedIds": ["wt-1"], - "routeActionState": {}, - "serverWorkspaces": { - "hosts": [], - "worktrees": [] - }, - "worktrees": [] - }, "38ff2d87e8b1": { "name": "worktree.set#1", "ordinal": 4, @@ -376,6 +316,53 @@ "ordinal": 8, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.activate\",\"params\":{\"worktree\":\"id:wt-1\",\"notifyClients\":false,\"navigation\":\"caller\"}}" }, + "71653dfadc9c": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ], + "optimisticActiveWorktreeIdentity": { + "$rpc": "null" + }, + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [ + { + "branch": "feature/pin", + "displayName": "marlin", + "hasAttachedPty": false, + "isPinned": true, + "linkedPR": { + "$rpc": "null" + }, + "liveTerminalCount": 0, + "path": "/repos/marlin/wt-1", + "preview": "", + "repo": "marlin", + "repoId": "repo-1", + "unread": false, + "worktreeId": "wt-1" + } + ] + }, "74a52ecbdd8d": { "name": "worktree.set#1", "ordinal": 4, @@ -573,6 +560,17 @@ } ] }, + "d5cd618d0458": { + "confirmRemoveHost": false, + "lastKnownWorktrees": [], + "optimisticActiveWorktreeIdentity": "|wt-1", + "pinnedIds": ["wt-1"], + "serverWorkspaces": { + "hosts": [], + "worktrees": [] + }, + "worktrees": [] + }, "da6c9e1a507d": { "name": "lastKnownWorktrees", "ordinal": 2, @@ -678,7 +676,7 @@ "mount": "eb79a9b3682a", "toggle-pin": "eb79a9b3682a" }, - "state": "2ef4e83a1a85", + "state": "71653dfadc9c", "effects": ["ca91c555e48e", "da6c9e1a507d", "14ce9f03298e"] } }, @@ -693,7 +691,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -715,7 +713,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -737,7 +735,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -759,7 +757,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -781,7 +779,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -803,7 +801,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -825,7 +823,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -847,7 +845,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -869,7 +867,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -891,7 +889,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -913,7 +911,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -935,7 +933,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -957,7 +955,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -979,7 +977,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1001,7 +999,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1023,7 +1021,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1045,7 +1043,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1067,7 +1065,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1089,7 +1087,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1111,7 +1109,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1133,7 +1131,7 @@ "open-session": "eb79a9b3682a", "delete": "9270aeb7d9c6" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", @@ -1155,7 +1153,7 @@ "open-session": "eb79a9b3682a", "delete": "eb79a9b3682a" }, - "state": "38c12b517ad0", + "state": "d5cd618d0458", "effects": [ "ca91c555e48e", "da6c9e1a507d", diff --git a/mobile/src/components/HostProtocolGate.tsx b/mobile/src/components/HostProtocolGate.tsx index 64f06523286..e03ef51e105 100644 --- a/mobile/src/components/HostProtocolGate.tsx +++ b/mobile/src/components/HostProtocolGate.tsx @@ -9,11 +9,20 @@ import { ProtocolBlockScreen } from './ProtocolBlockScreen' type Props = { hostId: string | undefined children: ReactNode + /** For a route drawn over another screen: nothing shows while status is pending, not a spinner. */ + transparentWhilePending?: boolean + /** In place of the block screen, for a route that hands a blocked host to the host list. */ + blocked?: ReactNode } // Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the // whole host UI (sidebar + detail stack) while the host list and other hosts stay usable. -export function HostProtocolGate({ hostId, children }: Props) { +export function HostProtocolGate({ + hostId, + children, + transparentWhilePending = false, + blocked: blockedView +}: Props) { const { client, state } = useHostClient(hostId) const gates = useHostStatusGates({ hostId, client, connState: state }) const { compatVerdict, statusPending } = gates @@ -24,6 +33,7 @@ export function HostProtocolGate({ hostId, children }: Props) { const blocked = compatVerdict.kind === 'blocked' const pending = statusPending && resolvedHostIdRef.current !== hostKey const holdBack = pending && mountedHostIdRef.current !== hostKey + const covered = pending && !transparentWhilePending // Why: React can replay or discard a render, so the latches record committed // outcomes only — a discarded children render must not count as mounted. @@ -40,6 +50,9 @@ export function HostProtocolGate({ hostId, children }: Props) { } }) + if (holdBack && transparentWhilePending) { + return null + } if (holdBack) { // Why: nothing is mounted yet for this host, so hold the routes back entirely // rather than letting them mount (and fire their connect RPCs) pre-verdict. @@ -53,7 +66,7 @@ export function HostProtocolGate({ hostId, children }: Props) { ) } if (blocked) { - return + return blockedView ?? } // Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route. return ( @@ -63,11 +76,11 @@ export function HostProtocolGate({ hostId, children }: Props) { style={styles.host} // Why: the overlay blocks in-tree touches but TalkBack can still walk the covered // stack; hide it while pending (Android counterpart of the overlay's iOS modal flag). - importantForAccessibility={pending ? 'no-hide-descendants' : 'auto'} + importantForAccessibility={covered ? 'no-hide-descendants' : 'auto'} > {children} - {pending ? ( + {covered ? ( // Why: once the stack is mounted, unmounting it for a pending status.get destroys // in-flight nested navigation, so cover it instead. Mount effects underneath still // run — they wait for connState 'connected' and every capability-dependent call diff --git a/mobile/src/components/MobileHomeQuickActions.test.ts b/mobile/src/components/MobileHomeQuickActions.test.ts index 59d0e408727..478776acac6 100644 --- a/mobile/src/components/MobileHomeQuickActions.test.ts +++ b/mobile/src/components/MobileHomeQuickActions.test.ts @@ -16,24 +16,6 @@ vi.mock('lucide-react-native', () => ({ QrCode: 'QrCode' })) -vi.mock('./PickerModal', async () => { - const React = await import('react') - return { - PickerModal: (props: unknown) => React.createElement('PickerModal', props) - } -}) - -function host(id: string, name: string, endpoint: string): HostProfile { - return { - id, - name, - endpoint, - deviceToken: `token-${id}`, - publicKeyB64: `key-${id}`, - lastConnected: 1 - } -} - describe('MobileHomeQuickActions', () => { let renderer: ReactTestRenderer | null = null @@ -57,56 +39,16 @@ describe('MobileHomeQuickActions', () => { ) }) consoleError.mockRestore() - return { onCreateWorkspace } } function newWorkspaceButton() { return renderer!.root.findAllByType('Pressable')[1] } - function picker() { - return renderer!.root.findByType('PickerModal') - } - it('disables workspace creation without a connected host', async () => { await renderQuickActions([]) expect(newWorkspaceButton().props.disabled).toBe(true) expect(newWorkspaceButton().props.accessibilityState).toEqual({ disabled: true }) - expect(picker().props.visible).toBe(false) - }) - - it('opens the only connected host directly', async () => { - const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768') - const callbacks = await renderQuickActions([desk]) - - act(() => newWorkspaceButton().props.onPress()) - - expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('desk') - expect(picker().props.visible).toBe(false) - }) - - it('asks which host to use when multiple are connected', async () => { - const callbacks = await renderQuickActions([ - host('desk', 'Desk', 'ws://192.168.1.2:6768'), - host('laptop', 'Laptop', 'wss://relay.example.com/mobile') - ]) - - act(() => newWorkspaceButton().props.onPress()) - - expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled() - expect(picker().props.visible).toBe(true) - expect(picker().props.title).toBe('Create Workspace On') - expect(picker().props.options).toEqual([ - { value: 'desk', label: 'Desk', subtitle: '192.168.1.2:6768' }, - { value: 'laptop', label: 'Laptop', subtitle: 'relay.example.com' } - ]) - - act(() => picker().props.onSelect('laptop')) - expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled() - expect(picker().props.visible).toBe(false) - - act(() => picker().props.onAfterClose()) - expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('laptop') }) }) diff --git a/mobile/src/components/MobileHomeQuickActions.tsx b/mobile/src/components/MobileHomeQuickActions.tsx index addd3de01de..385aa1af990 100644 --- a/mobile/src/components/MobileHomeQuickActions.tsx +++ b/mobile/src/components/MobileHomeQuickActions.tsx @@ -2,17 +2,14 @@ import { Plus, QrCode } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import type { HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' -import { PickerModal } from './PickerModal' -import { useConnectedHostPicker } from './use-connected-host-picker' type Props = { connectedHosts: HostProfile[] onPairDesktop: () => void - onCreateWorkspace: (hostId: string) => void + onCreateWorkspace: () => void } export function MobileHomeQuickActions(props: Props) { - const hostPicker = useConnectedHostPicker(props.connectedHosts) const canCreateWorkspace = props.connectedHosts.length > 0 return ( @@ -38,7 +35,7 @@ export function MobileHomeQuickActions(props: Props) { !canCreateWorkspace && styles.quickActionDisabled, pressed && styles.quickActionPressed ]} - onPress={() => hostPicker.pickHost(props.onCreateWorkspace)} + onPress={props.onCreateWorkspace} > @@ -46,7 +43,6 @@ export function MobileHomeQuickActions(props: Props) { New Workspace - ) } diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 698a7a97139..4e2163ea3c3 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -468,16 +468,16 @@ describe('NewWorktreeModal project targets', () => { const listCalls = () => desktop.sendRequest.mock.calls.filter(([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD) // The host list's own names: no server rows have loaded behind a sheet opened from Home. - const sheet = (routeVisible: boolean) => + const sheet = (visible: boolean) => createElement(NewWorktreeModalController, { - routeVisible, + visible, client: desktop, hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY], hostId: 'host-1', hostLabelById: new Map(), openExternalUrl: () => {}, onCreated: () => {}, - onRouteVisibleChange: () => {} + onClose: () => {} }) await act(async () => { diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index bc126751e2a..e82cba24d77 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { useMemo } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' import { useReachableServerClients } from '../worktree/use-reachable-server-clients' @@ -6,81 +6,34 @@ import { withServerHosts } from '../worktree/server-workspaces' import { NewWorktreeModal } from './NewWorktreeModal' import type { NewWorktreeModalProps } from './new-worktree-modal-types' -export type NewWorktreeModalControllerHandle = { - open: () => void -} - -type Props = Omit< - NewWorktreeModalProps, - 'visible' | 'onClose' | 'serverClients' | 'hostLabelById' -> & { +type Props = Omit & { /** The desktop's capabilities, which say whether its servers are reachable through it. */ hostCapabilities: readonly string[] /** The desktop's own host names; the sheet adds its servers' from the feed. */ hostLabelById: ReadonlyMap - routeVisible: boolean - onVisibleChange?: (visible: boolean) => void - onRouteVisibleChange: (visible: boolean) => void } -export const NewWorktreeModalController = forwardRef( - function NewWorktreeModalController( - { - routeVisible, - client, - hostCapabilities, - hostId, - hostLabelById, - existingWorktreePaths, - existingWorktrees, - openExternalUrl, - onVisibleChange, - onRouteVisibleChange, - onCreated - }, - ref - ) { - const [manualVisible, setManualVisible] = useState(false) - const visible = routeVisible || manualVisible - // Why the feed, not the list's server rows: the list stops refreshing behind the sheet. - const { servers, serverClients } = useReachableServerClients(client, hostCapabilities, visible) - const serverLabelById = useMemo( - () => withServerHosts(hostLabelById, servers ?? [], (host) => host.label), - [hostLabelById, servers] - ) +export function NewWorktreeModalController({ + visible, + client, + hostCapabilities, + hostLabelById, + ...modalProps +}: Props) { + // Why the feed, not the list's server rows: the list stops refreshing behind the sheet. + const { servers, serverClients } = useReachableServerClients(client, hostCapabilities, visible) + const serverLabelById = useMemo( + () => withServerHosts(hostLabelById, servers ?? [], (host) => host.label), + [hostLabelById, servers] + ) - useImperativeHandle( - ref, - () => ({ - open: () => setManualVisible(true) - }), - [] - ) - - const close = useCallback(() => { - setManualVisible(false) - if (routeVisible) { - onRouteVisibleChange(false) - } - }, [onRouteVisibleChange, routeVisible]) - - useEffect(() => { - onVisibleChange?.(visible) - }, [onVisibleChange, visible]) - - return ( - - ) - } -) + return ( + + ) +} diff --git a/mobile/src/components/bottom-drawer-constants.ts b/mobile/src/components/bottom-drawer-constants.ts index d021d3d4b78..d6578279030 100644 --- a/mobile/src/components/bottom-drawer-constants.ts +++ b/mobile/src/components/bottom-drawer-constants.ts @@ -1 +1,2 @@ +export const BOTTOM_DRAWER_SHOW_DURATION_MS = 180 export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150 diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index bac83538a59..d3fccd7b397 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -14,7 +14,10 @@ import Animated, { import { spacing } from '../theme/mobile-theme' import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height' import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset' -import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants' +import { + BOTTOM_DRAWER_HIDE_DURATION_MS, + BOTTOM_DRAWER_SHOW_DURATION_MS +} from './bottom-drawer-constants' import { bottomDrawerStyles as styles } from './bottom-drawer-styles' import { useBottomDrawerModalHost, @@ -32,7 +35,6 @@ const SPRING_CONFIG = { damping: 28, stiffness: 400 } // so the drawer resists upward dragging — a subtle polish touch that signals // the drawer cannot expand further. const RUBBER_BAND_FACTOR = 0.25 -const SHOW_DURATION = 180 const TOP_SCROLL_EPSILON = 1 export type MountedBottomDrawerProps = { @@ -114,7 +116,7 @@ export function MountedBottomDrawer({ return } translateY.value = 0 - progress.value = withTiming(1, { duration: SHOW_DURATION }) + progress.value = withTiming(1, { duration: BOTTOM_DRAWER_SHOW_DURATION_MS }) setWindowEpoch((epoch) => epoch + 1) }, [interactive, visible]) @@ -122,7 +124,7 @@ export function MountedBottomDrawer({ if (visible) { translateY.value = 0 scrollOffsetY.value = 0 - progress.value = withTiming(1, { duration: SHOW_DURATION }) + progress.value = withTiming(1, { duration: BOTTOM_DRAWER_SHOW_DURATION_MS }) } else { Keyboard.dismiss() setKeyboardInset(0) diff --git a/mobile/src/components/use-connected-host-picker.ts b/mobile/src/components/use-connected-host-picker.ts index 79523bace82..773c3952a74 100644 --- a/mobile/src/components/use-connected-host-picker.ts +++ b/mobile/src/components/use-connected-host-picker.ts @@ -2,7 +2,8 @@ import { useRef, useState } from 'react' import { hostEndpointLabel } from '../transport/host-endpoint-label' import type { HostProfile } from '../transport/types' -function hostPickerOptions(hosts: HostProfile[]) { +/** Picker rows for desktops, telling apart two that share a name and endpoint. */ +export function hostPickerOptions(hosts: HostProfile[]) { const entries = hosts.map((host) => ({ host, endpointLabel: hostEndpointLabel(host.endpoint) diff --git a/mobile/src/home/MobileHomeListFooter.test.tsx b/mobile/src/home/MobileHomeListFooter.test.tsx index 933c7c17ba8..43f577ec27a 100644 --- a/mobile/src/home/MobileHomeListFooter.test.tsx +++ b/mobile/src/home/MobileHomeListFooter.test.tsx @@ -185,18 +185,4 @@ describe('MobileHomeListFooter tasks', () => { expect(callbacks.onOpenTasks).toHaveBeenCalledWith('server', 'jira') }) - - it('keeps the New Workspace picker separate from the Tasks picker', async () => { - const callbacks = await renderFooter([desk, laptop]) - - act(() => pressableWithText('New Workspace').props.onPress()) - expect(picker('Create Workspace On').props.visible).toBe(true) - expect(tasksPicker().props.visible).toBe(false) - - act(() => picker('Create Workspace On').props.onSelect('desk')) - act(() => picker('Create Workspace On').props.onAfterClose()) - - expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('desk') - expect(callbacks.onOpenTasks).not.toHaveBeenCalled() - }) }) diff --git a/mobile/src/home/MobileHomeListFooter.tsx b/mobile/src/home/MobileHomeListFooter.tsx index c4133b62cc0..fb7c395df7f 100644 --- a/mobile/src/home/MobileHomeListFooter.tsx +++ b/mobile/src/home/MobileHomeListFooter.tsx @@ -18,7 +18,7 @@ export function MobileHomeListFooter(props: { connectedHosts: HostProfile[] resumeCard: HomeResumeCard | null taskProvidersByHost: Record - onCreateWorkspace: (hostId: string) => void + onCreateWorkspace: () => void onOpenAccounts: (hostId: string) => void onOpenResume: (card: HomeResumeCard) => void onOpenTasks: (hostId: string, provider?: TaskProvider) => void diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx index b36835614a8..a4e59726ed7 100644 --- a/mobile/src/home/MobileHomeScreen.tsx +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -6,7 +6,6 @@ import { getProvenCachedWorktrees } from '../cache/worktree-cache' import { ActionSheetModal } from '../components/ActionSheetModal' import { ConfirmModal } from '../components/ConfirmModal' import { getHostListActionSheetActions } from '../host-list-action-sheet-actions' -import { hostNewWorktreeRoute } from '../host-route-action-state' import { hostRouteWithNotice } from '../host-route-notice' import { useResponsiveLayout } from '../layout/responsive-layout' import { triggerMediumImpact } from '../platform/haptics' @@ -31,6 +30,7 @@ import { MobileHomeHostList } from './MobileHomeHostList' import { MobileHomeListFooter } from './MobileHomeListFooter' import { MobileHomeTopBar } from './MobileHomeTopBar' import { useMobileHomeData } from './use-mobile-home-data' +import { newWorkspaceOverlayHref } from '../new-workspace/new-workspace-overlay-route' export function MobileHomeScreen() { const data = useMobileHomeData() @@ -122,7 +122,11 @@ export function MobileHomeScreen() { connectedHosts={data.connectedHosts} resumeCard={data.resumeCard} taskProvidersByHost={data.taskProvidersByHost} - onCreateWorkspace={(hostId) => data.router.push(hostNewWorktreeRoute(hostId))} + onCreateWorkspace={() => + data.router.push( + newWorkspaceOverlayHref(data.connectedHosts.map((host) => host.id)) + ) + } onOpenAccounts={openMobileAccounts} onOpenResume={openResume} onOpenTasks={openMobileTasks} diff --git a/mobile/src/host-new-worktree-session-route.test.ts b/mobile/src/host-new-worktree-session-route.test.ts new file mode 100644 index 00000000000..03a13462c1a --- /dev/null +++ b/mobile/src/host-new-worktree-session-route.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { hostNewWorktreeSessionRoute } from './host-new-worktree-session-route' + +describe('host new worktree session route', () => { + it('preserves opaque host and worktree ids after creation', () => { + expect(hostNewWorktreeSessionRoute('relay/one#50%', 'repo/one#20%', 'Relay workspace')).toBe( + '/h/relay%2Fone%2350%25/session/repo%2Fone%2320%25?name=Relay+workspace&created=1' + ) + }) + + // Why: the host reports a create that succeeded with a failed startup terminal via `warning`; + // dropping it here is what lands the phone on an unexplained empty session. + it('carries a host create warning into the session route', () => { + expect( + hostNewWorktreeSessionRoute( + 'local', + 'wt-1', + 'Hammerhead', + 'Failed to create the startup terminal' + ) + ).toBe( + '/h/local/session/wt-1?name=Hammerhead&created=1&warning=Failed+to+create+the+startup+terminal' + ) + }) + + it('omits an absent or blank create warning', () => { + expect(hostNewWorktreeSessionRoute('local', 'wt-1', 'Hammerhead', ' ')).toBe( + '/h/local/session/wt-1?name=Hammerhead&created=1' + ) + expect(hostNewWorktreeSessionRoute('local', 'wt-1', 'Hammerhead')).toBe( + '/h/local/session/wt-1?name=Hammerhead&created=1' + ) + }) +}) diff --git a/mobile/src/host-new-worktree-session-route.ts b/mobile/src/host-new-worktree-session-route.ts new file mode 100644 index 00000000000..ffdf1719c32 --- /dev/null +++ b/mobile/src/host-new-worktree-session-route.ts @@ -0,0 +1,13 @@ +export function hostNewWorktreeSessionRoute( + hostId: string, + worktreeId: string, + worktreeName: string, + /** Host-reported create warning (e.g. the startup terminal failed to spawn). */ + warning?: string +): `/h/${string}/session/${string}?${string}` { + const params = new URLSearchParams({ name: worktreeName, created: '1' }) + if (warning?.trim()) { + params.set('warning', warning) + } + return `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}?${params}` +} diff --git a/mobile/src/host-route-action-state.test.ts b/mobile/src/host-route-action-state.test.ts deleted file mode 100644 index fd2e75d311f..00000000000 --- a/mobile/src/host-route-action-state.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - createInitialHostRouteActionState, - hostNewWorktreeRoute, - hostNewWorktreeSessionRoute, - resolveHostRouteActionState, - setHostRouteNewWorktreeVisible -} from './host-route-action-state' - -describe('host route action state', () => { - it('encodes opaque host ids in the new-worktree route segment', () => { - expect(hostNewWorktreeRoute('relay/one#50%')).toBe('/h/relay%2Fone%2350%25?action=newWorktree') - }) - - it('preserves opaque host and worktree ids after creation', () => { - expect(hostNewWorktreeSessionRoute('relay/one#50%', 'repo/one#20%', 'Relay workspace')).toBe( - '/h/relay%2Fone%2350%25/session/repo%2Fone%2320%25?name=Relay+workspace&created=1' - ) - }) - - // Why: the host reports a create that succeeded with a failed startup terminal via `warning`; - // dropping it here is what lands the phone on an unexplained empty session. - it('carries a host create warning into the session route', () => { - expect( - hostNewWorktreeSessionRoute( - 'local', - 'wt-1', - 'Hammerhead', - 'Failed to create the startup terminal' - ) - ).toBe( - '/h/local/session/wt-1?name=Hammerhead&created=1&warning=Failed+to+create+the+startup+terminal' - ) - }) - - it('omits an absent or blank create warning', () => { - expect(hostNewWorktreeSessionRoute('local', 'wt-1', 'Hammerhead', ' ')).toBe( - '/h/local/session/wt-1?name=Hammerhead&created=1' - ) - expect(hostNewWorktreeSessionRoute('local', 'wt-1', 'Hammerhead')).toBe( - '/h/local/session/wt-1?name=Hammerhead&created=1' - ) - }) - - it('opens new worktree modal on an initial newWorktree action', () => { - expect(createInitialHostRouteActionState('newWorktree')).toEqual({ - routeAction: 'newWorktree', - showNewWorktree: true - }) - }) - - it('keeps initial non-action routes closed', () => { - expect(createInitialHostRouteActionState(undefined)).toEqual({ - routeAction: undefined, - showNewWorktree: false - }) - }) - - it('opens once when route action changes to newWorktree', () => { - expect( - resolveHostRouteActionState({ routeAction: undefined, showNewWorktree: false }, 'newWorktree') - ).toEqual({ - routeAction: 'newWorktree', - showNewWorktree: true - }) - }) - - it('does not reopen after user closes while route action is unchanged', () => { - const closed = setHostRouteNewWorktreeVisible( - { routeAction: 'newWorktree', showNewWorktree: true }, - false - ) - - expect(resolveHostRouteActionState(closed, 'newWorktree')).toBe(closed) - }) - - it('preserves an already-open modal when route action changes away', () => { - expect( - resolveHostRouteActionState({ routeAction: 'newWorktree', showNewWorktree: true }, undefined) - ).toEqual({ - routeAction: undefined, - showNewWorktree: true - }) - }) -}) diff --git a/mobile/src/host-route-action-state.ts b/mobile/src/host-route-action-state.ts deleted file mode 100644 index 5c2588b1e89..00000000000 --- a/mobile/src/host-route-action-state.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type HostRouteActionState = { - routeAction: string | undefined - showNewWorktree: boolean -} - -export function hostNewWorktreeRoute(hostId: string): `/h/${string}?action=newWorktree` { - return `/h/${encodeURIComponent(hostId)}?action=newWorktree` -} - -export function hostNewWorktreeSessionRoute( - hostId: string, - worktreeId: string, - worktreeName: string, - /** Host-reported create warning (e.g. the startup terminal failed to spawn). */ - warning?: string -): `/h/${string}/session/${string}?${string}` { - const params = new URLSearchParams({ name: worktreeName, created: '1' }) - if (warning?.trim()) { - params.set('warning', warning) - } - return `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}?${params}` -} - -export function createInitialHostRouteActionState( - routeAction: string | undefined -): HostRouteActionState { - return { - routeAction, - showNewWorktree: routeAction === 'newWorktree' - } -} - -export function resolveHostRouteActionState( - current: HostRouteActionState, - routeAction: string | undefined -): HostRouteActionState { - if (current.routeAction === routeAction) { - return current - } - return { - routeAction, - showNewWorktree: current.showNewWorktree || routeAction === 'newWorktree' - } -} - -export function setHostRouteNewWorktreeVisible( - current: HostRouteActionState, - showNewWorktree: boolean -): HostRouteActionState { - if (current.showNewWorktree === showNewWorktree) { - return current - } - return { ...current, showNewWorktree } -} diff --git a/mobile/src/host-screen/host-screen-overlays.test.tsx b/mobile/src/host-screen/host-screen-overlays.test.tsx index 566d03da0a1..e51b69b594b 100644 --- a/mobile/src/host-screen/host-screen-overlays.test.tsx +++ b/mobile/src/host-screen/host-screen-overlays.test.tsx @@ -49,7 +49,6 @@ async function renderSheet(row: Partial) { hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY], hostId: 'host-1', settings: {}, - showNewWorktree: false, state: { actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row }, confirmDelete: null, diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 91f0a84cfbb..c8e7ae2addf 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -8,7 +8,7 @@ import { ActionSheetContent } from '../components/ActionSheetModal' import { BottomDrawer } from '../components/BottomDrawer' import { ConfirmModal } from '../components/ConfirmModal' import { NewWorktreeModalController } from '../components/NewWorktreeModalController' -import { hostNewWorktreeSessionRoute } from '../host-route-action-state' +import { hostNewWorktreeSessionRoute } from '../host-new-worktree-session-route' import { isWorktreePinned } from '../worktree/workspace-list-sections' import { hostScreenStyles as styles } from './host-screen-styles' import { WorkspaceOptionsSheet } from './workspace-options-sheet' @@ -24,7 +24,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr existingWorktreePaths, hostCapabilities, hostId, - showNewWorktree, state } = controller const actionTarget = state.actionTarget @@ -156,8 +155,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr /> { - state.newWorktreeModalVisibleRef.current = visible - }} onCreated={(worktreeId, worktreeName, warning, executionHost) => { void catalog.fetchWorktrees({ allowDuringModal: true }) actions.navigateFromHostList( @@ -179,7 +174,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr ) ) }} - onRouteVisibleChange={actions.setShowNewWorktreeVisible} + onClose={actions.closeNewWorktreeModal} /> ) diff --git a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index 821f9ae354c..0afeb627b3d 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -126,7 +126,7 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis function Probe(): null { const [, setTick] = useState(0) rerender = () => setTick((tick) => tick + 1) - const state = useHostScreenState('host-1', undefined) + const state = useHostScreenState('host-1') state.clientRef.current = client const catalog = useHostWorktreeCatalog({ client, diff --git a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts index 5c071cb8465..a702a61996a 100644 --- a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts +++ b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts @@ -65,7 +65,7 @@ async function displayedRows(args: { } let setWorktrees: ((rows: Worktree[]) => void) | undefined function Probe(): null { - const state = useHostScreenState('host-1', undefined) + const state = useHostScreenState('host-1') state.clientRef.current = client setWorktrees = state.setWorktrees seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state }) diff --git a/mobile/src/host-screen/host-ssh-target-labels-read.ts b/mobile/src/host-screen/host-ssh-target-labels-read.ts new file mode 100644 index 00000000000..7743922c2d8 --- /dev/null +++ b/mobile/src/host-screen/host-ssh-target-labels-read.ts @@ -0,0 +1,54 @@ +import { optionalSettingsRead } from '../transport/settings-read-operations' +import type { RpcAcceptedResult } from '../transport/rpc-accepted-result' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { buildHostLabelById } from '../worktree/worktree-host-context-labels' +import { hostSshTargetSummariesRead } from './host-screen-operations' + +export async function settledMetadataReply( + send: () => Promise +): Promise { + try { + return await send() + } catch { + // Best-effort: hosts that predate a method still list repos; labels degrade to host ids. + return null + } +} + +/** An accepted metadata payload, or null for a refusal or a send that never landed. */ +export function acceptedMetadata( + reply: RpcResponse | null, + interpret: (reply: RpcResponse) => RpcAcceptedResult +): Value | null { + if (!reply) { + return null + } + const verdict = interpret(reply) + return verdict.accepted ? verdict.value : null +} + +function readHostSettingOverrides(result: unknown): unknown { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + return (result as { hostSettingOverrides?: unknown } | null)?.hostSettingOverrides +} + +/** The desktop's SSH targets and the names its hosts show under: SSH labels, then display overrides. */ +export async function readHostSshTargetLabels(client: RpcClient) { + const [sshTargets, hostSettings] = await Promise.all([ + settledMetadataReply(() => hostSshTargetSummariesRead.request(client)), + optionalSettingsRead.request(client).catch(() => null) + ]) + const hostSettingsResult = hostSettings ? optionalSettingsRead.interpret(hostSettings) : null + const sshTargetSummaries = + acceptedMetadata(sshTargets, hostSshTargetSummariesRead.interpret) ?? [] + return { + sshTargetSummaries, + hostLabelById: buildHostLabelById({ + sshTargets: sshTargetSummaries, + hostSettingOverrides: readHostSettingOverrides( + hostSettingsResult?.accepted ? hostSettingsResult.value : undefined + ) + }) + } +} diff --git a/mobile/src/host-screen/host-view-changes-reconnect.test.ts b/mobile/src/host-screen/host-view-changes-reconnect.test.ts index e9298790117..d3614d1d96d 100644 --- a/mobile/src/host-screen/host-view-changes-reconnect.test.ts +++ b/mobile/src/host-screen/host-view-changes-reconnect.test.ts @@ -67,7 +67,7 @@ afterEach(() => { async function mountScreen(client: FakeSession) { const held: { view?: ReturnType } = {} function Probe({ connState }: { connState: ConnectionState }): null { - const state = useHostScreenState('host-1', undefined) + const state = useHostScreenState('host-1') state.clientRef.current = client const view = useHostViewSettings({ client, connState, state }) held.view = view diff --git a/mobile/src/host-screen/host-worktree-child-navigation.test.tsx b/mobile/src/host-screen/host-worktree-child-navigation.test.tsx index da239ef773c..7dda722abe3 100644 --- a/mobile/src/host-screen/host-worktree-child-navigation.test.tsx +++ b/mobile/src/host-screen/host-worktree-child-navigation.test.tsx @@ -55,7 +55,7 @@ describe('a workspace child opens its parent pane', () => { const row = { ...item, ...(executionHost ? { hostId: executionHost } : {}) } const held: { open?: (item: Worktree, paneKey?: string) => void } = {} function Probe() { - const state = useHostScreenState('host', undefined) + const state = useHostScreenState('host') held.open = useHostWorktreeActions({ client, hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY], diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index 550b0eefed3..7a5199617b7 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -1,47 +1,22 @@ -import { optionalSettingsRead } from '../transport/settings-read-operations' import { useCallback } from 'react' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host' import { setCachedRepos } from '../cache/repo-cache' -import type { RpcAcceptedResult } from '../transport/rpc-accepted-result' import type { RpcClient } from '../transport/rpc-client' -import type { ConnectionState, RpcResponse } from '../transport/types' -import { - buildHostLabelById, - buildRepoHostIdByRepoId -} from '../worktree/worktree-host-context-labels' +import type { ConnectionState } from '../transport/types' +import { buildRepoHostIdByRepoId } from '../worktree/worktree-host-context-labels' import { buildSshHostHealthById } from '../worktree/worktree-host-health' import type { SectionRepo } from '../worktree/workspace-list-types' import type { HostRepoCatalogRow } from './host-screen-reply-schema' +import { hostPlatformRead, hostRepoCatalogRead } from './host-screen-operations' import { - hostPlatformRead, - hostRepoCatalogRead, - hostSshTargetSummariesRead -} from './host-screen-operations' + acceptedMetadata, + readHostSshTargetLabels, + settledMetadataReply +} from './host-ssh-target-labels-read' import type { HostScreenState } from './use-host-screen-state' const REPO_METADATA_REFRESH_MS = 60_000 -async function settledMetadataReply(send: () => Promise): Promise { - try { - return await send() - } catch { - // Best-effort: hosts that predate a method still list repos; labels degrade to host ids. - return null - } -} - -/** An accepted metadata payload, or null for a refusal or a send that never landed. */ -function acceptedMetadata( - reply: RpcResponse | null, - interpret: (reply: RpcResponse) => RpcAcceptedResult -): Value | null { - if (!reply) { - return null - } - const verdict = interpret(reply) - return verdict.accepted ? verdict.value : null -} - /** A desktop repo.list row as Project sections read it, for a desktop that serves no catalog. */ function desktopSectionRepo(repo: HostRepoCatalogRow): SectionRepo { return { @@ -54,11 +29,6 @@ function desktopSectionRepo(repo: HostRepoCatalogRow): SectionRepo { } } -function readHostSettingOverrides(result: unknown): unknown { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - return (result as { hostSettingOverrides?: unknown } | null)?.hostSettingOverrides -} - export function useHostRepoMetadata(args: { client: RpcClient | null connState: ConnectionState @@ -120,28 +90,15 @@ export function useHostRepoMetadata(args: { const hostIds = new Set(catalog.map((repo) => getRepoExecutionHostId(repo))) const hasSshHost = [...hostIds].some((id) => parseExecutionHostId(id)?.kind === 'ssh') if (hostIds.size > 1 || hasSshHost) { - const [sshTargets, hostSettings, hostPlatform] = await Promise.all([ - settledMetadataReply(() => hostSshTargetSummariesRead.request(requestClient)), - optionalSettingsRead.request(requestClient).catch(() => null), + const [{ sshTargetSummaries, hostLabelById }, hostPlatform] = await Promise.all([ + readHostSshTargetLabels(requestClient), settledMetadataReply(() => hostPlatformRead.request(requestClient)) ]) if (clientRef.current !== requestClient || hostId !== requestHostId) { return } - const hostSettingsResult = hostSettings - ? optionalSettingsRead.interpret(hostSettings) - : null - const sshTargetSummaries = - acceptedMetadata(sshTargets, hostSshTargetSummariesRead.interpret) ?? [] setHostHealthById(buildSshHostHealthById(sshTargetSummaries)) - setHostLabelById( - buildHostLabelById({ - sshTargets: sshTargetSummaries, - hostSettingOverrides: readHostSettingOverrides( - hostSettingsResult?.accepted ? hostSettingsResult.value : undefined - ) - }) - ) + setHostLabelById(hostLabelById) setHostPlatform(acceptedMetadata(hostPlatform, hostPlatformRead.interpret) ?? null) } else { // Why: a host that left the catalog must not keep badging rows that still name it. diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 20356316bde..5ffeaac944d 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -4,7 +4,6 @@ import { useRouteHandoff } from '../navigation/route-handoff' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useHostProtocolGates } from '../components/host-protocol-gates-context' import { visibleHostRouteNotice } from '../host-route-notice' -import { resolveHostRouteActionState } from '../host-route-action-state' import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll' import { useNow } from '../hooks/use-now' import { useResponsiveLayout } from '../layout/responsive-layout' @@ -36,19 +35,16 @@ export type HostScreenProps = { embedded?: boolean // Route params aren't in scope when rendered from the layout, so the caller passes these explicitly. hostId?: string - action?: string onHideSidebar?: () => void } export function useHostScreenController({ embedded = false, hostId: hostIdProp, - action: actionProp, onHideSidebar }: HostScreenProps = {}) { - const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>() + const params = useLocalSearchParams<{ hostId: string; notice?: string }>() const hostId = hostIdProp ?? params.hostId - const action = actionProp ?? params.action const [dismissedNotice, setDismissedNotice] = useState(null) const noticeParam = params.notice?.trim() const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice) @@ -70,7 +66,7 @@ export function useHostScreenController({ // One tick drives every visible agent row's relative timestamp. const now = useNow(30_000) const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates() - const state = useHostScreenState(hostId, action) + const state = useHostScreenState(hostId) const settings = useHostViewSettings({ client, connState, state }) const hostDisplay = useHostDisplay( hostId && state.hostName @@ -128,13 +124,6 @@ export function useHostScreenController({ state }) - const resolvedRouteActionState = resolveHostRouteActionState(state.routeActionState, action) - // Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists. - if (resolvedRouteActionState !== state.routeActionState) { - state.setRouteActionState(resolvedRouteActionState) - } - const showNewWorktree = resolvedRouteActionState.showNewWorktree - const hostPlatform = useResolvedHostPlatform(state, hostId) const displayWorktrees = useHostDisplayWorktrees(connState, state, hostPlatform) const feed = sidebarFeed?.feed ?? null @@ -193,7 +182,6 @@ export function useHostScreenController({ sectionsResult, setDismissedNotice, settings, - showNewWorktree, state } } diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index aed82bbfd21..9869f8a3773 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -1,8 +1,7 @@ -import { useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' import { getCachedWorktrees } from '../cache/worktree-cache' -import { createInitialHostRouteActionState } from '../host-route-action-state' import type { RpcClient } from '../transport/rpc-client' import { WorktreeCatalogSnapshotClient } from '../worktree/worktree-catalog-snapshot-client' import { INITIAL_MOBILE_VIEW, type MobileViewState } from '../worktree/workspace-view-settings' @@ -12,7 +11,7 @@ import type { SectionRepo } from '../worktree/workspace-list-types' import { NO_SERVER_WORKSPACES, type ServerWorkspaces } from '../worktree/server-workspaces' import type { ProjectGroup } from '../../../src/shared/project-group-types' -export function useHostScreenState(hostId: string | undefined, action: string | undefined) { +export function useHostScreenState(hostId: string | undefined) { const [initialCache] = useState(() => hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null ) @@ -24,7 +23,6 @@ export function useHostScreenState(hostId: string | undefined, action: string | const fetchRepoMetadataInFlightRef = useRef(new WeakSet()) const fetchRepoMetadataPendingRef = useRef(new WeakSet()) const repoMetadataFetchedAtRef = useRef(0) - const newWorktreeModalRef = useRef<{ open: () => void }>(null) const newWorktreeModalVisibleRef = useRef(false) const [worktrees, setWorktrees] = useState(initialCache ?? []) const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null) @@ -75,9 +73,12 @@ export function useHostScreenState(hostId: string | undefined, action: string | const [actionTarget, setActionTarget] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) - const [routeActionState, setRouteActionState] = useState(() => - createInitialHostRouteActionState(action) - ) + const [showNewWorktree, setShowNewWorktree] = useState(false) + // Why the ref too: the catalog poll reads it from callbacks that outlive this render. + const setNewWorktreeVisible = useCallback((visible: boolean) => { + newWorktreeModalVisibleRef.current = visible + setShowNewWorktree(visible) + }, []) const [sleptIds, setSleptIds] = useState>(new Set()) const [pinnedIds, setPinnedIds] = useState>(new Set()) // Desktop's "Also show pinned worktrees in their original lists"; off by default. @@ -103,7 +104,6 @@ export function useHostScreenState(hostId: string | undefined, action: string | hostPlatform, hostStoredDescriptor, lastKnownWorktrees, - newWorktreeModalRef, newWorktreeModalVisibleRef, optimisticActiveWorktreeIdentity, pinnedIds, @@ -111,7 +111,6 @@ export function useHostScreenState(hostId: string | undefined, action: string | servedProjectSections, desktopRepos, repoMetadataFetchedAtRef, - routeActionState, search, serverNotice, serverWorkspaces, @@ -133,7 +132,7 @@ export function useHostScreenState(hostId: string | undefined, action: string | setRepoHostIdByRepoId, setServedProjectSections, setDesktopRepos, - setRouteActionState, + setNewWorktreeVisible, setSearch, setServerNotice, setServerWorkspaces, @@ -143,6 +142,7 @@ export function useHostScreenState(hostId: string | undefined, action: string | setSleptIds, setWorktrees, setWorktreesLoaded, + showNewWorktree, showWorkspaceOptions, showPinnedInGroups, showSearch, diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 52bc2f65ef3..94ff9175683 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -13,7 +13,6 @@ import { } from '../transport/execution-host-scoped-rpc-client' import { serverUpdateNeededNotice } from '../host-route-notice' import type { ConnectionState } from '../transport/types' -import { setHostRouteNewWorktreeVisible } from '../host-route-action-state' import { leaveHostRoute } from '../host-route-exit' import { getWorktreeRowIdentity, @@ -55,8 +54,6 @@ export function useHostWorktreeActions(args: { state } = args const { - newWorktreeModalRef, - newWorktreeModalVisibleRef, pinnedIds, setActionError, setConfirmRemoveHost, @@ -64,7 +61,7 @@ export function useHostWorktreeActions(args: { setOptimisticActiveWorktreeIdentity, setPinnedIds, serverWorkspaces, - setRouteActionState, + setNewWorktreeVisible, setServerNotice, setServerWorkspaces, setSleptIds, @@ -82,18 +79,14 @@ export function useHostWorktreeActions(args: { leaveHostRoute(router) }, [router]) - const openNewWorktreeModal = useCallback(() => { - const modal = newWorktreeModalRef.current - if (!modal) { - return - } - newWorktreeModalVisibleRef.current = true - modal.open() - }, []) - - const setShowNewWorktreeVisible = useCallback((visible: boolean) => { - setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible)) - }, []) + const openNewWorktreeModal = useCallback( + () => setNewWorktreeVisible(true), + [setNewWorktreeVisible] + ) + const closeNewWorktreeModal = useCallback( + () => setNewWorktreeVisible(false), + [setNewWorktreeVisible] + ) const updateLocalPins = useCallback( (worktreeId: string, pinned: boolean) => { @@ -307,8 +300,8 @@ export function useHostWorktreeActions(args: { navigateFromHostList, openFloatingWorkspace, openNewWorktreeModal, + closeNewWorktreeModal, openWorktreeSession, - setShowNewWorktreeVisible, putWorktreeToSleep, togglePin } diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index f0be3de4d92..3bec9d7ef26 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -16,11 +16,8 @@ import type { MobileWebShellSessionState, MobileWebShellUpdateNotice } from './mobile-web-shell-session-contract' -import { - formatMobileWebShellDevFacts, - isDevelopmentBuild, - useMobileWebShellDroppedFrames -} from './mobile-web-shell-dev-facts' +import { useMobileWebShellDroppedFrames } from './mobile-web-shell-dev-facts' +import { ShellDevFacts } from './ShellDevFacts' import { cancelledShellNavigationTarget } from './cancelled-navigation-target' import { playPageHaptic } from './page-haptics' import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' @@ -38,6 +35,7 @@ import { useResponsiveLayout } from '../layout/responsive-layout' import { storageReadParamsSchema, type BridgeNativeVerb } from './bridge/bridge-native-verbs' import { SHELL_OPENING_LABEL, ShellPageCover, ShellWaitingFrame } from './ShellWaitingFrame' import { ShellDownloadProgress } from './ShellDownloadProgress' +import { useReportedShellOverlayLoad, type ShellOverlayLoad } from './shell-overlay-load' import { pageSafeAreaInsets, usePublishedSafeAreaInsets } from './page-safe-area-insets' import { hostProfileUnreadableLine, pageFaultLine } from './shell-lifecycle-log' import { recordShellLifecycleLine } from './shell-lifecycle-line-recorder' @@ -107,32 +105,11 @@ function Failed({ ) } -function DevFacts({ - state, - droppedBinaryFrames -}: { - state: Extract - droppedBinaryFrames: number -}) { - if (!isDevelopmentBuild()) { - return null - } - return ( - - - {formatMobileWebShellDevFacts({ - buildId: state.buildId, - totalBytes: state.totalBytes, - elapsedMs: state.elapsedMs, - droppedBinaryFrames - })} - - - ) -} - export type MobileWebShellScreenProps = { hostId: string + /** Makes this an overlay: transparent over the screen that opened it, whose page draws a drawer. + * The caller shows the wait and the exits; this shows nothing until the page has painted. */ + onOverlayLoad?: (load: ShellOverlayLoad) => void /** The screen this shell stands in for, which the page cannot derive from a document served at `/`. */ route: BridgeInitRoute /** False for the catch-all, whose pathnames have no screen in this app. */ @@ -158,9 +135,11 @@ export function MobileWebShellScreen({ route, appNamesRoute, onRouteParamClear, + onOverlayLoad, runtime }: MobileWebShellScreenProps) { const insets = useSafeAreaInsets() + const overlay = onOverlayLoad !== undefined const router = useRouter() const navigation = useNavigation() const popShellStack = useShellStackPop() @@ -275,6 +254,10 @@ export function MobileWebShellScreen({ // Pushed, never replaced: the page stays mounted underneath, so Back reveals it with no // download and no second `init`. onNavigate: (href: string) => { + // An overlay hands its place to where the page went: Back there returns to the screen beneath. + if (overlay) { + return void router.replace(href) + } // Wide, this host's route is the area-owning session below: back to it, or in place of this // one from a cold deep link, never a second on top. `dismissTo` takes the href's params. if (isWideLayout && isHostRouteOf(href, hostId)) { @@ -331,6 +314,7 @@ export function MobileWebShellScreen({ sendBack: bridge.sendBack, setOptions: navigation.setOptions }) + useReportedShellOverlayLoad(onOverlayLoad, state, pageFrame) // A profile read that rejected never becomes a host, so the session would otherwise sit in // `ready` behind an un-hidden view with nothing serving it and the page asking forever. @@ -350,6 +334,10 @@ export function MobileWebShellScreen({ } }, [hostId, unreadable]) + // The overlay's caller draws the wait and the exits. + if (overlay && state.kind !== 'ready') { + return null + } if (state.kind === 'route-unavailable') { return } @@ -384,7 +372,9 @@ export function MobileWebShellScreen({ styles.shellRoot, // Edge-to-edge like a native screen: the page pads for the bars itself from the insets // `init` carries. The banner takes the status bar strip when it shows. - { paddingTop: noticeShown ? insets.top : 0 } + { paddingTop: noticeShown ? insets.top : 0 }, + // An overlay's page draws over the screen beneath, which shows through. + overlay && { backgroundColor: 'transparent' } ]} testID="mobile-web-shell-ready" > @@ -400,7 +390,8 @@ export function MobileWebShellScreen({ - - + {overlay ? null : ( + + )} + ) } @@ -449,6 +442,9 @@ const styles = StyleSheet.create({ shellView: { flex: 1 }, + unpaintedOverlay: { + opacity: 0 + }, centered: { flex: 1, alignItems: 'center', @@ -481,18 +477,5 @@ const styles = StyleSheet.create({ }, pressed: { opacity: 0.7 - }, - devFacts: { - position: 'absolute', - left: spacing.sm, - bottom: spacing.sm, - paddingHorizontal: spacing.sm, - paddingVertical: 2, - borderRadius: radii.button, - backgroundColor: colors.bgPanel - }, - devFactsText: { - fontSize: typography.metaSize, - color: colors.textMuted } }) diff --git a/mobile/src/mobile-web-shell/ShellDevFacts.tsx b/mobile/src/mobile-web-shell/ShellDevFacts.tsx new file mode 100644 index 00000000000..02550240dac --- /dev/null +++ b/mobile/src/mobile-web-shell/ShellDevFacts.tsx @@ -0,0 +1,45 @@ +import { StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import { formatMobileWebShellDevFacts, isDevelopmentBuild } from './mobile-web-shell-dev-facts' + +/** The generation a development build is showing, pinned in a corner of the page. */ +export function ShellDevFacts({ + state, + droppedBinaryFrames +}: { + state: Extract + droppedBinaryFrames: number +}) { + if (!isDevelopmentBuild()) { + return null + } + return ( + + + {formatMobileWebShellDevFacts({ + buildId: state.buildId, + totalBytes: state.totalBytes, + elapsedMs: state.elapsedMs, + droppedBinaryFrames + })} + + + ) +} + +const styles = StyleSheet.create({ + devFacts: { + position: 'absolute', + left: spacing.sm, + bottom: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radii.button, + backgroundColor: colors.bgPanel + }, + devFactsText: { + fontSize: typography.metaSize, + color: colors.textMuted + } +}) diff --git a/mobile/src/mobile-web-shell/shell-overlay-load.ts b/mobile/src/mobile-web-shell/shell-overlay-load.ts new file mode 100644 index 00000000000..36d55e83659 --- /dev/null +++ b/mobile/src/mobile-web-shell/shell-overlay-load.ts @@ -0,0 +1,58 @@ +import { useLayoutEffect } from 'react' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import type { ShellPageFrame } from './shell-page-frame' + +export type ShellOverlayDownload = { receivedBytes: number; totalBytes: number } + +/** How far an overlay shell's page is from drawing over the screen beneath. */ +export type ShellOverlayLoad = + | { kind: 'loading'; download: ShellOverlayDownload | null } + | { kind: 'painted' } + /** The page cannot show here: an older desktop, a wall, a failure, or no connection. */ + | { kind: 'unavailable' } + +function overlayLoadOf( + state: MobileWebShellSessionState, + pageFrame: ShellPageFrame +): ShellOverlayLoad['kind'] { + switch (state.kind) { + case 'route-unavailable': + case 'wall': + case 'failed': + case 'offline': + return 'unavailable' + case 'checking': + case 'fetching': + case 'activating': + case 'ready': + return pageFrame === 'painted' ? 'painted' : 'loading' + } +} + +/** + * Reports an overlay shell's load to the route that draws its loading state and its exits, which + * outlive the shell's own state screens so one view covers every stage. + */ +export function useReportedShellOverlayLoad( + onLoad: ((load: ShellOverlayLoad) => void) | undefined, + state: MobileWebShellSessionState, + pageFrame: ShellPageFrame +): void { + const kind = overlayLoadOf(state, pageFrame) + const receivedBytes = state.kind === 'fetching' ? state.receivedBytes : null + const totalBytes = state.kind === 'fetching' ? state.totalBytes : 0 + // Before paint, so the caller's view swaps in the same frame the WebView is shown. + useLayoutEffect(() => { + if (onLoad === undefined) { + return + } + onLoad( + kind === 'loading' + ? { + kind, + download: receivedBytes === null ? null : { receivedBytes, totalBytes } + } + : { kind } + ) + }, [kind, onLoad, receivedBytes, totalBytes]) +} diff --git a/mobile/src/navigation/host-stack.web.tsx b/mobile/src/navigation/host-stack.web.tsx index a00386e0b9b..7b79d98459a 100644 --- a/mobile/src/navigation/host-stack.web.tsx +++ b/mobile/src/navigation/host-stack.web.tsx @@ -1,8 +1,9 @@ import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { StyleSheet, View } from 'react-native' -import { Navigator } from 'expo-router' +import { Navigator, usePathname } from 'expo-router' import { colors } from '../theme/mobile-theme' import { HOST_STACK_SCREENS, type HostStackAnimation } from './host-stack-screens' +import { isOverlayPageRoute } from '../new-workspace/overlay-page-route' /** * Web sibling: the page's host stack, with the push and pop slide the native stack has. @@ -125,6 +126,7 @@ function StackScreen({ children: ReactNode }) { const ref = useRef(null) + const transparent = isOverlayPageRoute(usePathname()) // Where an interrupted slide stopped, so a pop during a push leaves from there and not from 0. const stoppedAt = useRef(null) @@ -170,7 +172,10 @@ function StackScreen({ }, [motion, onSettled]) return ( - + {children} ) @@ -185,6 +190,9 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, backgroundColor: colors.bgBase }, + transparent: { + backgroundColor: 'transparent' + }, hidden: { display: 'none' } diff --git a/mobile/src/new-workspace/NewWorkspaceHostPicker.tsx b/mobile/src/new-workspace/NewWorkspaceHostPicker.tsx new file mode 100644 index 00000000000..600e0928290 --- /dev/null +++ b/mobile/src/new-workspace/NewWorkspaceHostPicker.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'expo-router' +import { PickerModal } from '../components/PickerModal' +import { hostPickerOptions } from '../components/use-connected-host-picker' +import { selectConnectableHostProfiles } from '../transport/host-catalog-selection' +import { loadHostCatalog } from '../transport/host-store' +import type { HostProfile } from '../transport/types' + +/** + * Home's "Create Workspace On" picker, inside the overlay so the drawer that follows opens from the + * same window. A pick starts the load at once, so the loading drawer opens as the picker leaves. + */ +export function NewWorkspaceHostPicker({ + hostIds, + onPick +}: { + hostIds: readonly string[] + onPick: (hostId: string) => void +}) { + const router = useRouter() + const hosts = useOfferedHosts(hostIds) + const [open, setOpen] = useState(true) + const pickedRef = useRef(false) + // Nothing to pick (an unreadable catalog, or every offered desktop gone) leaves the overlay rather + // than holding an invisible screen over Home. + const nothingToPick = hosts !== null && hosts.length === 0 + useEffect(() => { + if (nothingToPick) { + router.back() + } + }, [nothingToPick, router]) + return ( + { + pickedRef.current = true + onPick(hostId) + }} + onClose={() => setOpen(false)} + onAfterClose={() => { + if (!pickedRef.current) { + router.back() + } + }} + /> + ) +} + +/** The offered desktops' profiles, in the offered order; `null` until the catalog is read, `[]` if it can't be. */ +function useOfferedHosts(hostIds: readonly string[]): HostProfile[] | null { + const [hosts, setHosts] = useState(null) + const key = hostIds.join(',') + useEffect(() => { + let live = true + void loadHostCatalog() + .then((catalog) => { + const profiles = new Map( + selectConnectableHostProfiles(catalog).map((host) => [host.id, host] as const) + ) + return key.split(',').flatMap((hostId) => profiles.get(hostId) ?? []) + }) + .catch(() => []) + .then((offered) => { + if (live) { + setHosts(offered) + } + }) + return () => { + live = false + } + }, [key]) + return hosts +} diff --git a/mobile/src/new-workspace/NewWorkspaceLoadingDrawer.tsx b/mobile/src/new-workspace/NewWorkspaceLoadingDrawer.tsx new file mode 100644 index 00000000000..acbd6cefa62 --- /dev/null +++ b/mobile/src/new-workspace/NewWorkspaceLoadingDrawer.tsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useState } from 'react' +import { + ActivityIndicator, + Animated, + Easing, + PanResponder, + Pressable, + StyleSheet, + Text, + View, + useWindowDimensions +} from 'react-native' +import { BOTTOM_DRAWER_SHOW_DURATION_MS } from '../components/bottom-drawer-constants' +import { bottomDrawerStyles } from '../components/bottom-drawer-styles' +import { useResponsiveLayout } from '../layout/responsive-layout' +import { useWindowSafeAreaInsets } from '../layout/side-safe-area' +import type { ShellOverlayDownload } from '../mobile-web-shell/shell-overlay-load' +import { SHELL_PAGE_COVER_FADE_MS } from '../mobile-web-shell/ShellWaitingFrame' +import { colors, spacing, typography } from '../theme/mobile-theme' + +const CANCEL_DRAG_DISTANCE = 40 + +/** + * A short drawer while the page's Create Workspace drawer loads: a spinner, or the download while + * there is one. It opens and dims as the shared drawer does, but in the screen's own window: a + * native Modal over the loading WebView stalls the page behind it. It fades once the page has + * painted with its own drawer up. A tap on the backdrop, a drag down or Back cancels. + */ +export function NewWorkspaceLoadingDrawer({ + visible, + download, + onCancel +}: { + visible: boolean + download: ShellOverlayDownload | null + onCancel: () => void +}) { + const insets = useWindowSafeAreaInsets() + const { isWideLayout, modalMaxWidth } = useResponsiveLayout() + const { height: screenHeight } = useWindowDimensions() + const [progress] = useState(() => new Animated.Value(0)) + const [presence] = useState(() => new Animated.Value(1)) + const dragToCancel = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderRelease: (_event, gesture) => { + if (gesture.dy > CANCEL_DRAG_DISTANCE) { + onCancel() + } + } + }), + [onCancel] + ) + useEffect(() => { + // Leaving, it fades as the shell's page cover does, eased in: the page's drawer is already up + // beneath, and the WebView takes a frame or two after being shown to put it on screen. + const animation = visible + ? Animated.timing(progress, { + toValue: 1, + duration: BOTTOM_DRAWER_SHOW_DURATION_MS, + useNativeDriver: true + }) + : Animated.timing(presence, { + toValue: 0, + duration: SHELL_PAGE_COVER_FADE_MS, + easing: Easing.in(Easing.quad), + useNativeDriver: true + }) + if (visible) { + presence.setValue(1) + } + animation.start() + return () => animation.stop() + }, [presence, progress, visible]) + const slide = progress.interpolate({ inputRange: [0, 1], outputRange: [screenHeight, 0] }) + return ( + + + + + + + + + + + {download === null ? ( + + ) : ( + + )} + + + + + ) +} + +function DownloadProgress({ download }: { download: ShellOverlayDownload }) { + const fraction = + download.totalBytes > 0 ? Math.min(1, download.receivedBytes / download.totalBytes) : 0 + return ( + <> + + + + + Downloading… {megabytes(download.receivedBytes)} of {megabytes(download.totalBytes)} MB + + + ) +} + +function megabytes(bytes: number): string { + return (bytes / 1_000_000).toFixed(1) +} + +const styles = StyleSheet.create({ + body: { + height: 96, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm + }, + track: { + width: 180, + height: 4, + borderRadius: 2, + overflow: 'hidden', + backgroundColor: colors.bgRaised + }, + fill: { + height: '100%', + backgroundColor: colors.textSecondary + }, + caption: { + fontSize: typography.metaSize, + color: colors.textSecondary, + fontVariant: ['tabular-nums'] + } +}) diff --git a/mobile/src/new-workspace/NewWorkspaceOverlay.test.tsx b/mobile/src/new-workspace/NewWorkspaceOverlay.test.tsx new file mode 100644 index 00000000000..2bc7792168f --- /dev/null +++ b/mobile/src/new-workspace/NewWorkspaceOverlay.test.tsx @@ -0,0 +1,100 @@ +import { createElement, useEffect, type ReactNode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ShellOverlayDownload, ShellOverlayLoad } from '../mobile-web-shell/shell-overlay-load' + +type LoadingDrawerProps = { + visible: boolean + download: ShellOverlayDownload | null + onCancel: () => void +} + +const router = vi.hoisted(() => ({ back: vi.fn() })) +const shell = vi.hoisted((): { report: ((load: ShellOverlayLoad) => void) | null } => ({ + report: null +})) +const layerMounts = vi.hoisted(() => ({ count: 0 })) + +vi.mock('expo-router', () => ({ useRouter: () => router })) +vi.mock('../components/HostProtocolGate', () => ({ + HostProtocolGate: ({ children }: { children: ReactNode }) => children +})) +vi.mock('./open-host-list-instead', () => ({ OpenHostListInstead: 'OpenHostListInstead' })) +vi.mock('../mobile-web-shell/MobileWebShellScreen', () => ({ + MobileWebShellScreen: ({ + onOverlayLoad + }: { + onOverlayLoad: (load: ShellOverlayLoad) => void + }) => { + shell.report = onOverlayLoad + return null + } +})) +vi.mock('./NewWorkspaceLoadingDrawer', async () => { + const { createElement: element } = await import('react') + return { + NewWorkspaceLoadingDrawer: (props: LoadingDrawerProps) => { + useEffect(() => { + layerMounts.count += 1 + }, []) + return element('NewWorkspaceLoadingDrawer', props) + } + } +}) + +import { NewWorkspaceOverlay } from './NewWorkspaceOverlay' + +describe("the New Workspace overlay's loading drawer", () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + shell.report = null + layerMounts.count = 0 + vi.clearAllMocks() + }) + + function layer() { + return renderer!.root.findAll((node) => String(node.type) === 'NewWorkspaceLoadingDrawer')[0] + } + + it('stays one mounted drawer from the first wait through the download until the page paints', () => { + act(() => { + renderer = create(createElement(NewWorkspaceOverlay, { hostId: 'desk' })) + }) + expect(layer().props).toMatchObject({ visible: true, download: null }) + + act(() => shell.report!({ kind: 'loading', download: { receivedBytes: 1, totalBytes: 8 } })) + expect(layer().props.download).toEqual({ receivedBytes: 1, totalBytes: 8 }) + act(() => shell.report!({ kind: 'loading', download: null })) + expect(layer().props.download).toBeNull() + expect(layerMounts.count).toBe(1) + + act(() => shell.report!({ kind: 'painted' })) + expect(layer().props.visible).toBe(false) + expect(layerMounts.count).toBe(1) + }) + + it('leaves when the load is cancelled', () => { + act(() => { + renderer = create(createElement(NewWorkspaceOverlay, { hostId: 'desk' })) + }) + + act(() => layer().props.onCancel()) + + expect(router.back).toHaveBeenCalledTimes(1) + }) + + it('opens the host list instead when the page cannot show here', () => { + act(() => { + renderer = create(createElement(NewWorkspaceOverlay, { hostId: 'desk' })) + }) + + act(() => shell.report!({ kind: 'unavailable' })) + + expect( + renderer!.root.find((node) => String(node.type) === 'OpenHostListInstead').props.hostId + ).toBe('desk') + }) +}) diff --git a/mobile/src/new-workspace/NewWorkspaceOverlay.tsx b/mobile/src/new-workspace/NewWorkspaceOverlay.tsx new file mode 100644 index 00000000000..2ecf542e65c --- /dev/null +++ b/mobile/src/new-workspace/NewWorkspaceOverlay.tsx @@ -0,0 +1,42 @@ +import { useCallback, useState } from 'react' +import { useRouter } from 'expo-router' +import { HostProtocolGate } from '../components/HostProtocolGate' +import { MobileWebShellScreen } from '../mobile-web-shell/MobileWebShellScreen' +import type { ShellOverlayLoad } from '../mobile-web-shell/shell-overlay-load' +import { shellScreenRoute } from '../mobile-web-shell/shell-screen-route' +import { NewWorkspaceLoadingDrawer } from './NewWorkspaceLoadingDrawer' +import { OpenHostListInstead } from './open-host-list-instead' + +const LOADING: ShellOverlayLoad = { kind: 'loading', download: null } + +/** + * The picked desktop's Create Workspace page in the shell, as an overlay: the page draws the drawer + * over a transparent document, and one short loading drawer stands in until it has painted. + */ +export function NewWorkspaceOverlay({ hostId }: { hostId: string }) { + const router = useRouter() + const [load, setLoad] = useState(LOADING) + // Stable, so a drag in progress keeps its handler across the download's re-renders. + const cancel = useCallback(() => router.back(), [router]) + const route = shellScreenRoute({ pathname: `/h/${encodeURIComponent(hostId)}/new-workspace` }) + if (route === null || load.kind === 'unavailable') { + return + } + return ( + <> + {/* The gate every `/h` route gets from its layout, which this one sits outside of to overlay Home. */} + } + > + + + + + ) +} diff --git a/mobile/src/new-workspace/NewWorkspacePageScreen.test.tsx b/mobile/src/new-workspace/NewWorkspacePageScreen.test.tsx new file mode 100644 index 00000000000..6556dadc154 --- /dev/null +++ b/mobile/src/new-workspace/NewWorkspacePageScreen.test.tsx @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const handoff = vi.hoisted(() => ({ back: vi.fn(), replace: vi.fn() })) + +vi.mock('expo-router', () => ({ useLocalSearchParams: () => ({ hostId: 'laptop' }) })) +vi.mock('../navigation/route-handoff', () => ({ useRouteHandoff: () => handoff })) +vi.mock('../transport/host-client-hooks', () => ({ + useHostClient: () => ({ client: null, state: 'connected' }) +})) +vi.mock('../components/host-protocol-gates-context', () => ({ + useHostProtocolGates: () => ({ hostCapabilities: ['cap'] }) +})) +vi.mock('./use-host-worktree-rows', () => ({ + useHostWorktreeRows: () => [{ path: '/repo/marlin', repoId: 'r1', branch: 'marlin' }] +})) +vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() })) +vi.mock('../components/NewWorktreeModalController', () => ({ + NewWorktreeModalController: 'NewWorktreeModalController' +})) + +import { NewWorkspacePageScreen } from './NewWorkspacePageScreen' + +describe('the Create Workspace page', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + async function render() { + await act(async () => { + renderer = create(createElement(NewWorkspacePageScreen)) + }) + return renderer!.root.find((node) => String(node.type) === 'NewWorktreeModalController') + } + + it("shows the host screen's drawer for the route's desktop, aware of its worktrees", async () => { + const drawer = await render() + + expect(drawer.props).toMatchObject({ + visible: true, + hostId: 'laptop', + hostCapabilities: ['cap'], + existingWorktreePaths: ['/repo/marlin'] + }) + }) + + it('leaves the overlay when the drawer closes', async () => { + const drawer = await render() + + act(() => drawer.props.onClose()) + + expect(handoff.back).toHaveBeenCalledTimes(1) + }) + + it("opens the new workspace's session in the overlay's place", async () => { + const drawer = await render() + + act(() => drawer.props.onCreated('wt-1', 'Hammerhead', 'No startup terminal', 'ssh:box')) + + expect(handoff.replace).toHaveBeenCalledWith( + '/h/laptop/session/wt-1?name=Hammerhead&created=1&warning=No+startup+terminal&executionHost=ssh%3Abox' + ) + }) +}) diff --git a/mobile/src/new-workspace/NewWorkspacePageScreen.tsx b/mobile/src/new-workspace/NewWorkspacePageScreen.tsx new file mode 100644 index 00000000000..7d833faa00f --- /dev/null +++ b/mobile/src/new-workspace/NewWorkspacePageScreen.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react' +import { useLocalSearchParams } from 'expo-router' +import { NewWorktreeModalController } from '../components/NewWorktreeModalController' +import { hostNewWorktreeSessionRoute } from '../host-new-worktree-session-route' +import { useHostLabelById } from './use-host-label-by-id' +import { useHostWorktreeRows } from './use-host-worktree-rows' +import { useRouteHandoff } from '../navigation/route-handoff' +import { firstParam } from '../navigation/route-param-reader' +import { workspaceRouteHref } from '../navigation/workspace-execution-host' +import { openExternalLink } from '../platform/external-link' +import { useHostClient } from '../transport/host-client-hooks' +import { useHostProtocolGates } from '../components/host-protocol-gates-context' + +/** + * The host screen's Create Workspace drawer, alone on a transparent page. Closing it leaves the + * screen that opened it; creating opens the new workspace's session in its place. + */ +export function NewWorkspacePageScreen() { + const params = useLocalSearchParams<{ hostId?: string | string[] }>() + const hostId = firstParam(params.hostId) + const router = useRouteHandoff() + const { client, state: connState } = useHostClient(hostId) + const { hostCapabilities } = useHostProtocolGates() + const connected = connState === 'connected' + const worktrees = useHostWorktreeRows(client, connected, hostId) + const hostLabelById = useHostLabelById(client, connected) + const worktreePaths = useMemo(() => worktrees.map((worktree) => worktree.path), [worktrees]) + + return ( + + router.replace( + workspaceRouteHref( + hostNewWorktreeSessionRoute(hostId, worktreeId, name, warning), + executionHost + ) + ) + } + onClose={() => router.back()} + /> + ) +} diff --git a/mobile/src/new-workspace/new-workspace-overlay-route.test.tsx b/mobile/src/new-workspace/new-workspace-overlay-route.test.tsx new file mode 100644 index 00000000000..3f364dc3602 --- /dev/null +++ b/mobile/src/new-workspace/new-workspace-overlay-route.test.tsx @@ -0,0 +1,98 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { HostCatalogEntry, HostProfile } from '../transport/types' + +const router = vi.hoisted(() => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() })) +const params = vi.hoisted((): { current: Record } => ({ current: {} })) +const catalog = vi.hoisted((): { current: HostCatalogEntry[] } => ({ current: [] })) + +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => params.current, + useRouter: () => router +})) +vi.mock('../transport/host-store', () => ({ + loadHostCatalog: async () => catalog.current +})) +vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' })) +vi.mock('./NewWorkspaceOverlay', () => ({ NewWorkspaceOverlay: 'NewWorkspaceOverlay' })) + +import NewWorkspaceOverlayRoute from '../../app/new-workspace' +import { newWorkspaceOverlayHref } from './new-workspace-overlay-route' + +function entry(id: string, name: string): HostCatalogEntry { + const profile: HostProfile = { + id, + name, + endpoint: `ws://${id}.local:6768`, + deviceToken: `token-${id}`, + publicKeyB64: `key-${id}`, + lastConnected: 1 + } + const { deviceToken: _deviceToken, ...listed } = profile + return { ...listed, credentialStatus: 'ready', profile } +} + +describe("Home's New Workspace overlay", () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + async function open(hostIds: string[]) { + params.current = newWorkspaceOverlayHref(hostIds).params + await act(async () => { + renderer = create(createElement(NewWorkspaceOverlayRoute)) + }) + const root = renderer!.root + return { + overlayFor: () => + root.findAll((node) => String(node.type) === 'NewWorkspaceOverlay')[0]?.props.hostId, + picker: () => root.find((node) => String(node.type) === 'PickerModal') + } + } + + it("opens the only offered desktop's drawer without asking", async () => { + catalog.current = [entry('desk', 'Desk')] + const overlay = await open(['desk']) + + expect(overlay.overlayFor()).toBe('desk') + }) + + it('asks which desktop and starts its drawer while the picker slides away', async () => { + catalog.current = [entry('laptop', 'Laptop'), entry('desk', 'Desk')] + const overlay = await open(['desk', 'laptop']) + + expect(overlay.overlayFor()).toBeUndefined() + expect(overlay.picker().props).toMatchObject({ visible: true, title: 'Create Workspace On' }) + expect(overlay.picker().props.options.map((option: { value: string }) => option.value)).toEqual( + ['desk', 'laptop'] + ) + + // PickerModal closes itself after a select. + act(() => { + overlay.picker().props.onSelect('laptop') + overlay.picker().props.onClose() + }) + expect(overlay.picker().props.visible).toBe(false) + expect(overlay.overlayFor()).toBe('laptop') + + act(() => overlay.picker().props.onAfterClose()) + expect(overlay.overlayFor()).toBe('laptop') + expect(router.back).not.toHaveBeenCalled() + }) + + it('leaves for Home when the picker is dismissed without a pick', async () => { + catalog.current = [entry('laptop', 'Laptop'), entry('desk', 'Desk')] + const overlay = await open(['desk', 'laptop']) + + act(() => overlay.picker().props.onClose()) + act(() => overlay.picker().props.onAfterClose()) + + expect(router.back).toHaveBeenCalledTimes(1) + expect(overlay.overlayFor()).toBeUndefined() + }) +}) diff --git a/mobile/src/new-workspace/new-workspace-overlay-route.ts b/mobile/src/new-workspace/new-workspace-overlay-route.ts new file mode 100644 index 00000000000..b16b40fad1b --- /dev/null +++ b/mobile/src/new-workspace/new-workspace-overlay-route.ts @@ -0,0 +1,12 @@ +/** + * Home's New Workspace: a transparent overlay over Home that asks which desktop (when more than one + * is offered) and then shows that desktop's Create Workspace drawer. + */ +export function newWorkspaceOverlayHref(hostIds: readonly string[]) { + return { pathname: '/new-workspace', params: { hostIds: hostIds.join(',') } } as const +} + +/** The offered desktops, in Home's order, from the overlay route's `hostIds` param. */ +export function readOfferedHostIds(param: string): string[] { + return param.split(',').filter((hostId) => hostId !== '') +} diff --git a/mobile/src/new-workspace/open-host-list-instead.tsx b/mobile/src/new-workspace/open-host-list-instead.tsx new file mode 100644 index 00000000000..3ce7388daf9 --- /dev/null +++ b/mobile/src/new-workspace/open-host-list-instead.tsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react' +import { useRouteHandoff } from '../navigation/route-handoff' + +/** + * An overlay whose page cannot show (a blocked or older desktop, a failed load, no connection): + * the host list opens full screen in its place, where that host's state is shown. + */ +export function OpenHostListInstead({ hostId }: { hostId: string }) { + const router = useRouteHandoff() + useEffect(() => { + router.replace(`/h/${encodeURIComponent(hostId)}`) + }, [hostId, router]) + return null +} diff --git a/mobile/src/new-workspace/overlay-page-route.ts b/mobile/src/new-workspace/overlay-page-route.ts new file mode 100644 index 00000000000..bbcb83ea963 --- /dev/null +++ b/mobile/src/new-workspace/overlay-page-route.ts @@ -0,0 +1,7 @@ +/** + * Page routes an overlay opens over the screen beneath (Home's New Workspace): every layer above the + * route's own drawer stays transparent, so that screen shows through dimmed by the drawer's scrim. + */ +export function isOverlayPageRoute(pathname: string): boolean { + return /^\/h\/[^/]+\/new-workspace$/.test(pathname) +} diff --git a/mobile/src/new-workspace/use-host-label-by-id.ts b/mobile/src/new-workspace/use-host-label-by-id.ts new file mode 100644 index 00000000000..06651a27498 --- /dev/null +++ b/mobile/src/new-workspace/use-host-label-by-id.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { readHostSshTargetLabels } from '../host-screen/host-ssh-target-labels-read' +import type { RpcClient } from '../transport/rpc-client' + +const NO_HOST_LABELS: ReadonlyMap = new Map() + +/** The names the desktop's SSH hosts show under, as the host screen reads them: one read. */ +export function useHostLabelById( + client: RpcClient | null, + connected: boolean +): ReadonlyMap { + const [labels, setLabels] = useState(NO_HOST_LABELS) + + useEffect(() => { + if (!client || !connected) { + return + } + let live = true + readHostSshTargetLabels(client) + .then(({ hostLabelById }) => { + if (live) { + setLabels(hostLabelById) + } + }) + // Unread labels fall back to host ids, as the host screen's do before its first read. + .catch(() => {}) + return () => { + live = false + } + }, [client, connected]) + + return labels +} diff --git a/mobile/src/new-workspace/use-host-worktree-rows.ts b/mobile/src/new-workspace/use-host-worktree-rows.ts new file mode 100644 index 00000000000..833eb232523 --- /dev/null +++ b/mobile/src/new-workspace/use-host-worktree-rows.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { Worktree } from '../worktree/workspace-list-types' +import { worktreeCatalogRead } from '../worktree/worktree-catalog-operations' +import { WORKTREE_PS_FULL_LIMIT } from '../worktree/worktree-catalog-snapshot-client' + +type WorktreeRow = Pick + +const NO_ROWS: readonly WorktreeRow[] = [] + +/** The host's worktrees for the Create Workspace drawer's name and branch checks: one read. */ +export function useHostWorktreeRows( + client: RpcClient | null, + connected: boolean, + hostId: string +): readonly WorktreeRow[] { + const [rows, setRows] = useState(NO_ROWS) + + useEffect(() => { + if (!client || !connected) { + return + } + let live = true + worktreeCatalogRead + .requestSingleFlight(client, hostId, { limit: WORKTREE_PS_FULL_LIMIT }) + .then((reply) => { + const catalog = worktreeCatalogRead.interpret(reply) + if (live && catalog.accepted && catalog.value.worktrees) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: proven worktree.ps rows, which the host screen's snapshot client admits as `Worktree`; the drawer reads only these three. + setRows(catalog.value.worktrees as WorktreeRow[]) + } + }) + // An unread list leaves the checks without rows, as the host screen's drawer is before its first read. + .catch(() => {}) + return () => { + live = false + } + }, [client, connected, hostId]) + + return rows +} diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx index 721627e0f6f..5d3d9f09beb 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx @@ -1,4 +1,4 @@ -import { hostNewWorktreeSessionRoute } from '../host-route-action-state' +import { hostNewWorktreeSessionRoute } from '../host-new-worktree-session-route' import { workspaceRouteHref } from '../navigation/workspace-execution-host' import { taskRepoServer } from './task-repo-host-routing' import { settingsRead } from '../transport/settings-read-operations' diff --git a/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts index fdab0c5d246..6e4449142ba 100644 --- a/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts @@ -33,14 +33,11 @@ export function hostWorktreeActionMountAdapters( typeof import('../../../host-screen/use-host-worktree-actions') >('mobile/src/host-screen/use-host-worktree-actions.ts').useHostWorktreeActions const state = observableModel(context, { - newWorktreeModalRef: { current: null }, - newWorktreeModalVisibleRef: { current: false }, pinnedIds: new Set(), worktrees: [ROW], lastKnownWorktrees: [ROW], confirmRemoveHost: false, optimisticActiveWorktreeIdentity: null, - routeActionState: {}, serverWorkspaces: { hosts: [], worktrees: [] } }) let actions: ReturnType