feat(mobile): add workspace source selection

This commit is contained in:
Jinjing
2026-07-11 14:24:47 -07:00
parent ff8192a331
commit 3769a70a31
44 changed files with 3381 additions and 190 deletions
@@ -0,0 +1,127 @@
# Mobile Workspace Source Selector
## Problem and goal
Desktop workspace creation already exposes source selection through `SmartWorkspaceNameField` (`src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx:90`). Mobile `NewWorktreeModal` renders only Repository, Workspace Name, and Agent (`mobile/src/components/NewWorktreeModal.tsx:699`) and its create payload sends none of the source fields already accepted by `worktree.create` (`mobile/src/components/NewWorktreeModal.tsx:608`; schema at `src/main/runtime/rpc/methods/worktree-schemas.ts:62`).
Add an optional native **Start from** field with the same naming, branch-reuse, PR start-point, and linkage semantics as desktop. Reuse the existing runtime RPCs and `src/shared` functions; do not copy renderer logic or extend the current mobile-only naming/branch mirrors.
## Non-goals
- GitLab or another provider, cross-repository URL switching, or source creation.
- Provider credential setup inside the create sheet.
- Desktop composer changes or a new source-search RPC/cache.
- Immediate consistency with provider mutations hidden by the runtime's existing caches.
## Verified contracts and constraints
- `github.listWorkItems` returns `{ items, sources, errors? }`. Empty-query reads use the existing GitHub cache (up to 120 seconds). Issue-side partial errors may accompany valid PR rows. An empty-query PR-side failure rejects the RPC, but a queried PR-side failure is currently logged and returned as a missing PR subset without error metadata.
- `linear.status` returns the current connection/workspace metadata. `linear.searchIssues` returns an array. `linear.listIssues` returns `{ items, errors?, hasMore? }` on current hosts and may be an array on older hosts. Linear search and git-ref search can convert host/provider failures to an empty result, so the client cannot always distinguish “no matches” from a backend failure.
- `repo.searchRefs` returns `refDetails: { refName, localBranchName }[]` plus legacy `refs`. Exact local-branch reuse requires `refDetails`; do not infer reuse from legacy string rows.
- `worktree.resolvePrBase` returns `baseBranch`, `compareBaseRef`, `branchNameOverride`, optional `pushTarget`, and optional `maintainerCanModify`, or `{ error }`. It is not a free lookup: it fetches git refs and may query GitHub. A client timeout does not cancel that host work.
- All six source/read RPC methods above are already registered and mobile-allowlisted. Literal `sendRequest` calls are discovered by `mobile-rpc-allowlist.test.ts`; no production allowlist edit is expected.
- `resolveComposerBranchSelection` is only the first branch step. Desktop also uses `isBranchCheckedOutInWorktrees`, `resolveComposerBranchReuse`, `resolveComposerReuseOverride`, and `resolveComposerBranchNameOverrideForCreate`.
- Mobile Metro already watches `src/shared`, so `workspace-name.ts`, `composer-branch-selection.ts`, `linear-links.ts`, and their pure dependencies are directly reusable.
## Design
1. Add a static `mobile.workspace-source-selector.v1` runtime capability and pass the host capability list into `NewWorktreeModal`. Hide the optional field until capability state is loaded; omit it on older hosts. This fails closed because older `worktree.create` schemas may silently strip unknown optional linkage fields.
2. Keep `NewWorktreeModal` as wiring. Put RPC normalization, source-to-create mapping, name ownership, and request lifecycle state in focused named modules; add a dedicated `NewWorkspaceSourceDrawer` built from `BottomDrawer`, `MobileSearchField`, and `FlatList`. Pass `contentScrollable={false}` so `FlatList` owns scrolling instead of nesting inside the drawer's `ScrollView`. The already-grandfathered modal must not grow materially.
3. Extract the desktop smart-source 2 KiB UTF-8 query guard into `src/shared` and consume it from desktop and mobile. It is a UI safety bound, not an RPC/API limit. Debounce non-empty search by 200 ms, pass a limit of 12 to each backend, and cap the rendered mixed list to 12 rows.
4. Show All, GitHub, Branches, and Linear filters. GitHub and Branches are unavailable for folder repositories or a disconnected SSH target; Linear remains valid because its linkage is not repo-scoped. Read `linear.status` when the modal opens so a Linear-connected folder/disconnected-SSH repo can expose the field, refresh it each time the drawer opens, and omit Linear when disconnected. While the modal is open, consume the existing `runtime.clientEvents.subscribe` `sshStateChanged` event for the selected target (with `ssh.getState` as initial state), invalidate repo-backed reads on every transition, and expose GitHub/Branches again only after `connected`; do not add polling. Hide the field entirely when the selected repo has no available source kind.
5. Normalize only known envelopes. Drop malformed rows, preserve GitHub/Linear partial-error metadata, and treat a missing/malformed top-level collection as an incompatible-host error rather than a valid empty list. Legacy `repo.searchRefs.refs` may be shown only as “branch from” rows: mark them unverified and force their create-time `branchNameOverride` to `undefined`, so a local-looking legacy string cannot accidentally reuse an existing branch.
6. Track name ownership explicitly as `blank | source | user`. A selection replaces only `blank` or `source`; every keyboard edit becomes `user`, even when its text equals the suggestion. Clearing a source clears only a `source` name. A repo change clears GitHub/branch selection and its source-owned name, but preserves a Linear selection and all user-owned names.
7. Resolve a PR before committing the row selection. Keep the drawer open, guard the row with an immediate in-flight ref, and show a delayed spinner. On success store the full resolver result; on failure keep the prior selection unchanged. Extract and reuse the desktop fork-warning predicate: warn only when `maintainerCanModify === false` and `pushTarget` exists with a non-`origin` remote.
8. For a verified eligible local branch, show the desktop-compatible **Reuse existing branch** toggle and use `resolveComposerBranchReuse` for its default. Treat `selectionProducedOverride` as true only when the branch selection actually took ownership of a `blank` or `source` name; a `user` name must stay user-owned even when it prefixes the selected ref. Hide reuse for remote, unverified legacy, or known-busy branches. Preserve an enabled exact override across workspace-name edits; disabling it creates a fresh branch from the selected ref.
9. Feed the existing mobile create path through one pure candidate builder. Preserve setup, trust, SSH, agent, note, timeout, and collision behavior. On retries, suffix the workspace name and a fresh-branch/PR override together; keep a verified reusable local branch exact. Hand `worktree.displayName` from the successful response to `onCreated`/navigation, falling back to the candidate name only if it is absent, so an auto-managed display name is visible immediately. The runtime remains authoritative if another window or external git process wins the race.
## Data flow
- `status.get` capabilities plus repo kind, SSH state, and modal-open `linear.status` determine whether the field and filters exist.
- Drawer input passes the shared UTF-8 bound and debounce, then issues the eligible RPCs concurrently; normalized, generation-scoped results become rows.
- A row passes through source mapping, explicit name ownership, PR resolution or branch-reuse resolution, then the pure candidate builder feeds the existing `worktree.create` loop. Only its success response confirms the final branch/workspace.
### Source mapping
| Source | Name semantics | `worktree.create` additions |
| --- | --- | --- |
| GitHub issue | `getLinkedWorkItemWorkspaceName`, falling back to `issue-<number>` | `linkedIssue`; auto-managed `displayName` |
| GitHub PR | Same naming with `pr-<number>` fallback; full `resolvePrBase` result | `linkedPR`, `baseBranch`, `compareBaseRef`, `branchNameOverride`, `pushTarget`; auto-managed `displayName` |
| Linear issue | `getLinearIssueWorkspaceName` seed; `getLinkedWorkItemWorkspaceName` display from the Linear identity | `linkedLinearIssue`, `linkedLinearIssueWorkspaceId`, `linkedLinearIssueOrganizationUrlKey`; auto-managed `displayName` |
| Branch/ref | Full shared composer branch-selection/reuse pipeline | `baseBranch` and the create-time `branchNameOverride` when applicable |
Use the GitHub identity fallback for both the seed and auto-managed display name when the title has no sluggable characters, matching `getSmartGitHubSubmitResolution`; do not fall through to the unrelated blank-name creature. Derive the Linear organization key with `getLinearOrganizationUrlKeyFromIssueUrl`. Omit `displayName` after a user-owned name edit, matching desktop. Pass the selected repo ID to every repo-scoped RPC; do not trust or reuse a row's stale repo object.
## Request lifecycle and consistency
- Scope every response to `{ client epoch, modal open epoch, repo id, filter, query sequence, selection sequence }`. Closing/reopening, reconnecting, changing repo/filter/query/SSH state, clearing/changing a selection, or replacing the RPC client invalidates all older responses and PR resolutions.
- Generation guards suppress state commits; they do not cancel GitHub, Linear, git, or SSH work. Do not describe them as cancellation.
- Use an immediate ref guard for row selection and Create so two taps in one React turn cannot issue duplicate requests.
- Re-read repositories when the modal opens. If a repo is removed later, let the create RPC fail authoritatively, clear the stale repo on refresh, and retain the user's manual name.
- Treat provider rows and PR resolution as snapshots. A PR force-push after resolution still creates from the fetched SHA; a title/status change may remain cached. Reopening/retrying may refresh but must not promise immediate provider consistency.
- Use the current `worktree.ps` branch snapshot to avoid offering reuse for a known-busy local branch. External/unregistered worktrees and concurrent checkouts can still race; never promise exact reuse until the created worktree response confirms its branch.
## Edge cases, errors, and latency
- No source selected: creation and blank-name creature fallback are unchanged.
- Empty query in All: recent open GitHub work items plus assigned Linear issues. Do not query refs. Empty query in Branches: show a bounded ref list so the filter is useful without typing.
- Oversized query: issue no provider/git RPC and show inline validation.
- A GitHub title containing only non-sluggable characters still selects and creates with the deterministic `issue-<number>`/`pr-<number>` identity fallback.
- GitHub issue-side partial failure: render successful rows with the warning. RPC/contract failure: render an inline retry action; auth/tooling copy must name the selected host because `gh` runs there. A queried PR-side failure can only look like a missing subset with today's RPC contract, so use honest result/empty copy rather than fabricating a retryable error.
- Linear disconnected: omit the filter and rows. A disconnect after opening may fail or return empty; refresh status on the next modal/drawer open.
- SSH target transition: an `sshStateChanged` event immediately invalidates in-flight repo-backed reads and removes GitHub/Branches until the selected target is connected again; Linear availability is unchanged.
- Linear search and `repo.searchRefs` can mask backend errors as empty with today's RPC contracts. Use honest “No results” copy and retain this as a residual risk; do not fabricate an actionable provider error.
- PR resolution failure: keep the drawer and prior selection, show the error inline, and never fall back to the repo default branch.
- Known busy local branch: branch from the selected ref with no reuse override. Known reusable local branch defaults to exact reuse only when the branch selection took ownership of a blank/source-owned name.
- Folder repo: offer connected Linear only and hide the field otherwise. GitHub PRs and branches require a git repo.
- Use explicit 30-second timeouts for provider/ref/PR reads and the existing long create timeout. Disable immediately; show visible loading only after about 200 ms and keep row/button geometry fixed.
## Performance and blast radius
Source searches are initiated only while the drawer or a selection resolution is active; the lightweight `linear.status` availability read also runs when the modal opens. Already-started host work may finish after close. No polling, client cache, per-row lookup, or app-startup request is added. Omit GitHub `noCache` during normal search to preserve the runtime cache and rate-limit budget.
All mode is not one call: a settled query issues up to three concurrent RPCs (GitHub, Linear, refs). Internally, GitHub can perform separate issue/PR reads, Linear can fan out across connected workspaces, and slash ref queries can run two git probes plus an SSH capability fallback. Use a development transport counter/timer to record exact RPC fanout and raw cold/warm settled-result timings for representative All and Branches queries on local and connected SSH repos; do not claim percentiles from ad hoc samples or claim the debounce/generation guard makes this work free.
## Test plan
- Unit: exact GitHub, Linear, and ref envelopes; partial errors; malformed top-level rejection; legacy refs never enabling reuse; UTF-8 query bound.
- Unit: every source mapping, non-sluggable GitHub title fallback, Linear workspace/org metadata, fork warning, known-busy/reusable branches, create-time override resolution, retry candidates, and explicit name ownership.
- Component: capability gate, folder/disconnected-SSH source availability, live `sshStateChanged` disconnect/reconnect, filters, empty/loading/partial/error states, debounce, clear, duplicate-tap guards, keyboard/back behavior, and stale responses across query/filter/repo/close/client reconnect.
- Integration: exact `worktree.create` payload and returned-display-name navigation handoff for each source, including Linear on a folder repo; PR resolver failure; repo removal; manual-name preservation; no-source regression; setup/agent/collision behavior.
- Contract: runtime status advertises the new capability; all literal mobile RPCs remain registered and allowlisted. No Git compatibility-matrix change is needed because this feature adds no git command.
- Validation: focused Vitest, mobile and repository typecheck/lint, max-lines ratchet, an Expo production bundle, paired phone/emulator checks against local and SSH repos, and the development-only RPC-count/raw-timing evidence above. Provider validation must not mutate an issue/PR/Linear item.
## UI quality bar
Match the Repository and Agent field hierarchy, mobile tokens, 44-point touch targets, accessibility labels, disabled treatment, and drawer row geometry. Keep the Create action dominant; provider/type icons are secondary and row text must carry the meaning. `BottomDrawer` owns keyboard/safe-area movement and Android back dismissal. Do not add colors, type sizes, or elevation tiers.
## Review screenshots
Required, uncommitted device/emulator screenshots:
1. Create Workspace with the empty optional field at phone width.
2. Search drawer with keyboard visible, filters, and a mixed authenticated result set (or the honest available subset).
3. Collapsed selected PR or branch with its auto-owned name and any fork warning.
4. Deterministic oversized-query validation, or an available live provider partial/error state, with stable drawer geometry.
5. Adjacent Repository/Agent picker smoke showing correct top-drawer layering and a reachable Create action.
Manual-name preservation, stale suppression, exact payloads, and branch reuse are behavioral claims; prove them with tests/validation notes, not static screenshots.
## Rollout
1. Add the runtime capability and shared query bound, then cover mixed-version gating.
2. Add and test normalized source RPC, selection, name-ownership, branch-reuse, and create-candidate modules.
3. Add and test the native source drawer.
4. Wire the field, host capabilities, worktree branch snapshot, and source payload into the modal without materially growing the grandfathered file.
5. Run focused tests, typecheck, lint, max-lines ratchet, production bundle, performance audit, and paired mobile/Electron validation.
## Lightweight Eng Review
- **Scope:** one mobile field/drawer, pure shared/client modules, and one capability string; no provider mutation or new search backend.
- **Architecture/data flow:** native presentation owns transient state; runtime RPC owns host/provider/git work; `src/shared` owns naming and branch semantics; the runtime owns final conflict resolution.
- **Failure modes covered:** mixed versions, malformed/partial envelopes, auth/network loss, stale async work, repo removal, PR resolution, duplicate taps, and external branch races.
- **Test coverage required:** unit normalization/mapping/ownership/retry tests, component lifecycle/availability/interaction tests, exact-payload integration tests, and runtime capability/allowlist contracts.
- **Performance/blast radius:** bounded visible work with no polling, but All mode and PR resolution are multi-operation and must be measured locally and over SSH.
- **UI quality bar:** match adjacent mobile fields/drawers and `docs/STYLEGUIDE.md`; preserve touch targets, stable loading/error geometry, keyboard movement, and Create-action hierarchy.
- **Required review screenshots:** empty field, mixed search with keyboard, selected source, inline error/partial state, and adjacent-picker layering.
- **Residual risks:** Linear search/ref RPCs and queried GitHub PR reads can mask backend failures as missing results; GitHub results may be cached; provider and git state can change after selection.
+8 -3
View File
@@ -175,7 +175,7 @@ export function HostScreen({
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [hostCapabilities, setHostCapabilities] = useState<string[]>([])
const [hostCapabilities, setHostCapabilities] = useState<string[] | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [routeActionState, setRouteActionState] = useState(() =>
@@ -510,7 +510,7 @@ export function HostScreen({
// Why: drop the prior host's capabilities while disconnected/switching so
// a capability-gated action (e.g. Agent Session History) can't linger for
// a host that doesn't support it.
setHostCapabilities([])
setHostCapabilities(null)
return
}
let cancelled = false
@@ -1362,7 +1362,7 @@ export function HostScreen({
hostId,
worktreeId: actionTarget.worktreeId,
worktreeName: actionTarget.displayName || actionTarget.repo,
hostCapabilities,
hostCapabilities: hostCapabilities ?? [],
navigate: navigateFromHostList,
onDone: () => setActionTarget(null)
}),
@@ -1417,6 +1417,11 @@ export function HostScreen({
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktreeBranches={displayWorktrees.map((worktree) => ({
repoId: worktree.repoId,
branch: worktree.branch
}))}
hostCapabilities={hostCapabilities}
onVisibleChange={(visible) => {
newWorktreeModalVisibleRef.current = visible
}}
+8 -2
View File
@@ -1,10 +1,12 @@
import type { WebSocket } from 'ws'
import {
DESKTOP_PROTOCOL_VERSION,
MIN_COMPATIBLE_MOBILE_VERSION
MIN_COMPATIBLE_MOBILE_VERSION,
MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY
} from '../../src/shared/protocol-version'
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockWorkspaceSourceRequest } from './mock-server-workspace-source'
import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures'
import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario'
@@ -103,6 +105,9 @@ export function handleRequest(
if (handleMockFilePreviewRequest(request, respond, success, error)) {
return
}
if (handleMockWorkspaceSourceRequest(request, respond, success, FAKE_REPOS[0]?.id ?? 'repo-1')) {
return
}
switch (request.method) {
case 'status.get':
@@ -114,7 +119,8 @@ export function handleRequest(
graphStatus: 'ready',
windowCount: 1,
tabCount: 2,
terminalCount: 2
terminalCount: 2,
capabilities: [MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY]
})
)
break
@@ -0,0 +1,90 @@
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
type Respond = (response: RpcResponse) => void
type Success = (id: string, result: unknown) => RpcResponse
function repoSelectorToId(repoSelector: unknown): string | null {
if (typeof repoSelector !== 'string') {
return null
}
return repoSelector.startsWith('id:') ? repoSelector.slice(3) : repoSelector
}
export function handleMockWorkspaceSourceRequest(
request: RpcRequest,
respond: Respond,
success: Success,
defaultRepoId: string
): boolean {
if (request.method === 'linear.status') {
respond(success(request.id, { connected: true, selectedWorkspaceId: 'workspace-mock' }))
return true
}
if (request.method === 'github.listWorkItems') {
const repoId = repoSelectorToId(request.params?.repo) ?? defaultRepoId
respond(
success(request.id, {
items: [
{
id: 'mock-issue-42',
type: 'issue',
number: 42,
title: 'Improve mobile workspace creation',
url: 'https://github.com/stablyai/orca/issues/42',
repoId
},
{
id: 'mock-pr-77',
type: 'pr',
number: 77,
title: 'Add source selector',
url: 'https://github.com/stablyai/orca/pull/77',
branchName: 'feature/source-selector',
baseRefName: 'main',
repoId
}
],
sources: { issues: null, prs: null, originCandidate: null, upstreamCandidate: null }
})
)
return true
}
if (request.method === 'linear.listIssues' || request.method === 'linear.searchIssues') {
respond(
success(request.id, [
{
id: 'linear-mock-1',
workspaceId: 'workspace-mock',
identifier: 'ENG-123',
title: 'Polish mobile source search',
url: 'https://linear.app/acme/issue/ENG-123/polish-mobile-source-search'
}
])
)
return true
}
if (request.method === 'repo.searchRefs') {
respond(
success(request.id, {
refDetails: [
{ refName: 'feature/mobile-ready', localBranchName: 'feature/mobile-ready' },
{ refName: 'origin/main', localBranchName: 'main' }
],
refs: ['feature/mobile-ready', 'origin/main']
})
)
return true
}
if (request.method === 'worktree.resolvePrBase') {
respond(
success(request.id, {
baseBranch: 'refs/pull/77/head',
compareBaseRef: 'origin/main',
branchNameOverride: 'feature/source-selector',
maintainerCanModify: true
})
)
return true
}
return false
}
+126
View File
@@ -0,0 +1,126 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
const animation = vi.hoisted(() => ({
timingCallbacks: [] as Array<((finished: boolean) => void) | undefined>
}))
vi.mock('react-native', () => ({
BackHandler: { addEventListener: () => ({ remove: vi.fn() }) },
Keyboard: { addListener: () => ({ remove: vi.fn() }), dismiss: vi.fn() },
Modal: 'Modal',
Platform: {
OS: 'ios',
select: (options: { ios?: unknown; default?: unknown }) => options.ios ?? options.default
},
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: {
absoluteFill: {},
absoluteFillObject: {},
create: (styles: unknown) => styles
},
View: 'View',
useWindowDimensions: () => ({ height: 800, width: 400 })
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
function chainableGesture() {
const gesture: Record<string, (...args: unknown[]) => unknown> = {}
for (const method of [
'activeOffsetY',
'onBegin',
'onEnd',
'onUpdate',
'simultaneousWithExternalGesture'
]) {
gesture[method] = () => gesture
}
return gesture
}
vi.mock('react-native-gesture-handler', () => ({
Gesture: { Native: chainableGesture, Pan: chainableGesture },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}))
vi.mock('react-native-reanimated', () => ({
default: { ScrollView: 'AnimatedScrollView', View: 'AnimatedView' },
Extrapolation: { CLAMP: 'clamp' },
interpolate: (_value: number, _input: number[], output: number[]) => output[1],
runOnJS: (fn: () => void) => fn,
useAnimatedScrollHandler: () => vi.fn(),
useAnimatedStyle: (factory: () => unknown) => factory(),
useSharedValue: (value: number | boolean) => ({ value }),
withSpring: (value: number) => value,
withTiming: (value: number, _config: unknown, callback?: (finished: boolean) => void) => {
animation.timingCallbacks.push(callback)
return value
}
}))
vi.mock('../layout/responsive-layout', () => ({
useResponsiveLayout: () => ({ isWideLayout: false, modalMaxWidth: 640 })
}))
import { BottomDrawer } from './BottomDrawer'
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
describe('BottomDrawer exit animation', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
animation.timingCallbacks = []
vi.restoreAllMocks()
})
it('reports hidden only after iOS dismisses the native modal', () => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const onHidden = vi.fn()
const renderDrawer = (visible: boolean, label: string) =>
createElement(BottomDrawer, { visible, onClose: vi.fn(), onHidden }, label)
const restoreWarning = suppressRendererWarning()
try {
act(() => {
renderer = create(renderDrawer(true, 'source picker'))
})
} finally {
restoreWarning()
}
animation.timingCallbacks = []
act(() => {
renderer?.update(renderDrawer(false, 'source picker'))
})
act(() => {
renderer?.update(renderDrawer(false, 'source picker after parent refresh'))
})
const exitCallbacks = animation.timingCallbacks.filter(Boolean)
expect(exitCallbacks).toHaveLength(1)
act(() => exitCallbacks[0]?.(true))
expect(renderer?.root.findByType('Modal').props.visible).toBe(false)
expect(onHidden).not.toHaveBeenCalled()
act(() => renderer?.root.findByType('Modal').props.onDismiss())
expect(renderer?.root.findAllByType('Modal')).toHaveLength(0)
expect(onHidden).toHaveBeenCalledTimes(1)
})
})
+28 -7
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import {
View,
Pressable,
@@ -24,6 +24,7 @@ import Animated, {
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { useBottomDrawerNativeVisibility } from './use-bottom-drawer-native-visibility'
import { useResponsiveLayout } from '../layout/responsive-layout'
const DISMISS_THRESHOLD = 80
@@ -39,6 +40,7 @@ const TOP_SCROLL_EPSILON = 1
type Props = {
visible: boolean
onClose: () => void
onHidden?: () => void
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
@@ -48,6 +50,7 @@ type Props = {
export function BottomDrawer({
visible,
onClose,
onHidden,
children,
dragContentToDismiss = true,
contentScrollable = true,
@@ -55,6 +58,12 @@ export function BottomDrawer({
}: Props) {
const [mounted, setMounted] = useState(visible)
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
const onHiddenRef = useRef(onHidden)
onHiddenRef.current = onHidden
const handleHidden = useCallback(() => {
setMounted(false)
onHiddenRef.current?.()
}, [])
// Why: opening drawers should mount before commit; waiting for a passive
// Effect adds a null render before every drawer can animate in.
@@ -72,7 +81,7 @@ export function BottomDrawer({
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => setMounted(false)}
onHidden={handleHidden}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
zIndex={zIndex}
@@ -83,7 +92,7 @@ export function BottomDrawer({
}
type MountedBottomDrawerProps = Props & {
onHidden: () => void
onHidden: NonNullable<Props['onHidden']>
}
function MountedBottomDrawer({
@@ -103,6 +112,10 @@ function MountedBottomDrawer({
const contentDragCanDismiss = useSharedValue(false)
const { height: screenHeight } = useWindowDimensions()
const insets = useSafeAreaInsets()
const { nativeVisible, hideNativeModal, reportHidden } = useBottomDrawerNativeVisibility(
visible,
onHidden
)
// Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
@@ -117,11 +130,11 @@ function MountedBottomDrawer({
Keyboard.dismiss()
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
if (finished) {
runOnJS(onHidden)()
runOnJS(hideNativeModal)()
}
})
}
}, [onHidden, visible])
}, [hideNativeModal, visible])
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
@@ -271,9 +284,17 @@ function MountedBottomDrawer({
// the scrolled content and clips the sheet. The Modal stays mounted (visible)
// for the whole life of MountedBottomDrawer so the reanimated exit animation
// runs before the parent unmounts us; show/hide is driven by `progress`, so
// animationType stays "none". onRequestClose handles the Android back button.
// animationType stays "none". On iOS, onDismiss is the ownership boundary:
// UIKit must finish dismissing this controller before a sibling can present.
return (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
<Modal
visible={nativeVisible}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={dismiss}
onDismiss={reportHidden}
>
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
@@ -0,0 +1,303 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { NewWorkspaceSourceDrawer } from './NewWorkspaceSourceDrawer'
const sourceRpc = vi.hoisted(() => ({
searchWorkspaceSources: vi.fn(),
resolveWorkspaceSourcePr: vi.fn()
}))
vi.mock('../workspace-source/workspace-source-rpc', () => ({
NEW_WORKSPACE_SOURCE_RESULT_LIMIT: 12,
resolveWorkspaceSourcePr: sourceRpc.resolveWorkspaceSourcePr,
searchWorkspaceSources: sourceRpc.searchWorkspaceSources
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: 'FlatList',
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
CircleDot: 'CircleDot',
GitBranch: 'GitBranch',
GitPullRequest: 'GitPullRequest',
ListTodo: 'ListTodo'
}))
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
vi.mock('./MobileSearchField', () => ({ MobileSearchField: 'MobileSearchField' }))
const client = { sendRequest: vi.fn() } as unknown as RpcClient
const initialAvailability = { github: true, branches: true, linear: false }
const initialResult = {
rows: [
{
kind: 'github',
key: 'github:issue:1',
item: {
id: 'issue-1',
type: 'issue',
number: 1,
title: 'First issue',
url: 'https://github.com/acme/app/issues/1',
repoId: 'repo-1',
state: 'open',
labels: [],
updatedAt: '',
author: null
}
}
],
warnings: [],
errors: []
}
const prResult = {
...initialResult,
rows: [
{
kind: 'github',
key: 'github:pr:2',
item: {
...initialResult.rows[0]!.item,
id: 'pr-2',
type: 'pr',
number: 2,
title: 'Second PR',
url: 'https://github.com/acme/app/pull/2'
}
}
]
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
function drawerProps(
overrides: Partial<Parameters<typeof NewWorkspaceSourceDrawer>[0]> = {}
): Parameters<typeof NewWorkspaceSourceDrawer>[0] {
return {
visible: true,
client,
repoId: 'repo-1',
availability: initialAvailability,
sshStateGeneration: 0,
name: { value: '', owner: 'blank' },
worktreeBranches: [],
onSelect: vi.fn(),
onClose: vi.fn(),
onOpen: vi.fn(),
...overrides
}
}
async function mountDrawer(
overrides: Partial<Parameters<typeof NewWorkspaceSourceDrawer>[0]> = {}
): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(NewWorkspaceSourceDrawer, drawerProps(overrides)))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('workspace source drawer did not render')
}
return renderer
}
function renderedRows(renderer: ReactTestRenderer): unknown[] {
return renderer.root.findByType('FlatList').props.data as unknown[]
}
describe('NewWorkspaceSourceDrawer result generations', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
sourceRpc.searchWorkspaceSources.mockReset()
sourceRpc.resolveWorkspaceSourcePr.mockReset()
sourceRpc.searchWorkspaceSources
.mockResolvedValueOnce(initialResult)
.mockReturnValue(new Promise(() => {}))
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
vi.useRealTimers()
})
it('removes prior rows immediately when the query enters debounce', async () => {
renderer = await mountDrawer()
expect(renderedRows(renderer)).toHaveLength(1)
act(() => {
renderer?.root.findByType('MobileSearchField').props.onChangeText('second')
})
expect(renderedRows(renderer)).toEqual([])
})
it('removes prior rows immediately when the filter changes', async () => {
renderer = await mountDrawer()
const githubFilter = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityLabel === 'GitHub source filter')
act(() => githubFilter?.props.onPress())
expect(renderedRows(renderer)).toEqual([])
})
it('removes prior rows immediately when source availability changes', async () => {
renderer = await mountDrawer()
act(() => {
renderer?.update(
createElement(
NewWorkspaceSourceDrawer,
drawerProps({ availability: { github: false, branches: false, linear: true } })
)
)
})
expect(renderedRows(renderer)).toEqual([])
})
it('ignores search results from before a same-availability SSH transition', async () => {
let resolveStaleSearch: ((value: typeof initialResult) => void) | null = null
sourceRpc.searchWorkspaceSources
.mockReset()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStaleSearch = resolve
})
)
.mockReturnValueOnce(new Promise(() => {}))
renderer = await mountDrawer()
act(() => {
renderer?.update(
createElement(NewWorkspaceSourceDrawer, drawerProps({ sshStateGeneration: 1 }))
)
})
await act(async () => {
resolveStaleSearch?.(initialResult)
await Promise.resolve()
})
expect(sourceRpc.searchWorkspaceSources).toHaveBeenCalledTimes(2)
expect(renderedRows(renderer)).toEqual([])
})
it('ignores PR resolution from before a same-availability SSH transition', async () => {
sourceRpc.searchWorkspaceSources.mockReset().mockResolvedValue(prResult)
let resolveStalePr: ((value: { baseBranch: string; compareBaseRef: string }) => void) | null =
null
sourceRpc.resolveWorkspaceSourcePr.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStalePr = resolve
})
)
const onSelect = vi.fn()
renderer = await mountDrawer({ onSelect })
act(() => {
renderer!.root
.findByType('FlatList')
.props.renderItem({ item: prResult.rows[0] })
.props.onPress()
})
act(() => {
renderer?.update(
createElement(NewWorkspaceSourceDrawer, drawerProps({ onSelect, sshStateGeneration: 1 }))
)
})
await act(async () => {
resolveStalePr?.({ baseBranch: 'main', compareBaseRef: 'origin/main' })
await Promise.resolve()
})
expect(onSelect).not.toHaveBeenCalled()
})
it('releases PR selection ownership when closed and keeps a reopened request isolated', async () => {
vi.useFakeTimers()
sourceRpc.searchWorkspaceSources.mockReset().mockResolvedValue(prResult)
let resolveFirst: ((value: { baseBranch: string; compareBaseRef: string }) => void) | null =
null
sourceRpc.resolveWorkspaceSourcePr
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
})
)
.mockReturnValueOnce(new Promise(() => {}))
renderer = await mountDrawer()
const renderRow = () =>
renderer!.root.findByType('FlatList').props.renderItem({ item: prResult.rows[0] })
const pressRenderedRow = (): void => {
renderRow().props.onPress()
}
act(pressRenderedRow)
const pendingRow = renderRow()
expect(pendingRow.props.disabled).toBe(true)
expect(pendingRow.props.accessibilityState).toEqual({ disabled: true })
expect(pendingRow.props.children[2].props.children).toBeNull()
await act(async () => vi.advanceTimersByTimeAsync(199))
expect(renderRow().props.children[2].props.children).toBeNull()
await act(async () => vi.advanceTimersByTimeAsync(1))
expect(renderRow().props.children[2].props.children.type).toBe('ActivityIndicator')
act(() =>
renderer?.update(createElement(NewWorkspaceSourceDrawer, drawerProps({ visible: false })))
)
await act(async () => {
renderer?.update(createElement(NewWorkspaceSourceDrawer, drawerProps()))
await Promise.resolve()
await Promise.resolve()
})
expect(
renderer.root.findByType('FlatList').props.renderItem({ item: prResult.rows[0] }).props
.disabled
).toBe(false)
act(pressRenderedRow)
expect(sourceRpc.resolveWorkspaceSourcePr).toHaveBeenCalledTimes(2)
act(() => resolveFirst?.({ baseBranch: 'main', compareBaseRef: 'origin/main' }))
await act(async () => {
await Promise.resolve()
})
act(pressRenderedRow)
expect(sourceRpc.resolveWorkspaceSourcePr).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,418 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native'
import { CircleDot, GitBranch, GitPullRequest, ListTodo } from 'lucide-react-native'
import { isWorkspaceSourceQueryWithinLimit } from '../../../src/shared/workspace-source-query'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import type {
NewWorkspaceSourceFilter,
NewWorkspaceSourceRow,
ResolvedNewWorkspaceSource,
WorkspaceSourceAvailability
} from '../workspace-source/new-workspace-source-types'
import {
NEW_WORKSPACE_SOURCE_RESULT_LIMIT,
resolveWorkspaceSourcePr,
searchWorkspaceSources
} from '../workspace-source/workspace-source-rpc'
import {
resolveBranchWorkspaceSource,
resolveGitHubWorkspaceSource,
resolveLinearWorkspaceSource
} from '../workspace-source/workspace-source-selection'
import type { WorkspaceNameState } from '../workspace-source/workspace-source-name-state'
import { getAvailableWorkspaceSourceFilters } from '../workspace-source/workspace-source-availability'
import { BottomDrawer } from './BottomDrawer'
import { MobileSearchField } from './MobileSearchField'
import {
workspaceSourceAccessibilityLabel,
workspaceSourceFilterLabel,
workspaceSourceSubtitle,
workspaceSourceTitle
} from './new-workspace-source-drawer-presentation'
const SEARCH_DEBOUNCE_MS = 200
const VISIBLE_LOADING_DELAY_MS = 200
const SOURCE_LIST_HEIGHT = 250
type Props = {
visible: boolean
client: RpcClient | null
repoId: string
availability: WorkspaceSourceAvailability
sshStateGeneration: number
name: WorkspaceNameState
worktreeBranches: readonly string[]
onSelect: (source: ResolvedNewWorkspaceSource) => void
onClose: () => void
onHidden?: () => void
onOpen: () => void
}
export function NewWorkspaceSourceDrawer({
visible,
client,
repoId,
availability,
sshStateGeneration,
name,
worktreeBranches,
onSelect,
onClose,
onHidden,
onOpen
}: Props) {
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<NewWorkspaceSourceFilter>('all')
const [rows, setRows] = useState<NewWorkspaceSourceRow[]>([])
const [warnings, setWarnings] = useState<string[]>([])
const [errors, setErrors] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [showLoading, setShowLoading] = useState(false)
const [retryKey, setRetryKey] = useState(0)
const [selectionError, setSelectionError] = useState('')
// Why: selection disables immediately, while visible progress stays delayed for fast hosts.
const [selectionPending, setSelectionPending] = useState(false)
const [resolvingKey, setResolvingKey] = useState<string | null>(null)
const generationRef = useRef(0)
const selectionEpochRef = useRef(0)
const selectionInFlightRef = useRef(false)
const filters = useMemo(() => getAvailableWorkspaceSourceFilters(availability), [availability])
useEffect(() => {
if (visible) {
onOpen()
}
}, [onOpen, visible])
useEffect(
() => () => {
generationRef.current += 1
selectionEpochRef.current += 1
selectionInFlightRef.current = false
},
[]
)
useEffect(() => {
if (!filters.includes(filter)) {
setFilter('all')
}
}, [filter, filters])
useEffect(() => {
const generation = ++generationRef.current
// Why: stale host work must not retain request-owned selection UI.
selectionEpochRef.current += 1
selectionInFlightRef.current = false
setSelectionPending(false)
setResolvingKey(null)
setSelectionError('')
// Why: rows belong to the query/filter/availability generation that loaded them;
// retaining them during debounce would leave stale sources selectable.
setRows([])
setWarnings([])
setErrors([])
if (!visible || !client || !isWorkspaceSourceQueryWithinLimit(query)) {
setLoading(false)
setShowLoading(false)
return
}
setLoading(true)
setShowLoading(false)
let requestTimer: ReturnType<typeof setTimeout> | null = null
const loadingTimer = setTimeout(() => {
if (generation === generationRef.current) {
setShowLoading(true)
}
}, VISIBLE_LOADING_DELAY_MS)
const run = () => {
void searchWorkspaceSources({
client,
repoId,
query,
filter,
availability
})
.then((result) => {
if (generation !== generationRef.current) {
return
}
setRows(result.rows)
setWarnings(result.warnings)
setErrors(result.errors)
})
.finally(() => {
if (generation === generationRef.current) {
setLoading(false)
setShowLoading(false)
}
})
}
if (query.trim()) {
requestTimer = setTimeout(run, SEARCH_DEBOUNCE_MS)
} else {
run()
}
return () => {
clearTimeout(loadingTimer)
if (requestTimer) {
clearTimeout(requestTimer)
}
}
}, [availability, client, filter, query, repoId, retryKey, sshStateGeneration, visible])
const selectRow = useCallback(
async (row: NewWorkspaceSourceRow): Promise<void> => {
if (selectionInFlightRef.current) {
return
}
selectionInFlightRef.current = true
generationRef.current += 1
const selectionEpoch = ++selectionEpochRef.current
setSelectionPending(true)
setSelectionError('')
let spinnerTimer: ReturnType<typeof setTimeout> | null = null
try {
if (row.kind === 'github') {
if (row.item.type === 'pr') {
spinnerTimer = setTimeout(() => {
if (selectionEpoch === selectionEpochRef.current) {
setResolvingKey(row.key)
}
}, VISIBLE_LOADING_DELAY_MS)
const startPoint = await resolveWorkspaceSourcePr({
client: client!,
repoId,
item: row.item
})
if (selectionEpoch !== selectionEpochRef.current) {
return
}
onSelect(resolveGitHubWorkspaceSource(row.item, startPoint))
} else {
onSelect(resolveGitHubWorkspaceSource(row.item))
}
} else if (row.kind === 'linear') {
onSelect(resolveLinearWorkspaceSource(row.issue))
} else {
onSelect(
resolveBranchWorkspaceSource({
...row,
name,
worktreeBranches
})
)
}
} catch (error) {
if (selectionEpoch === selectionEpochRef.current) {
setSelectionError(
error instanceof Error ? error.message : 'Failed to resolve the selected source.'
)
}
} finally {
if (spinnerTimer) {
clearTimeout(spinnerTimer)
}
// Why: a stale completion must not release guards owned by a newer tap.
if (selectionEpoch === selectionEpochRef.current) {
selectionInFlightRef.current = false
setSelectionPending(false)
setResolvingKey(null)
}
}
},
[client, name, onSelect, repoId, worktreeBranches]
)
const oversized = !isWorkspaceSourceQueryWithinLimit(query)
const message = oversized
? 'Search must be 2 KB or less.'
: selectionError || errors[0] || warnings[0] || ''
const emptyCopy = query.trim() ? 'No results.' : 'No recent sources.'
return (
<BottomDrawer visible={visible} onClose={onClose} onHidden={onHidden} contentScrollable={false}>
<View style={styles.header}>
<Text style={styles.title}>Start from</Text>
<Text style={styles.subtitle}>Choose an issue, pull request, or branch.</Text>
</View>
<MobileSearchField
value={query}
onChangeText={setQuery}
placeholder="Search sources"
autoFocus
focusKey={visible}
accessibilityLabel="Search workspace sources"
/>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filters}
keyboardShouldPersistTaps="handled"
>
{filters.map((item) => (
<Pressable
key={item}
accessibilityRole="button"
accessibilityState={{ selected: filter === item }}
accessibilityLabel={`${workspaceSourceFilterLabel(item)} source filter`}
style={[styles.filter, filter === item && styles.filterSelected]}
onPress={() => setFilter(item)}
>
<Text style={[styles.filterText, filter === item && styles.filterTextSelected]}>
{workspaceSourceFilterLabel(item)}
</Text>
</Pressable>
))}
</ScrollView>
<View style={styles.statusSlot}>
{message ? (
<View style={styles.statusRow}>
<Text
style={[
styles.statusText,
(oversized || errors.length > 0 || selectionError) && styles.errorText
]}
numberOfLines={2}
>
{message}
</Text>
{!oversized && errors.length > 0 ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry source search"
onPress={() => setRetryKey((value) => value + 1)}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
<FlatList
data={oversized ? [] : rows.slice(0, NEW_WORKSPACE_SOURCE_RESULT_LIMIT)}
keyExtractor={(row) => row.key}
style={styles.list}
contentContainerStyle={rows.length === 0 ? styles.emptyList : undefined}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
nestedScrollEnabled
ItemSeparatorComponent={SourceSeparator}
ListEmptyComponent={
<View style={styles.emptyState}>
{showLoading ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : !loading && !oversized && !errors.length ? (
<Text style={styles.emptyText}>{emptyCopy}</Text>
) : null}
</View>
}
renderItem={({ item }) => (
<Pressable
accessibilityRole="button"
accessibilityLabel={workspaceSourceAccessibilityLabel(item)}
accessibilityState={{ disabled: selectionPending }}
disabled={selectionPending}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void selectRow(item)}
>
<SourceIcon row={item} />
<View style={styles.rowCopy}>
<Text style={styles.rowTitle} numberOfLines={1}>
{workspaceSourceTitle(item)}
</Text>
<Text style={styles.rowSubtitle} numberOfLines={1}>
{workspaceSourceSubtitle(item)}
</Text>
</View>
<View style={styles.trailing}>
{resolvingKey === item.key ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</View>
</Pressable>
)}
/>
</BottomDrawer>
)
}
function SourceIcon({ row }: { row: NewWorkspaceSourceRow }) {
const props = { size: 16, color: colors.textSecondary, strokeWidth: 2.1 }
return row.kind === 'branch' ? (
<GitBranch {...props} />
) : row.kind === 'linear' ? (
<ListTodo {...props} />
) : row.item.type === 'pr' ? (
<GitPullRequest {...props} />
) : (
<CircleDot {...props} />
)
}
function SourceSeparator() {
return <View style={styles.separator} />
}
const styles = StyleSheet.create({
header: { paddingHorizontal: spacing.xs, marginBottom: spacing.md },
title: { fontSize: 15, fontWeight: '600', color: colors.textPrimary },
subtitle: { fontSize: 13, color: colors.textMuted, marginTop: 2 },
filters: { gap: spacing.xs, paddingVertical: spacing.sm },
filter: {
minHeight: 44,
justifyContent: 'center',
paddingHorizontal: spacing.md,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
filterSelected: { backgroundColor: colors.bgRaised, borderColor: colors.textSecondary },
filterText: { fontSize: 12, color: colors.textSecondary },
filterTextSelected: { color: colors.textPrimary, fontWeight: '600' },
statusSlot: { minHeight: 34, justifyContent: 'center' },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
statusText: { flex: 1, fontSize: 12, color: colors.textSecondary },
errorText: { color: colors.statusRed },
retryText: { fontSize: 12, fontWeight: '600', color: colors.textPrimary, padding: spacing.xs },
list: {
flexGrow: 0,
height: SOURCE_LIST_HEIGHT,
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
overflow: 'hidden'
},
emptyList: { flexGrow: 1 },
emptyState: {
minHeight: SOURCE_LIST_HEIGHT,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: { fontSize: typography.bodySize, color: colors.textSecondary },
row: {
minHeight: 58,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.md
},
rowPressed: { backgroundColor: colors.bgRaised },
rowCopy: { flex: 1, minWidth: 0 },
rowTitle: { fontSize: typography.bodySize, color: colors.textPrimary },
rowSubtitle: { fontSize: 12, color: colors.textMuted, marginTop: 2 },
trailing: { width: 20, height: 20, alignItems: 'center', justifyContent: 'center' },
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
@@ -0,0 +1,143 @@
import { Pressable, StyleSheet, Switch, Text, View } from 'react-native'
import { ChevronDown, CircleDot, GitBranch, GitPullRequest, ListTodo, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { NewWorkspaceSourceController } from '../workspace-source/use-new-workspace-source'
type Props = {
controller: NewWorkspaceSourceController
onPrepareOpen: () => void
}
export function NewWorkspaceSourceField({ controller, onPrepareOpen }: Props) {
if (!controller.fieldVisible) {
return null
}
const source = controller.source
return (
<View style={styles.field}>
<Text style={styles.label}>
Start from <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={
source ? `Start from ${sourceLabel(source)}` : 'Choose a workspace source'
}
style={styles.fieldButton}
onPress={() => {
onPrepareOpen()
controller.setDrawerVisible(true)
}}
>
<SourceIcon source={source} />
<Text style={[styles.fieldButtonText, !source && styles.placeholder]} numberOfLines={1}>
{source ? sourceLabel(source) : 'Default branch'}
</Text>
{source ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Clear workspace source"
hitSlop={10}
style={styles.clearButton}
onPress={(event) => {
event.stopPropagation()
controller.clearSource()
}}
>
<X size={15} color={colors.textSecondary} />
</Pressable>
) : (
<ChevronDown size={14} color={colors.textMuted} />
)}
</Pressable>
{source?.kind === 'branch' && source.reuseEligibleBranch ? (
<View style={styles.reuseRow}>
<View style={styles.reuseCopy}>
<Text style={styles.reuseTitle}>Reuse existing branch</Text>
<Text style={styles.reuseSubtitle} numberOfLines={1}>
Check out {source.reuseEligibleBranch} without creating a new branch.
</Text>
</View>
<Switch
accessibilityLabel="Reuse existing branch"
value={source.reuseEnabled}
onValueChange={controller.setReuseEnabled}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
) : null}
{source?.kind === 'github' && source.forkWarning ? (
<Text style={styles.warning}>{source.forkWarning}</Text>
) : null}
</View>
)
}
function sourceLabel(source: NonNullable<NewWorkspaceSourceController['source']>): string {
return source.kind === 'github'
? `${source.item.type === 'pr' ? 'PR' : 'Issue'} #${source.item.number}: ${source.item.title}`
: source.kind === 'linear'
? `${source.issue.identifier}: ${source.issue.title}`
: source.refName
}
function SourceIcon({ source }: { source: NewWorkspaceSourceController['source'] }) {
const props = { size: 15, color: colors.textSecondary, strokeWidth: 2.1 }
return !source || source.kind === 'branch' ? (
<GitBranch {...props} />
) : source?.kind === 'linear' ? (
<ListTodo {...props} />
) : source?.kind === 'github' && source.item.type === 'pr' ? (
<GitPullRequest {...props} />
) : (
<CircleDot {...props} />
)
}
const styles = StyleSheet.create({
field: { marginBottom: spacing.md },
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
labelHint: { fontWeight: '400', color: colors.textMuted },
fieldButton: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: colors.borderSubtle
},
fieldButtonText: {
flex: 1,
minWidth: 0,
fontSize: typography.bodySize,
color: colors.textPrimary
},
placeholder: { color: colors.textMuted },
clearButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
marginRight: -spacing.md
},
reuseRow: {
minHeight: 52,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
marginTop: spacing.xs
},
reuseCopy: { flex: 1, minWidth: 0 },
reuseTitle: { fontSize: 13, color: colors.textPrimary },
reuseSubtitle: { fontSize: 12, color: colors.textMuted, marginTop: 1 },
warning: { fontSize: 12, color: colors.statusAmber, marginTop: spacing.xs }
})
@@ -0,0 +1,217 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const fixture = vi.hoisted(() => {
const repo = { id: 'repo-1', displayName: 'App', path: '/app', kind: 'git' as const }
return {
repo,
sourceDrawerVisible: false,
setDrawerVisible: vi.fn((visible: boolean) => {
fixture.sourceDrawerVisible = visible
})
}
})
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Keyboard: { dismiss: vi.fn() },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Switch: 'Switch',
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronUp: 'ChevronUp'
}))
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
vi.mock('./PickerListDrawer', () => ({ PickerListDrawer: 'PickerListDrawer' }))
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
vi.mock('./MobileWorkspaceNameInput', () => ({ MobileWorkspaceNameInput: 'NameInput' }))
vi.mock('./NewWorkspaceSourceDrawer', () => ({
NewWorkspaceSourceDrawer: 'NewWorkspaceSourceDrawer'
}))
vi.mock('./NewWorkspaceSourceField', () => ({
NewWorkspaceSourceField: 'NewWorkspaceSourceField'
}))
vi.mock('../cache/repo-cache', () => ({
getCachedRepos: () => [fixture.repo],
setCachedRepos: vi.fn()
}))
vi.mock('../worktree/use-last-visited-worktree-repo', () => ({
useLastVisitedWorktreeRepoId: () => ({ loaded: true, repoId: fixture.repo.id })
}))
vi.mock('../workspace-source/use-live-workspace-ssh-state', () => ({
useLiveWorkspaceSshState: () => ({
state: null,
connecting: false,
generation: 0,
connect: vi.fn()
})
}))
vi.mock('../workspace-source/use-new-workspace-source', () => ({
useNewWorkspaceSource: () => ({
name: { value: '', owner: 'blank' },
source: null,
availability: { github: true, branches: true, linear: false },
sshStateGeneration: 0,
fieldVisible: true,
drawerVisible: fixture.sourceDrawerVisible,
setDrawerVisible: fixture.setDrawerVisible,
setManualName: vi.fn(),
selectSource: vi.fn(),
clearSource: vi.fn(),
setReuseEnabled: vi.fn(),
refreshLinearStatus: vi.fn()
})
}))
import { NewWorktreeModal } from './NewWorktreeModal'
const client = {
sendRequest: vi.fn(async (method: string) => {
if (method === 'repo.list') {
return { ok: true, result: { repos: [fixture.repo] } }
}
if (method === 'settings.get') {
return { ok: true, result: { settings: {} } }
}
if (method === 'ui.get') {
return { ok: true, result: { ui: {} } }
}
if (method === 'preflight.detectAgents') {
return { ok: true, result: ['codex'] }
}
if (method === 'repo.hooks') {
return { ok: true, result: { hooks: null, source: null } }
}
return { ok: true, result: {} }
})
} as never
function modal(overrides: { existingWorktreePaths?: string[] } = {}) {
return createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-1',
existingWorktreePaths: overrides.existingWorktreePaths,
hostCapabilities: ['mobile.workspace-source-selector.v1'],
onCreated: vi.fn(),
onClose: vi.fn()
})
}
function textContent(node: ReactTestInstance): string {
return node
.findAllByType('Text')
.map((text) => text.props.children)
.join(' ')
}
function fieldPress(renderer: ReactTestRenderer, label: string): ReactTestInstance {
const field = renderer.root
.findAllByType('Pressable')
.find((node) => textContent(node).includes(label))
if (!field) {
throw new Error(`Missing ${label} field`)
}
return field
}
function visibleNativeLayers(renderer: ReactTestRenderer): ReactTestInstance[] {
return [
...renderer.root.findAllByType('BottomDrawer'),
...renderer.root.findAllByType('PickerListDrawer'),
...renderer.root.findAllByType('NewWorkspaceSourceDrawer')
].filter((node) => node.props.visible === true)
}
function layer(renderer: ReactTestRenderer, type: string, title?: string): ReactTestInstance {
return renderer.root
.findAllByType(type)
.find((node) => title === undefined || node.props.title === title)!
}
describe('NewWorktreeModal native drawer ownership', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
fixture.sourceDrawerVisible = false
fixture.setDrawerVisible.mockClear()
vi.mocked(client.sendRequest).mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
async function mount(): Promise<ReactTestRenderer> {
await act(async () => {
renderer = create(modal())
await Promise.resolve()
await Promise.resolve()
})
return renderer!
}
it('never presents a picker while the Create drawer still owns the native modal', async () => {
const view = await mount()
expect(visibleNativeLayers(view)).toHaveLength(1)
act(() => fieldPress(view, 'App').props.onPress())
expect(visibleNativeLayers(view)).toHaveLength(0)
act(() => layer(view, 'BottomDrawer').props.onHidden())
expect(visibleNativeLayers(view)).toHaveLength(1)
expect(layer(view, 'PickerListDrawer', 'Repository').props.visible).toBe(true)
act(() => layer(view, 'PickerListDrawer', 'Repository').props.onClose())
expect(visibleNativeLayers(view)).toHaveLength(0)
act(() => layer(view, 'PickerListDrawer', 'Repository').props.onHidden())
expect(visibleNativeLayers(view)).toHaveLength(1)
})
it('preserves source and adjacent-picker ordering across parent refreshes', async () => {
const view = await mount()
const sourceField = () => layer(view, 'NewWorkspaceSourceField')
fixture.sourceDrawerVisible = true
act(() => {
sourceField().props.onPrepareOpen()
view.update(modal({ existingWorktreePaths: ['/app/first'] }))
})
expect(visibleNativeLayers(view)).toHaveLength(0)
act(() => layer(view, 'BottomDrawer').props.onHidden())
expect(layer(view, 'NewWorkspaceSourceDrawer').props.visible).toBe(true)
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onClose())
act(() => view.update(modal({ existingWorktreePaths: ['/app/second'] })))
expect(visibleNativeLayers(view)).toHaveLength(0)
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onHidden())
expect(visibleNativeLayers(view)).toHaveLength(1)
fixture.sourceDrawerVisible = true
act(() => sourceField().props.onPrepareOpen())
expect(visibleNativeLayers(view)).toHaveLength(0)
act(() => layer(view, 'BottomDrawer').props.onHidden())
expect(layer(view, 'NewWorkspaceSourceDrawer').props.visible).toBe(true)
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onClose())
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onHidden())
expect(visibleNativeLayers(view)).toHaveLength(1)
act(() => fieldPress(view, 'Codex').props.onPress())
expect(visibleNativeLayers(view)).toHaveLength(0)
act(() => layer(view, 'BottomDrawer').props.onHidden())
expect(layer(view, 'PickerListDrawer', 'Agent').props.visible).toBe(true)
})
})
+134 -111
View File
@@ -18,6 +18,9 @@ import { BottomDrawer } from './BottomDrawer'
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput'
import { NewWorkspaceSourceDrawer } from './NewWorkspaceSourceDrawer'
import { NewWorkspaceSourceField } from './NewWorkspaceSourceField'
import { useNewWorktreeModalPresentation } from './new-worktree-modal-presentation'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
@@ -34,7 +37,6 @@ import {
MOBILE_TUI_AGENT_LAUNCH_COMMANDS
} from '../tasks/mobile-tui-agents'
import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import {
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
@@ -49,6 +51,9 @@ import {
refreshMobileNewWorkspaceDialogSelectedRepo,
resolveMobileNewWorkspaceDialogRepoId
} from '../worktree/new-workspace-dialog-repo-selection'
import { useNewWorkspaceSource } from '../workspace-source/use-new-workspace-source'
import { buildWorkspaceSourceCreateCandidate } from '../workspace-source/workspace-source-create-candidate'
import { useLiveWorkspaceSshState } from '../workspace-source/use-live-workspace-ssh-state'
type Repo = {
id: string
@@ -56,6 +61,7 @@ type Repo = {
path: string
badgeColor?: string
connectionId?: string | null
kind?: 'git' | 'folder'
}
type SetupDecision = 'inherit' | 'run' | 'skip'
@@ -124,6 +130,8 @@ type Props = {
// on the on-disk directory basename, so paths (not displayNames) are
// what the suggestion logic must dedupe against.
existingWorktreePaths?: readonly string[]
existingWorktreeBranches?: readonly { repoId: string; branch: string }[]
hostCapabilities?: readonly string[] | null
onCreated: (worktreeId: string, name: string) => void
onClose: () => void
}
@@ -133,6 +141,8 @@ export function NewWorktreeModal({
client,
hostId,
existingWorktreePaths,
existingWorktreeBranches,
hostCapabilities,
onCreated,
onClose
}: Props) {
@@ -157,6 +167,8 @@ export function NewWorktreeModal({
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktreeBranches={existingWorktreeBranches}
hostCapabilities={hostCapabilities}
onCreated={onCreated}
onClose={onClose}
/>
@@ -168,6 +180,8 @@ function NewWorktreeModalContent({
client,
hostId,
existingWorktreePaths,
existingWorktreeBranches,
hostCapabilities,
onCreated,
onClose
}: Props) {
@@ -183,9 +197,6 @@ function NewWorktreeModalContent({
)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
@@ -197,9 +208,11 @@ function NewWorktreeModalContent({
> | null>(null)
const [runSetup, setRunSetup] = useState(true)
const [creating, setCreating] = useState(false)
const createInFlightRef = useRef(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(initialRepos == null)
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
const modalPresentation = useNewWorktreeModalPresentation()
// Why: matches the desktop UI — the input shows a generic "Workspace name"
// placeholder, not the suggested creature. The creature name is only used
@@ -208,11 +221,31 @@ function NewWorktreeModalContent({
// existingWorktreePaths at submission time.
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
const liveSsh = useLiveWorkspaceSshState({
visible,
client,
targetId: selectedRepoConnectionId
})
const sshGate = deriveWorkspaceSshGate({
connectionId: selectedRepoConnectionId,
state: sshState,
connecting: sshConnectingTargetId === selectedRepoConnectionId
state: liveSsh.state,
connecting: liveSsh.connecting
})
const workspaceSource = useNewWorkspaceSource({
visible,
client,
capabilities: hostCapabilities,
repo: selectedRepo,
repoConnected: !selectedRepoConnectionId || sshGate.status === 'connected',
sshStateGeneration: liveSsh.generation
})
const selectedRepoWorktreeBranches = useMemo(
() =>
(existingWorktreeBranches ?? [])
.filter((worktree) => worktree.repoId === selectedRepo?.id)
.map((worktree) => worktree.branch),
[existingWorktreeBranches, selectedRepo?.id]
)
const detectedAgentIds =
detectedAgentIdsState?.connectionId === selectedRepoConnectionId &&
(selectedRepoConnectionId === null || sshGate.status === 'connected')
@@ -325,45 +358,6 @@ function NewWorktreeModalContent({
}
}, [visible, client, hostId])
useEffect(() => {
if (!visible || !client || !selectedRepoConnectionId) {
return
}
let stale = false
void client
.sendRequest('ssh.getState', { targetId: selectedRepoConnectionId })
.then((response) => {
if (stale) {
return
}
if (!response.ok) {
throw new Error(response.error.message)
}
const state = (response as RpcSuccess).result as { state?: SshConnectionState | null }
setSshState(
state.state ?? {
targetId: selectedRepoConnectionId,
status: 'disconnected',
error: null,
reconnectAttempt: 0
}
)
})
.catch((err) => {
if (!stale) {
setSshState({
targetId: selectedRepoConnectionId,
status: 'error',
error: err instanceof Error ? err.message : 'Failed to read SSH connection state.',
reconnectAttempt: 0
})
}
})
return () => {
stale = true
}
}, [client, selectedRepoConnectionId, visible])
useEffect(() => {
if (!visible || !client) {
return
@@ -445,47 +439,6 @@ function NewWorktreeModalContent({
}
}, [client, selectedRepo])
async function connectSelectedSshRepo(): Promise<void> {
if (!client || !selectedRepoConnectionId) {
return
}
setSshConnectingTargetId(selectedRepoConnectionId)
setSshState({
targetId: selectedRepoConnectionId,
status: 'connecting',
error: null,
reconnectAttempt: 0
})
try {
const response = await client.sendRequest(
'ssh.connect',
{ targetId: selectedRepoConnectionId },
{ timeoutMs: 120_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
setSshState(
result.state ?? {
targetId: selectedRepoConnectionId,
status: 'connected',
error: null,
reconnectAttempt: 0
}
)
} catch (err) {
setSshState({
targetId: selectedRepoConnectionId,
status: 'error',
error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.',
reconnectAttempt: 0
})
} finally {
setSshConnectingTargetId((current) => (current === selectedRepoConnectionId ? null : current))
}
}
async function persistSetupHookTrust(
repoId: string,
contentHash: string,
@@ -508,9 +461,10 @@ function NewWorktreeModalContent({
}
async function handleCreate(options: CreateOptions = {}) {
if (!client || !selectedRepo) {
if (!client || !selectedRepo || createInFlightRef.current) {
return
}
createInFlightRef.current = true
setCreating(true)
setError('')
@@ -555,7 +509,7 @@ function NewWorktreeModalContent({
// server invent one. The pre-flight basename dedupe is only a hint;
// the authoritative collision is checked server-side against git
// branches/remotes/PRs, so we also retry-with-suffix on conflict.
const trimmedName = name.trim()
const trimmedName = workspaceSource.name.value.trim()
const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? [])
// Why: mirrors src/renderer/src/store/slices/worktrees.ts
@@ -602,32 +556,41 @@ function NewWorktreeModalContent({
contentHash: setupTrust.contentHash,
previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id)
})
modalPresentation.openLayer('setup-trust')
return
}
let lastError: string | null = null
for (let attempt = 0; attempt < 25; attempt += 1) {
const candidateName = candidateFor(attempt)
const params: Record<string, unknown> = {
repo: `id:${selectedRepo.id}`,
const baseParams: Record<string, unknown> = {
startupCommand: command,
setupDecision,
name: candidateName
setupDecision
}
if (selectedAgent.id !== '__blank__') {
params.createdWithAgent = selectedAgent.id
baseParams.createdWithAgent = selectedAgent.id
}
if (note.trim()) {
params.comment = note.trim()
baseParams.comment = note.trim()
}
const params = buildWorkspaceSourceCreateCandidate({
baseParams,
baseName,
name: workspaceSource.name,
source: workspaceSource.source,
selectedRepoId: selectedRepo.id,
attempt
})
const response = await client.sendRequest('worktree.create', params, {
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
if (response.ok) {
const result = (response as RpcSuccess).result as { worktree: { id: string } }
const result = (response as RpcSuccess).result as {
worktree: { id: string; displayName?: string }
}
onClose()
onCreated(result.worktree.id, candidateName)
onCreated(result.worktree.id, result.worktree.displayName ?? candidateName)
return
}
@@ -640,6 +603,7 @@ function NewWorktreeModalContent({
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create workspace')
} finally {
createInFlightRef.current = false
setCreating(false)
}
}
@@ -676,9 +640,34 @@ function NewWorktreeModalContent({
Keyboard.dismiss()
}
function openSelectionLayer(layer: 'source' | 'repository' | 'agent'): void {
prepareSelectionPickerOpen()
modalPresentation.openLayer(layer)
}
function closeSelectionLayer(layer: 'source' | 'repository' | 'agent'): void {
if (layer === 'source') {
workspaceSource.setDrawerVisible(false)
} else if (layer === 'repository') {
setShowRepoPicker(false)
} else {
setShowAgentPicker(false)
}
modalPresentation.closeLayer(layer)
}
function closeSetupTrustPrompt(): void {
setSetupTrustPrompt(null)
modalPresentation.closeLayer('setup-trust')
}
return (
<>
<BottomDrawer visible={visible} onClose={onClose}>
<BottomDrawer
visible={visible && modalPresentation.visibleLayer === 'form'}
onClose={onClose}
onHidden={() => modalPresentation.handleLayerHidden('form')}
>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
@@ -701,7 +690,7 @@ function NewWorktreeModalContent({
<Pressable
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
openSelectionLayer('repository')
setShowRepoPicker(true)
}}
>
@@ -750,7 +739,7 @@ function NewWorktreeModalContent({
sshGate.connectInProgress && styles.disabled
]}
disabled={sshGate.connectInProgress}
onPress={() => void connectSelectedSshRepo()}
onPress={() => void liveSsh.connect()}
>
<Text style={styles.sshConnectText}>
{sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
@@ -763,15 +752,22 @@ function NewWorktreeModalContent({
</View>
) : null}
{selectedRepo ? (
<NewWorkspaceSourceField
controller={workspaceSource}
onPrepareOpen={() => openSelectionLayer('source')}
/>
) : null}
<View style={styles.field}>
<Text style={styles.label}>
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<MobileWorkspaceNameInput
style={styles.input}
value={name}
value={workspaceSource.name.value}
onChangeText={(t) => {
setName(t)
workspaceSource.setManualName(t)
setError('')
}}
placeholderTextColor={colors.textMuted}
@@ -791,7 +787,7 @@ function NewWorktreeModalContent({
style={[styles.fieldButton, sshGate.requiresConnection && styles.disabled]}
disabled={sshGate.requiresConnection}
onPress={() => {
prepareSelectionPickerOpen()
openSelectionLayer('agent')
setShowAgentPicker(true)
}}
>
@@ -905,20 +901,43 @@ function NewWorktreeModalContent({
{/* Sub-modals for pickers — rendered outside the main modal so they
layer on top and scroll without touch conflicts. */}
<NewWorkspaceSourceDrawer
visible={
visible &&
modalPresentation.visibleLayer === 'source' &&
selectedRepo != null &&
workspaceSource.drawerVisible
}
client={client}
repoId={selectedRepo?.id ?? ''}
availability={workspaceSource.availability}
sshStateGeneration={workspaceSource.sshStateGeneration}
name={workspaceSource.name}
worktreeBranches={selectedRepoWorktreeBranches}
onSelect={(source) => {
workspaceSource.selectSource(source)
modalPresentation.closeLayer('source')
}}
onClose={() => closeSelectionLayer('source')}
onHidden={() => modalPresentation.handleLayerHidden('source')}
onOpen={workspaceSource.refreshLinearStatus}
/>
<PickerListDrawer
visible={visible && showRepoPicker}
visible={visible && modalPresentation.visibleLayer === 'repository' && showRepoPicker}
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => setSelectedRepo(item.repo)}
onClose={() => setShowRepoPicker(false)}
onClose={() => closeSelectionLayer('repository')}
onHidden={() => modalPresentation.handleLayerHidden('repository')}
renderIcon={(item) => {
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
}}
/>
<PickerListDrawer
visible={visible && showAgentPicker}
visible={visible && modalPresentation.visibleLayer === 'agent' && showAgentPicker}
title="Agent"
items={pickerAgentOptions}
selectedId={selectedAgent.id}
@@ -926,13 +945,17 @@ function NewWorktreeModalContent({
setAgentOverridden(true)
setSelectedAgent(agent)
}}
onClose={() => setShowAgentPicker(false)}
onClose={() => closeSelectionLayer('agent')}
onHidden={() => modalPresentation.handleLayerHidden('agent')}
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
/>
<BottomDrawer
visible={visible && setupTrustPrompt != null}
onClose={() => setSetupTrustPrompt(null)}
visible={
visible && modalPresentation.visibleLayer === 'setup-trust' && setupTrustPrompt != null
}
onClose={closeSetupTrustPrompt}
onHidden={() => modalPresentation.handleLayerHidden('setup-trust')}
>
{setupTrustPrompt ? (
<View>
@@ -968,7 +991,7 @@ function NewWorktreeModalContent({
false
)
const approvedHash = setupTrustPrompt.contentHash
setSetupTrustPrompt(null)
closeSetupTrustPrompt()
await handleCreate({
setupOverride: 'run',
approvedSetupContentHash: approvedHash
@@ -995,7 +1018,7 @@ function NewWorktreeModalContent({
true
)
const approvedHash = setupTrustPrompt.contentHash
setSetupTrustPrompt(null)
closeSetupTrustPrompt()
await handleCreate({
setupOverride: 'run',
approvedSetupContentHash: approvedHash
@@ -1014,7 +1037,7 @@ function NewWorktreeModalContent({
style={styles.trustActionRow}
disabled={creating}
onPress={() => {
setSetupTrustPrompt(null)
closeSetupTrustPrompt()
void handleCreate({ setupOverride: 'skip' })
}}
>
@@ -12,6 +12,8 @@ type Props = {
client: RpcClient | null
hostId?: string
existingWorktreePaths?: readonly string[]
existingWorktreeBranches?: readonly { repoId: string; branch: string }[]
hostCapabilities?: readonly string[] | null
onVisibleChange?: (visible: boolean) => void
onRouteVisibleChange: (visible: boolean) => void
onCreated: (worktreeId: string, name: string) => void
@@ -24,6 +26,8 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client,
hostId,
existingWorktreePaths,
existingWorktreeBranches,
hostCapabilities,
onVisibleChange,
onRouteVisibleChange,
onCreated
@@ -58,6 +62,8 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktreeBranches={existingWorktreeBranches}
hostCapabilities={hostCapabilities}
onCreated={onCreated}
onClose={close}
/>
@@ -12,6 +12,7 @@ type Props<T extends { id: string; label: string }> = {
selectedId: string
onSelect: (item: T) => void
onClose: () => void
onHidden?: () => void
renderIcon?: (item: T) => ReactNode
}
@@ -22,6 +23,7 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
selectedId,
onSelect,
onClose,
onHidden,
renderIcon
}: Props<T>) {
const [closing, setClosing] = useState(false)
@@ -64,6 +66,7 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
<BottomDrawer
visible={drawerVisible}
onClose={finishClose}
onHidden={onHidden}
dragContentToDismiss={false}
contentScrollable={false}
>
@@ -0,0 +1,36 @@
import type {
NewWorkspaceSourceFilter,
NewWorkspaceSourceRow
} from '../workspace-source/new-workspace-source-types'
export function workspaceSourceFilterLabel(filter: NewWorkspaceSourceFilter): string {
return filter === 'all'
? 'All'
: filter === 'github'
? 'GitHub'
: filter === 'branches'
? 'Branches'
: 'Linear'
}
export function workspaceSourceTitle(row: NewWorkspaceSourceRow): string {
return row.kind === 'github'
? row.item.title
: row.kind === 'linear'
? row.issue.title
: row.refName
}
export function workspaceSourceSubtitle(row: NewWorkspaceSourceRow): string {
return row.kind === 'github'
? `${row.item.type === 'pr' ? 'PR' : 'Issue'} #${row.item.number}`
: row.kind === 'linear'
? row.issue.identifier
: row.verified
? 'Branch or ref'
: 'Branch or ref · reuse unavailable'
}
export function workspaceSourceAccessibilityLabel(row: NewWorkspaceSourceRow): string {
return `${workspaceSourceSubtitle(row)}, ${workspaceSourceTitle(row)}`
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { getAvailableWorkspaceSourceFilters } from '../workspace-source/workspace-source-availability'
describe('new workspace source drawer filters', () => {
it('shows only source kinds supported by the selected repository state', () => {
expect(
getAvailableWorkspaceSourceFilters({ github: false, branches: false, linear: true })
).toEqual(['all', 'linear'])
expect(
getAvailableWorkspaceSourceFilters({ github: true, branches: true, linear: false })
).toEqual(['all', 'github', 'branches'])
})
})
@@ -0,0 +1,53 @@
import { useCallback, useState } from 'react'
export type NewWorktreeModalLayer = 'form' | 'source' | 'repository' | 'agent' | 'setup-trust'
type PresentationState = {
visible: NewWorktreeModalLayer | null
exiting: NewWorktreeModalLayer | null
next: NewWorktreeModalLayer | null
}
const INITIAL_PRESENTATION: PresentationState = {
visible: 'form',
exiting: null,
next: null
}
export function useNewWorktreeModalPresentation() {
const [state, setState] = useState(INITIAL_PRESENTATION)
const openLayer = useCallback((layer: Exclude<NewWorktreeModalLayer, 'form'>) => {
setState((current) =>
current.visible === 'form' && current.exiting === null
? { visible: null, exiting: 'form', next: layer }
: current
)
}, [])
const closeLayer = useCallback((layer: Exclude<NewWorktreeModalLayer, 'form'>) => {
setState((current) =>
current.visible === layer && current.exiting === null
? { visible: null, exiting: layer, next: 'form' }
: current
)
}, [])
const handleLayerHidden = useCallback((layer: NewWorktreeModalLayer) => {
setState((current) => {
if (current.exiting === layer) {
return { visible: current.next, exiting: null, next: null }
}
return current.visible === layer && layer !== 'form'
? { visible: 'form', exiting: null, next: null }
: current
})
}, [])
return {
visibleLayer: state.visible,
openLayer,
closeLayer,
handleLayerHidden
}
}
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Platform } from 'react-native'
export function useBottomDrawerNativeVisibility(visible: boolean, onHidden: () => void) {
const [nativeVisible, setNativeVisible] = useState(true)
const nativeHideRequestedRef = useRef(false)
const hiddenReportedRef = useRef(false)
const reportHidden = useCallback(() => {
if (hiddenReportedRef.current) {
return
}
hiddenReportedRef.current = true
onHidden()
}, [onHidden])
const hideNativeModal = useCallback(() => {
if (nativeHideRequestedRef.current) {
return
}
nativeHideRequestedRef.current = true
setNativeVisible(false)
if (Platform.OS !== 'ios') {
reportHidden()
}
}, [reportHidden])
useEffect(() => {
if (!visible) {
return
}
nativeHideRequestedRef.current = false
hiddenReportedRef.current = false
setNativeVisible(true)
}, [visible])
return { nativeVisible, hideNativeModal, reportHidden }
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { resolveComposerBranchSelection } from './mobile-composer-branch-selection'
describe('resolveComposerBranchSelection', () => {
it('preserves a manual Tasks workspace name that prefixes the selected branch', () => {
expect(
resolveComposerBranchSelection({
refName: 'feature/mobile',
localBranchName: 'feature/mobile',
currentName: 'feat',
lastAutoName: ''
})
).toEqual({
baseBranch: 'feature/mobile',
branchNameOverride: undefined,
branchAutoName: '',
name: undefined,
lastAutoName: undefined
})
})
})
@@ -12,6 +12,8 @@ export function resolveComposerBranchSelection(args: {
currentName: string
lastAutoName: string
}): ComposerBranchSelection {
// Why: the existing Tasks composer treats any non-auto name as user-owned,
// even when it happens to prefix the selected branch.
const shouldAutoName = !args.currentName.trim() || args.currentName === args.lastAutoName
if (!shouldAutoName) {
return {
+4 -38
View File
@@ -1,38 +1,4 @@
export function resolveMobileWorkspaceCreateName(args: {
draft: string | undefined
fallback: string
}): string {
return args.draft?.trim() || args.fallback
}
// Why: mirrors the desktop issue-name slugger so contractions do not create
// branch/path names like `can-t-enable` in mobile-created workspaces.
function removeIntraWordApostrophes(input: string): string {
return input.replace(/[‘’]/g, "'").replace(/([\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1')
}
function slugifyForWorkspaceName(input: string): string {
return removeIntraWordApostrophes(input)
.trim()
.toLowerCase()
.replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[.-]+|[.-]+$/g, '')
.slice(0, 48)
.replace(/[-._]+$/g, '')
}
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
const withoutLeadingNumber = item.title
.trim()
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
.replace(/^#\d+\s*[:-]\s*/, '')
.replace(/\(#\d+\)/gi, '')
.replace(/\b#\d+\b/g, '')
.trim()
const seed = withoutLeadingNumber || item.title.trim()
return slugifyForWorkspaceName(seed)
}
export {
getLinkedWorkItemSuggestedName,
resolveWorkspaceCreateName as resolveMobileWorkspaceCreateName
} from '../../../src/shared/workspace-name'
@@ -0,0 +1,47 @@
import type { GitHubPrStartPoint, GitHubWorkItem, LinearIssue } from '../../../src/shared/types'
export type NewWorkspaceSourceFilter = 'all' | 'github' | 'branches' | 'linear'
export type NewWorkspaceSourceRow =
| { kind: 'github'; key: string; item: GitHubWorkItem }
| {
kind: 'branch'
key: string
refName: string
localBranchName: string
verified: boolean
}
| { kind: 'linear'; key: string; issue: LinearIssue }
export type ResolvedNewWorkspaceSource =
| {
kind: 'github'
item: GitHubWorkItem
suggestedName: string
displayName: string
prStartPoint?: GitHubPrStartPoint
forkWarning: string | null
}
| {
kind: 'branch'
refName: string
localBranchName: string
verified: boolean
branchAutoName: string
branchNameOverride?: string
reuseEligibleBranch: string | null
reuseEnabled: boolean
}
| {
kind: 'linear'
issue: LinearIssue
suggestedName: string
displayName: string
organizationUrlKey: string | null
}
export type WorkspaceSourceAvailability = {
github: boolean
branches: boolean
linear: boolean
}
@@ -0,0 +1,181 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import type { RpcClient } from '../transport/rpc-client'
import { useLiveWorkspaceSshState } from './use-live-workspace-ssh-state'
type HookState = ReturnType<typeof useLiveWorkspaceSshState>
type EventListener = (payload: unknown) => void
let current: HookState | null = null
let eventListener: EventListener | null = null
let renderer: ReactTestRenderer | null = null
const unsubscribe = vi.fn()
const sendRequest = vi.fn()
const subscribe = vi.fn(
(_method: string, _params: unknown, listener: EventListener): (() => void) => {
eventListener = listener
return unsubscribe
}
)
const client = { sendRequest, subscribe } as unknown as RpcClient
function sshState(status: SshConnectionState['status'], targetId = 'ssh-1'): SshConnectionState {
return { targetId, status, error: null, reconnectAttempt: 0 }
}
function response(status: SshConnectionState['status'], targetId = 'ssh-1') {
return {
id: `ssh-state-${status}`,
ok: true as const,
result: { state: sshState(status, targetId) },
_meta: { runtimeId: 'runtime-1' }
}
}
function Harness({ targetId = 'ssh-1' }: { targetId?: string }): null {
current = useLiveWorkspaceSshState({ visible: true, client, targetId })
return null
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
async function mountHook(targetId = 'ssh-1'): Promise<void> {
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(Harness, { targetId }))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
}
async function emit(payload: unknown): Promise<void> {
await act(async () => {
eventListener?.(payload)
await Promise.resolve()
await Promise.resolve()
})
}
describe('useLiveWorkspaceSshState stream lifecycle', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
current = null
eventListener = null
sendRequest.mockReset()
sendRequest.mockResolvedValue(response('connected'))
subscribe.mockClear()
unsubscribe.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('fails closed only on a ready replay and restores fresh SSH state', async () => {
let resolveRefresh: ((value: ReturnType<typeof response>) => void) | null = null
sendRequest.mockResolvedValueOnce(response('connected')).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve
})
)
await mountHook()
expect(current?.state?.status).toBe('connected')
await emit({ type: 'ready', subscriptionId: 'runtime-events-1' })
expect(current?.state?.status).toBe('connected')
expect(sendRequest).toHaveBeenCalledTimes(1)
await emit({ type: 'ready', subscriptionId: 'runtime-events-2' })
expect(current?.state).toBeNull()
expect(current?.generation).toBe(1)
act(() => resolveRefresh?.(response('disconnected')))
await act(async () => {
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'ssh.getState', { targetId: 'ssh-1' })
expect(current?.state?.status).toBe('disconnected')
expect(current?.generation).toBe(2)
})
it('ignores connect completion after the selected target changes', async () => {
let resolveConnect: ((value: ReturnType<typeof response>) => void) | null = null
sendRequest.mockImplementation((method: string, params: { targetId: string }) => {
if (method === 'ssh.connect') {
return new Promise((resolve) => {
resolveConnect = resolve
})
}
return Promise.resolve(response('disconnected', params.targetId))
})
await mountHook()
await act(async () => {
void current?.connect()
await Promise.resolve()
})
expect(current?.connecting).toBe(true)
await act(async () => {
renderer?.update(createElement(Harness, { targetId: 'ssh-2' }))
await Promise.resolve()
await Promise.resolve()
})
expect(current?.state).toEqual(sshState('disconnected', 'ssh-2'))
const generationAfterSwitch = current?.generation
act(() => resolveConnect?.(response('connected', 'ssh-1')))
await act(async () => {
await Promise.resolve()
})
expect(current?.state).toEqual(sshState('disconnected', 'ssh-2'))
expect(current?.connecting).toBe(false)
expect(current?.generation).toBe(generationAfterSwitch)
})
it('keeps a completed connect result when an earlier state read resolves late', async () => {
let resolveRead: ((value: ReturnType<typeof response>) => void) | null = null
sendRequest.mockImplementation((method: string) => {
if (method === 'ssh.getState') {
return new Promise((resolve) => {
resolveRead = resolve
})
}
return Promise.resolve(response('connected'))
})
await mountHook()
await act(async () => {
await current?.connect()
})
expect(current?.state).toEqual(sshState('connected'))
act(() => resolveRead?.(response('disconnected')))
await act(async () => {
await Promise.resolve()
})
expect(current?.state).toEqual(sshState('connected'))
})
})
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
function fallbackState(
targetId: string,
status: SshConnectionState['status'],
error: string | null
) {
return { targetId, status, error, reconnectAttempt: 0 } satisfies SshConnectionState
}
async function readSshState(client: RpcClient, targetId: string): Promise<SshConnectionState> {
const response = await client.sendRequest('ssh.getState', { targetId })
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
return result.state ?? fallbackState(targetId, 'disconnected', null)
}
export function useLiveWorkspaceSshState(args: {
visible: boolean
client: RpcClient | null
targetId: string | null
}) {
const [state, setState] = useState<SshConnectionState | null>(null)
const [connecting, setConnecting] = useState(false)
const [generation, setGeneration] = useState(0)
// Why: reads, stream events, and connects can race to publish the same target's state.
const stateEpochRef = useRef(0)
const connectOperationEpochRef = useRef(0)
useEffect(() => {
connectOperationEpochRef.current += 1
setConnecting(false)
if (!args.visible || !args.client || !args.targetId) {
setState(null)
return
}
const client = args.client
const targetId = args.targetId
let stale = false
let eventStreamReady = false
const refreshState = (invalidate: boolean): void => {
const stateEpoch = ++stateEpochRef.current
void readSshState(client, targetId)
.then((nextState) => {
if (!stale && stateEpoch === stateEpochRef.current) {
setState(nextState)
if (invalidate) {
setGeneration((value) => value + 1)
}
}
})
.catch((error) => {
if (!stale && stateEpoch === stateEpochRef.current) {
setState(
fallbackState(
targetId,
'error',
error instanceof Error ? error.message : 'Failed to read SSH connection state.'
)
)
if (invalidate) {
setGeneration((value) => value + 1)
}
}
})
}
const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, (payload) => {
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
if (event.type === 'ready') {
const replayedAfterReconnect = eventStreamReady
eventStreamReady = true
if (replayedAfterReconnect) {
// Why: events missed while offline make the last connected state unsafe
// until a fresh host read closes the replay gap.
setState(null)
setGeneration((value) => value + 1)
refreshState(true)
}
return
}
if (event.type === 'sshStateChanged' && event.targetId === targetId && event.state) {
stateEpochRef.current += 1
setState(event.state)
setGeneration((value) => value + 1)
}
})
refreshState(false)
return () => {
stale = true
unsubscribe()
}
}, [args.client, args.targetId, args.visible])
const connect = useCallback(async (): Promise<void> => {
if (!args.client || !args.targetId) {
return
}
const targetId = args.targetId
const operationEpoch = ++connectOperationEpochRef.current
const stateEpoch = ++stateEpochRef.current
setConnecting(true)
setState(fallbackState(targetId, 'connecting', null))
setGeneration((value) => value + 1)
try {
const response = await args.client.sendRequest(
'ssh.connect',
{ targetId },
{ timeoutMs: 120_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
if (
operationEpoch === connectOperationEpochRef.current &&
stateEpoch === stateEpochRef.current
) {
setState(result.state ?? fallbackState(targetId, 'connected', null))
}
} catch (error) {
if (
operationEpoch === connectOperationEpochRef.current &&
stateEpoch === stateEpochRef.current
) {
setState(
fallbackState(
targetId,
'error',
error instanceof Error ? error.message : 'Failed to connect to SSH repository.'
)
)
}
} finally {
// Why: switching repositories transfers ownership of connection state;
// the previous target's completion cannot publish into the new target.
if (operationEpoch === connectOperationEpochRef.current) {
setGeneration((value) => value + 1)
setConnecting(false)
}
}
}, [args.client, args.targetId])
return { state, connecting, generation, connect }
}
@@ -0,0 +1,132 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
import type { RpcClient } from '../transport/rpc-client'
import type { ResolvedNewWorkspaceSource } from './new-workspace-source-types'
import {
useNewWorkspaceSource,
type NewWorkspaceSourceController
} from './use-new-workspace-source'
type HookArgs = Parameters<typeof useNewWorkspaceSource>[0]
const client = {
sendRequest: vi.fn().mockResolvedValue({
id: 'linear-status',
ok: true,
result: { connected: false },
_meta: { runtimeId: 'runtime-1' }
})
} as unknown as RpcClient
const capabilities = [MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY]
let controller: NewWorkspaceSourceController | null = null
let renderer: ReactTestRenderer | null = null
function Harness(props: HookArgs): null {
controller = useNewWorkspaceSource(props)
return null
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
function hookArgs(overrides: Partial<HookArgs> = {}): HookArgs {
return {
visible: true,
client,
capabilities,
repo: { id: 'repo-1', displayName: 'App', kind: 'git' },
repoConnected: true,
sshStateGeneration: 0,
...overrides
}
}
async function mountHook(): Promise<void> {
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(Harness, hookArgs()))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
}
function githubSource(): ResolvedNewWorkspaceSource {
return {
kind: 'github',
item: { type: 'issue', number: 7, title: 'Fix mobile source' } as never,
suggestedName: 'fix-mobile-source',
displayName: 'Issue 7 · Fix mobile source',
forkWarning: null
}
}
function branchSource(): ResolvedNewWorkspaceSource {
return {
kind: 'branch',
refName: 'feature/mobile',
localBranchName: 'feature/mobile',
verified: true,
branchAutoName: 'feature/mobile',
branchNameOverride: 'feature/mobile',
reuseEligibleBranch: 'feature/mobile',
reuseEnabled: true
}
}
describe('useNewWorkspaceSource availability lifecycle', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
controller = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('drops a capability-gated source and clears its source-owned name', async () => {
await mountHook()
act(() => controller?.selectSource(githubSource()))
expect(controller?.name).toEqual({ value: 'fix-mobile-source', owner: 'source' })
act(() => renderer?.update(createElement(Harness, hookArgs({ capabilities: [] }))))
expect(controller?.source).toBeNull()
expect(controller?.name).toEqual({ value: '', owner: 'blank' })
})
it('preserves a manual name when the selected kind becomes unavailable', async () => {
await mountHook()
act(() => {
controller?.selectSource(branchSource())
controller?.setManualName('my workspace')
})
act(() => {
renderer?.update(
createElement(
Harness,
hookArgs({ repo: { id: 'repo-1', displayName: 'App', kind: 'folder' } })
)
)
})
expect(controller?.source).toBeNull()
expect(controller?.name).toEqual({ value: 'my workspace', owner: 'user' })
})
})
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type {
ResolvedNewWorkspaceSource,
WorkspaceSourceAvailability
} from './new-workspace-source-types'
import {
getWorkspaceSourceAvailability,
hasWorkspaceSourceAvailability
} from './workspace-source-availability'
import {
applyManualWorkspaceName,
applySourceWorkspaceName,
clearSourceWorkspaceName,
createWorkspaceNameState,
type WorkspaceNameState
} from './workspace-source-name-state'
type SourceRepo = {
id: string
displayName: string
kind?: 'git' | 'folder'
}
export type NewWorkspaceSourceController = {
name: WorkspaceNameState
source: ResolvedNewWorkspaceSource | null
availability: WorkspaceSourceAvailability
sshStateGeneration: number
fieldVisible: boolean
drawerVisible: boolean
setDrawerVisible: (visible: boolean) => void
setManualName: (value: string) => void
selectSource: (source: ResolvedNewWorkspaceSource) => void
clearSource: () => void
setReuseEnabled: (enabled: boolean) => void
refreshLinearStatus: () => void
}
export function useNewWorkspaceSource(args: {
visible: boolean
client: RpcClient | null
capabilities: readonly string[] | null | undefined
repo: SourceRepo | null
repoConnected: boolean
sshStateGeneration: number
}): NewWorkspaceSourceController {
const [name, setName] = useState(() => createWorkspaceNameState())
const [source, setSource] = useState<ResolvedNewWorkspaceSource | null>(null)
const [linearConnected, setLinearConnected] = useState(false)
const [drawerVisible, setDrawerVisible] = useState(false)
const linearGenerationRef = useRef(0)
const previousRepoIdRef = useRef<string | null>(null)
const previousSshGenerationRef = useRef(args.sshStateGeneration)
const refreshLinearStatus = useCallback(() => {
const generation = ++linearGenerationRef.current
if (!args.visible || !args.client) {
setLinearConnected(false)
return
}
void args.client
.sendRequest('linear.status')
.then((response) => {
if (generation !== linearGenerationRef.current) {
return
}
const result = response.ok
? ((response as RpcSuccess).result as { connected?: unknown })
: null
setLinearConnected(result?.connected === true)
})
.catch(() => {
if (generation === linearGenerationRef.current) {
setLinearConnected(false)
}
})
}, [args.client, args.visible])
useEffect(() => {
refreshLinearStatus()
}, [refreshLinearStatus])
useEffect(() => {
const previousRepoId = previousRepoIdRef.current
previousRepoIdRef.current = args.repo?.id ?? null
if (!previousRepoId || previousRepoId === args.repo?.id) {
return
}
if (source && source.kind !== 'linear') {
setName(clearSourceWorkspaceName)
setSource(null)
}
setDrawerVisible(false)
}, [args.repo?.id, source])
useEffect(() => {
if (previousSshGenerationRef.current === args.sshStateGeneration) {
return
}
previousSshGenerationRef.current = args.sshStateGeneration
if (source && source.kind !== 'linear') {
setName(clearSourceWorkspaceName)
setSource(null)
}
}, [args.sshStateGeneration, source])
const availability = useMemo(
() =>
getWorkspaceSourceAvailability({
capabilities: args.capabilities,
repoKind: args.repo?.kind,
repoConnected: args.repoConnected,
linearConnected
}),
[args.capabilities, args.repo?.kind, args.repoConnected, linearConnected]
)
const effectiveSource =
source &&
(source.kind === 'github'
? availability.github
: source.kind === 'branch'
? availability.branches
: availability.linear)
? source
: null
useEffect(() => {
if (!source || effectiveSource) {
return
}
// Why: a hidden source must not leak into Create, while a manual name remains user-owned.
setName(clearSourceWorkspaceName)
setSource(null)
setDrawerVisible(false)
}, [effectiveSource, source])
const selectSource = useCallback((next: ResolvedNewWorkspaceSource) => {
setSource(next)
setName((current) => {
const suggestion =
next.kind === 'github' || next.kind === 'linear' ? next.suggestedName : next.branchAutoName
return applySourceWorkspaceName(current, suggestion).state
})
setDrawerVisible(false)
}, [])
return {
name,
source: effectiveSource,
availability,
sshStateGeneration: args.sshStateGeneration,
fieldVisible: Boolean(args.repo) && hasWorkspaceSourceAvailability(availability),
drawerVisible,
setDrawerVisible,
setManualName: (value) => setName((current) => applyManualWorkspaceName(current, value)),
selectSource,
clearSource: () => {
setSource(null)
setName(clearSourceWorkspaceName)
},
setReuseEnabled: (enabled) =>
setSource((current) =>
current?.kind === 'branch' && current.reuseEligibleBranch
? { ...current, reuseEnabled: enabled }
: current
),
refreshLinearStatus
}
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
import {
getWorkspaceSourceAvailability,
supportsMobileWorkspaceSourceSelector
} from './workspace-source-availability'
describe('workspace source capability gating', () => {
it('fails closed while status is loading and on older hosts', () => {
expect(supportsMobileWorkspaceSourceSelector(null)).toBe(false)
expect(supportsMobileWorkspaceSourceSelector([])).toBe(false)
})
it('keeps Linear available for folder and disconnected SSH repositories', () => {
const capabilities = [MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY]
expect(
getWorkspaceSourceAvailability({
capabilities,
repoKind: 'folder',
repoConnected: true,
linearConnected: true
})
).toEqual({ github: false, branches: false, linear: true })
expect(
getWorkspaceSourceAvailability({
capabilities,
repoKind: 'git',
repoConnected: false,
linearConnected: true
})
).toEqual({ github: false, branches: false, linear: true })
})
})
@@ -0,0 +1,43 @@
import { MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
import type {
NewWorkspaceSourceFilter,
WorkspaceSourceAvailability
} from './new-workspace-source-types'
export function supportsMobileWorkspaceSourceSelector(
capabilities: readonly string[] | null | undefined
): boolean {
return capabilities?.includes(MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY) === true
}
export function getWorkspaceSourceAvailability(args: {
capabilities: readonly string[] | null | undefined
repoKind?: 'git' | 'folder'
repoConnected: boolean
linearConnected: boolean
}): WorkspaceSourceAvailability {
if (!supportsMobileWorkspaceSourceSelector(args.capabilities)) {
return { github: false, branches: false, linear: false }
}
const repoScoped = args.repoKind !== 'folder' && args.repoConnected
return {
github: repoScoped,
branches: repoScoped,
linear: args.linearConnected
}
}
export function hasWorkspaceSourceAvailability(availability: WorkspaceSourceAvailability): boolean {
return availability.github || availability.branches || availability.linear
}
export function getAvailableWorkspaceSourceFilters(
availability: WorkspaceSourceAvailability
): NewWorkspaceSourceFilter[] {
return [
'all',
...(availability.github ? (['github'] as const) : []),
...(availability.branches ? (['branches'] as const) : []),
...(availability.linear ? (['linear'] as const) : [])
]
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceSourceCreateCandidate } from './workspace-source-create-candidate'
describe('workspace source create candidates', () => {
it('suffixes fresh workspace and PR branch overrides together', () => {
expect(
buildWorkspaceSourceCreateCandidate({
baseParams: { setupDecision: 'inherit' },
baseName: 'review-pr',
name: { value: 'review-pr', owner: 'source' },
selectedRepoId: 'repo',
attempt: 1,
source: {
kind: 'github',
item: { type: 'pr', number: 2 } as never,
suggestedName: 'review-pr',
displayName: 'Review PR 2',
forkWarning: null,
prStartPoint: { baseBranch: 'sha', branchNameOverride: 'feature/review' }
}
})
).toMatchObject({
name: 'review-pr-2',
linkedPR: 2,
branchNameOverride: 'feature/review-2',
displayName: 'Review PR 2'
})
})
it('keeps verified exact branch reuse across name edits and retries', () => {
expect(
buildWorkspaceSourceCreateCandidate({
baseParams: {},
baseName: 'my-name',
name: { value: 'my-name', owner: 'user' },
selectedRepoId: 'repo',
attempt: 2,
source: {
kind: 'branch',
refName: 'feature/exact',
localBranchName: 'feature/exact',
verified: true,
branchAutoName: 'feature/exact',
branchNameOverride: 'feature/exact',
reuseEligibleBranch: 'feature/exact',
reuseEnabled: true
}
})
).toMatchObject({ name: 'my-name-3', branchNameOverride: 'feature/exact' })
})
})
@@ -0,0 +1,82 @@
import { resolveComposerBranchNameOverrideForCreate } from '../../../src/shared/composer-branch-selection'
import type { ResolvedNewWorkspaceSource } from './new-workspace-source-types'
import type { WorkspaceNameState } from './workspace-source-name-state'
function suffixed(value: string, attempt: number): string {
return attempt === 0 ? value : `${value}-${attempt + 1}`
}
export function buildWorkspaceSourceCreateCandidate(args: {
baseParams: Record<string, unknown>
baseName: string
name: WorkspaceNameState
source: ResolvedNewWorkspaceSource | null
selectedRepoId: string
attempt: number
}): Record<string, unknown> {
const candidateName = suffixed(args.baseName, args.attempt)
const source = args.source
const params: Record<string, unknown> = {
...args.baseParams,
repo: `id:${args.selectedRepoId}`,
name: candidateName
}
if (!source) {
return params
}
if (source.kind === 'github') {
const startPoint = source.prStartPoint
const branchNameOverride = startPoint?.branchNameOverride
? suffixed(startPoint.branchNameOverride, args.attempt)
: undefined
return {
...params,
...(args.name.owner === 'source' ? { displayName: source.displayName } : {}),
...(source.item.type === 'issue'
? { linkedIssue: source.item.number }
: { linkedPR: source.item.number }),
...(startPoint
? {
baseBranch: startPoint.baseBranch,
...(startPoint.compareBaseRef ? { compareBaseRef: startPoint.compareBaseRef } : {}),
...(branchNameOverride ? { branchNameOverride } : {}),
...(startPoint.pushTarget ? { pushTarget: startPoint.pushTarget } : {})
}
: {})
}
}
if (source.kind === 'linear') {
return {
...params,
...(args.name.owner === 'source' ? { displayName: source.displayName } : {}),
linkedLinearIssue: source.issue.identifier,
...(source.issue.workspaceId
? { linkedLinearIssueWorkspaceId: source.issue.workspaceId }
: {}),
...(source.organizationUrlKey
? { linkedLinearIssueOrganizationUrlKey: source.organizationUrlKey }
: {})
}
}
const exactReuse = source.reuseEligibleBranch !== null && source.reuseEnabled
const resolvedOverride = exactReuse
? source.reuseEligibleBranch!
: source.reuseEligibleBranch
? undefined
: resolveComposerBranchNameOverrideForCreate({
branchNameOverride: source.branchNameOverride,
branchAutoName: source.branchAutoName,
workspaceName: args.name.value.trim(),
preserveWorkspaceNameEdits: false
})
const branchNameOverride =
resolvedOverride && !exactReuse ? suffixed(resolvedOverride, args.attempt) : resolvedOverride
return {
...params,
baseBranch: source.refName,
...(branchNameOverride ? { branchNameOverride } : {})
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
applyManualWorkspaceName,
applySourceWorkspaceName,
clearSourceWorkspaceName
} from './workspace-source-name-state'
describe('workspace source name ownership', () => {
it('lets source suggestions replace blank/source names but never manual edits', () => {
const first = applySourceWorkspaceName({ value: '', owner: 'blank' }, 'issue-8').state
expect(first).toEqual({ value: 'issue-8', owner: 'source' })
const manual = applyManualWorkspaceName(first, 'issue-8')
expect(manual.owner).toBe('user')
expect(applySourceWorkspaceName(manual, 'issue-9').state).toBe(manual)
})
it('clears only source-owned names', () => {
expect(clearSourceWorkspaceName({ value: 'auto', owner: 'source' })).toEqual({
value: '',
owner: 'blank'
})
expect(clearSourceWorkspaceName({ value: 'mine', owner: 'user' })).toEqual({
value: 'mine',
owner: 'user'
})
})
})
@@ -0,0 +1,36 @@
export type WorkspaceNameOwner = 'blank' | 'source' | 'user'
export type WorkspaceNameState = {
value: string
owner: WorkspaceNameOwner
}
export function createWorkspaceNameState(value = ''): WorkspaceNameState {
return { value, owner: value.trim() ? 'user' : 'blank' }
}
export function applyManualWorkspaceName(
_current: WorkspaceNameState,
value: string
): WorkspaceNameState {
// Why: even a value equal to the current suggestion is a user decision and
// must not be overwritten by later source changes.
return { value, owner: 'user' }
}
export function applySourceWorkspaceName(
current: WorkspaceNameState,
suggestedName: string
): { state: WorkspaceNameState; sourceTookOwnership: boolean } {
if (current.owner === 'user') {
return { state: current, sourceTookOwnership: false }
}
return {
state: { value: suggestedName, owner: suggestedName.trim() ? 'source' : 'blank' },
sourceTookOwnership: suggestedName.trim().length > 0
}
}
export function clearSourceWorkspaceName(current: WorkspaceNameState): WorkspaceNameState {
return current.owner === 'source' ? { value: '', owner: 'blank' } : current
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
IncompatibleWorkspaceSourceResponseError,
normalizeGitHubSourceResponse,
normalizeLinearSourceResponse,
normalizeRefSourceResponse
} from './workspace-source-normalization'
const githubItem = {
id: 'I_1',
type: 'issue',
number: 8,
title: 'Fix mobile selector',
url: 'https://github.com/acme/app/issues/8'
}
describe('workspace source response normalization', () => {
it('preserves GitHub rows beside issue-side partial errors and stamps the repo', () => {
const result = normalizeGitHubSourceResponse(
{
items: [githubItem, { nope: true }],
sources: { issues: null, prs: null },
errors: { issues: { message: 'Issues unavailable' } }
},
'repo-current'
)
expect(result.rows).toHaveLength(1)
expect(result.rows[0]).toMatchObject({ kind: 'github', item: { repoId: 'repo-current' } })
expect(result.warnings).toEqual(['Issues unavailable'])
})
it('rejects malformed top-level collections instead of reporting empty results', () => {
expect(() => normalizeGitHubSourceResponse({ items: [] }, 'repo')).toThrow(
IncompatibleWorkspaceSourceResponseError
)
expect(() => normalizeLinearSourceResponse({ errors: [] })).toThrow(
IncompatibleWorkspaceSourceResponseError
)
expect(() => normalizeRefSourceResponse({})).toThrow(IncompatibleWorkspaceSourceResponseError)
})
it('accepts current and legacy Linear collections', () => {
const issue = {
id: 'linear-1',
identifier: 'ENG-1',
title: 'Ship selector',
url: 'https://linear.app/acme/issue/ENG-1/ship-selector'
}
expect(normalizeLinearSourceResponse([issue]).rows).toHaveLength(1)
expect(
normalizeLinearSourceResponse({
items: [issue],
errors: [{ message: 'One workspace failed' }]
}).warnings
).toEqual(['One workspace failed'])
})
it('marks legacy ref strings unverified so they can never imply reuse', () => {
expect(
normalizeRefSourceResponse({
refDetails: [{ refName: 'main', localBranchName: 'main' }],
refs: ['main', 'legacy']
})
).toEqual([
expect.objectContaining({ refName: 'main', verified: true }),
expect.objectContaining({ refName: 'legacy', verified: false })
])
})
})
@@ -0,0 +1,143 @@
import type { GitHubWorkItem, LinearIssue } from '../../../src/shared/types'
import type { NewWorkspaceSourceRow } from './new-workspace-source-types'
export class IncompatibleWorkspaceSourceResponseError extends Error {}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function messageFrom(value: unknown): string | null {
if (typeof value === 'string') {
return value.trim() || null
}
const candidate = record(value)
return typeof candidate?.message === 'string' ? candidate.message.trim() || null : null
}
function githubItem(value: unknown, repoId: string): GitHubWorkItem | null {
const item = record(value)
if (
!item ||
(item.type !== 'issue' && item.type !== 'pr') ||
typeof item.number !== 'number' ||
!Number.isInteger(item.number) ||
item.number <= 0 ||
typeof item.id !== 'string' ||
typeof item.title !== 'string' ||
typeof item.url !== 'string'
) {
return null
}
// Why: rows are snapshots for the selected repo; never carry a repo id from
// an old or malformed host response into later repo-scoped RPCs.
return { ...item, repoId } as GitHubWorkItem
}
function linearIssue(value: unknown): LinearIssue | null {
const issue = record(value)
if (
!issue ||
typeof issue.id !== 'string' ||
typeof issue.identifier !== 'string' ||
typeof issue.title !== 'string' ||
typeof issue.url !== 'string'
) {
return null
}
return issue as LinearIssue
}
export function normalizeGitHubSourceResponse(
value: unknown,
repoId: string
): { rows: NewWorkspaceSourceRow[]; warnings: string[] } {
const envelope = record(value)
if (!envelope || !Array.isArray(envelope.items) || !record(envelope.sources)) {
throw new IncompatibleWorkspaceSourceResponseError(
'The selected host returned incompatible GitHub results.'
)
}
const rows = envelope.items.flatMap((raw) => {
const item = githubItem(raw, repoId)
return item
? [{ kind: 'github' as const, key: `github-${repoId}-${item.type}-${item.number}`, item }]
: []
})
const issuesError = record(envelope.errors)?.issues
const warning = messageFrom(issuesError)
return { rows, warnings: warning ? [warning] : [] }
}
export function normalizeLinearSourceResponse(value: unknown): {
rows: NewWorkspaceSourceRow[]
warnings: string[]
} {
const envelope = Array.isArray(value) ? null : record(value)
const items = Array.isArray(value) ? value : envelope?.items
if (!Array.isArray(items)) {
throw new IncompatibleWorkspaceSourceResponseError(
'The selected host returned incompatible Linear results.'
)
}
const rows = items.flatMap((raw) => {
const issue = linearIssue(raw)
return issue
? [
{
kind: 'linear' as const,
key: `linear-${issue.workspaceId ?? 'default'}-${issue.id}`,
issue
}
]
: []
})
const errors = Array.isArray(envelope?.errors) ? envelope.errors : []
return { rows, warnings: errors.map(messageFrom).filter((item): item is string => Boolean(item)) }
}
export function normalizeRefSourceResponse(value: unknown): NewWorkspaceSourceRow[] {
const envelope = record(value)
const hasDetails = Array.isArray(envelope?.refDetails)
const hasLegacy = Array.isArray(envelope?.refs)
if (!envelope || (!hasDetails && !hasLegacy)) {
throw new IncompatibleWorkspaceSourceResponseError(
'The selected host returned incompatible branch results.'
)
}
const verified = hasDetails
? (envelope.refDetails as unknown[]).flatMap((raw) => {
const row = record(raw)
return typeof row?.refName === 'string' && typeof row.localBranchName === 'string'
? [
{
kind: 'branch' as const,
key: `branch-verified-${row.refName}`,
refName: row.refName,
localBranchName: row.localBranchName,
verified: true
}
]
: []
})
: []
const verifiedNames = new Set(verified.map((row) => row.refName))
const legacy = hasLegacy
? (envelope.refs as unknown[]).flatMap((raw) =>
typeof raw === 'string' && raw && !verifiedNames.has(raw)
? [
{
kind: 'branch' as const,
key: `branch-legacy-${raw}`,
refName: raw,
localBranchName: raw,
verified: false
}
]
: []
)
: []
return [...verified, ...legacy]
}
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { searchWorkspaceSources } from './workspace-source-rpc'
function ok(result: unknown) {
return { ok: true as const, id: 'request', result }
}
describe('workspace source RPC planning', () => {
it('fans empty All out to GitHub and Linear but not refs', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'github.listWorkItems') {
return ok({ items: [], sources: { issues: null, prs: null } })
}
return ok([])
})
await searchWorkspaceSources({
client: { sendRequest } as unknown as RpcClient,
repoId: 'repo-1',
query: '',
filter: 'all',
availability: { github: true, branches: true, linear: true }
})
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
'github.listWorkItems',
'linear.listIssues'
])
})
it('fans a settled All query out to all three bounded reads', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'github.listWorkItems') {
return ok({ items: [], sources: { issues: null, prs: null } })
}
if (method === 'repo.searchRefs') {
return ok({ refDetails: [], refs: [] })
}
return ok([])
})
await searchWorkspaceSources({
client: { sendRequest } as unknown as RpcClient,
repoId: 'repo-1',
query: 'mobile',
filter: 'all',
availability: { github: true, branches: true, linear: true }
})
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
'github.listWorkItems',
'repo.searchRefs',
'linear.searchIssues'
])
for (const [, params] of sendRequest.mock.calls) {
expect(params.limit).toBe(12)
}
})
it('names the selected host rather than the repository in GitHub errors', async () => {
const sendRequest = vi.fn().mockRejectedValue(new Error('Authentication failed.'))
const result = await searchWorkspaceSources({
client: { sendRequest } as unknown as RpcClient,
repoId: 'repo-1',
query: '',
filter: 'github',
availability: { github: true, branches: true, linear: false }
})
expect(result.errors).toEqual(['Authentication failed. GitHub runs on the selected host.'])
})
})
@@ -0,0 +1,137 @@
import type { GitHubPrStartPoint } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type {
NewWorkspaceSourceFilter,
NewWorkspaceSourceRow,
WorkspaceSourceAvailability
} from './new-workspace-source-types'
import {
normalizeGitHubSourceResponse,
normalizeLinearSourceResponse,
normalizeRefSourceResponse
} from './workspace-source-normalization'
const SOURCE_READ_TIMEOUT_MS = 30_000
export const NEW_WORKSPACE_SOURCE_RESULT_LIMIT = 12
type SourceRead = { rows: NewWorkspaceSourceRow[]; warnings: string[] }
export type WorkspaceSourceSearchResult = SourceRead & { errors: string[] }
function responseResult(response: Awaited<ReturnType<RpcClient['sendRequest']>>): unknown {
if (!response.ok) {
throw new Error(response.error.message)
}
return (response as RpcSuccess).result
}
async function readGitHub(client: RpcClient, repoId: string, query: string): Promise<SourceRead> {
const response = await client.sendRequest(
'github.listWorkItems',
{ repo: `id:${repoId}`, query: query || undefined, limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
)
return normalizeGitHubSourceResponse(responseResult(response), repoId)
}
async function readLinear(client: RpcClient, query: string): Promise<SourceRead> {
const response = query
? await client.sendRequest(
'linear.searchIssues',
{ query, limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
)
: await client.sendRequest(
'linear.listIssues',
{ filter: 'assigned', limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
)
return normalizeLinearSourceResponse(responseResult(response))
}
async function readBranches(client: RpcClient, repoId: string, query: string): Promise<SourceRead> {
const response = await client.sendRequest(
'repo.searchRefs',
{ repo: `id:${repoId}`, query, limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
)
return { rows: normalizeRefSourceResponse(responseResult(response)), warnings: [] }
}
export async function searchWorkspaceSources(args: {
client: RpcClient
repoId: string
query: string
filter: NewWorkspaceSourceFilter
availability: WorkspaceSourceAvailability
}): Promise<WorkspaceSourceSearchResult> {
const query = args.query.trim()
const reads: Array<{ kind: 'github' | 'branches' | 'linear'; read: Promise<SourceRead> }> = []
if ((args.filter === 'all' || args.filter === 'github') && args.availability.github) {
reads.push({ kind: 'github', read: readGitHub(args.client, args.repoId, query) })
}
if (
(args.filter === 'all' || args.filter === 'branches') &&
args.availability.branches &&
(query.length > 0 || args.filter === 'branches')
) {
reads.push({ kind: 'branches', read: readBranches(args.client, args.repoId, query) })
}
if ((args.filter === 'all' || args.filter === 'linear') && args.availability.linear) {
reads.push({ kind: 'linear', read: readLinear(args.client, query) })
}
const settled = await Promise.allSettled(reads.map((entry) => entry.read))
const rows: NewWorkspaceSourceRow[] = []
const warnings: string[] = []
const errors: string[] = []
for (const [index, result] of settled.entries()) {
if (result.status === 'fulfilled') {
rows.push(...result.value.rows)
warnings.push(...result.value.warnings)
} else {
const detail =
result.reason instanceof Error ? result.reason.message : 'Source search failed.'
errors.push(
reads[index]?.kind === 'github' ? `${detail} GitHub runs on the selected host.` : detail
)
}
}
return { rows: rows.slice(0, NEW_WORKSPACE_SOURCE_RESULT_LIMIT), warnings, errors }
}
export async function resolveWorkspaceSourcePr(args: {
client: RpcClient
repoId: string
item: {
number: number
branchName?: string
baseRefName?: string
isCrossRepository?: boolean
}
}): Promise<GitHubPrStartPoint> {
const response = await args.client.sendRequest(
'worktree.resolvePrBase',
{
repo: `id:${args.repoId}`,
prNumber: args.item.number,
...(args.item.branchName ? { headRefName: args.item.branchName } : {}),
...(args.item.baseRefName ? { baseRefName: args.item.baseRefName } : {}),
...(args.item.isCrossRepository !== undefined
? { isCrossRepository: args.item.isCrossRepository }
: {})
},
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
)
const result = responseResult(response)
if (!result || typeof result !== 'object') {
throw new Error('The selected host returned an incompatible PR start point.')
}
if ('error' in result && typeof result.error === 'string') {
throw new Error(result.error)
}
if (!('baseBranch' in result) || typeof result.baseBranch !== 'string') {
throw new Error('The selected host returned an incompatible PR start point.')
}
return result as GitHubPrStartPoint
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
resolveBranchWorkspaceSource,
resolveGitHubWorkspaceSource
} from './workspace-source-selection'
describe('workspace source selection', () => {
it('uses deterministic GitHub identity when the title is not sluggable', () => {
const source = resolveGitHubWorkspaceSource({
id: 'pr-4',
type: 'pr',
number: 4,
title: '你好',
url: 'https://github.com/acme/app/pull/4',
repoId: 'repo',
state: 'open',
labels: [],
updatedAt: '',
author: null
})
expect(source).toMatchObject({ suggestedName: 'pr-4', displayName: 'PR 4' })
})
it('offers exact reuse only for verified, non-busy local branches', () => {
expect(
resolveBranchWorkspaceSource({
refName: 'feature/mobile',
localBranchName: 'feature/mobile',
verified: true,
name: { value: '', owner: 'blank' },
worktreeBranches: []
})
).toMatchObject({ reuseEligibleBranch: 'feature/mobile', reuseEnabled: true })
expect(
resolveBranchWorkspaceSource({
refName: 'feature/mobile',
localBranchName: 'feature/mobile',
verified: true,
name: { value: '', owner: 'blank' },
worktreeBranches: ['refs/heads/feature/mobile']
})
).toMatchObject({ reuseEligibleBranch: null, branchNameOverride: undefined })
expect(
resolveBranchWorkspaceSource({
refName: 'legacy',
localBranchName: 'legacy',
verified: false,
name: { value: '', owner: 'blank' },
worktreeBranches: []
})
).toMatchObject({ reuseEligibleBranch: null, branchNameOverride: undefined })
})
})
@@ -0,0 +1,112 @@
import {
isBranchCheckedOutInWorktrees,
resolveComposerBranchReuse,
resolveComposerBranchSelection,
resolveComposerReuseOverride
} from '../../../src/shared/composer-branch-selection'
import { getForkPushWarning } from '../../../src/shared/fork-push-warning'
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../src/shared/linear-links'
import {
getLinearIssueWorkspaceName,
getLinkedWorkItemWorkspaceName
} from '../../../src/shared/workspace-name'
import type { GitHubPrStartPoint, GitHubWorkItem, LinearIssue } from '../../../src/shared/types'
import type { ResolvedNewWorkspaceSource } from './new-workspace-source-types'
import type { WorkspaceNameState } from './workspace-source-name-state'
function githubWorkspaceIdentity(item: GitHubWorkItem): {
seedName: string
displayName: string
} {
const identity = getLinkedWorkItemWorkspaceName({
type: item.type,
number: item.number,
title: item.title,
provider: 'github'
})
return (
identity ?? {
seedName: `${item.type}-${item.number}`,
displayName: item.type === 'pr' ? `PR ${item.number}` : `Issue ${item.number}`
}
)
}
export function resolveGitHubWorkspaceSource(
item: GitHubWorkItem,
prStartPoint?: GitHubPrStartPoint
): ResolvedNewWorkspaceSource {
const identity = githubWorkspaceIdentity(item)
return {
kind: 'github',
item,
suggestedName: identity.seedName,
displayName: identity.displayName,
...(prStartPoint ? { prStartPoint } : {}),
forkWarning: prStartPoint ? getForkPushWarning(prStartPoint) : null
}
}
export function resolveLinearWorkspaceSource(issue: LinearIssue): ResolvedNewWorkspaceSource {
const identity = getLinkedWorkItemWorkspaceName({
type: 'issue',
number: 0,
title: issue.title,
provider: 'linear',
linearIdentifier: issue.identifier
})
return {
kind: 'linear',
issue,
suggestedName: getLinearIssueWorkspaceName(issue) || issue.identifier.toLowerCase(),
displayName: identity?.displayName ?? issue.identifier,
organizationUrlKey: getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
}
}
export function resolveBranchWorkspaceSource(args: {
refName: string
localBranchName: string
verified: boolean
name: WorkspaceNameState
worktreeBranches: readonly string[]
}): ResolvedNewWorkspaceSource {
const selection = resolveComposerBranchSelection({
refName: args.refName,
localBranchName: args.localBranchName,
currentName: args.name.value,
lastAutoName: args.name.owner === 'source' ? args.name.value : ''
})
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
args.localBranchName,
args.worktreeBranches
)
const selectionProducedOverride =
args.verified && args.name.owner !== 'user' && selection.branchNameOverride !== undefined
const reuse = args.verified
? resolveComposerBranchReuse({
refName: args.refName,
localBranchName: args.localBranchName,
selectionProducedOverride,
branchCheckedOutElsewhere
})
: { reuseEligibleBranch: null, defaultReuse: false }
const branchNameOverride = args.verified
? resolveComposerReuseOverride({
refName: args.refName,
localBranchName: args.localBranchName,
branchNameOverride: selection.branchNameOverride,
branchCheckedOutElsewhere
})
: undefined
return {
kind: 'branch',
refName: args.refName,
localBranchName: args.localBranchName,
verified: args.verified,
branchAutoName: selection.branchAutoName,
branchNameOverride,
reuseEligibleBranch: reuse.reuseEligibleBranch,
reuseEnabled: reuse.defaultReuse
}
}
+2 -4
View File
@@ -30,10 +30,7 @@ import {
removeWorktree
} from '../git/worktree'
import * as gitRunner from '../git/runner'
import {
clearSubmodulePathsCacheForTests,
listSubmodulePaths
} from '../git/status'
import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status'
import {
createSetupRunnerScript,
getEffectiveHooks,
@@ -1412,6 +1409,7 @@ describe('OrcaRuntimeService', () => {
expect(status.capabilities).toContain('mobile.tasks.v1')
expect(status.capabilities).toContain('project-host-setup.v1')
expect(status.capabilities).toContain('linear.issue-attribute-filter.v1')
expect(status.capabilities).toContain('mobile.workspace-source-selector.v1')
expect(status.capabilities).not.toContain('browser.screencast.v1')
expect(typeof status.protocolVersion).toBe('number')
expect(typeof status.minCompatibleMobileVersion).toBe('number')
@@ -5,11 +5,14 @@ import type {
LinearCollectionResult,
LinearIssue
} from '../../../../shared/types'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import {
WORKSPACE_SOURCE_QUERY_MAX_BYTES,
isWorkspaceSourceQueryWithinLimit
} from '../../../../shared/workspace-source-query'
export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text'
export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048
export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = WORKSPACE_SOURCE_QUERY_MAX_BYTES
export type SmartWorkspaceSourceRow =
| { kind: 'use-name'; value: string; name: string }
@@ -38,7 +41,7 @@ export function isSmartWorkspaceSourceQueryWithinLimit(
query: string,
maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES
): boolean {
return !isClipboardTextByteLengthOverLimit(query, maxBytes)
return isWorkspaceSourceQueryWithinLimit(query, maxBytes)
}
export function getBranchSearchRequest({
+5 -21
View File
@@ -1,21 +1,5 @@
import type { GitHubPrStartPoint } from '../../../shared/types'
export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING =
'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.'
// Why: only warn for fork PRs where the push target points away from origin and
// whose author left "Allow edits from maintainers" off. That's the one case
// where our push to the contributor's fork can be rejected by GitHub. Returns
// the warning text to show, or null when no warning applies.
export function getForkPushWarning(
result: Pick<GitHubPrStartPoint, 'pushTarget' | 'maintainerCanModify'>
): string | null {
if (
result.maintainerCanModify === false &&
result.pushTarget !== undefined &&
result.pushTarget.remoteName !== 'origin'
) {
return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
}
return null
}
// Keep the renderer import stable while mobile and desktop share the predicate.
export {
FORK_PUSH_NO_MAINTAINER_EDIT_WARNING,
getForkPushWarning
} from '../../../shared/fork-push-warning'
+19
View File
@@ -0,0 +1,19 @@
import type { GitHubPrStartPoint } from './types'
export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING =
'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.'
// Why: only fork targets with maintainer edits explicitly disabled have the
// GitHub rejection risk; same-repo origin pushes do not.
export function getForkPushWarning(
result: Pick<GitHubPrStartPoint, 'pushTarget' | 'maintainerCanModify'>
): string | null {
if (
result.maintainerCanModify === false &&
result.pushTarget !== undefined &&
result.pushTarget.remoteName !== 'origin'
) {
return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
}
return null
}
+4 -1
View File
@@ -43,6 +43,8 @@ export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as cons
// floor-taking input. Mobile must not forward replies unless advertised.
export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY =
'terminal.query-reply-input.v1' as const
export const MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY =
'mobile.workspace-source-selector.v1' as const
export const RUNTIME_CAPABILITIES = [
'runtime.status.compat.v1',
@@ -59,7 +61,8 @@ export const RUNTIME_CAPABILITIES = [
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
AI_VAULT_RUNTIME_CAPABILITY,
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
WORKSPACE_SOURCE_QUERY_MAX_BYTES,
isWorkspaceSourceQueryWithinLimit
} from './workspace-source-query'
describe('workspace source query bound', () => {
it('measures UTF-8 bytes instead of JavaScript code units', () => {
expect(isWorkspaceSourceQueryWithinLimit('x'.repeat(WORKSPACE_SOURCE_QUERY_MAX_BYTES))).toBe(
true
)
expect(
isWorkspaceSourceQueryWithinLimit('é'.repeat(WORKSPACE_SOURCE_QUERY_MAX_BYTES / 2))
).toBe(true)
expect(
isWorkspaceSourceQueryWithinLimit(`é${'x'.repeat(WORKSPACE_SOURCE_QUERY_MAX_BYTES - 1)}`)
).toBe(false)
})
})
+12
View File
@@ -0,0 +1,12 @@
import { isClipboardTextByteLengthOverLimit } from './clipboard-text'
// Why: bound provider/ref fanout before dispatch while still allowing long,
// intentional search text across desktop and mobile clients.
export const WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048
export function isWorkspaceSourceQueryWithinLimit(
query: string,
maxBytes = WORKSPACE_SOURCE_QUERY_MAX_BYTES
): boolean {
return !isClipboardTextByteLengthOverLimit(query, maxBytes)
}