feat(mobile): Home's New Workspace opens the desktop's create drawer over Home (native part ships with the next app build) (#31305)

* fix(mobile): the home screen's New Workspace opens the create sheet again

The native host-list route dropped the home screen's action=newWorktree when it built
the page's route, so the page opened as a plain list. The route now forwards it, and
the page names it back once applied so a reload does not reopen the sheet.

Reaches phones only with the next native build.

* test(mobile): drop the New Workspace tests that only re-check types and a null guard

* fix(mobile): Home's New Workspace opens the create sheet over Home

Replaces the action=newWorktree route hand-off. The quick action picks a
host (as Tasks does), then mounts the host's create sheet on Home; a
create opens the new workspace's session marked as just created.

The sheet controller becomes a plain visible/onClose component, and the
host screen's route-action state, the action route param and its builder
are deleted.

* fix(mobile): Home's create sheet seeds only proven worktree rows

Home's cold-start seed is a pathless resume row that the name suggester
cannot read, so the sheet now seeds from proven rows and is keyed by host.
The host screen gets one setter for the sheet's visibility and its ref.

* test(mobile): name the Home new-workspace test after the screen it renders

* proto(mobile): Home's New Workspace opens a native drawer hosting the page's create form

Home pushes /new-workspace/<host>, a react-native-screens formSheet over Home whose content is
MobileWebShellScreen for the page route /h/<host>/new-workspace. The page renders the shared
Create Workspace form with its drawers inline, so the form and its pickers fill the native
drawer. The shell's loading, download and failure states render inside the drawer. Prototype only.

* proto(mobile): the New Workspace drawer is the host screen's own sheet over a transparent modal

The native route is a transparentModal hosting the shell over Home; the page route renders the
host screen's NewWorktreeModal on a transparent page, so the drawer is the same component. While
the page loads, the shell shows a placeholder of that sheet (scrim, grabber, spinner or download
bar), and the page's sheet opens in place without its slide. A host that cannot serve the page
(old desktop, blocked, failed) opens the host list instead of a wall in a drawer.

* feat(mobile): Home's New Workspace opens the page's create drawer over a dimmed Home

A transparent native overlay route hosts the host picker and then the page's
/h/[hostId]/new-workspace route, which renders the same NewWorktreeModal as the
host screen's +. The native scrim matches the drawer backdrop and the page's
first drawer opens already dimmed, so Home stays dimmed throughout. Old desktops
without the route fall back to the host list.

* fix(mobile): one loading layer for Home's New Workspace overlay

The overlay route owns a single tappable loading layer (spinner, or the
download on a small card) that stays mounted from the pick until the page
paints, so the fade and spinner no longer restart per shell state. The shell
reports its load and shows the host list for anything it cannot open; the
picker hands the pick over at once and keeps sliding away. The page's first
drawer takes the dimmed backdrop once, and a gate's overlay pending view no
longer stacks a second scrim over a mounted stack. Native /h/[hostId]/new-workspace
redirects to the overlay. Drops the unused session-route params, the
clipboard grant, and pass-through tests.

* feat(mobile): New Workspace loads in a short drawer over an undimmed Home

While the page loads, the overlay shows one short native drawer in the shared
drawer chrome with a spinner or the download, and nothing dims Home: a tap
above it, a drag down or Back cancels. The page's real drawer then slides up
with its own backdrop exactly as on the host screen's +. Drops the native
scrim, the loading card and the already-dimmed backdrop handoff; the overlay's
WebView stays invisible until the page paints, since a fresh one draws opaque.

* feat(mobile): the New Workspace loading drawer dims Home like any drawer

The short loading drawer opens with the shared drawer backdrop and show
duration, and a tap on that backdrop, a drag down or Back cancels. It stays
in the screen's own window (a native Modal over the loading WebView stalls
the page) and goes in the frame the page paints: the shell reports its load
from a layout effect so the WebView shows and the drawer leaves together.

* fix(mobile): the page's first paint waits for a drawer opening with it

A drawer opening as its page screen mounts holds that screen's first-paint
report until its open animation ends, through a hold on the existing report
rather than a new bridge message. The New Workspace overlay's loading drawer
now stays until the create drawer is up, so a cold open no longer shows Home
undimmed between the two.

* refactor(mobile): the New Workspace overlay owns its exits; the loading drawer fades out

The shell no longer imports the feature: an overlay is a shell with
onOverlayLoad, which reports `unavailable` for what it cannot show, and the
overlay route opens the host list for every exit. HostProtocolGate takes one
transparentWhilePending flag. The loading drawer's drag handler follows a
stable cancel, and on paint the drawer fades out eased in, as the shell's page
cover does, because the WebView takes a frame or two to show the page after it
is revealed; removing it outright left Home undimmed for those frames.

* Revert "fix(mobile): the page's first paint waits for a drawer opening with it"

This reverts commit be0913b9d5.

* fix(mobile): Home's host picker leaves when there is no desktop to pick

An unreadable host catalog, or offered desktops that are all gone, left the
picker invisible inside the transparent overlay, holding a screen over Home
with nothing to dismiss it. The catalog read now resolves to no hosts on
failure, and an empty pick list goes back to Home.

* fix(mobile): Home's create drawer names SSH hosts as the host screen does

The page drawer opened from Home passed no host labels, so a project on an SSH
host showed its run target by raw host id. Reading the SSH target summaries
and display overrides moves into one function that both the host screen's
metadata fetch and the page drawer call.
This commit is contained in:
Jinwoo Hong
2026-10-21 20:02:11 -04:00
committed by GitHub
parent 79c4b46624
commit f47f632bd7
60 changed files with 1465 additions and 810 deletions
@@ -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/
)
})
})
@@ -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'],
@@ -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',
@@ -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.
//
@@ -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)
+10
View File
@@ -240,6 +240,16 @@ export default function RootLayout() {
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
<Stack.Screen
name="new-workspace"
options={{
headerShown: false,
// Draws nothing of its own: the drawer is the page's, over Home.
presentation: 'transparentModal',
animation: 'none',
contentStyle: { backgroundColor: 'transparent' }
}}
/>
</Stack>
</SideSafeArea>
</View>
+7 -2
View File
@@ -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 (
<SafeAreaFrameContext.Provider value={frame}>
<SafeAreaInsetsContext.Provider value={insets}>
<View style={styles.root}>
<View style={[styles.root, transparent && styles.transparent]}>
<SideSafeArea>
<Slot />
</SideSafeArea>
@@ -38,5 +40,8 @@ const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bgBase
},
transparent: {
backgroundColor: 'transparent'
}
})
+12
View File
@@ -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 <Redirect href={newWorkspaceOverlayHref([firstParam(params.hostId)])} />
}
@@ -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 <NewWorkspacePageScreen />
}
+8 -4
View File
@@ -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 (
<HostProtocolGate hostId={hostId}>
<View style={styles.row}>
<HostProtocolGate hostId={hostId} transparentWhilePending={overlayRoute}>
<View style={[styles.row, overlayRoute && styles.transparent]}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth }]}>
<HostScreen
embedded
hostId={hostId}
action={action}
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
/>
{/* 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
+21
View File
@@ -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 : (
<NewWorkspaceHostPicker hostIds={offered} onPick={setHostId} />
)}
{hostId === null ? null : <NewWorkspaceOverlay hostId={hostId} />}
</>
)
}
@@ -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",
@@ -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"]
}
}
@@ -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",
@@ -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",
@@ -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",
+17 -4
View File
@@ -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 <ProtocolBlockScreen verdict={compatVerdict} />
return blockedView ?? <ProtocolBlockScreen verdict={compatVerdict} />
}
// 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}
</View>
{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
@@ -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')
})
})
@@ -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}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
@@ -46,7 +43,6 @@ export function MobileHomeQuickActions(props: Props) {
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
<PickerModal title="Create Workspace On" {...hostPicker.pickerProps} />
</>
)
}
@@ -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 () => {
@@ -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<NewWorktreeModalProps, 'serverClients' | 'hostLabelById'> & {
/** 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<ExecutionHostId, string>
routeVisible: boolean
onVisibleChange?: (visible: boolean) => void
onRouteVisibleChange: (visible: boolean) => void
}
export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerHandle, Props>(
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 (
<NewWorktreeModal
visible={visible}
client={client}
serverClients={serverClients}
hostId={hostId}
hostLabelById={serverLabelById}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
openExternalUrl={openExternalUrl}
onCreated={onCreated}
onClose={close}
/>
)
}
)
return (
<NewWorktreeModal
{...modalProps}
visible={visible}
client={client}
serverClients={serverClients}
hostLabelById={serverLabelById}
/>
)
}
@@ -1 +1,2 @@
export const BOTTOM_DRAWER_SHOW_DURATION_MS = 180
export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150
@@ -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)
@@ -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)
@@ -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()
})
})
+1 -1
View File
@@ -18,7 +18,7 @@ export function MobileHomeListFooter(props: {
connectedHosts: HostProfile[]
resumeCard: HomeResumeCard | null
taskProvidersByHost: Record<string, TaskProvider[]>
onCreateWorkspace: (hostId: string) => void
onCreateWorkspace: () => void
onOpenAccounts: (hostId: string) => void
onOpenResume: (card: HomeResumeCard) => void
onOpenTasks: (hostId: string, provider?: TaskProvider) => void
+6 -2
View File
@@ -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}
@@ -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'
)
})
})
@@ -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}`
}
@@ -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
})
})
})
-54
View File
@@ -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 }
}
@@ -49,7 +49,6 @@ async function renderSheet(row: Partial<Worktree>) {
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
hostId: 'host-1',
settings: {},
showNewWorktree: false,
state: {
actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row },
confirmDelete: null,
@@ -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
/>
<NewWorktreeModalController
ref={state.newWorktreeModalRef}
routeVisible={showNewWorktree}
visible={state.showNewWorktree}
client={client}
hostCapabilities={hostCapabilities}
hostId={hostId}
@@ -167,9 +165,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
// The seam, not react-native's `Linking`: this screen is in the tasks page closure, and
// inside the shell's WebView `openURL` resolves without opening anything.
openExternalUrl={openExternalLink}
onVisibleChange={(visible) => {
state.newWorktreeModalVisibleRef.current = visible
}}
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}
/>
</>
)
@@ -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,
@@ -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 })
@@ -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<RpcResponse>
): Promise<RpcResponse | null> {
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<Value>(
reply: RpcResponse | null,
interpret: (reply: RpcResponse) => RpcAcceptedResult<Value>
): 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
)
})
}
}
@@ -67,7 +67,7 @@ afterEach(() => {
async function mountScreen(client: FakeSession) {
const held: { view?: ReturnType<typeof useHostViewSettings> } = {}
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
@@ -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],
@@ -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<RpcResponse>): Promise<RpcResponse | null> {
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<Value>(
reply: RpcResponse | null,
interpret: (reply: RpcResponse) => RpcAcceptedResult<Value>
): 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.
@@ -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<string | null>(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
}
}
+10 -10
View File
@@ -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<RpcClient>())
const fetchRepoMetadataPendingRef = useRef(new WeakSet<RpcClient>())
const repoMetadataFetchedAtRef = useRef(0)
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
const newWorktreeModalVisibleRef = useRef(false)
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
@@ -75,9 +73,12 @@ export function useHostScreenState(hostId: string | undefined, action: string |
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(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<Set<string>>(new Set())
const [pinnedIds, setPinnedIds] = useState<Set<string>>(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,
@@ -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
}
@@ -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<MobileWebShellSessionState, { kind: 'ready' }>
droppedBinaryFrames: number
}) {
if (!isDevelopmentBuild()) {
return null
}
return (
<View style={styles.devFacts} pointerEvents="none">
<Text style={styles.devFactsText} testID="mobile-web-shell-dev-facts">
{formatMobileWebShellDevFacts({
buildId: state.buildId,
totalBytes: state.totalBytes,
elapsedMs: state.elapsedMs,
droppedBinaryFrames
})}
</Text>
</View>
)
}
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 <PageRouteUnavailableScreen hostId={hostId} />
}
@@ -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({
<OrcaMobileWebShellView
key={state.sessionId}
ref={bridge.viewRef}
style={styles.shellView}
// An overlay has no cover over the screen beneath: a fresh WebView draws opaque until the page paints.
style={[styles.shellView, overlay && pageFrame !== 'painted' && styles.unpaintedOverlay]}
generationDirectory={state.generationDirectory}
sessionId={state.sessionId}
bridgeEnabled={bridge.bridgeEnabled}
@@ -435,8 +426,10 @@ export function MobileWebShellScreen({
}
}}
/>
<ShellPageCover label={SHELL_OPENING_LABEL} visible={pageFrame === 'unpainted'} />
<DevFacts state={state} droppedBinaryFrames={droppedBinaryFrames} />
{overlay ? null : (
<ShellPageCover label={SHELL_OPENING_LABEL} visible={pageFrame === 'unpainted'} />
)}
<ShellDevFacts state={state} droppedBinaryFrames={droppedBinaryFrames} />
</View>
)
}
@@ -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
}
})
@@ -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<MobileWebShellSessionState, { kind: 'ready' }>
droppedBinaryFrames: number
}) {
if (!isDevelopmentBuild()) {
return null
}
return (
<View style={styles.devFacts} pointerEvents="none">
<Text style={styles.devFactsText} testID="mobile-web-shell-dev-facts">
{formatMobileWebShellDevFacts({
buildId: state.buildId,
totalBytes: state.totalBytes,
elapsedMs: state.elapsedMs,
droppedBinaryFrames
})}
</Text>
</View>
)
}
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
}
})
@@ -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])
}
+10 -2
View File
@@ -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<View>(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<string | null>(null)
@@ -170,7 +172,10 @@ function StackScreen({
}, [motion, onSettled])
return (
<View ref={ref} style={[styles.screen, visible ? null : styles.hidden]}>
<View
ref={ref}
style={[styles.screen, transparent && styles.transparent, visible ? null : styles.hidden]}
>
{children}
</View>
)
@@ -185,6 +190,9 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject,
backgroundColor: colors.bgBase
},
transparent: {
backgroundColor: 'transparent'
},
hidden: {
display: 'none'
}
@@ -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 (
<PickerModal
title="Create Workspace On"
visible={hosts !== null && !nothingToPick && open}
options={hostPickerOptions(hosts ?? [])}
selected=""
onSelect={(hostId) => {
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<HostProfile[] | null>(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
}
@@ -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 (
<Animated.View
style={[StyleSheet.absoluteFill, { opacity: presence }]}
pointerEvents={visible ? 'auto' : 'none'}
testID="new-workspace-loading-drawer"
>
<Animated.View style={[bottomDrawerStyles.backdrop, { opacity: progress }]}>
<Pressable
style={bottomDrawerStyles.backdropPressable}
onPress={onCancel}
accessibilityLabel="Cancel"
/>
</Animated.View>
<View
style={[
bottomDrawerStyles.anchor,
isWideLayout && bottomDrawerStyles.anchorWide,
{ paddingLeft: insets.left, paddingRight: insets.right }
]}
pointerEvents="box-none"
>
<Animated.View
style={[
bottomDrawerStyles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
paddingBottom: insets.bottom + spacing.lg,
transform: [{ translateY: slide }]
}
]}
{...dragToCancel.panHandlers}
>
<View style={bottomDrawerStyles.handleHitArea}>
<View style={bottomDrawerStyles.handle} />
</View>
<View style={styles.body}>
{download === null ? (
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Loading" />
) : (
<DownloadProgress download={download} />
)}
</View>
</Animated.View>
</View>
</Animated.View>
)
}
function DownloadProgress({ download }: { download: ShellOverlayDownload }) {
const fraction =
download.totalBytes > 0 ? Math.min(1, download.receivedBytes / download.totalBytes) : 0
return (
<>
<View
style={styles.track}
accessibilityRole="progressbar"
accessibilityLabel="Downloading"
accessibilityValue={{ min: 0, max: 100, now: Math.round(fraction * 100) }}
>
<View style={[styles.fill, { width: `${fraction * 100}%` }]} />
</View>
<Text style={styles.caption}>
Downloading… {megabytes(download.receivedBytes)} of {megabytes(download.totalBytes)} MB
</Text>
</>
)
}
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']
}
})
@@ -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')
})
})
@@ -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 <OpenHostListInstead hostId={hostId} />
}
return (
<>
{/* The gate every `/h` route gets from its layout, which this one sits outside of to overlay Home. */}
<HostProtocolGate
hostId={hostId}
transparentWhilePending
blocked={<OpenHostListInstead hostId={hostId} />}
>
<MobileWebShellScreen hostId={hostId} route={route} onOverlayLoad={setLoad} />
</HostProtocolGate>
<NewWorkspaceLoadingDrawer
visible={load.kind === 'loading'}
download={load.kind === 'loading' ? load.download : null}
onCancel={cancel}
/>
</>
)
}
@@ -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'
)
})
})
@@ -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 (
<NewWorktreeModalController
visible
client={client}
hostCapabilities={hostCapabilities}
hostId={hostId}
hostLabelById={hostLabelById}
existingWorktreePaths={worktreePaths}
existingWorktrees={worktrees}
openExternalUrl={openExternalLink}
onCreated={(worktreeId, name, warning, executionHost) =>
router.replace(
workspaceRouteHref(
hostNewWorktreeSessionRoute(hostId, worktreeId, name, warning),
executionHost
)
)
}
onClose={() => router.back()}
/>
)
}
@@ -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<string, string> } => ({ 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()
})
})
@@ -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 !== '')
}
@@ -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
}
@@ -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)
}
@@ -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<ExecutionHostId, string> = 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<ExecutionHostId, string> {
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
}
@@ -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<Worktree, 'path' | 'repoId' | 'branch'>
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<readonly WorktreeRow[]>(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
}
@@ -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'
@@ -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<string>(),
worktrees: [ROW],
lastKnownWorktrees: [ROW],
confirmRemoveHost: false,
optimisticActiveWorktreeIdentity: null,
routeActionState: {},
serverWorkspaces: { hosts: [], worktrees: [] }
})
let actions: ReturnType<typeof useActions>