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