mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(mobile): create workspaces on the desktop's servers from the phone (#26533)
* test(e2e): add a paired phone client helper and phone-mirror baseline specs
Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the
phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve
restart and today's gap: a desktop's phone does not list the desktop's server workspaces.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): show SSH host health on the workspace row host badge
Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(e2e): compose the phone-mirror topology and check the cold snapshot
Slices need one composer for host, desktop and phone with a single teardown, so specs stop
copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold
start, which is what the phone builds its tab strip from.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): name an unhealthy SSH host even in a single-host list
The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(e2e): keep phone-mirror teardown errors from masking test failures
Teardown failures become a test attachment instead of replacing the real failure, and the
cold-snapshot poll reports stream errors together with the PTY list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear stale SSH host badges and announce them without the dot
A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(runtime): let a paired desktop register its phones as delegated mobile devices
A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of
its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the
desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(runtime): move the mobile device revoke primitive below dispatch
Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in
a layer under it as a pure move; this drops the abstract method seam. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(runtime): keep a runtime grant until its delegated phones are revoked
A failed child cleanup now leaves the parent in place so a retry can still find the children, and
revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage,
shares credential minting, and drops the timing-dependent census.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(runtime): fail a delegated sync whose dropped phone cannot be revoked
Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors
before minting anything, so the desktop retries instead of believing the server matches its list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): relay a paired phone's server-workspace requests through the desktop
A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the single-host health fixtures inline so the test tsc passes
Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the desktop relay contract in its entry module
main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the relay's configured-server adapter
The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the repo-metadata goldens for SSH host health
The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): tighten the desktop relay after elegance review
Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound relay frames and stop sticky relay verdicts
Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): run targeted desktop-owned calls on the desktop
The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): list the desktop's configured servers and their last workspace rows for the phone
The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): list server rows through one desktop fetch, with no side effect on listing
The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the
desktop's own fetch is both the live source and the cache, and stamping stays in main.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers
The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows
when a server hangs, drops a server's rows when it is removed or re-paired, and checks the
server's identity after the fetch so a reply from before a re-pair is never served.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): name a server workspace's host on the phone's calls
A phone reaches a server's workspace through its desktop only if each call names the host; the
direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for
replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete
A server Orca manages on an SSH host now lists once under that host's name, or not at all while
the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is
deleting are left out at reply time, as a phone asking the server itself would see.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch
Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied
the phone's list; rows are now pruned only once a server is no longer configured. Fetches are
shared per server identity, so a poll after a re-pair asks the new server right away.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): list the paired desktop's server workspaces on the phone
Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's
mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the
relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin that a desktop failing the hosts list leaves only its own rows
The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave
today's list with no server rows and no catalog error.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): mirror the desktop's shown hosts exactly, one host included
Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a
one-host list narrows the phone too instead of being read as a single-host focus.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): scope a server workspace at its route, never the desktop
Every screen under a server workspace's route now reads one context and
gets that server's client and a router that keeps pushes on it, refusing
to fall back to the desktop; pins write to the row's owner like the Mac.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): never open an unreachable server workspace, and prove the relay in e2e
A server workspace the phone can't reach now gets one explicit screen at its route
and no row tap or action-sheet entry opens it; native routes read the route's host,
a census pins every workspace route, and the e2e reads files and git on the server.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): gate a server workspace on that server's own features
A targeted status.get now relays to the server, so a server workspace's route
provides that server's status to every screen under it and its action sheet,
while whether the phone can reach the server stays the desktop's answer.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(ci): run the remote-runtime WebSocket opener test on Node
Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): sleep, wake and new workspaces on a desktop's servers from the phone
The desktop renderer alone sleeps a server's workspace and holds its slept agents, so the phone
now asks the desktop (two paired-desktop methods) instead of the headless server; the
new-workspace sheet lists and creates on reachable servers the way the desktop's composer does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): sleep and wake only a server the desktop has configured
A phone's sleep or wake names a server by host id; the desktop now refuses one that is not in
its own configured servers, read from its settings rather than any fetched workspace list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep server workspaces' repos, picks and sleeps on their own host
A hung server no longer holds back the desktop's repos, a repo id shared by two hosts no longer
swaps the pick, and a server row's optimistic sleep waits for the server's own list to confirm it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the server-workspaces test rows every Worktree field
The tests-typecheck ratchet checks mobile test files; the fixture row was missing required
Worktree members, so the file no longer typechecked.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): give the file preview the workspace router, back to 128 page assets
The preview was the one workspace screen on the plain router, so the scoped router
was reached by a route set of its own and esbuild split it into an extra chunk
(129 assets); every workspace screen now uses it and it shares the route's chunk.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* revert(mobile): drop server sleep and wake from the phone for now
The workspace-layout refactor deletes the notifier path these used; sleep and
wake on a server come back later as server-side layout.sleep / layout.wake.
A server row's Sleep behaves exactly as on feat/mobile-remote-workspaces.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-relay): derive the phone allowlist from the routing table
Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-relay): drop unread freshness fields from server workspace lists
The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): read only the rows of a server's workspace list
The desktop no longer sends a freshness flag, so the phone's schema drops it.
The relay capability list names the delegated-devices constant instead of
repeating its literal, which also retires a now-circular type check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): show a server workspace as disconnected while its server is down
The phone read the desktop's connection state for a server workspace, so a stopped
server kept a green dot and "Loading tabs" spun forever. The workspace route now
narrows that state by the server's health from mobileRelay.hosts.list, reusing the
existing disconnected session UI and refetching tabs when the server returns.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name run targets and the desktop's own row as the host list does
The Run on picker showed a server's raw environment id behind "Remote ·" and
always a monitor glyph; it now takes the host list's badge labels and icon, as
the desktop's picker does. Server rows also made the desktop row read "This
computer" because its platform was only read for a mixed desktop catalog.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): share one desktop server-list feed between the host list and workspaces
A server workspace polled mobileRelay.hosts.list beside the host list's own read, so
the phone could ask the desktop twice per tick and the row badge and the screen could
disagree. One feed per desktop client now owns that read; both readers join it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(e2e): read a server's reachability from the hosts list, not a dropped stale flag
eff410fe30 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's
schema never parsed and its first poll timed out. It now waits on the server's
relay/health from mobileRelay.hosts.list, the field the phone itself reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): re-measure the page's script sweep for server workspaces
Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace
route's shared modules split importer sets three ways more, reaching 81. Re-swept per
route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mock react-native where the server-list feed now loads it
The merged server-list feed imports react-native's AppState, which vitest cannot parse unmocked.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): offer the desktop's servers in a new workspace opened before the list loads
The sheet's run targets came from the host list's server rows, which never load while
the sheet is open (the list deliberately pauses its refresh behind it). From Home's
New Workspace that left only the desktop's repos. The sheet now reads the desktop's
server-list feed itself while it is open.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the desktop's servers in a new workspace opened before the list loads
The previous fix offered the servers but still named them from the host list's rows,
so a sheet opened from Home showed a server's raw id under Run on. The sheet now takes
both the servers and their names from the server-list feed while it is open.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): export the server-list feed's React reader beside the feed
useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the server run-target fixture's host id as a runtime id
A plain string executionHostId is not an ExecutionHostId, so the mobile tests typecheck ratchet refused the file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): let the new-workspace sheet read its servers where it knows it is open
The sheet's controller already owns whether it is open, so it now reads the desktop's
server list and names itself; the overlay's mirrored visibility state and the list-side
hook go away. A server row no longer offers Sleep, which would sleep it on the desktop.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep polling the server list and read a server's reachability from relay
A hosts-list read slower than the poll stopped the feed for good, since the next tick was
scheduled when the read started; it is now scheduled from the finish. A server workspace now
reads connected only when the desktop relays to it, so a server with no status yet is not shown up.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): show a reached server as connecting and a too-old one as disconnected
A server the desktop has reached but whose status is not answered yet is moments from relaying,
so it reads connecting; a server too old to relay to never will, so it reads disconnected.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(shared): move the account capabilities into their own group module
Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out
like the relay and skill groups did, same strings in the same order; the page's script sweep is
re-measured because the split changed its chunking (81 to 79 at 16 routes).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): name a server workspace through the request envelope
The server name now rides the generic request envelope from #27815 instead of
its own executionHost option, bridge grant and per-transport plumbing. Server
rows show only where the transport carries an envelope (native, or a shell
that grants rpcEnvelope), so an older app keeps today's list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the server-call wire and the no-envelope deep link
A server workspace call still reaches the desktop as the same top-level
executionHost frame, on the direct and relay wires, and a deep link into a
server workspace under an app without rpcEnvelope shows the unreachable screen.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): read a relay stream's envelope slot in sorted key order
Equal envelopes name one cleanup slot whatever their key order, the scoped view
passes its client's envelope answer through as it does the generation, and the
direct wire harness is shared instead of rebuilt.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting
A relayed agent.launch ran on the server, which picked chat vs terminal from its
own setting. The desktop relay now stamps its own default as nativeChatDefault;
new servers use it in place of their setting (feasibility still decides), older
servers strip it. Outside the replay fingerprint.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(mobile): move the relay's delegated phone grant cache into its own module
Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(mobile): show the early terminal tab from the relaying desktop's chat default
The early-tab preflight read the server's own chat setting while the launch
used the desktop's stamped nativeChatDefault, so on a windowed server with
chats off a relayed chat launch flashed a terminal tab. Both now read the
settings through readAgentLaunchModeSettings(runtime, nativeChatDefault).
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { Monitor, Server } from 'lucide-react-native'
|
||||
import { parseExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
/** The desktop's own machine is a monitor, any other host a server, as on the desktop's run targets. */
|
||||
export function HostKindIcon({ hostId, size }: { hostId: string | undefined; size: number }) {
|
||||
// Rows from hosts that predate hostId stamping are local: a remote row always carries one.
|
||||
const Icon = (parseExecutionHostId(hostId)?.kind ?? 'local') === 'local' ? Monitor : Server
|
||||
return <Icon size={size} color={colors.textMuted} />
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function NewWorktreeFormSheet(props: {
|
||||
loading: boolean
|
||||
hasRepos: boolean
|
||||
project: Selection | null
|
||||
runTarget: Selection | null
|
||||
runTarget: (Selection & { hostId: string }) | null
|
||||
projectBadgeColor: string | null
|
||||
selectedRepoIsGit: boolean
|
||||
selectedRepoConnectionId: string | null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement, Suspense, type ReactElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
@@ -11,6 +12,7 @@ const asyncStorage = vi.hoisted(() => ({
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
AppState: { currentState: 'active' },
|
||||
FlatList: 'FlatList',
|
||||
Image: 'Image',
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
@@ -44,8 +46,15 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
||||
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
|
||||
|
||||
import { setCachedRepos } from '../cache/repo-cache'
|
||||
import { recordHostDescriptor } from '../transport/host-descriptor-store'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
import { NewWorktreeModalController } from './NewWorktreeModalController'
|
||||
|
||||
const RelayedOptionsSchema = z.object({ envelope: z.object({ executionHost: z.string() }) })
|
||||
|
||||
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
|
||||
|
||||
@@ -75,6 +84,15 @@ function sourceInputs(renderer: ReactTestRenderer) {
|
||||
)
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the modal only sends requests.
|
||||
const requestsOnly = (sendRequest: unknown) => ({ sendRequest }) as unknown as RpcClient
|
||||
|
||||
function runTargetPicker(renderer: ReactTestRenderer) {
|
||||
return renderer.root
|
||||
.findAll((node) => node.type === 'PickerListDrawer')
|
||||
.find((node) => node.props.title === 'Run on')!
|
||||
}
|
||||
|
||||
async function flushUpdates(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
@@ -174,7 +192,7 @@ describe('NewWorktreeModal project targets', () => {
|
||||
expect(pickerItems(renderer, 'Run on')).toEqual([
|
||||
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
|
||||
expect.objectContaining({
|
||||
label: 'SSH · build-server',
|
||||
label: 'build-server',
|
||||
detail: '/home/dev/orca'
|
||||
})
|
||||
])
|
||||
@@ -306,6 +324,193 @@ describe('NewWorktreeModal project targets', () => {
|
||||
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
|
||||
})
|
||||
|
||||
it("creates a server repo's workspace on that server and names it for the route", async () => {
|
||||
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
|
||||
const serverSshRepo = { ...serverRepo, id: 'server-ssh', connectionId: 'its-own-ssh' }
|
||||
const reply = (method: string, listed: unknown[], hostPlatform: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: listed } })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform } })
|
||||
}
|
||||
if (method === 'worktree.create') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
result: { worktree: { id: 'srv-wt', displayName: 'srv-wt', path: '/srv/orca-wt' } }
|
||||
})
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return Promise.resolve({ ok: true, result: { settings: {} } })
|
||||
}
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
const desktop = vi.fn((method: string) => reply(method, repos, 'darwin'))
|
||||
const server = vi.fn((method: string) => reply(method, [serverRepo, serverSshRepo], 'linux'))
|
||||
const onCreated = vi.fn()
|
||||
recordHostDescriptor('host-1', { machineName: null, platform: 'darwin' })
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client: requestsOnly(desktop),
|
||||
serverClients: new Map([['runtime:env-1', requestsOnly(server)]]),
|
||||
hostId: 'host-1',
|
||||
hostLabelById: new Map([['runtime:env-1', 'scratch-server']]),
|
||||
onCreated,
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
const runTargets = pickerItems(renderer, 'Run on')
|
||||
expect(runTargets.map((item) => [item.label, item.detail])).toEqual([
|
||||
[LOCAL_HOST_LABEL, '/src/orca'],
|
||||
['scratch-server', '/srv/orca']
|
||||
])
|
||||
const serverTarget = runTargetPicker(renderer)
|
||||
const iconOf = (item: unknown) => {
|
||||
let icon: ReactTestRenderer | undefined
|
||||
act(() => {
|
||||
icon = create(serverTarget.props.renderIcon(item))
|
||||
})
|
||||
return icon?.toJSON()
|
||||
}
|
||||
expect(serverTarget.props.items.map(iconOf)).toMatchObject([
|
||||
{ type: 'Monitor' },
|
||||
{ type: 'Server' }
|
||||
])
|
||||
await act(async () => serverTarget.props.onSelect(serverTarget.props.items[1]))
|
||||
await flushUpdates()
|
||||
// The Run on field shows the picked server's glyph, as its row does.
|
||||
expect(renderer.root.findAll((node) => node.type === 'Server')).toHaveLength(1)
|
||||
expect(pickerItems(renderer, 'Run on')[0]?.label).toBe(LOCAL_HOST_LABEL)
|
||||
act(() => sourceInputs(renderer)[0]!.props.onChangeText('srv-wt'))
|
||||
const form = renderer.root.find((node) => typeof node.props.onCreate === 'function')
|
||||
await act(async () => form.props.onCreate())
|
||||
await flushUpdates()
|
||||
|
||||
expect(server).toHaveBeenCalledWith(
|
||||
'worktree.create',
|
||||
expect.objectContaining({ repo: 'id:server-repo' }),
|
||||
expect.anything()
|
||||
)
|
||||
expect(desktop).not.toHaveBeenCalledWith(
|
||||
'worktree.create',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
expect(onCreated).toHaveBeenCalledWith('srv-wt', expect.any(String), undefined, 'runtime:env-1')
|
||||
})
|
||||
|
||||
it("offers the desktop's servers, by name, in a sheet opened before the list has them", async () => {
|
||||
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
|
||||
// One desktop: its own repos, its server list, and a server's repos relayed through it.
|
||||
const desktop = new FakeSession('connected')
|
||||
desktop.sendRequest.mockImplementation(async (method: string, ...rest: unknown[]) => {
|
||||
const relayed = RelayedOptionsSchema.safeParse(rest[1]).data?.envelope.executionHost
|
||||
const result =
|
||||
method === MOBILE_RELAY_HOSTS_LIST_METHOD
|
||||
? {
|
||||
hosts: [
|
||||
{ hostId: 'runtime:vm', label: 'scratch-vm', health: 'available', relay: 'ready' }
|
||||
]
|
||||
}
|
||||
: method === 'repo.list'
|
||||
? { repos: relayed === 'runtime:vm' ? [serverRepo] : repos }
|
||||
: {}
|
||||
return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
})
|
||||
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) =>
|
||||
createElement(NewWorktreeModalController, {
|
||||
routeVisible,
|
||||
client: desktop,
|
||||
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
|
||||
hostId: 'host-1',
|
||||
hostLabelById: new Map(),
|
||||
openExternalUrl: () => {},
|
||||
onCreated: () => {},
|
||||
onRouteVisibleChange: () => {}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(sheet(false))
|
||||
})
|
||||
await flushUpdates()
|
||||
// Closed, it asks nothing of the desktop.
|
||||
expect(listCalls()).toHaveLength(0)
|
||||
|
||||
await act(async () => renderer.update(sheet(true)))
|
||||
await flushUpdates()
|
||||
expect(pickerItems(renderer, 'Run on').map((item) => [item.label, item.detail])).toEqual([
|
||||
[expect.any(String), '/src/orca'],
|
||||
['scratch-vm', '/srv/orca']
|
||||
])
|
||||
})
|
||||
|
||||
it("lists the desktop's repos at once while a server has not answered", async () => {
|
||||
const desktop = vi.fn((method: string) =>
|
||||
method === 'repo.list'
|
||||
? Promise.resolve({ ok: true, result: { repos } })
|
||||
: new Promise(() => {})
|
||||
)
|
||||
const hungServer = vi.fn(() => new Promise(() => {}))
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client: requestsOnly(desktop),
|
||||
serverClients: new Map([['runtime:env-1', requestsOnly(hungServer)]]),
|
||||
hostId: 'host-cold',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
expect(hungServer).toHaveBeenCalledWith('repo.list')
|
||||
expect(pickerItems(renderer, 'Run on').map((item) => item.detail)).toEqual(['/src/orca'])
|
||||
})
|
||||
|
||||
it('keeps a server repo picked when the desktop lists a repo with the same id', async () => {
|
||||
const serverRepo = { ...repos[0]!, path: '/srv/orca' }
|
||||
const listing = (listed: unknown[]) =>
|
||||
vi.fn((method: string) =>
|
||||
method === 'repo.list'
|
||||
? Promise.resolve({ ok: true, result: { repos: listed } })
|
||||
: new Promise(() => {})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client: requestsOnly(listing(repos)),
|
||||
serverClients: new Map([['runtime:env-1', requestsOnly(listing([serverRepo]))]]),
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
const picker = runTargetPicker(renderer)
|
||||
await act(async () => picker.props.onSelect(picker.props.items[1]))
|
||||
await flushUpdates()
|
||||
|
||||
expect(runTargetPicker(renderer).props.selectedId).toBe('runtime:env-1')
|
||||
expect(
|
||||
renderer.root.find((node) => node.props.runTarget !== undefined).props.runTarget.detail
|
||||
).toBe('/srv/orca')
|
||||
})
|
||||
|
||||
it('ignores a stale repo list after the client changes', async () => {
|
||||
let resolveOldList: ((value: unknown) => void) | undefined
|
||||
const oldList = new Promise((resolve) => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Keyboard } from 'react-native'
|
||||
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
|
||||
import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
|
||||
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
|
||||
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
|
||||
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
|
||||
import { useHostDescriptor } from '../transport/host-descriptor-store'
|
||||
import { isSameMobileWorkspaceRepo } from '../worktree/new-workspace-dialog-repo-selection'
|
||||
import {
|
||||
buildRetiredWorktreeNamesRefreshKey,
|
||||
useRetiredWorktreeNames
|
||||
@@ -32,6 +35,12 @@ import { useNewWorkspaceRuntimeContext } from './use-new-workspace-runtime-conte
|
||||
import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script'
|
||||
import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
|
||||
|
||||
/** The server a repo runs on; undefined for the desktop's own (local, SSH, folder). */
|
||||
function serverOf(repo: MobileWorkspaceRepo): `runtime:${string}` | undefined {
|
||||
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
return host?.kind === 'runtime' ? host.id : undefined
|
||||
}
|
||||
|
||||
export function NewWorktreeModal(props: NewWorktreeModalProps) {
|
||||
// Why: each drawer opening is a fresh form session; remounting resets local
|
||||
// form state before paint instead of clearing it in a visible-prop Effect.
|
||||
@@ -57,8 +66,10 @@ export function NewWorktreeModal(props: NewWorktreeModalProps) {
|
||||
function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
||||
const {
|
||||
visible,
|
||||
client,
|
||||
client: desktopClient,
|
||||
serverClients,
|
||||
hostId,
|
||||
hostLabelById,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
openExternalUrl,
|
||||
@@ -66,16 +77,24 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
||||
onClose
|
||||
} = props
|
||||
const { repos, selectedRepo, setSelectedRepo, loading } = useNewWorkspaceRepositories({
|
||||
client,
|
||||
client: desktopClient,
|
||||
serverClients,
|
||||
hostId,
|
||||
visible
|
||||
})
|
||||
// Why: everything about the picked repo runs where it does; the routing table keeps the
|
||||
// desktop's own concerns (settings, accounts) on the desktop.
|
||||
const repoServer = selectedRepo ? serverOf(selectedRepo) : undefined
|
||||
const client = repoServer ? (serverClients?.get(repoServer) ?? null) : desktopClient
|
||||
const navigation = useNewWorktreeDrawerNavigation(visible)
|
||||
const [note, setNote] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId)
|
||||
const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } =
|
||||
useNewWorktreeRuntimeCapabilities(client, visible)
|
||||
const repoRuntime = useNewWorktreeRuntimeCapabilities(client, visible)
|
||||
const { tasksSupported, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } = repoRuntime
|
||||
// The desktop's own run target is named for the desktop, whichever host the picked repo is on.
|
||||
const desktopPlatform = useHostDescriptor(hostId)?.platform ?? null
|
||||
const hostPlatform = repoServer ? desktopPlatform : repoRuntime.hostPlatform
|
||||
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
|
||||
const executionTarget = useNewWorkspaceExecutionTarget({
|
||||
client,
|
||||
@@ -132,7 +151,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
||||
getAgentLaunchSupport,
|
||||
transitionDrawer: navigation.transitionDrawer,
|
||||
setError,
|
||||
onCreated,
|
||||
onCreated: (worktreeId, name, warning) => onCreated(worktreeId, name, warning, repoServer),
|
||||
onClose
|
||||
})
|
||||
|
||||
@@ -145,25 +164,30 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
||||
gitlabAvailable: runtime.availableProviders.includes('gitlab'),
|
||||
linearAvailable: runtime.availableProviders.includes('linear')
|
||||
}
|
||||
// Why one client's: a source's repo switch is looked up through the picked repo's client.
|
||||
const hostRepos = useMemo(
|
||||
() => repos.filter((repo) => serverOf(repo) === repoServer),
|
||||
[repoServer, repos]
|
||||
)
|
||||
const pasteRepos = useMemo<PasteRepoCandidate[]>(
|
||||
() =>
|
||||
repos.map((repo) => ({
|
||||
hostRepos.map((repo) => ({
|
||||
id: repo.id,
|
||||
displayName: repo.displayName,
|
||||
slug: deriveRepoSlug(repo)
|
||||
})),
|
||||
[repos]
|
||||
[hostRepos]
|
||||
)
|
||||
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
|
||||
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
|
||||
const selectedProject =
|
||||
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
|
||||
const runTargetPickerItems = useMemo(
|
||||
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform),
|
||||
[hostPlatform, repos, selectedProjectId]
|
||||
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById),
|
||||
[hostLabelById, hostPlatform, repos, selectedProjectId]
|
||||
)
|
||||
const selectedRunTarget = selectedRepo
|
||||
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
|
||||
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform, hostLabelById)
|
||||
: null
|
||||
const needsSetupChoice = Boolean(setupScript.setupCommand) && setupScript.setupRunPolicy === 'ask'
|
||||
const canCreate =
|
||||
@@ -178,7 +202,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
||||
}
|
||||
|
||||
function selectRepo(repo: MobileWorkspaceRepo, clearRepoScopedSource: boolean): void {
|
||||
const repoChanged = repo.id !== selectedRepo?.id
|
||||
const repoChanged = !selectedRepo || !isSameMobileWorkspaceRepo(repo, selectedRepo)
|
||||
setSelectedRepo(repo)
|
||||
if (
|
||||
clearRepoScopedSource &&
|
||||
@@ -247,7 +271,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
||||
composer={composer}
|
||||
sourceAvailability={sourceAvailability}
|
||||
selectedRepo={selectedRepo}
|
||||
repos={repos}
|
||||
repos={hostRepos}
|
||||
pasteRepos={pasteRepos}
|
||||
sshReady={!executionTarget.sshGate.requiresConnection}
|
||||
projectPickerItems={projectPickerItems}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client'
|
||||
import { useRelayHosts } from '../worktree/relay-hosts-feed'
|
||||
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 = {
|
||||
type Props = Omit<
|
||||
NewWorktreeModalProps,
|
||||
'visible' | 'onClose' | '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
|
||||
client: RpcClient | null
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
openExternalUrl: (url: string) => void
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
onRouteVisibleChange: (visible: boolean) => void
|
||||
onCreated: (worktreeId: string, name: string, warning?: string) => void
|
||||
}
|
||||
|
||||
export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerHandle, Props>(
|
||||
@@ -24,7 +29,9 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
{
|
||||
routeVisible,
|
||||
client,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
hostLabelById,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
openExternalUrl,
|
||||
@@ -36,6 +43,16 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
) {
|
||||
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 = useRelayHosts(visible ? client : null)
|
||||
const serverClients = useMemo(
|
||||
() => reachableServerClients(client, hostCapabilities, servers ?? []),
|
||||
[client, hostCapabilities, servers]
|
||||
)
|
||||
const serverLabelById = useMemo(
|
||||
() => withServerHosts(hostLabelById, servers ?? [], (host) => host.label),
|
||||
[hostLabelById, servers]
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -60,7 +77,9 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
<NewWorktreeModal
|
||||
visible={visible}
|
||||
client={client}
|
||||
serverClients={serverClients}
|
||||
hostId={hostId}
|
||||
hostLabelById={serverLabelById}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={existingWorktrees}
|
||||
openExternalUrl={openExternalUrl}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { View } from 'react-native'
|
||||
import { Monitor } from 'lucide-react-native'
|
||||
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
|
||||
import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { HostKindIcon } from './HostKindIcon'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
@@ -27,6 +27,7 @@ export function NewWorktreeModalDrawers(props: {
|
||||
composer: Composer
|
||||
sourceAvailability: SmartModeAvailabilityInput
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
/** The picked repo's host's repos: a source can switch repos only within that host. */
|
||||
repos: MobileWorkspaceRepo[]
|
||||
pasteRepos: PasteRepoCandidate[]
|
||||
sshReady: boolean
|
||||
@@ -85,10 +86,10 @@ export function NewWorktreeModalDrawers(props: {
|
||||
visible={props.visible && props.drawerView === 'runTarget'}
|
||||
title="Run on"
|
||||
items={props.runTargetPickerItems}
|
||||
selectedId={props.selectedRepo?.id ?? ''}
|
||||
selectedId={props.selectedRepo ? getRepoExecutionHostId(props.selectedRepo) : ''}
|
||||
onSelect={(item) => props.onRepoChange(item.repo)}
|
||||
onClose={props.onTransitionToForm}
|
||||
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
|
||||
renderIcon={(item) => <HostKindIcon hostId={item.hostId} size={16} />}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
|
||||
import { ChevronDown, Monitor } from 'lucide-react-native'
|
||||
import { ChevronDown } from 'lucide-react-native'
|
||||
import { HostKindIcon } from './HostKindIcon'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
type Selection = { label: string; detail?: string }
|
||||
@@ -12,7 +13,7 @@ export function NewWorktreeProjectTargetFields({
|
||||
onOpenRunTarget
|
||||
}: {
|
||||
project: Selection | null
|
||||
runTarget: Selection | null
|
||||
runTarget: (Selection & { hostId: string }) | null
|
||||
projectBadgeColor: string | null
|
||||
onOpenProject: () => void
|
||||
onOpenRunTarget: () => void
|
||||
@@ -33,7 +34,7 @@ export function NewWorktreeProjectTargetFields({
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Run on</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
|
||||
<Monitor size={14} color={colors.textMuted} />
|
||||
<HostKindIcon hostId={runTarget?.hostId} size={14} />
|
||||
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
Monitor,
|
||||
Server
|
||||
} from 'lucide-react-native'
|
||||
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { parseExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { MobileRenderableRepoIcon } from '../host-screen/host-screen-reply-schema'
|
||||
import { triggerMediumImpact } from '../platform/haptics'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { areStructurallyEqual } from '../worktree/structural-equality'
|
||||
import { AgentSpinner } from './AgentSpinner'
|
||||
import { HostKindIcon } from './HostKindIcon'
|
||||
import { MobileRepoIcon } from './MobileRepoIcon'
|
||||
import { WorktreeAgentList } from './WorktreeAgentList'
|
||||
import { WorktreeMetaGlyphs, prStateColor } from './WorktreeMetaGlyphs'
|
||||
@@ -140,13 +132,7 @@ function WorktreeListRowComponent({
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
>
|
||||
{/* Rows from hosts that predate hostId stamping are local: a remote row always carries one. */}
|
||||
{(parseExecutionHostId(item.hostContextHostId ?? item.hostId)?.kind ?? 'local') ===
|
||||
'local' ? (
|
||||
<Monitor size={10} color={colors.textMuted} />
|
||||
) : (
|
||||
<Server size={10} color={colors.textMuted} />
|
||||
)}
|
||||
<HostKindIcon hostId={item.hostContextHostId ?? item.hostId} size={10} />
|
||||
<Text style={[styles.childBadgeText, styles.hostBadgeText]} numberOfLines={1}>
|
||||
{item.hostContextLabel}
|
||||
</Text>
|
||||
|
||||
@@ -43,16 +43,16 @@ describe('new workspace project targets', () => {
|
||||
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
|
||||
})
|
||||
|
||||
it('labels local, SSH, and paired runtime targets', () => {
|
||||
it('labels local, SSH, and paired runtime targets as the host list names them', () => {
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin')
|
||||
).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
|
||||
).toEqual({ hostId: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' })
|
||||
).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' })
|
||||
).toMatchObject({ label: 'This computer' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32')
|
||||
).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' })
|
||||
).toMatchObject({ label: 'Local Windows' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'ssh',
|
||||
@@ -60,15 +60,19 @@ describe('new workspace project targets', () => {
|
||||
path: 'C:\\src\\orca',
|
||||
executionHostId: 'ssh:Windows%20VM'
|
||||
})
|
||||
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'runtime',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
executionHostId: 'runtime:devbox'
|
||||
})
|
||||
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
|
||||
).toEqual({ hostId: 'ssh:Windows%20VM', label: 'Windows VM', detail: 'C:\\src\\orca' })
|
||||
const server = {
|
||||
id: 'runtime',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
executionHostId: 'runtime:20269ff3-60aa-4e79-a581-513a3f8e394b'
|
||||
} as const
|
||||
const labels = new Map([[server.executionHostId, 'scratch-server']] as const)
|
||||
expect(getNewWorkspaceRunTarget(server, 'darwin', labels)).toEqual({
|
||||
hostId: server.executionHostId,
|
||||
label: 'scratch-server',
|
||||
detail: '/src/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows one target per host when the project has multiple local worktrees', () => {
|
||||
@@ -87,8 +91,12 @@ describe('new workspace project targets', () => {
|
||||
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
|
||||
|
||||
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
|
||||
expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
|
||||
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
|
||||
expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
|
||||
expect.objectContaining({
|
||||
id: 'ssh:build-server',
|
||||
label: 'build-server',
|
||||
detail: '/home/dev/orca'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { Repo } from '../../../src/shared/repo-types'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getLocalExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import {
|
||||
getProjectIdentityKey,
|
||||
getProjectProviderIdentity
|
||||
} from '../../../src/shared/project-host-setup-projection'
|
||||
import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels'
|
||||
|
||||
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
@@ -24,6 +20,7 @@ export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
|
||||
|
||||
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
|
||||
id: string
|
||||
hostId: ExecutionHostId
|
||||
label: string
|
||||
detail: string
|
||||
repo: TRepo
|
||||
@@ -58,24 +55,23 @@ export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
|
||||
})
|
||||
}
|
||||
|
||||
/** Named like the host list's badge; the desktop's picker shows the bare host label too. */
|
||||
export function getNewWorkspaceRunTarget(
|
||||
repo: WorkspaceRepo,
|
||||
localPlatform: NodeJS.Platform | null = null
|
||||
localPlatform: NodeJS.Platform | null = null,
|
||||
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
|
||||
): {
|
||||
hostId: ExecutionHostId
|
||||
label: string
|
||||
detail: string
|
||||
} {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const host = parseExecutionHostId(hostId)
|
||||
const hostLabel = getExecutionHostLabel(hostId)
|
||||
if (host?.kind === 'ssh') {
|
||||
return { label: `SSH · ${hostLabel}`, detail: repo.path }
|
||||
}
|
||||
if (host?.kind === 'runtime') {
|
||||
return { label: `Remote · ${hostLabel}`, detail: repo.path }
|
||||
}
|
||||
return {
|
||||
label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer',
|
||||
hostId,
|
||||
label: getHostContextLabel(hostId, {
|
||||
hostLabelById,
|
||||
hostPlatform: localPlatform
|
||||
}),
|
||||
detail: repo.path
|
||||
}
|
||||
}
|
||||
@@ -83,7 +79,8 @@ export function getNewWorkspaceRunTarget(
|
||||
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
|
||||
repos: readonly TRepo[],
|
||||
projectId: string | null,
|
||||
localPlatform: NodeJS.Platform | null = null
|
||||
localPlatform: NodeJS.Platform | null = null,
|
||||
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
|
||||
): NewWorkspaceRunTargetOption<TRepo>[] {
|
||||
if (!projectId) {
|
||||
return []
|
||||
@@ -95,9 +92,10 @@ export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
|
||||
}
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
if (!options.has(hostId)) {
|
||||
// Keyed by host: a repo id names one repo per host, so two hosts can share one.
|
||||
options.set(hostId, {
|
||||
id: repo.id,
|
||||
...getNewWorkspaceRunTarget(repo, localPlatform),
|
||||
id: hostId,
|
||||
...getNewWorkspaceRunTarget(repo, localPlatform, hostLabelById),
|
||||
repo
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { SetupHookTrust } from '../tasks/setup-hook-trust'
|
||||
@@ -20,11 +21,21 @@ export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'
|
||||
export type NewWorktreeModalProps = {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
/** The desktop's servers this phone can reach now; their repos are offered and created there. */
|
||||
serverClients?: ReadonlyMap<ExecutionHostId, RpcClient>
|
||||
hostId?: string
|
||||
/** The host list's badge labels, so a run target reads as its row does. */
|
||||
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
openExternalUrl: (url: string) => void
|
||||
onCreated: (worktreeId: string, name: string, warning?: string) => void
|
||||
/** `executionHost` names the server the workspace was created on; absent for the desktop's own. */
|
||||
onCreated: (
|
||||
worktreeId: string,
|
||||
name: string,
|
||||
warning?: string,
|
||||
executionHost?: ExecutionHostId
|
||||
) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { nativeChatRepoListRead } from '../session/mobile-session-read-operations'
|
||||
import { getCachedRepos, setCachedRepos } from '../cache/repo-cache'
|
||||
@@ -10,8 +11,39 @@ import {
|
||||
} from '../worktree/new-workspace-dialog-repo-selection'
|
||||
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
|
||||
|
||||
const NO_SERVER_CLIENTS: ReadonlyMap<ExecutionHostId, RpcClient> = new Map()
|
||||
const NO_SERVER_REPOS: ReadonlyMap<ExecutionHostId, MobileWorkspaceRepo[]> = new Map()
|
||||
|
||||
/** One host's repos, or null when it refused or failed to answer. */
|
||||
async function listHostRepos(client: RpcClient): Promise<MobileWorkspaceRepo[] | null> {
|
||||
const listed = nativeChatRepoListRead.interpret(await nativeChatRepoListRead.request(client))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
return listed.accepted ? (listed.value as MobileWorkspaceRepo[]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's repos tagged with that server, as the desktop's composer offers them. Its SSH repos
|
||||
* are left out: their connection is the server's, which the phone's SSH gate cannot read.
|
||||
*/
|
||||
async function listServerRepos(
|
||||
executionHostId: ExecutionHostId,
|
||||
client: RpcClient
|
||||
): Promise<MobileWorkspaceRepo[] | null> {
|
||||
const listed = await listHostRepos(client).catch(() => null)
|
||||
return (
|
||||
listed?.filter((repo) => !repo.connectionId).map((repo) => ({ ...repo, executionHostId })) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop's repos, then each reachable server's as it answers: a slow server never holds back
|
||||
* the desktop's own list.
|
||||
*/
|
||||
export function useNewWorkspaceRepositories(args: {
|
||||
client: RpcClient | null
|
||||
/** The desktop's servers this phone can reach now, whose repos are offered too. */
|
||||
serverClients?: ReadonlyMap<ExecutionHostId, RpcClient>
|
||||
hostId?: string
|
||||
visible: boolean
|
||||
}): {
|
||||
@@ -20,14 +52,27 @@ export function useNewWorkspaceRepositories(args: {
|
||||
setSelectedRepo: (repo: MobileWorkspaceRepo | null) => void
|
||||
loading: boolean
|
||||
} {
|
||||
const { client, hostId, visible } = args
|
||||
const { client, serverClients = NO_SERVER_CLIENTS, hostId, visible } = args
|
||||
const [initialRepos] = useState(() =>
|
||||
hostId ? (getCachedRepos(hostId) as MobileWorkspaceRepo[] | null) : null
|
||||
)
|
||||
const [repos, setRepos] = useState<MobileWorkspaceRepo[]>(initialRepos ?? [])
|
||||
const [selectedRepo, setSelectedRepo] = useState<MobileWorkspaceRepo | null>(null)
|
||||
const [desktopRepos, setDesktopRepos] = useState<MobileWorkspaceRepo[]>(initialRepos ?? [])
|
||||
const [serverRepos, setServerRepos] = useState(NO_SERVER_REPOS)
|
||||
const [pickedRepo, setSelectedRepo] = useState<MobileWorkspaceRepo | null>(null)
|
||||
const [loading, setLoading] = useState(initialRepos == null)
|
||||
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
|
||||
const repos = useMemo(
|
||||
() =>
|
||||
serverRepos.size === 0
|
||||
? desktopRepos
|
||||
: [...desktopRepos, ...[...serverRepos.values()].flat()],
|
||||
[desktopRepos, serverRepos]
|
||||
)
|
||||
// Why derived: the pick follows its repo's latest listing, and goes once its host stops listing it.
|
||||
const selectedRepo = useMemo(
|
||||
() => refreshMobileNewWorkspaceDialogSelectedRepo(repos, pickedRepo),
|
||||
[pickedRepo, repos]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
|
||||
@@ -50,25 +95,15 @@ export function useNewWorkspaceRepositories(args: {
|
||||
}
|
||||
let stale = false
|
||||
setLoading(true)
|
||||
void nativeChatRepoListRead
|
||||
.request(client)
|
||||
.then((response) => {
|
||||
if (stale) {
|
||||
void listHostRepos(client)
|
||||
.then((listed) => {
|
||||
if (stale || !listed) {
|
||||
return
|
||||
}
|
||||
const listed = nativeChatRepoListRead.interpret(response)
|
||||
if (!listed.accepted) {
|
||||
return
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
const listedRepos = listed.value as MobileWorkspaceRepo[]
|
||||
setRepos(listedRepos)
|
||||
setDesktopRepos(listed)
|
||||
if (hostId) {
|
||||
setCachedRepos(hostId, listedRepos)
|
||||
setCachedRepos(hostId, listed)
|
||||
}
|
||||
setSelectedRepo((current) =>
|
||||
refreshMobileNewWorkspaceDialogSelectedRepo(listedRepos, current)
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
@@ -81,5 +116,27 @@ export function useNewWorkspaceRepositories(args: {
|
||||
}
|
||||
}, [visible, client, hostId])
|
||||
|
||||
useEffect(() => {
|
||||
// A server the phone can no longer reach takes its repos with it.
|
||||
setServerRepos((previous) => {
|
||||
const kept = [...previous].filter(([host]) => serverClients.has(host))
|
||||
return kept.length === previous.size ? previous : new Map(kept)
|
||||
})
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
for (const [executionHostId, serverClient] of serverClients) {
|
||||
void listServerRepos(executionHostId, serverClient).then((listed) => {
|
||||
if (!stale && listed) {
|
||||
setServerRepos((previous) => new Map(previous).set(executionHostId, listed))
|
||||
}
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [visible, serverClients])
|
||||
|
||||
return { repos, selectedRepo, setSelectedRepo, loading: loading && repos.length === 0 }
|
||||
}
|
||||
|
||||
@@ -92,6 +92,17 @@ export const worktreePinWrite = bindDeferredRpcOperation(
|
||||
})
|
||||
)
|
||||
|
||||
/** Sleeping a row. Fire and forget: the row shows slept at once, and nothing reads the reply. */
|
||||
export const worktreeSleep = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'worktree.sleep-or-skip',
|
||||
method: 'worktree.sleep',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcResultVariant('worktree-slept', hostScreenUnreadReplySchema)
|
||||
})
|
||||
)
|
||||
|
||||
/** Deleting a row. Only acceptance is read: a refusal is what puts the row back. */
|
||||
export const worktreeRemove = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active' },
|
||||
Linking: { openURL: vi.fn() },
|
||||
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
|
||||
vi.mock('lucide-react-native', () => ({ Check: 'Check', Moon: 'Moon' }))
|
||||
vi.mock('../agent-history/worktree-navigation-actions', () => ({
|
||||
buildWorktreeNavigationActions: () => []
|
||||
}))
|
||||
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetContent: 'ActionSheetContent' }))
|
||||
vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
|
||||
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' }))
|
||||
vi.mock('../components/NewWorktreeModalController', () => ({
|
||||
NewWorktreeModalController: 'NewWorktreeModalController'
|
||||
}))
|
||||
vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
|
||||
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { HostScreenOverlays } from './host-screen-overlays'
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
/** The long-press sheet's action labels for `row`. */
|
||||
async function actionLabels(row: Partial<Worktree>): Promise<string[]> {
|
||||
const controller = {
|
||||
actions: {},
|
||||
catalog: {},
|
||||
client: new FakeSession('connected'),
|
||||
connState: 'connected',
|
||||
existingWorktreePaths: [],
|
||||
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
|
||||
hostId: 'host-1',
|
||||
sectionsResult: { uniqueRepos: [] },
|
||||
settings: { activeFilterCount: 0 },
|
||||
showNewWorktree: false,
|
||||
state: {
|
||||
actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row },
|
||||
confirmDelete: null,
|
||||
filters: { filterRepoIds: new Set() },
|
||||
hostLabelById: new Map(),
|
||||
pinnedIds: new Set()
|
||||
}
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(HostScreenOverlays, {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action sheet reads only these members; the drawers and pickers that read the rest are mocked.
|
||||
controller: controller as unknown as Parameters<typeof HostScreenOverlays>[0]['controller']
|
||||
})
|
||||
)
|
||||
})
|
||||
const sheet = renderer!.root.find((node) => String(node.type) === 'ActionSheetContent')
|
||||
return sheet.props.actions.map((action: { label: string }) => action.label)
|
||||
}
|
||||
|
||||
describe('the workspace long-press sheet', () => {
|
||||
it("offers Sleep for the desktop's own workspace", async () => {
|
||||
expect(await actionLabels({})).toContain('Sleep')
|
||||
})
|
||||
|
||||
it("does not offer Sleep for a server's workspace, which the desktop would sleep by its own id", async () => {
|
||||
expect(await actionLabels({ worktreeId: 'runtime:vm-wt', hostId: 'runtime:vm' })).toEqual([
|
||||
'Pin',
|
||||
'Delete'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { openExternalLink } from '../platform/external-link'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Check, Moon } from 'lucide-react-native'
|
||||
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
|
||||
import { workspaceRouteHref } from '../navigation/workspace-execution-host'
|
||||
import { useWorkspaceServer } from '../transport/workspace-server'
|
||||
import { ActionSheetContent } from '../components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
@@ -10,7 +11,6 @@ import { NewWorktreeModalController } from '../components/NewWorktreeModalContro
|
||||
import { PickerModal } from '../components/PickerModal'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
|
||||
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
|
||||
import {
|
||||
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS,
|
||||
@@ -179,23 +179,19 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
navigate: actions.navigateFromHostList,
|
||||
onDone: () => state.setActionTarget(null)
|
||||
}),
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
if (client) {
|
||||
state.setSleptIds((prev) =>
|
||||
new Set(prev).add(getWorktreeRowIdentity(actionTarget))
|
||||
)
|
||||
void client
|
||||
.sendRequest('worktree.sleep', {
|
||||
worktree: `id:${actionTarget.worktreeId}`
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
state.setActionTarget(null)
|
||||
}
|
||||
},
|
||||
// Server sleep is not offered yet; the desktop would sleep its own id.
|
||||
...(actionServer.server
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
actions.putWorktreeToSleep(actionTarget)
|
||||
state.setActionTarget(null)
|
||||
}
|
||||
}
|
||||
]),
|
||||
{
|
||||
label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin',
|
||||
onPress: () => {
|
||||
@@ -230,7 +226,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
ref={state.newWorktreeModalRef}
|
||||
routeVisible={showNewWorktree}
|
||||
client={client}
|
||||
hostCapabilities={hostCapabilities}
|
||||
hostId={hostId}
|
||||
hostLabelById={state.hostLabelById}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={state.worktrees}
|
||||
// The seam, not react-native's `Linking`: this screen is in the tasks page closure, and
|
||||
@@ -239,10 +237,13 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
onVisibleChange={(visible) => {
|
||||
state.newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
onCreated={(worktreeId, worktreeName, warning) => {
|
||||
onCreated={(worktreeId, worktreeName, warning, executionHost) => {
|
||||
void catalog.fetchWorktrees({ allowDuringModal: true })
|
||||
actions.navigateFromHostList(
|
||||
hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName, warning)
|
||||
workspaceRouteHref(
|
||||
hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName, warning),
|
||||
executionHost
|
||||
)
|
||||
)
|
||||
}}
|
||||
onRouteVisibleChange={actions.setShowNewWorktreeVisible}
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock('../transport/use-worktree-resync', () => ({
|
||||
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import { createBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
|
||||
import {
|
||||
recordHostDescriptor,
|
||||
resetHostDescriptorStoreForTests
|
||||
} from '../transport/host-descriptor-store'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
@@ -99,6 +103,8 @@ type Screen = {
|
||||
notice: () => string | null
|
||||
catalogError: () => string | null
|
||||
pin: (worktreeId: string) => void
|
||||
sleep: (worktreeId: string) => void
|
||||
slept: () => ReadonlySet<string> | undefined
|
||||
serverWorkspaces: () => unknown
|
||||
swapClient: (next: RpcClient) => Promise<void>
|
||||
navigations: string[]
|
||||
@@ -145,7 +151,7 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis
|
||||
})
|
||||
held.state = state
|
||||
held.fetch = catalog.fetchWorktrees
|
||||
held.rows = useHostDisplayWorktrees('connected', state)
|
||||
held.rows = useHostDisplayWorktrees('connected', state, 'host-1')
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
@@ -174,6 +180,13 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis
|
||||
act(() => {
|
||||
held.actions?.togglePin(held.rows.find((entry) => entry.worktreeId === worktreeId)!)
|
||||
}),
|
||||
sleep: (worktreeId) =>
|
||||
act(() => {
|
||||
held.actions?.putWorktreeToSleep(
|
||||
held.rows.find((entry) => entry.worktreeId === worktreeId)!
|
||||
)
|
||||
}),
|
||||
slept: () => held.state?.sleptIds,
|
||||
serverWorkspaces: () => held.state?.serverWorkspaces,
|
||||
swapClient: async (next) => {
|
||||
client = next
|
||||
@@ -206,6 +219,19 @@ describe("a desktop's server workspaces on the phone", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("names the desktop's own row for the desktop's platform once servers mix the list", async () => {
|
||||
recordHostDescriptor('host-1', { machineName: null, platform: 'darwin' })
|
||||
try {
|
||||
const screen = await mountScreen(desktop(), RELAYS)
|
||||
expect(screen.rows()[0]).toMatchObject({
|
||||
worktreeId: 'mac-wt',
|
||||
hostContextLabel: 'Local Mac'
|
||||
})
|
||||
} finally {
|
||||
resetHostDescriptorStoreForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps showing today’s view under an older desktop or an older shell', async () => {
|
||||
const oldDesktop = desktop()
|
||||
const oldShell = desktop({ carriesRpcEnvelope: false })
|
||||
@@ -339,6 +365,14 @@ describe("a desktop's server workspaces on the phone", () => {
|
||||
expect(screen.rows().find((entry) => entry.worktreeId === 'runtime:vm-wt')?.isPinned).toBe(true)
|
||||
})
|
||||
|
||||
it('sleeps a desktop workspace on the desktop and shows it slept at once', async () => {
|
||||
const client = desktop()
|
||||
const screen = await mountScreen(client, RELAYS)
|
||||
screen.sleep('mac-wt')
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { worktree: 'id:mac-wt' })
|
||||
expect(screen.slept()?.size).toBe(1)
|
||||
})
|
||||
|
||||
it('reads a replaced client\u2019s servers at once, and keeps an unchanged poll\u2019s list', async () => {
|
||||
const stuck = desktop()
|
||||
const base = stuck.sendRequest.getMockImplementation()!
|
||||
|
||||
@@ -69,7 +69,7 @@ async function displayedRows(args: {
|
||||
state.clientRef.current = client
|
||||
setWorktrees = state.setWorktrees
|
||||
seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state })
|
||||
seen.rows = useHostDisplayWorktrees('connected', state)
|
||||
seen.rows = useHostDisplayWorktrees('connected', state, 'host-1')
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { useHostDescriptor } from '../transport/host-descriptor-store'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { serverHostHealth } from '../worktree/server-workspaces'
|
||||
import { serverHostHealth, withServerHosts } from '../worktree/server-workspaces'
|
||||
import { filterVisibleHostRows } from '../worktree/visible-host-rows'
|
||||
import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
|
||||
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
/** The rows the host list renders: the live or last-known catalog with display state and host badges. */
|
||||
export function useHostDisplayWorktrees(connState: ConnectionState, state: HostScreenState) {
|
||||
export function useHostDisplayWorktrees(
|
||||
connState: ConnectionState,
|
||||
state: HostScreenState,
|
||||
hostId: string | undefined
|
||||
) {
|
||||
const { serverWorkspaces } = state
|
||||
// Why: a server's label and health come with its rows, alongside the desktop's SSH ones.
|
||||
const hostLabelById = useMemo(
|
||||
@@ -19,6 +23,9 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS
|
||||
() => withServerHosts(state.hostHealthById, serverWorkspaces.hosts, serverHostHealth),
|
||||
[state.hostHealthById, serverWorkspaces.hosts]
|
||||
)
|
||||
const descriptorPlatform = useHostDescriptor(hostId)?.platform ?? null
|
||||
// Why: metadata reads the platform only for a mixed desktop catalog; server rows mix it too.
|
||||
const hostPlatform = state.hostPlatform ?? descriptorPlatform
|
||||
return useMemo(() => {
|
||||
// Why: live `worktrees` is authoritative only while connected; under the amber
|
||||
// mount default, connecting/handshaking must keep the pre-reconnect list too.
|
||||
@@ -33,7 +40,7 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS
|
||||
{
|
||||
repoHostIdByRepoId: state.repoHostIdByRepoId,
|
||||
hostLabelById,
|
||||
hostPlatform: state.hostPlatform,
|
||||
hostPlatform,
|
||||
hostHealthById
|
||||
}
|
||||
)
|
||||
@@ -47,22 +54,7 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS
|
||||
state.optimisticActiveWorktreeIdentity,
|
||||
state.repoHostIdByRepoId,
|
||||
hostLabelById,
|
||||
state.hostPlatform,
|
||||
hostPlatform,
|
||||
hostHealthById
|
||||
])
|
||||
}
|
||||
|
||||
function withServerHosts<Value>(
|
||||
desktop: ReadonlyMap<ExecutionHostId, Value>,
|
||||
hosts: HostScreenState['serverWorkspaces']['hosts'],
|
||||
read: (host: HostScreenState['serverWorkspaces']['hosts'][number]) => Value
|
||||
): ReadonlyMap<ExecutionHostId, Value> {
|
||||
if (hosts.length === 0) {
|
||||
return desktop
|
||||
}
|
||||
const merged = new Map(desktop)
|
||||
for (const host of hosts) {
|
||||
merged.set(host.hostId, read(host))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function useHostScreenController({
|
||||
}
|
||||
const showNewWorktree = resolvedRouteActionState.showNewWorktree
|
||||
|
||||
const displayWorktrees = useHostDisplayWorktrees(connState, state)
|
||||
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
|
||||
const sectionsResult = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode: state.sortMode,
|
||||
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
removeWorktreeRow
|
||||
} from '../worktree/worktree-host-row-identity'
|
||||
import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections'
|
||||
import { worktreeActivate, worktreePinWrite, worktreeRemove } from './host-screen-operations'
|
||||
import {
|
||||
worktreeActivate,
|
||||
worktreePinWrite,
|
||||
worktreeRemove,
|
||||
worktreeSleep
|
||||
} from './host-screen-operations'
|
||||
import type { HostScreenState } from './use-host-screen-state'
|
||||
|
||||
export function useHostWorktreeActions(args: {
|
||||
@@ -60,6 +65,7 @@ export function useHostWorktreeActions(args: {
|
||||
setRouteActionState,
|
||||
setServerNotice,
|
||||
setServerWorkspaces,
|
||||
setSleptIds,
|
||||
setWorktrees,
|
||||
worktrees
|
||||
} = state
|
||||
@@ -153,6 +159,17 @@ export function useHostWorktreeActions(args: {
|
||||
[client, clientForRow, worktrees, pinnedIds, updateLocalPins]
|
||||
)
|
||||
|
||||
const putWorktreeToSleep = useCallback(
|
||||
(item: Worktree) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
setSleptIds((prev) => new Set(prev).add(getWorktreeRowIdentity(item)))
|
||||
void worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }).catch(() => null)
|
||||
},
|
||||
[client, setSleptIds]
|
||||
)
|
||||
|
||||
const handleDeleteWorktree = useCallback(
|
||||
async (item: Worktree) => {
|
||||
const rowClient = clientForRow(item)
|
||||
@@ -279,6 +296,7 @@ export function useHostWorktreeActions(args: {
|
||||
openNewWorktreeModal,
|
||||
openWorktreeSession,
|
||||
setShowNewWorktreeVisible,
|
||||
putWorktreeToSleep,
|
||||
togglePin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import {
|
||||
reachableServerClients,
|
||||
relaysToServers,
|
||||
scopeRpcClientToExecutionHost,
|
||||
workspaceRouteExecutionHost
|
||||
@@ -157,4 +158,19 @@ describe('scopeRpcClientToExecutionHost', () => {
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('offers only the servers the desktop can relay to now, each as its scoped view', () => {
|
||||
const client = fakeClient()
|
||||
const relays = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
|
||||
const hosts = [
|
||||
{ hostId: SERVER, relay: 'ready' },
|
||||
{ hostId: 'runtime:old', relay: 'update-needed' },
|
||||
{ hostId: 'runtime:down', relay: 'unavailable' }
|
||||
] as const
|
||||
const clients = reachableServerClients(client, relays, hosts)
|
||||
expect([...clients.keys()]).toEqual([SERVER])
|
||||
expect(clients.get(SERVER)).toBe(scopeRpcClientToExecutionHost(client, SERVER))
|
||||
expect(reachableServerClients(client, [], hosts).size).toBe(0)
|
||||
expect(reachableServerClients(fakeClient(false), relays, hosts).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,3 +91,21 @@ export function rpcClientForExecutionHost(
|
||||
}
|
||||
return routeHost && scopeRpcClientToExecutionHost(client, routeHost)
|
||||
}
|
||||
|
||||
/** A scoped client for each of the desktop's servers this phone can reach now, by host. */
|
||||
export function reachableServerClients(
|
||||
client: RpcClient | null,
|
||||
hostCapabilities: readonly string[],
|
||||
hosts: readonly { hostId: `runtime:${string}`; relay: string }[]
|
||||
): ReadonlyMap<ExecutionHostId, RpcClient> {
|
||||
const clients = new Map<ExecutionHostId, RpcClient>()
|
||||
if (!client || !relaysToServers(client, hostCapabilities)) {
|
||||
return clients
|
||||
}
|
||||
for (const host of hosts) {
|
||||
if (host.relay === 'ready') {
|
||||
clients.set(host.hostId, scopeRpcClientToExecutionHost(client, host.hostId))
|
||||
}
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
@@ -100,16 +100,10 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques
|
||||
// in tasks/mobile-workspace-source-operations.ts, and the repo.list readers the dialog now shares
|
||||
// in session/mobile-session-read-operations.ts.
|
||||
|
||||
// src/host-screen/ — host screen catalog and actions. The repo and label metadata reads, the
|
||||
// desktop view-settings mirror and the list's pin, remove and activate mutations migrated in
|
||||
// step 4; see host-screen-operations.ts.
|
||||
// Holdout: the last `worktree.sleep` is an `onPress` this file builds for `ActionSheetContent`,
|
||||
// which renders only inside an open `BottomDrawer`. Nothing gates those children — the drawer
|
||||
// mounts on `visible || mounted` and `MountedBottomDrawer` renders them unconditionally inside
|
||||
// its `Modal`. The block is that module's imports: reanimated and gesture-handler, neither of
|
||||
// which has a substitute, so reaching this send means standing in for both engines rather than
|
||||
// pinning a device input.
|
||||
{ file: 'src/host-screen/host-screen-overlays.tsx', references: 1 },
|
||||
// src/host-screen/ — host screen catalog and actions. Nothing is left here: the repo and label
|
||||
// metadata reads, the desktop view-settings mirror and the list's pin, remove and activate
|
||||
// mutations migrated in step 4, and the row sleep followed once it moved into the actions hook;
|
||||
// see host-screen-operations.ts.
|
||||
|
||||
// src/notifications/ — push registration and delivery. Nothing is left here. Registration and
|
||||
// unregistration migrated in step 4; see mobile-push-registration-operations.ts. Tray
|
||||
|
||||
@@ -94,4 +94,14 @@ describe('mobile new workspace dialog repo selection', () => {
|
||||
refreshMobileNewWorkspaceDialogSelectedRepo([makeRepo('fresh')], makeRepo('stale'))
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('keeps a repo picked on a server when the desktop lists one with the same id', () => {
|
||||
const onDesktop = makeRepo('repo', { path: '/mac/repo' })
|
||||
const onServer = makeRepo('repo', { path: '/srv/repo', executionHostId: 'runtime:env-1' })
|
||||
|
||||
expect(refreshMobileNewWorkspaceDialogSelectedRepo([onDesktop, onServer], onServer)).toBe(
|
||||
onServer
|
||||
)
|
||||
expect(refreshMobileNewWorkspaceDialogSelectedRepo([onDesktop], onServer)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,12 +52,20 @@ export function resolveMobileNewWorkspaceDialogRepoId({
|
||||
return resolvedRepo?.id ?? ''
|
||||
}
|
||||
|
||||
export function refreshMobileNewWorkspaceDialogSelectedRepo<T extends { id: string }>(
|
||||
/** Whether two listed repos are the same one: a repo id names one repo per host, not overall. */
|
||||
export function isSameMobileWorkspaceRepo(
|
||||
a: MobileNewWorkspaceDialogRepo,
|
||||
b: MobileNewWorkspaceDialogRepo
|
||||
): boolean {
|
||||
return a.id === b.id && getMobileRepoExecutionHostId(a) === getMobileRepoExecutionHostId(b)
|
||||
}
|
||||
|
||||
export function refreshMobileNewWorkspaceDialogSelectedRepo<T extends MobileNewWorkspaceDialogRepo>(
|
||||
repos: readonly T[],
|
||||
current: T | null
|
||||
): T | null {
|
||||
if (!current) {
|
||||
return null
|
||||
}
|
||||
return repos.find((repo) => repo.id === current.id) ?? null
|
||||
return repos.find((repo) => isSameMobileWorkspaceRepo(repo, current)) ?? null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The desktop's server-list feed loads react-native; its timer never runs without a subscriber.
|
||||
vi.mock('react-native', () => ({}))
|
||||
import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import { applyServerWorkspaces, NO_SERVER_WORKSPACES } from './server-workspaces'
|
||||
import type { Worktree } from './workspace-list-types'
|
||||
|
||||
const host: MobileRelayHost = {
|
||||
hostId: 'runtime:env-1',
|
||||
label: 'Box',
|
||||
health: 'available',
|
||||
relay: 'ready'
|
||||
}
|
||||
|
||||
function row(worktreeId: string): Worktree {
|
||||
return {
|
||||
worktreeId,
|
||||
repoId: 'repo-1',
|
||||
hostId: host.hostId,
|
||||
repo: 'orca',
|
||||
branch: 'main',
|
||||
displayName: worktreeId,
|
||||
path: `/srv/${worktreeId}`,
|
||||
liveTerminalCount: 0,
|
||||
hasAttachedPty: false,
|
||||
unread: false,
|
||||
isPinned: false,
|
||||
linkedPR: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyServerWorkspaces', () => {
|
||||
it('keeps the same hosts list when only the rows change', () => {
|
||||
const first = applyServerWorkspaces(NO_SERVER_WORKSPACES, { hosts: [host], rows: [[row('a')]] })
|
||||
const next = applyServerWorkspaces(first, {
|
||||
hosts: [{ ...host }],
|
||||
rows: [[row('a'), row('b')]]
|
||||
})
|
||||
expect(next.worktrees.map((worktree) => worktree.worktreeId)).toEqual(['a', 'b'])
|
||||
expect(next.hosts).toBe(first.hosts)
|
||||
const relabeled = applyServerWorkspaces(next, {
|
||||
hosts: [{ ...host, relay: 'unavailable' }],
|
||||
rows: [null]
|
||||
})
|
||||
expect(relabeled.hosts).not.toBe(first.hosts)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,22 @@ export function serverHostHealth(host: MobileRelayHost): ExecutionHostHealth {
|
||||
return host.relay === 'update-needed' ? 'blocked' : host.health
|
||||
}
|
||||
|
||||
/** `desktop`'s per-host values with each server's read from its feed entry. */
|
||||
export function withServerHosts<Value>(
|
||||
desktop: ReadonlyMap<ExecutionHostId, Value>,
|
||||
hosts: readonly MobileRelayHost[],
|
||||
read: (host: MobileRelayHost) => Value
|
||||
): ReadonlyMap<ExecutionHostId, Value> {
|
||||
if (hosts.length === 0) {
|
||||
return desktop
|
||||
}
|
||||
const merged = new Map(desktop)
|
||||
for (const host of hosts) {
|
||||
merged.set(host.hostId, read(host))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function serverRowSchema(hostId: ExecutionHostId) {
|
||||
return z.custom<Worktree>(
|
||||
(row) =>
|
||||
@@ -83,8 +99,11 @@ export function applyServerWorkspaces(
|
||||
(host, index) =>
|
||||
fetched.rows[index] ?? previous.worktrees.filter((row) => row.hostId === host.hostId)
|
||||
)
|
||||
const unchanged =
|
||||
JSON.stringify(fetched.hosts) === JSON.stringify(previous.hosts) &&
|
||||
areWorktreeListsEqual(worktrees, previous.worktrees)
|
||||
return unchanged ? previous : { hosts: fetched.hosts, worktrees }
|
||||
// Why kept: screens key per-server clients on `hosts`, which rows changing must not rebuild.
|
||||
const hosts =
|
||||
JSON.stringify(fetched.hosts) === JSON.stringify(previous.hosts)
|
||||
? previous.hosts
|
||||
: fetched.hosts
|
||||
const unchanged = hosts === previous.hosts && areWorktreeListsEqual(worktrees, previous.worktrees)
|
||||
return unchanged ? previous : { hosts, worktrees }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology'
|
||||
import { openPairedSocket, type PairedMobileSocket } from './helpers/paired-mobile-client'
|
||||
@@ -19,6 +20,11 @@ const WorktreeListSchema = z.object({
|
||||
worktrees: z.array(z.looseObject({ worktreeId: z.string(), path: z.string() }))
|
||||
})
|
||||
const CreatedTerminalSchema = z.object({ tab: z.looseObject({ terminal: z.string().nullable() }) })
|
||||
const CreatedTabSchema = z.object({ tab: z.looseObject({ id: z.string(), terminal: z.string() }) })
|
||||
const ReposSchema = z.object({
|
||||
repos: z.array(z.looseObject({ id: z.string(), path: z.string() }))
|
||||
})
|
||||
const ServerTabsSchema = z.looseObject({ tabs: z.array(z.looseObject({ id: z.string() })) })
|
||||
const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId: z.number() })
|
||||
const HostsSchema = z.object({
|
||||
hosts: z.array(z.looseObject({ hostId: z.string(), health: z.string(), relay: z.string() }))
|
||||
@@ -315,3 +321,118 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
/** The terminal handles the desktop window shows for a workspace (its remote pty ids end in one). */
|
||||
function desktopTerminalHandles(desktop: { page: Page }, worktreeId: string): Promise<string[]> {
|
||||
return desktop.page.evaluate(
|
||||
(id) =>
|
||||
(window.__store?.getState().tabsByWorktree[id] ?? []).map(
|
||||
(tab) => tab.ptyId?.split('@@').at(-1) ?? ''
|
||||
),
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
test('phone tab and workspace actions on a server show on the server and the desktop', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
const serverFolder = testInfo.outputPath('server-folder')
|
||||
mkdirSync(serverFolder, { recursive: true })
|
||||
const serverRepo = testInfo.outputPath('server-repo')
|
||||
mkdirSync(serverRepo, { recursive: true })
|
||||
writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n')
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' })
|
||||
git('init', '-b', 'main')
|
||||
git('add', 'README.md')
|
||||
git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init')
|
||||
const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology(
|
||||
{ phoneTo: 'desktop' },
|
||||
testInfo
|
||||
)
|
||||
try {
|
||||
await host.client.call('repo.add', { path: serverFolder, kind: 'folder' })
|
||||
await host.client.call('repo.add', { path: serverRepo, kind: 'git' })
|
||||
const socket = await phone.openSocket()
|
||||
const { hosts } = await call(
|
||||
socket,
|
||||
{ id: 'hosts', method: 'mobileRelay.hosts.list', params: {} },
|
||||
HostsSchema
|
||||
)
|
||||
const serverHostId = hosts[0]?.hostId ?? ''
|
||||
// The new-workspace sheet lists the server's repos there and creates on it.
|
||||
const { repos } = await call(
|
||||
socket,
|
||||
{ id: 'repos', method: 'repo.list', params: {}, executionHost: serverHostId },
|
||||
ReposSchema
|
||||
)
|
||||
const repoId = repos.find((repo) => repo.path === serverRepo)?.id ?? ''
|
||||
expect(repoId, 'the server lists its own repo for the phone').not.toBe('')
|
||||
await call(
|
||||
socket,
|
||||
{
|
||||
id: 'create-workspace',
|
||||
method: 'worktree.create',
|
||||
params: { repo: `id:${repoId}`, name: 'from-phone', setupDecision: 'skip' },
|
||||
executionHost: serverHostId
|
||||
},
|
||||
z.unknown()
|
||||
)
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await hostWorktrees(socket, serverHostId))?.worktrees.some((row) =>
|
||||
row.path.endsWith('from-phone')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
let worktreeId = ''
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const listed = await hostWorktrees(socket, serverHostId)
|
||||
worktreeId = listed?.worktrees.find((row) => row.path === serverFolder)?.worktreeId ?? ''
|
||||
return worktreeId
|
||||
})
|
||||
.not.toBe('')
|
||||
const worktree = `id:${worktreeId}`
|
||||
const serverTabs = async () =>
|
||||
ServerTabsSchema.parse((await host.client.call('session.tabs.list', { worktree })).result)
|
||||
.tabs
|
||||
const create = (id: string) =>
|
||||
call(
|
||||
socket,
|
||||
{
|
||||
id,
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: { worktree, activate: false, select: false, navigation: 'caller' },
|
||||
executionHost: serverHostId
|
||||
},
|
||||
CreatedTabSchema
|
||||
)
|
||||
const { tab: kept } = await create('create-kept')
|
||||
const { tab: closed } = await create('create-closed')
|
||||
expect((await serverTabs()).map((tab) => tab.id)).toEqual(
|
||||
expect.arrayContaining([kept.id, closed.id])
|
||||
)
|
||||
await expect
|
||||
.poll(() => desktopTerminalHandles(desktop, worktreeId), { timeout: 30_000 })
|
||||
.toEqual(expect.arrayContaining([kept.terminal, closed.terminal]))
|
||||
|
||||
await call(
|
||||
socket,
|
||||
{
|
||||
id: 'close',
|
||||
method: 'session.tabs.close',
|
||||
params: { worktree, tabId: closed.id, reason: 'user' },
|
||||
executionHost: serverHostId
|
||||
},
|
||||
z.unknown()
|
||||
)
|
||||
await expect
|
||||
.poll(async () => (await serverTabs()).map((tab) => tab.id), { timeout: 30_000 })
|
||||
.toEqual([kept.id])
|
||||
await expect
|
||||
.poll(() => desktopTerminalHandles(desktop, worktreeId), { timeout: 30_000 })
|
||||
.toEqual([kept.terminal])
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user