From f47f632bd7de70476e5d63b20595d980c149e34d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:02:11 -0400 Subject: [PATCH] feat(mobile): Home's New Workspace opens the desktop's create drawer over Home (native part ships with the next app build) (#31305) * fix(mobile): the home screen's New Workspace opens the create sheet again The native host-list route dropped the home screen's action=newWorktree when it built the page's route, so the page opened as a plain list. The route now forwards it, and the page names it back once applied so a reload does not reopen the sheet. Reaches phones only with the next native build. * test(mobile): drop the New Workspace tests that only re-check types and a null guard * fix(mobile): Home's New Workspace opens the create sheet over Home Replaces the action=newWorktree route hand-off. The quick action picks a host (as Tasks does), then mounts the host's create sheet on Home; a create opens the new workspace's session marked as just created. The sheet controller becomes a plain visible/onClose component, and the host screen's route-action state, the action route param and its builder are deleted. * fix(mobile): Home's create sheet seeds only proven worktree rows Home's cold-start seed is a pathless resume row that the name suggester cannot read, so the sheet now seeds from proven rows and is keyed by host. The host screen gets one setter for the sheet's visibility and its ref. * test(mobile): name the Home new-workspace test after the screen it renders * proto(mobile): Home's New Workspace opens a native drawer hosting the page's create form Home pushes /new-workspace/, a react-native-screens formSheet over Home whose content is MobileWebShellScreen for the page route /h//new-workspace. The page renders the shared Create Workspace form with its drawers inline, so the form and its pickers fill the native drawer. The shell's loading, download and failure states render inside the drawer. Prototype only. * proto(mobile): the New Workspace drawer is the host screen's own sheet over a transparent modal The native route is a transparentModal hosting the shell over Home; the page route renders the host screen's NewWorktreeModal on a transparent page, so the drawer is the same component. While the page loads, the shell shows a placeholder of that sheet (scrim, grabber, spinner or download bar), and the page's sheet opens in place without its slide. A host that cannot serve the page (old desktop, blocked, failed) opens the host list instead of a wall in a drawer. * feat(mobile): Home's New Workspace opens the page's create drawer over a dimmed Home A transparent native overlay route hosts the host picker and then the page's /h/[hostId]/new-workspace route, which renders the same NewWorktreeModal as the host screen's +. The native scrim matches the drawer backdrop and the page's first drawer opens already dimmed, so Home stays dimmed throughout. Old desktops without the route fall back to the host list. * fix(mobile): one loading layer for Home's New Workspace overlay The overlay route owns a single tappable loading layer (spinner, or the download on a small card) that stays mounted from the pick until the page paints, so the fade and spinner no longer restart per shell state. The shell reports its load and shows the host list for anything it cannot open; the picker hands the pick over at once and keeps sliding away. The page's first drawer takes the dimmed backdrop once, and a gate's overlay pending view no longer stacks a second scrim over a mounted stack. Native /h/[hostId]/new-workspace redirects to the overlay. Drops the unused session-route params, the clipboard grant, and pass-through tests. * feat(mobile): New Workspace loads in a short drawer over an undimmed Home While the page loads, the overlay shows one short native drawer in the shared drawer chrome with a spinner or the download, and nothing dims Home: a tap above it, a drag down or Back cancels. The page's real drawer then slides up with its own backdrop exactly as on the host screen's +. Drops the native scrim, the loading card and the already-dimmed backdrop handoff; the overlay's WebView stays invisible until the page paints, since a fresh one draws opaque. * feat(mobile): the New Workspace loading drawer dims Home like any drawer The short loading drawer opens with the shared drawer backdrop and show duration, and a tap on that backdrop, a drag down or Back cancels. It stays in the screen's own window (a native Modal over the loading WebView stalls the page) and goes in the frame the page paints: the shell reports its load from a layout effect so the WebView shows and the drawer leaves together. * fix(mobile): the page's first paint waits for a drawer opening with it A drawer opening as its page screen mounts holds that screen's first-paint report until its open animation ends, through a hold on the existing report rather than a new bridge message. The New Workspace overlay's loading drawer now stays until the create drawer is up, so a cold open no longer shows Home undimmed between the two. * refactor(mobile): the New Workspace overlay owns its exits; the loading drawer fades out The shell no longer imports the feature: an overlay is a shell with onOverlayLoad, which reports `unavailable` for what it cannot show, and the overlay route opens the host list for every exit. HostProtocolGate takes one transparentWhilePending flag. The loading drawer's drag handler follows a stable cancel, and on paint the drawer fades out eased in, as the shell's page cover does, because the WebView takes a frame or two to show the page after it is revealed; removing it outright left Home undimmed for those frames. * Revert "fix(mobile): the page's first paint waits for a drawer opening with it" This reverts commit be0913b9d5f9bcf46612b7cefb772ff643b225f6. * fix(mobile): Home's host picker leaves when there is no desktop to pick An unreadable host catalog, or offered desktops that are all gone, left the picker invisible inside the transparent overlay, holding a screen over Home with nothing to dismiss it. The catalog read now resolves to no hosts on failure, and an empty pick list goes back to Home. * fix(mobile): Home's create drawer names SSH hosts as the host screen does The page drawer opened from Home passed no host labels, so a project on an SSH host showed its run target by raw host id. Reading the SSH target summaries and display overrides moves into one function that both the host screen's metadata fetch and the page drawer call. --- .../build-mobile-web-app-bundle.test.mjs | 12 +- .../mobile-web-app-page-route-modules.mjs | 1 + ...web-app-session-dictation-capture.test.mjs | 1 + config/scripts/mobile-web-page-routes.mjs | 6 + .../scripts/verify-mobile-web-app-bundle.mjs | 17 +- mobile/app/_layout.tsx | 10 + mobile/app/_layout.web.tsx | 9 +- mobile/app/h/[hostId]/new-workspace.tsx | 12 ++ mobile/app/h/[hostId]/new-workspace.web.tsx | 6 + mobile/app/h/_layout.tsx | 12 +- mobile/app/new-workspace.tsx | 21 +++ ...host-worktree-actions-pin-open-delete.json | 124 ++++++------- .../goldens/host-worktree-delete-refused.json | 34 ++-- ....worktree-actions-worktree.activate-1.json | 164 ++++++++--------- ...x-host.worktree-actions-worktree.rm-1.json | 155 ++++++++-------- ...-host.worktree-actions-worktree.set-1.json | 164 ++++++++--------- mobile/src/components/HostProtocolGate.tsx | 21 ++- .../components/MobileHomeQuickActions.test.ts | 58 ------ .../src/components/MobileHomeQuickActions.tsx | 8 +- .../src/components/NewWorktreeModal.test.tsx | 6 +- .../components/NewWorktreeModalController.tsx | 97 +++------- .../src/components/bottom-drawer-constants.ts | 1 + .../src/components/mounted-bottom-drawer.tsx | 10 +- .../components/use-connected-host-picker.ts | 3 +- mobile/src/home/MobileHomeListFooter.test.tsx | 14 -- mobile/src/home/MobileHomeListFooter.tsx | 2 +- mobile/src/home/MobileHomeScreen.tsx | 8 +- .../host-new-worktree-session-route.test.ts | 35 ++++ mobile/src/host-new-worktree-session-route.ts | 13 ++ mobile/src/host-route-action-state.test.ts | 86 --------- mobile/src/host-route-action-state.ts | 54 ------ .../host-screen/host-screen-overlays.test.tsx | 1 - .../src/host-screen/host-screen-overlays.tsx | 11 +- .../host-server-workspaces-wiring.test.ts | 2 +- .../host-ssh-health-badge-wiring.test.ts | 2 +- .../host-ssh-target-labels-read.ts | 54 ++++++ .../host-view-changes-reconnect.test.ts | 2 +- .../host-worktree-child-navigation.test.tsx | 2 +- .../src/host-screen/use-host-repo-metadata.ts | 63 +------ .../host-screen/use-host-screen-controller.ts | 16 +- .../src/host-screen/use-host-screen-state.ts | 20 +- .../host-screen/use-host-worktree-actions.ts | 27 +-- .../mobile-web-shell/MobileWebShellScreen.tsx | 75 +++----- mobile/src/mobile-web-shell/ShellDevFacts.tsx | 45 +++++ .../mobile-web-shell/shell-overlay-load.ts | 58 ++++++ mobile/src/navigation/host-stack.web.tsx | 12 +- .../new-workspace/NewWorkspaceHostPicker.tsx | 76 ++++++++ .../NewWorkspaceLoadingDrawer.tsx | 173 ++++++++++++++++++ .../NewWorkspaceOverlay.test.tsx | 100 ++++++++++ .../src/new-workspace/NewWorkspaceOverlay.tsx | 42 +++++ .../NewWorkspacePageScreen.test.tsx | 69 +++++++ .../new-workspace/NewWorkspacePageScreen.tsx | 50 +++++ .../new-workspace-overlay-route.test.tsx | 98 ++++++++++ .../new-workspace-overlay-route.ts | 12 ++ .../new-workspace/open-host-list-instead.tsx | 14 ++ .../src/new-workspace/overlay-page-route.ts | 7 + .../src/new-workspace/use-host-label-by-id.ts | 34 ++++ .../new-workspace/use-host-worktree-rows.ts | 41 +++++ ...-mobile-tasks-workspace-create-actions.tsx | 2 +- .../host-worktree-action-mount-adapters.ts | 3 - 60 files changed, 1465 insertions(+), 810 deletions(-) create mode 100644 mobile/app/h/[hostId]/new-workspace.tsx create mode 100644 mobile/app/h/[hostId]/new-workspace.web.tsx create mode 100644 mobile/app/new-workspace.tsx create mode 100644 mobile/src/host-new-worktree-session-route.test.ts create mode 100644 mobile/src/host-new-worktree-session-route.ts delete mode 100644 mobile/src/host-route-action-state.test.ts delete mode 100644 mobile/src/host-route-action-state.ts create mode 100644 mobile/src/host-screen/host-ssh-target-labels-read.ts create mode 100644 mobile/src/mobile-web-shell/ShellDevFacts.tsx create mode 100644 mobile/src/mobile-web-shell/shell-overlay-load.ts create mode 100644 mobile/src/new-workspace/NewWorkspaceHostPicker.tsx create mode 100644 mobile/src/new-workspace/NewWorkspaceLoadingDrawer.tsx create mode 100644 mobile/src/new-workspace/NewWorkspaceOverlay.test.tsx create mode 100644 mobile/src/new-workspace/NewWorkspaceOverlay.tsx create mode 100644 mobile/src/new-workspace/NewWorkspacePageScreen.test.tsx create mode 100644 mobile/src/new-workspace/NewWorkspacePageScreen.tsx create mode 100644 mobile/src/new-workspace/new-workspace-overlay-route.test.tsx create mode 100644 mobile/src/new-workspace/new-workspace-overlay-route.ts create mode 100644 mobile/src/new-workspace/open-host-list-instead.tsx create mode 100644 mobile/src/new-workspace/overlay-page-route.ts create mode 100644 mobile/src/new-workspace/use-host-label-by-id.ts create mode 100644 mobile/src/new-workspace/use-host-worktree-rows.ts 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