From 3769a70a31410d5660bbf178f470d02719db3bcc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:24:47 -0700 Subject: [PATCH] feat(mobile): add workspace source selection --- .../mobile-workspace-source-selector.md | 127 ++++++ mobile/app/h/[hostId]/index.tsx | 11 +- mobile/scripts/mock-server-rpc-handlers.ts | 10 +- .../scripts/mock-server-workspace-source.ts | 90 ++++ mobile/src/components/BottomDrawer.test.ts | 126 ++++++ mobile/src/components/BottomDrawer.tsx | 35 +- .../NewWorkspaceSourceDrawer.test.ts | 303 +++++++++++++ .../components/NewWorkspaceSourceDrawer.tsx | 418 ++++++++++++++++++ .../components/NewWorkspaceSourceField.tsx | 143 ++++++ .../src/components/NewWorktreeModal.test.ts | 217 +++++++++ mobile/src/components/NewWorktreeModal.tsx | 245 +++++----- .../components/NewWorktreeModalController.tsx | 6 + mobile/src/components/PickerListDrawer.tsx | 3 + ...ew-workspace-source-drawer-presentation.ts | 36 ++ .../new-workspace-source-drawer-state.test.ts | 13 + .../new-worktree-modal-presentation.ts | 53 +++ .../use-bottom-drawer-native-visibility.ts | 38 ++ .../mobile-composer-branch-selection.test.ts | 21 + .../tasks/mobile-composer-branch-selection.ts | 2 + mobile/src/tasks/mobile-workspace-name.ts | 42 +- .../new-workspace-source-types.ts | 47 ++ .../use-live-workspace-ssh-state.test.ts | 181 ++++++++ .../use-live-workspace-ssh-state.ts | 150 +++++++ .../use-new-workspace-source.test.ts | 132 ++++++ .../use-new-workspace-source.ts | 171 +++++++ .../workspace-source-availability.test.ts | 33 ++ .../workspace-source-availability.ts | 43 ++ .../workspace-source-create-candidate.test.ts | 51 +++ .../workspace-source-create-candidate.ts | 82 ++++ .../workspace-source-name-state.test.ts | 27 ++ .../workspace-source-name-state.ts | 36 ++ .../workspace-source-normalization.test.ts | 69 +++ .../workspace-source-normalization.ts | 143 ++++++ .../workspace-source-rpc.test.ts | 69 +++ .../workspace-source/workspace-source-rpc.ts | 137 ++++++ .../workspace-source-selection.test.ts | 53 +++ .../workspace-source-selection.ts | 112 +++++ src/main/runtime/orca-runtime.test.ts | 6 +- .../smart-workspace-source-results.ts | 9 +- src/renderer/src/hooks/fork-push-warning.ts | 26 +- src/shared/fork-push-warning.ts | 19 + src/shared/protocol-version.ts | 5 +- src/shared/workspace-source-query.test.ts | 19 + src/shared/workspace-source-query.ts | 12 + 44 files changed, 3381 insertions(+), 190 deletions(-) create mode 100644 docs/reference/mobile-workspace-source-selector.md create mode 100644 mobile/scripts/mock-server-workspace-source.ts create mode 100644 mobile/src/components/BottomDrawer.test.ts create mode 100644 mobile/src/components/NewWorkspaceSourceDrawer.test.ts create mode 100644 mobile/src/components/NewWorkspaceSourceDrawer.tsx create mode 100644 mobile/src/components/NewWorkspaceSourceField.tsx create mode 100644 mobile/src/components/NewWorktreeModal.test.ts create mode 100644 mobile/src/components/new-workspace-source-drawer-presentation.ts create mode 100644 mobile/src/components/new-workspace-source-drawer-state.test.ts create mode 100644 mobile/src/components/new-worktree-modal-presentation.ts create mode 100644 mobile/src/components/use-bottom-drawer-native-visibility.ts create mode 100644 mobile/src/tasks/mobile-composer-branch-selection.test.ts create mode 100644 mobile/src/workspace-source/new-workspace-source-types.ts create mode 100644 mobile/src/workspace-source/use-live-workspace-ssh-state.test.ts create mode 100644 mobile/src/workspace-source/use-live-workspace-ssh-state.ts create mode 100644 mobile/src/workspace-source/use-new-workspace-source.test.ts create mode 100644 mobile/src/workspace-source/use-new-workspace-source.ts create mode 100644 mobile/src/workspace-source/workspace-source-availability.test.ts create mode 100644 mobile/src/workspace-source/workspace-source-availability.ts create mode 100644 mobile/src/workspace-source/workspace-source-create-candidate.test.ts create mode 100644 mobile/src/workspace-source/workspace-source-create-candidate.ts create mode 100644 mobile/src/workspace-source/workspace-source-name-state.test.ts create mode 100644 mobile/src/workspace-source/workspace-source-name-state.ts create mode 100644 mobile/src/workspace-source/workspace-source-normalization.test.ts create mode 100644 mobile/src/workspace-source/workspace-source-normalization.ts create mode 100644 mobile/src/workspace-source/workspace-source-rpc.test.ts create mode 100644 mobile/src/workspace-source/workspace-source-rpc.ts create mode 100644 mobile/src/workspace-source/workspace-source-selection.test.ts create mode 100644 mobile/src/workspace-source/workspace-source-selection.ts create mode 100644 src/shared/fork-push-warning.ts create mode 100644 src/shared/workspace-source-query.test.ts create mode 100644 src/shared/workspace-source-query.ts diff --git a/docs/reference/mobile-workspace-source-selector.md b/docs/reference/mobile-workspace-source-selector.md new file mode 100644 index 00000000000..40f929501bd --- /dev/null +++ b/docs/reference/mobile-workspace-source-selector.md @@ -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-` | `linkedIssue`; auto-managed `displayName` | +| GitHub PR | Same naming with `pr-` 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-`/`pr-` 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. diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d613bff4575..114ef5e298f 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -175,7 +175,7 @@ export function HostScreen({ const [showGroupPicker, setShowGroupPicker] = useState(false) const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) - const [hostCapabilities, setHostCapabilities] = useState([]) + const [hostCapabilities, setHostCapabilities] = useState(null) const [confirmDelete, setConfirmDelete] = useState(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 }} diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index 22b725ad129..81a6da4d14b 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -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 diff --git a/mobile/scripts/mock-server-workspace-source.ts b/mobile/scripts/mock-server-workspace-source.ts new file mode 100644 index 00000000000..d2d4a2f7826 --- /dev/null +++ b/mobile/scripts/mock-server-workspace-source.ts @@ -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 +} diff --git a/mobile/src/components/BottomDrawer.test.ts b/mobile/src/components/BottomDrawer.test.ts new file mode 100644 index 00000000000..55c421e897b --- /dev/null +++ b/mobile/src/components/BottomDrawer.test.ts @@ -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 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) + }) +}) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 4e24e8c2e06..9e78928bdd3 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -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({ setMounted(false)} + onHidden={handleHidden} dragContentToDismiss={dragContentToDismiss} contentScrollable={contentScrollable} zIndex={zIndex} @@ -83,7 +92,7 @@ export function BottomDrawer({ } type MountedBottomDrawerProps = Props & { - onHidden: () => void + onHidden: NonNullable } 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 ( - + ({ + 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[0]> = {} +): Parameters[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[0]> = {} +): Promise { + 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) + }) +}) diff --git a/mobile/src/components/NewWorkspaceSourceDrawer.tsx b/mobile/src/components/NewWorkspaceSourceDrawer.tsx new file mode 100644 index 00000000000..cde9f1656f4 --- /dev/null +++ b/mobile/src/components/NewWorkspaceSourceDrawer.tsx @@ -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('all') + const [rows, setRows] = useState([]) + const [warnings, setWarnings] = useState([]) + const [errors, setErrors] = useState([]) + 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(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 | 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 => { + if (selectionInFlightRef.current) { + return + } + selectionInFlightRef.current = true + generationRef.current += 1 + const selectionEpoch = ++selectionEpochRef.current + setSelectionPending(true) + setSelectionError('') + let spinnerTimer: ReturnType | 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 ( + + + Start from + Choose an issue, pull request, or branch. + + + + {filters.map((item) => ( + setFilter(item)} + > + + {workspaceSourceFilterLabel(item)} + + + ))} + + + {message ? ( + + 0 || selectionError) && styles.errorText + ]} + numberOfLines={2} + > + {message} + + {!oversized && errors.length > 0 ? ( + setRetryKey((value) => value + 1)} + > + Retry + + ) : null} + + ) : null} + + row.key} + style={styles.list} + contentContainerStyle={rows.length === 0 ? styles.emptyList : undefined} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + nestedScrollEnabled + ItemSeparatorComponent={SourceSeparator} + ListEmptyComponent={ + + {showLoading ? ( + + ) : !loading && !oversized && !errors.length ? ( + {emptyCopy} + ) : null} + + } + renderItem={({ item }) => ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => void selectRow(item)} + > + + + + {workspaceSourceTitle(item)} + + + {workspaceSourceSubtitle(item)} + + + + {resolvingKey === item.key ? ( + + ) : null} + + + )} + /> + + ) +} + +function SourceIcon({ row }: { row: NewWorkspaceSourceRow }) { + const props = { size: 16, color: colors.textSecondary, strokeWidth: 2.1 } + return row.kind === 'branch' ? ( + + ) : row.kind === 'linear' ? ( + + ) : row.item.type === 'pr' ? ( + + ) : ( + + ) +} + +function SourceSeparator() { + return +} + +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 + } +}) diff --git a/mobile/src/components/NewWorkspaceSourceField.tsx b/mobile/src/components/NewWorkspaceSourceField.tsx new file mode 100644 index 00000000000..9642365862f --- /dev/null +++ b/mobile/src/components/NewWorkspaceSourceField.tsx @@ -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 ( + + + Start from [Optional] + + { + onPrepareOpen() + controller.setDrawerVisible(true) + }} + > + + + {source ? sourceLabel(source) : 'Default branch'} + + {source ? ( + { + event.stopPropagation() + controller.clearSource() + }} + > + + + ) : ( + + )} + + {source?.kind === 'branch' && source.reuseEligibleBranch ? ( + + + Reuse existing branch + + Check out {source.reuseEligibleBranch} without creating a new branch. + + + + + ) : null} + {source?.kind === 'github' && source.forkWarning ? ( + {source.forkWarning} + ) : null} + + ) +} + +function sourceLabel(source: NonNullable): 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' ? ( + + ) : source?.kind === 'linear' ? ( + + ) : source?.kind === 'github' && source.item.type === 'pr' ? ( + + ) : ( + + ) +} + +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 } +}) diff --git a/mobile/src/components/NewWorktreeModal.test.ts b/mobile/src/components/NewWorktreeModal.test.ts new file mode 100644 index 00000000000..b364a1876a0 --- /dev/null +++ b/mobile/src/components/NewWorktreeModal.test.ts @@ -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 { + 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) + }) +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 961a62a7eaa..667f682742e 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -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(null) - const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [name, setName] = useState('') const [note, setNote] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(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 { - 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 = { - repo: `id:${selectedRepo.id}`, + const baseParams: Record = { 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 ( <> - + modalPresentation.handleLayerHidden('form')} + > Create Workspace @@ -701,7 +690,7 @@ function NewWorktreeModalContent({ { - 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()} > {sshGate.connectInProgress ? 'Connecting...' : 'Connect'} @@ -763,15 +752,22 @@ function NewWorktreeModalContent({ ) : null} + {selectedRepo ? ( + openSelectionLayer('source')} + /> + ) : null} + Workspace Name [Optional] { - 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. */} + { + workspaceSource.selectSource(source) + modalPresentation.closeLayer('source') + }} + onClose={() => closeSelectionLayer('source')} + onHidden={() => modalPresentation.handleLayerHidden('source')} + onOpen={workspaceSource.refreshLinearStatus} + /> + setSelectedRepo(item.repo)} - onClose={() => setShowRepoPicker(false)} + onClose={() => closeSelectionLayer('repository')} + onHidden={() => modalPresentation.handleLayerHidden('repository')} renderIcon={(item) => { return }} /> setShowAgentPicker(false)} + onClose={() => closeSelectionLayer('agent')} + onHidden={() => modalPresentation.handleLayerHidden('agent')} renderIcon={(agent) => } /> setSetupTrustPrompt(null)} + visible={ + visible && modalPresentation.visibleLayer === 'setup-trust' && setupTrustPrompt != null + } + onClose={closeSetupTrustPrompt} + onHidden={() => modalPresentation.handleLayerHidden('setup-trust')} > {setupTrustPrompt ? ( @@ -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' }) }} > diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index df422642951..0dda1cc4059 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -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 diff --git a/mobile/src/components/PickerListDrawer.tsx b/mobile/src/components/PickerListDrawer.tsx index 018832cdf98..a9d5bc34e9c 100644 --- a/mobile/src/components/PickerListDrawer.tsx +++ b/mobile/src/components/PickerListDrawer.tsx @@ -12,6 +12,7 @@ type Props = { selectedId: string onSelect: (item: T) => void onClose: () => void + onHidden?: () => void renderIcon?: (item: T) => ReactNode } @@ -22,6 +23,7 @@ export function PickerListDrawer({ selectedId, onSelect, onClose, + onHidden, renderIcon }: Props) { const [closing, setClosing] = useState(false) @@ -64,6 +66,7 @@ export function PickerListDrawer({ diff --git a/mobile/src/components/new-workspace-source-drawer-presentation.ts b/mobile/src/components/new-workspace-source-drawer-presentation.ts new file mode 100644 index 00000000000..4efe0712a62 --- /dev/null +++ b/mobile/src/components/new-workspace-source-drawer-presentation.ts @@ -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)}` +} diff --git a/mobile/src/components/new-workspace-source-drawer-state.test.ts b/mobile/src/components/new-workspace-source-drawer-state.test.ts new file mode 100644 index 00000000000..b41399a20b9 --- /dev/null +++ b/mobile/src/components/new-workspace-source-drawer-state.test.ts @@ -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']) + }) +}) diff --git a/mobile/src/components/new-worktree-modal-presentation.ts b/mobile/src/components/new-worktree-modal-presentation.ts new file mode 100644 index 00000000000..cbc284747d2 --- /dev/null +++ b/mobile/src/components/new-worktree-modal-presentation.ts @@ -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) => { + setState((current) => + current.visible === 'form' && current.exiting === null + ? { visible: null, exiting: 'form', next: layer } + : current + ) + }, []) + + const closeLayer = useCallback((layer: Exclude) => { + 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 + } +} diff --git a/mobile/src/components/use-bottom-drawer-native-visibility.ts b/mobile/src/components/use-bottom-drawer-native-visibility.ts new file mode 100644 index 00000000000..6ece2981fed --- /dev/null +++ b/mobile/src/components/use-bottom-drawer-native-visibility.ts @@ -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 } +} diff --git a/mobile/src/tasks/mobile-composer-branch-selection.test.ts b/mobile/src/tasks/mobile-composer-branch-selection.test.ts new file mode 100644 index 00000000000..32c70c83890 --- /dev/null +++ b/mobile/src/tasks/mobile-composer-branch-selection.test.ts @@ -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 + }) + }) +}) diff --git a/mobile/src/tasks/mobile-composer-branch-selection.ts b/mobile/src/tasks/mobile-composer-branch-selection.ts index 042ab14ba17..1e98d3f0b83 100644 --- a/mobile/src/tasks/mobile-composer-branch-selection.ts +++ b/mobile/src/tasks/mobile-composer-branch-selection.ts @@ -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 { diff --git a/mobile/src/tasks/mobile-workspace-name.ts b/mobile/src/tasks/mobile-workspace-name.ts index 52330e834d7..32f877ae3a9 100644 --- a/mobile/src/tasks/mobile-workspace-name.ts +++ b/mobile/src/tasks/mobile-workspace-name.ts @@ -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' diff --git a/mobile/src/workspace-source/new-workspace-source-types.ts b/mobile/src/workspace-source/new-workspace-source-types.ts new file mode 100644 index 00000000000..8529216a1fd --- /dev/null +++ b/mobile/src/workspace-source/new-workspace-source-types.ts @@ -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 +} diff --git a/mobile/src/workspace-source/use-live-workspace-ssh-state.test.ts b/mobile/src/workspace-source/use-live-workspace-ssh-state.test.ts new file mode 100644 index 00000000000..e4d16741a61 --- /dev/null +++ b/mobile/src/workspace-source/use-live-workspace-ssh-state.test.ts @@ -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 +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 { + 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 { + 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) => 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) => 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) => 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')) + }) +}) diff --git a/mobile/src/workspace-source/use-live-workspace-ssh-state.ts b/mobile/src/workspace-source/use-live-workspace-ssh-state.ts new file mode 100644 index 00000000000..71812b59b17 --- /dev/null +++ b/mobile/src/workspace-source/use-live-workspace-ssh-state.ts @@ -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 { + 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(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 => { + 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 } +} diff --git a/mobile/src/workspace-source/use-new-workspace-source.test.ts b/mobile/src/workspace-source/use-new-workspace-source.test.ts new file mode 100644 index 00000000000..7417af5d4c7 --- /dev/null +++ b/mobile/src/workspace-source/use-new-workspace-source.test.ts @@ -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[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 { + return { + visible: true, + client, + capabilities, + repo: { id: 'repo-1', displayName: 'App', kind: 'git' }, + repoConnected: true, + sshStateGeneration: 0, + ...overrides + } +} + +async function mountHook(): Promise { + 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' }) + }) +}) diff --git a/mobile/src/workspace-source/use-new-workspace-source.ts b/mobile/src/workspace-source/use-new-workspace-source.ts new file mode 100644 index 00000000000..4deabb7f775 --- /dev/null +++ b/mobile/src/workspace-source/use-new-workspace-source.ts @@ -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(null) + const [linearConnected, setLinearConnected] = useState(false) + const [drawerVisible, setDrawerVisible] = useState(false) + const linearGenerationRef = useRef(0) + const previousRepoIdRef = useRef(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 + } +} diff --git a/mobile/src/workspace-source/workspace-source-availability.test.ts b/mobile/src/workspace-source/workspace-source-availability.test.ts new file mode 100644 index 00000000000..6b39b4fd33b --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-availability.test.ts @@ -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 }) + }) +}) diff --git a/mobile/src/workspace-source/workspace-source-availability.ts b/mobile/src/workspace-source/workspace-source-availability.ts new file mode 100644 index 00000000000..2a2893387af --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-availability.ts @@ -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) : []) + ] +} diff --git a/mobile/src/workspace-source/workspace-source-create-candidate.test.ts b/mobile/src/workspace-source/workspace-source-create-candidate.test.ts new file mode 100644 index 00000000000..7e3ba908b79 --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-create-candidate.test.ts @@ -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' }) + }) +}) diff --git a/mobile/src/workspace-source/workspace-source-create-candidate.ts b/mobile/src/workspace-source/workspace-source-create-candidate.ts new file mode 100644 index 00000000000..e341c2f388f --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-create-candidate.ts @@ -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 + baseName: string + name: WorkspaceNameState + source: ResolvedNewWorkspaceSource | null + selectedRepoId: string + attempt: number +}): Record { + const candidateName = suffixed(args.baseName, args.attempt) + const source = args.source + const params: Record = { + ...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 } : {}) + } +} diff --git a/mobile/src/workspace-source/workspace-source-name-state.test.ts b/mobile/src/workspace-source/workspace-source-name-state.test.ts new file mode 100644 index 00000000000..6adf9a8eb05 --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-name-state.test.ts @@ -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' + }) + }) +}) diff --git a/mobile/src/workspace-source/workspace-source-name-state.ts b/mobile/src/workspace-source/workspace-source-name-state.ts new file mode 100644 index 00000000000..c6fbbb0257c --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-name-state.ts @@ -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 +} diff --git a/mobile/src/workspace-source/workspace-source-normalization.test.ts b/mobile/src/workspace-source/workspace-source-normalization.test.ts new file mode 100644 index 00000000000..539c92fc487 --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-normalization.test.ts @@ -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 }) + ]) + }) +}) diff --git a/mobile/src/workspace-source/workspace-source-normalization.ts b/mobile/src/workspace-source/workspace-source-normalization.ts new file mode 100644 index 00000000000..5df2a0a196d --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-normalization.ts @@ -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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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] +} diff --git a/mobile/src/workspace-source/workspace-source-rpc.test.ts b/mobile/src/workspace-source/workspace-source-rpc.test.ts new file mode 100644 index 00000000000..530f23474d5 --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-rpc.test.ts @@ -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.']) + }) +}) diff --git a/mobile/src/workspace-source/workspace-source-rpc.ts b/mobile/src/workspace-source/workspace-source-rpc.ts new file mode 100644 index 00000000000..62ca274778e --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-rpc.ts @@ -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>): 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 { + 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 { + 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 { + 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 { + const query = args.query.trim() + const reads: Array<{ kind: 'github' | 'branches' | 'linear'; read: Promise }> = [] + 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 { + 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 +} diff --git a/mobile/src/workspace-source/workspace-source-selection.test.ts b/mobile/src/workspace-source/workspace-source-selection.test.ts new file mode 100644 index 00000000000..b536e6744ae --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-selection.test.ts @@ -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 }) + }) +}) diff --git a/mobile/src/workspace-source/workspace-source-selection.ts b/mobile/src/workspace-source/workspace-source-selection.ts new file mode 100644 index 00000000000..3ae41b18de2 --- /dev/null +++ b/mobile/src/workspace-source/workspace-source-selection.ts @@ -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 + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8d9eee175f..2b4bc287194 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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') diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts index abbbfb04062..6ef7fecf7a2 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts @@ -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({ diff --git a/src/renderer/src/hooks/fork-push-warning.ts b/src/renderer/src/hooks/fork-push-warning.ts index 27ab7af8073..f7bb5860855 100644 --- a/src/renderer/src/hooks/fork-push-warning.ts +++ b/src/renderer/src/hooks/fork-push-warning.ts @@ -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 -): 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' diff --git a/src/shared/fork-push-warning.ts b/src/shared/fork-push-warning.ts new file mode 100644 index 00000000000..1f1d1300798 --- /dev/null +++ b/src/shared/fork-push-warning.ts @@ -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 +): string | null { + if ( + result.maintainerCanModify === false && + result.pushTarget !== undefined && + result.pushTarget.remoteName !== 'origin' + ) { + return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING + } + return null +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 89e4303014c..14a251568bc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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 & {}) diff --git a/src/shared/workspace-source-query.test.ts b/src/shared/workspace-source-query.test.ts new file mode 100644 index 00000000000..1f8a113eae2 --- /dev/null +++ b/src/shared/workspace-source-query.test.ts @@ -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) + }) +}) diff --git a/src/shared/workspace-source-query.ts b/src/shared/workspace-source-query.ts new file mode 100644 index 00000000000..b4515d49b0a --- /dev/null +++ b/src/shared/workspace-source-query.ts @@ -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) +}