feat(mobile): match desktop's Smart source picker exactly

Rework the mobile create-workspace source picker to be a faithful port of
desktop's Smart picker instead of the earlier divergent "Start from" drawer.

The mobile field is now the workspace-name input AND the source search, with the
exact desktop tabs — Smart · GitHub · Linear · GitLab · Branch · Name. "Smart"
fans out across GitHub + GitLab + Linear + branches, prepends a "Use '<name>'"
row, and resolves pasted URLs / #123 / STA-42 to exact items (with a cross-repo
switch prompt). Selecting a source shows a pill and moves the editable name into
Advanced. The invented "Blank workspace" concept is removed — the neutral state
is just a typed/empty name (blank submit still yields a creature name).

DRY: the pure desktop logic (smart-workspace-source-results, -command-value,
github-links, gitlab-links, work-item-link-query-bounds, github-work-item-identity)
moves to src/shared/new-workspace/ with re-export shims at the old renderer paths,
so both renderer and mobile share one implementation. composer-branch-selection
and workspace-name were already shared and are reused directly.

Two read-only lookup RPCs are allowlisted for mobile so pasted GitLab URLs and
cross-repo GitHub URLs resolve to exact items (github.workItemByOwnerRepo,
gitlab.workItemByPath).

New mobile modules are split for max-lines: use-mobile-composer-source (selection
state + desktop-parity handlers, PR/MR base resolve), use-smart-workspace-source
+ smart-source-fan-out/-search-requests/-paste-intent (RPC orchestration),
composer-linked-work-item / work-item-lookup-text / mobile-smart-source-modes
(pure logic), and SmartWorkspaceSourceField/Drawer/Row + SmartWorkspaceAdvancedFields.
Replaces WorkspaceSourcePickerDrawer/Row, workspace-source-selection,
use-workspace-source-search, and MobileWorkspaceNameInput.

Reviewed by three adversarial agents + re-reviewed after fixes: GitHub search now
returns issues AND PRs (not issues-only), Linear defaults to assigned, create-branch
preserves slashy names, cross-repo PR base resolves against the item's own repo,
displayName is suppressed for user-edited names, and the smart-mode GitHub fan-out
respects availability. tsc/oxlint/max-lines-ratchet clean; 1328 mobile tests pass.
This commit is contained in:
Brennan Benson
2026-07-09 21:03:34 -07:00
parent d53c985b52
commit efefb6485e
45 changed files with 3949 additions and 2017 deletions
+61 -42
View File
@@ -1895,48 +1895,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -2009,48 +2017,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2512,36 +2528,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -2842,11 +2864,6 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.17.0:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -4821,24 +4838,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -6945,22 +6966,22 @@ snapshots:
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -6970,7 +6991,7 @@ snapshots:
'@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -7005,12 +7026,12 @@ snapshots:
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -7025,42 +7046,42 @@ snapshots:
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -8422,7 +8443,7 @@ snapshots:
'@jest/console@29.7.0':
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.6.0
'@types/node': 25.9.3
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
@@ -8589,7 +8610,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 25.6.0
'@types/node': 25.9.3
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -9347,24 +9368,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@types/chai@5.2.3':
dependencies:
@@ -9379,7 +9400,7 @@ snapshots:
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 25.6.0
'@types/node': 25.9.3
'@types/hammerjs@2.0.46': {}
@@ -9626,9 +9647,9 @@ snapshots:
acorn: 8.15.0
acorn-walk: 8.3.5
acorn-jsx@5.3.2(acorn@8.17.0):
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.17.0
acorn: 8.15.0
acorn-walk@8.3.5:
dependencies:
@@ -9636,8 +9657,6 @@ snapshots:
acorn@8.15.0: {}
acorn@8.17.0: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@@ -9792,7 +9811,7 @@ snapshots:
babel-plugin-istanbul@6.1.1:
dependencies:
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@istanbuljs/load-nyc-config': 1.1.0
'@istanbuljs/schema': 0.1.6
istanbul-lib-instrument: 5.2.1
@@ -10465,7 +10484,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.3
hasown: 2.0.4
es-shim-unscopables@1.1.0:
dependencies:
@@ -10761,8 +10780,8 @@ snapshots:
espree@10.4.0:
dependencies:
acorn: 8.17.0
acorn-jsx: 5.3.2(acorn@8.17.0)
acorn: 8.15.0
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1
esprima@4.0.1: {}
@@ -11295,7 +11314,7 @@ snapshots:
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.3
hasown: 2.0.4
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
@@ -11867,7 +11886,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
'@types/node': 25.6.0
'@types/node': 25.9.3
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -12022,7 +12041,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.6.0
'@types/node': 25.9.3
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -12067,7 +12086,7 @@ snapshots:
jest-worker@29.7.0:
dependencies:
'@types/node': 25.6.0
'@types/node': 25.9.3
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -13740,7 +13759,7 @@ snapshots:
terser@5.48.0:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.17.0
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
+95 -112
View File
@@ -17,7 +17,6 @@ import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import {
@@ -49,13 +48,7 @@ import {
resolveMobileNewWorkspaceDialogRepoId
} from '../worktree/new-workspace-dialog-repo-selection'
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
import { createWorkspaceFromSource } from '../tasks/source-workspace-create'
import {
describeWorkspaceSource,
isRepoBoundSource,
resolveSourceRepoId,
type WorkspaceSource
} from '../tasks/workspace-source-selection'
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import {
@@ -63,7 +56,11 @@ import {
normalizeVisibleTaskProviders,
type TaskProvider
} from '../tasks/mobile-task-providers'
import { WorkspaceSourcePickerDrawer } from './WorkspaceSourcePickerDrawer'
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
type Repo = {
@@ -72,6 +69,9 @@ type Repo = {
path: string
badgeColor?: string
connectionId?: string | null
kind?: 'git' | 'folder'
upstream?: { owner: string; repo: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}
type SetupDecision = 'inherit' | 'run' | 'skip'
@@ -183,7 +183,6 @@ function NewWorktreeModalContent({
const [repos, setRepos] = useState<Repo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [showRepoPicker, setShowRepoPicker] = useState(false)
const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
@@ -193,10 +192,7 @@ function NewWorktreeModalContent({
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [workspaceSource, setWorkspaceSource] = useState<WorkspaceSource>({ kind: 'blank' })
const [showSourcePicker, setShowSourcePicker] = useState(false)
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
const [tasksSupported, setTasksSupported] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
@@ -213,11 +209,15 @@ function NewWorktreeModalContent({
const [loading, setLoading] = useState(initialRepos == null)
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
// Why: matches the desktop UI — the input shows a generic "Workspace name"
// placeholder, not the suggested creature. The creature name is only used
// as a server-bound fallback when the user submits with a blank field, so
// it's recomputed lazily inside handleCreate() to stay fresh against
// existingWorktreePaths at submission time.
// The Smart source picker owns the workspace name AND the linked-source
// selection: typing names the workspace and drives source search, and picking
// a source resolves the base/branch/push metadata (matching desktop). The
// creature-name fallback is only computed lazily at submit for a blank name.
const composer = useMobileComposerSource({
client,
selectedRepoId: selectedRepo?.id ?? null,
onError: setError
})
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
const sshGate = deriveWorkspaceSshGate({
@@ -254,6 +254,25 @@ function NewWorktreeModalContent({
}
const selectedAgent = selectedAgentResolution.selectedAgent
const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true
const sourceAvailability: SmartModeAvailabilityInput = {
textOnly: selectedRepo != null && !selectedRepoIsGit,
tasksSupported,
hasRepo: selectedRepo != null,
githubAvailable: availableProviders.includes('github'),
gitlabAvailable: availableProviders.includes('gitlab'),
linearAvailable: availableProviders.includes('linear')
}
const pasteRepos = useMemo<PasteRepoCandidate[]>(
() =>
repos.map((repo) => ({
id: repo.id,
displayName: repo.displayName,
slug: deriveRepoSlug(repo)
})),
[repos]
)
useEffect(() => {
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
return
@@ -608,7 +627,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 = composer.name.trim()
const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? [])
let setupDecision: SetupDecision = 'inherit'
@@ -645,29 +664,30 @@ function NewWorktreeModalContent({
const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined
const trimmedNote = note.trim() || undefined
const result =
workspaceSource.kind === 'blank'
? await createBlankWorkspace({
client,
repoId: selectedRepo.id,
baseName,
startupCommand: command,
createdWithAgentId,
comment: trimmedNote,
setupDecision
})
: await createWorkspaceFromSource({
client,
source: workspaceSource,
targetRepoId: resolveSourceRepoId(workspaceSource) ?? selectedRepo.id,
setupDecision,
agent: {
choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank',
startupCommand: command
},
workspaceName: name.trim() || undefined,
note: trimmedNote
})
const createSelection = composer.createSelection
const result = createSelection
? await createWorkspaceFromComposerSource({
client,
selection: createSelection,
targetRepoId: selectedRepo.id,
setupDecision,
agent: {
choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank',
startupCommand: command
},
workspaceName: trimmedName || undefined,
note: trimmedNote,
nameIsAutoManaged: composer.isNameAutoManaged
})
: await createBlankWorkspace({
client,
repoId: selectedRepo.id,
baseName,
startupCommand: command,
createdWithAgentId,
comment: trimmedNote,
setupDecision
})
if ('error' in result) {
setError(result.error)
return
@@ -707,22 +727,19 @@ function NewWorktreeModalContent({
)
function prepareSelectionPickerOpen(): void {
// Why: picker taps can beat the delayed name-field focus; suppressing it
// prevents the keyboard from reopening under the picker drawer.
setNameAutoFocusEnabled(false)
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
// keyboard from reopening under the picker drawer.
Keyboard.dismiss()
}
function handleSourceSelected(source: WorkspaceSource): void {
// A GitHub/GitLab work item dictates its own repo; pin the repo picker to it.
const pinnedRepoId = resolveSourceRepoId(source)
if (pinnedRepoId) {
const pinnedRepo = repos.find((repo) => repo.id === pinnedRepoId)
if (pinnedRepo) {
setSelectedRepo(pinnedRepo)
}
function handleRepoSelected(repo: Repo): void {
const repoChanged = repo.id !== selectedRepo?.id
setSelectedRepo(repo)
// Branch and GitHub/GitLab sources are tied to the repo they were searched
// from; switching repos invalidates them, so clear the selection.
if (repoChanged) {
composer.handleClearSmartNameSelection()
}
setWorkspaceSource(source)
}
async function approveSetupTrust(alwaysTrust: boolean): Promise<void> {
@@ -787,21 +804,24 @@ function NewWorktreeModalContent({
</Pressable>
</View>
<View style={styles.field}>
<Text style={styles.label}>Start from</Text>
<Pressable
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
setShowSourcePicker(true)
}}
>
<Text style={styles.fieldButtonText} numberOfLines={1}>
{describeWorkspaceSource(workspaceSource).label}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<SmartWorkspaceSourceField
visible={visible}
composer={composer}
client={client}
availability={sourceAvailability}
repoId={selectedRepo?.id ?? null}
repos={pasteRepos}
sshReady={!sshGate.requiresConnection}
label={selectedRepoIsGit ? 'Name or Create From' : 'Workspace name'}
disabled={sshGate.requiresConnection}
onRepoChange={(repoId) => {
const nextRepo = repos.find((repo) => repo.id === repoId)
if (nextRepo) {
setSelectedRepo(nextRepo)
}
}}
onBeforeOpen={() => setError('')}
/>
{selectedRepoConnectionId ? (
<View style={styles.field}>
@@ -846,28 +866,6 @@ function NewWorktreeModalContent({
</View>
) : null}
<View style={styles.field}>
<Text style={styles.label}>
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<MobileWorkspaceNameInput
style={styles.input}
value={name}
onChangeText={(t) => {
setName(t)
setError('')
}}
placeholderTextColor={colors.textMuted}
shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0}
returnKeyType="done"
onSubmitEditing={() => {
if (canCreate) {
void handleCreate()
}
}}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Agent</Text>
<Pressable
@@ -897,6 +895,11 @@ function NewWorktreeModalContent({
{showAdvanced && (
<>
<SmartWorkspaceAdvancedFields
composer={composer}
selectedRepoIsGit={selectedRepoIsGit}
/>
<View style={styles.field}>
<Text style={styles.label}>Note</Text>
<TextInput
@@ -993,16 +996,7 @@ function NewWorktreeModalContent({
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => {
const repoChanged = item.repo.id !== selectedRepo?.id
setSelectedRepo(item.repo)
// Branch and GitHub/GitLab sources are tied to the repo they were
// searched from; switching repos invalidates them, so reset to blank.
// Linear issues are repo-agnostic and survive.
if (repoChanged && isRepoBoundSource(workspaceSource)) {
setWorkspaceSource({ kind: 'blank' })
}
}}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => setShowRepoPicker(false)}
renderIcon={(item) => {
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
@@ -1022,17 +1016,6 @@ function NewWorktreeModalContent({
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
/>
<WorkspaceSourcePickerDrawer
visible={visible && showSourcePicker}
client={client}
tasksSupported={tasksSupported}
availableProviders={availableProviders}
repoId={selectedRepo?.id ?? null}
sshReady={!sshGate.requiresConnection}
onSelect={handleSourceSelected}
onClose={() => setShowSourcePicker(false)}
/>
<SetupHookTrustDrawer
visible={visible && setupTrustPrompt != null}
prompt={setupTrustPrompt}
@@ -0,0 +1,18 @@
import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes'
import { TaskProviderLogo } from './TaskProviderLogo'
// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes,
// lucide glyphs for the neutral modes.
export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) {
if (icon.type === 'provider') {
return <TaskProviderLogo provider={icon.provider} size={14} color={color} />
}
if (icon.name === 'sparkles') {
return <Sparkles size={14} color={color} />
}
if (icon.name === 'git-branch') {
return <GitBranch size={14} color={color} />
}
return <CaseSensitive size={14} color={color} />
}
@@ -0,0 +1,102 @@
import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Props = {
composer: MobileComposerSource
selectedRepoIsGit: boolean
}
// The Advanced-section source controls: the editable Name appears once a source
// pill is shown (the field itself is no longer the name input); the branch-name
// override and reuse toggle mirror the desktop composer's advanced branch fields.
export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) {
const selection = composer.smartNameSelection
const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch')
return (
<>
{selection ? (
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
value={composer.name}
onChangeText={composer.setName}
placeholder="Workspace name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{showBranchOverride ? (
<View style={styles.field}>
<Text style={styles.label}>Branch name</Text>
<TextInput
style={styles.input}
value={composer.branchNameOverride ?? ''}
onChangeText={composer.handleBranchNameOverrideChange}
placeholder="Derived from name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{composer.reuseEligibleBranch ? (
<View style={styles.field}>
<View style={styles.reuseRow}>
<Text style={styles.reuseLabel} numberOfLines={1}>
Reuse branch “{composer.reuseEligibleBranch}”
</Text>
<Switch
value={composer.reuseSelectedBranch}
onValueChange={composer.setReuseSelectedBranch}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
style={styles.reuseSwitch}
/>
</View>
</View>
) : null}
</>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reuseRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
reuseLabel: {
flex: 1,
fontSize: 13,
color: colors.textSecondary
},
reuseSwitch: {
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
}
})
@@ -0,0 +1,425 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import {
MR_STATE_FILTER_OPTIONS,
resolveAvailableSmartModes,
resolveDefaultSmartMode,
SMART_MODE_OPTIONS,
type SmartModeAvailabilityInput,
type SmartModeOption
} from '../tasks/mobile-smart-source-modes'
import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types'
import {
lookupGitHubItemByOwnerRepo,
type PasteRepoCandidate
} from '../tasks/smart-source-paste-intent'
import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { SmartSourceModeIcon } from './SmartSourceModeIcon'
import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow'
type Props = {
visible: boolean
client: RpcClient | null
composer: MobileComposerSource
availability: SmartModeAvailabilityInput
repoId: string | null
repos: readonly PasteRepoCandidate[]
linearWorkspaceId?: string | null
sshReady: boolean
onRepoChange: (repoId: string) => void
onClose: () => void
}
export function SmartWorkspaceSourceDrawer({
visible,
client,
composer,
availability,
repoId,
repos,
linearWorkspaceId,
sshReady,
onRepoChange,
onClose
}: Props) {
const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability])
const [mode, setMode] = useState<SmartNameMode>(() => resolveDefaultSmartMode(availability))
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
// Why: read latest availability inside the open effect without making it a
// reactive dep (the object is recreated each render), so re-seeding happens
// only on open, not on every availability recompute.
const availabilityRef = useRef(availability)
availabilityRef.current = availability
// Reset to the default mode each time the drawer opens.
useEffect(() => {
if (visible) {
setMode(resolveDefaultSmartMode(availabilityRef.current))
}
}, [visible])
// Snap the chosen mode back into the available set if availability changes.
const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text')
// Linear searches without a repo; every other provider/branch search needs a
// connected repo-backed target.
const searchEnabled = visible && (effectiveMode === 'linear' || sshReady)
const {
rows,
loading,
error,
needsGitHubRemote,
emptyHint,
crossRepoPrompt,
dismissCrossRepoPrompt
} = useSmartWorkspaceSource({
client,
enabled: searchEnabled,
mode: effectiveMode,
query: composer.name,
repoId,
githubAvailable: availability.githubAvailable,
gitlabAvailable: availability.gitlabAvailable,
linearAvailable: availability.linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos
})
function closeSoon(): void {
setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS)
}
function handleSelectRow(row: SourceRow): void {
switch (row.kind) {
case 'use-name':
composer.setName(row.name)
break
case 'create-branch':
composer.handleSmartCreateBranch(row.name)
break
case 'github':
composer.handleSmartGitHubItemSelect(row.item)
break
case 'gitlab':
composer.handleSmartGitLabItemSelect(row.item)
break
case 'branch':
composer.handleSmartBranchSelect(row.refName, row.localBranchName)
break
case 'linear':
composer.handleSmartLinearIssueSelect(row.issue)
break
}
onClose()
}
async function handleAcceptCrossRepo(): Promise<void> {
if (!client || !crossRepoPrompt) {
return
}
const { link, matchingRepo } = crossRepoPrompt
try {
const item = await lookupGitHubItemByOwnerRepo(
client,
matchingRepo.id,
link.slug,
link.number,
link.type
)
if (item) {
onRepoChange(matchingRepo.id)
composer.handleSmartGitHubItemSelect(item)
onClose()
}
} catch {
dismissCrossRepoPrompt()
}
}
const showEmpty =
!loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0
return (
<BottomDrawer
visible={visible}
onClose={onClose}
dragContentToDismiss={false}
contentScrollable={false}
>
<View style={styles.header}>
<Text style={styles.title}>Name or Create From</Text>
<Pressable onPress={closeSoon} hitSlop={8}>
<Text style={styles.done}>Done</Text>
</Pressable>
</View>
<TextInput
style={styles.search}
value={composer.name}
onChangeText={composer.setName}
placeholder="Type a name or search a source"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<View style={styles.tabRow}>
{SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
availableModes.includes(option.id)
).map((option) => {
const selected = option.id === effectiveMode
const tint = selected ? colors.textPrimary : colors.textSecondary
return (
<Pressable
key={option.id}
style={[styles.tab, selected && styles.tabSelected]}
onPress={() => setMode(option.id)}
>
<SmartSourceModeIcon icon={option.icon} color={tint} />
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
{effectiveMode === 'gitlab' ? (
<View style={styles.chipRow}>
{MR_STATE_FILTER_OPTIONS.map((option) => {
const selected = option.id === mrStateFilter
return (
<Pressable
key={option.id}
style={[styles.chip, selected && styles.chipSelected]}
onPress={() => setMrStateFilter(option.id)}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
) : null}
{crossRepoPrompt ? (
<View style={styles.crossRepo}>
<Text style={styles.crossRepoText}>
This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
</Text>
<View style={styles.crossRepoActions}>
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
<Text style={styles.crossRepoDismissText}>Cancel</Text>
</Pressable>
<Pressable style={styles.crossRepoSwitch} onPress={() => void handleAcceptCrossRepo()}>
<Text style={styles.crossRepoSwitchText}>
Switch to {crossRepoPrompt.matchingRepo.displayName}
</Text>
</Pressable>
</View>
</View>
) : null}
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
<Text style={styles.notice}>Connect the repository to search sources.</Text>
) : needsGitHubRemote ? (
<Text style={styles.notice}>
This SSH repo needs a GitHub remote to list issues and PRs.
</Text>
) : error ? (
<Text style={styles.errorNotice}>{error}</Text>
) : null}
<FlatList
data={rows}
keyExtractor={(row) => row.value}
style={styles.list}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
ListFooterComponent={
loading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : showEmpty ? (
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
) : null
}
renderItem={({ item }) => (
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
)}
/>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
done: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.accentBlue
},
search: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.sm
},
tabRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginBottom: spacing.sm
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
tabText: {
fontSize: 13,
color: colors.textSecondary
},
tabTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
chipRow: {
flexDirection: 'row',
gap: spacing.xs,
marginBottom: spacing.sm
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
chipSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
chipText: {
fontSize: 12,
color: colors.textSecondary
},
chipTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
crossRepo: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.sm,
gap: spacing.sm
},
crossRepoText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm
},
crossRepoDismiss: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
crossRepoDismissText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoSwitch: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.textSecondary
},
crossRepoSwitchText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
notice: {
fontSize: 12,
color: colors.textMuted,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
errorNotice: {
fontSize: 12,
color: colors.statusRed,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
list: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden',
maxHeight: 420,
flexGrow: 0
},
loading: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
empty: {
paddingVertical: spacing.lg,
textAlign: 'center',
color: colors.textMuted,
fontSize: 13
}
})
@@ -0,0 +1,178 @@
import { useState } from 'react'
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import {
CircleDot,
ExternalLink,
GitBranch,
GitMerge,
GitPullRequest,
X
} from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { SmartNameSelection } from '../tasks/mobile-composer-source-types'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
import { TaskProviderLogo } from './TaskProviderLogo'
type Props = {
visible: boolean
composer: MobileComposerSource
client: RpcClient | null
availability: SmartModeAvailabilityInput
repoId: string | null
repos: readonly PasteRepoCandidate[]
linearWorkspaceId?: string | null
sshReady: boolean
label: string
disabled?: boolean
onRepoChange: (repoId: string) => void
onBeforeOpen?: () => void
}
function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) {
if (kind === 'github-pr') {
return <GitPullRequest size={15} color={colors.textSecondary} />
}
if (kind === 'gitlab-mr') {
return <GitMerge size={15} color={colors.textSecondary} />
}
if (kind === 'github-issue' || kind === 'gitlab-issue') {
return <CircleDot size={15} color={colors.textSecondary} />
}
if (kind === 'branch') {
return <GitBranch size={15} color={colors.textSecondary} />
}
return <TaskProviderLogo provider="linear" size={15} color={colors.textSecondary} />
}
export function SmartWorkspaceSourceField({
visible,
composer,
client,
availability,
repoId,
repos,
linearWorkspaceId,
sshReady,
label,
disabled,
onRepoChange,
onBeforeOpen
}: Props) {
const [showDrawer, setShowDrawer] = useState(false)
const selection = composer.smartNameSelection
function openDrawer(): void {
if (disabled) {
return
}
onBeforeOpen?.()
setShowDrawer(true)
}
return (
<View style={styles.field}>
<Text style={styles.label}>
{label} <Text style={styles.labelHint}>[Optional]</Text>
</Text>
{selection ? (
<View style={styles.pill}>
<SelectionIcon kind={selection.kind} />
<Text style={styles.pillLabel} numberOfLines={1}>
{selection.label}
</Text>
{selection.url ? (
<Pressable
hitSlop={6}
onPress={() => selection.url && void Linking.openURL(selection.url)}
>
<ExternalLink size={15} color={colors.textMuted} />
</Pressable>
) : null}
<Pressable hitSlop={6} onPress={composer.handleClearSmartNameSelection}>
<X size={15} color={colors.textMuted} />
</Pressable>
</View>
) : (
<Pressable
style={[styles.input, disabled && styles.disabled]}
disabled={disabled}
onPress={openDrawer}
>
<Text
style={[styles.inputText, !composer.name && styles.inputPlaceholder]}
numberOfLines={1}
>
{composer.name || 'Type a name or search a source'}
</Text>
</Pressable>
)}
<SmartWorkspaceSourceDrawer
visible={visible && showDrawer}
client={client}
composer={composer}
availability={availability}
repoId={repoId}
repos={repos}
linearWorkspaceId={linearWorkspaceId}
sshReady={sshReady}
onRepoChange={onRepoChange}
onClose={() => setShowDrawer(false)}
/>
</View>
)
}
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
},
input: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderWidth: 1,
borderColor: colors.borderSubtle
},
disabled: {
opacity: 0.55
},
inputText: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
inputPlaceholder: {
color: colors.textMuted
},
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle
},
pillLabel: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
}
})
@@ -0,0 +1,134 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { TaskProviderLogo } from './TaskProviderLogo'
type Props = {
row: SourceRow
onPress: () => void
}
type RowContent = {
icon: React.ReactNode
title: string
subtitle?: string
status?: string
}
function resolveRowContent(row: SourceRow): RowContent {
switch (row.kind) {
case 'use-name':
return {
icon: <Sparkles size={16} color={colors.textSecondary} />,
title: `Use "${row.name}"`,
subtitle: 'Name this workspace'
}
case 'create-branch':
return {
icon: <GitBranch size={16} color={colors.accentBlue} />,
title: `Create branch "${row.name}"`,
subtitle: 'New branch'
}
case 'github':
return {
icon: <TaskProviderLogo provider="github" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'gitlab':
return {
icon: <TaskProviderLogo provider="gitlab" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'branch':
return {
icon: <GitBranch size={16} color={colors.textSecondary} />,
title: row.localBranchName || row.refName,
subtitle: row.refName
}
case 'linear':
return {
icon: <TaskProviderLogo provider="linear" size={16} color={colors.textSecondary} />,
title: row.issue.title,
subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`,
status: row.issue.state?.name
}
default:
return { icon: <CaseSensitive size={16} color={colors.textSecondary} />, title: '' }
}
}
export function SmartWorkspaceSourceRow({ row, onPress }: Props) {
const content = resolveRowContent(row)
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.icon}>{content.icon}</View>
<View style={styles.copy}>
<Text style={styles.title} numberOfLines={1}>
{content.title}
</Text>
{content.subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{content.subtitle}
</Text>
) : null}
</View>
{content.status ? (
<View style={styles.pill}>
<Text style={styles.pillText} numberOfLines={1}>
{content.status}
</Text>
</View>
) : null}
</Pressable>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
icon: {
width: 18,
alignItems: 'center'
},
copy: {
flex: 1,
minWidth: 0
},
title: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
subtitle: {
fontSize: 12,
color: colors.textMuted,
marginTop: 1
},
pill: {
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: 2
},
pillText: {
fontSize: 11,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'capitalize'
}
})
@@ -1,349 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import { Plus } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { TaskProvider } from '../tasks/mobile-task-providers'
import {
buildNewBranchSource,
type WorkspaceSource,
type WorkspaceSourceTab
} from '../tasks/workspace-source-selection'
import {
useWorkspaceSourceSearch,
type WorkspaceSourceSearchResult
} from '../tasks/use-workspace-source-search'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { WorkspaceSourceResultRow } from './WorkspaceSourceResultRow'
const TAB_LABELS: Record<WorkspaceSourceTab, string> = {
branch: 'Branch',
github: 'GitHub',
gitlab: 'GitLab',
linear: 'Linear'
}
const SEARCH_PLACEHOLDERS: Record<WorkspaceSourceTab, string> = {
branch: 'Search branches',
github: 'Search issues (type is:pr for PRs)',
gitlab: 'Search issues and MRs',
linear: 'Search Linear issues'
}
type Props = {
visible: boolean
client: RpcClient | null
tasksSupported: boolean
availableProviders: readonly TaskProvider[]
repoId: string | null
sshReady: boolean
linearWorkspaceId?: string | null
onSelect: (source: WorkspaceSource) => void
onClose: () => void
}
type Row =
| { kind: 'reset' }
| { kind: 'new-branch'; name: string }
| { kind: 'result'; result: WorkspaceSourceSearchResult }
export function WorkspaceSourcePickerDrawer({
visible,
client,
tasksSupported,
availableProviders,
repoId,
sshReady,
linearWorkspaceId,
onSelect,
onClose
}: Props) {
const [query, setQuery] = useState('')
const [activeTab, setActiveTab] = useState<WorkspaceSourceTab>('branch')
const [closing, setClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const drawerVisible = visible && !closing
const tabs = useMemo<WorkspaceSourceTab[]>(
() => ['branch', ...(tasksSupported ? availableProviders : [])],
[availableProviders, tasksSupported]
)
const effectiveTab = tabs.includes(activeTab) ? activeTab : (tabs[0] ?? 'branch')
useEffect(() => {
if (visible) {
setClosing(false)
setQuery('')
setActiveTab('branch')
}
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}
}, [visible])
// Every provider search (incl. repo.searchRefs) requires the mobile.tasks
// capability, so gate all search on it. Linear is repo/SSH-independent; the
// other tabs need a connected repo.
const searchActive = tasksSupported && (effectiveTab === 'linear' || sshReady)
const { results, loading, error, needsGitHubRemote } = useWorkspaceSourceSearch({
client,
enabled: visible && searchActive,
activeTab: effectiveTab,
query,
repoId,
linearWorkspaceId
})
const finishClose = useCallback(() => {
setClosing(false)
onClose()
}, [onClose])
const selectThenClose = useCallback(
(source: WorkspaceSource) => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
}
setClosing(true)
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null
onClose()
onSelect(source)
}, BOTTOM_DRAWER_HIDE_DURATION_MS)
},
[onClose, onSelect]
)
const rows = useMemo<Row[]>(() => {
const list: Row[] = [{ kind: 'reset' }]
const trimmed = query.trim()
// Gate on !loading so the row is computed from settled results, not the
// previous query's debounce-lagged set.
if (effectiveTab === 'branch' && trimmed && !loading) {
const hasExact = results.some(
(result) => result.source.kind === 'branch' && result.source.localBranchName === trimmed
)
if (!hasExact) {
list.push({ kind: 'new-branch', name: trimmed })
}
}
for (const result of results) {
list.push({ kind: 'result', result })
}
return list
}, [effectiveTab, query, results, loading])
const branchHint =
effectiveTab === 'branch' && !query.trim()
? tasksSupported
? 'Type to search branches.'
: 'Type a name to create a new branch.'
: null
const showEmpty = !loading && !error && !needsGitHubRemote && searchActive && results.length === 0
return (
<BottomDrawer
visible={drawerVisible}
onClose={finishClose}
dragContentToDismiss={false}
contentScrollable={false}
>
<View style={styles.header}>
<Text style={styles.title}>Start from</Text>
</View>
<TextInput
style={styles.search}
value={query}
onChangeText={setQuery}
placeholder={SEARCH_PLACEHOLDERS[effectiveTab]}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.tabRow}>
{tabs.map((tab) => {
const selected = tab === effectiveTab
return (
<Pressable
key={tab}
style={[styles.tab, selected && styles.tabSelected]}
onPress={() => setActiveTab(tab)}
>
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{TAB_LABELS[tab]}
</Text>
</Pressable>
)
})}
</View>
{tasksSupported && !sshReady && effectiveTab !== 'linear' ? (
<Text style={styles.notice}>Connect the repository to search sources.</Text>
) : needsGitHubRemote ? (
<Text style={styles.notice}>
This SSH repo needs a GitHub remote to list issues and PRs.
</Text>
) : error ? (
<Text style={styles.errorNotice}>{error}</Text>
) : null}
<FlatList
data={rows}
keyExtractor={(row, index) =>
row.kind === 'result' ? row.result.id : `${row.kind}:${index}`
}
style={styles.list}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
ListFooterComponent={
loading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : branchHint ? (
<Text style={styles.empty}>{branchHint}</Text>
) : showEmpty ? (
<Text style={styles.empty}>No results found.</Text>
) : null
}
renderItem={({ item }) => {
if (item.kind === 'reset') {
return (
<WorkspaceSourceResultRow
title="Blank workspace"
subtitle="Start without a linked source"
onPress={() => selectThenClose({ kind: 'blank' })}
/>
)
}
if (item.kind === 'new-branch') {
return (
<Pressable
style={({ pressed }) => [styles.newBranchRow, pressed && styles.rowPressed]}
onPress={() => selectThenClose(buildNewBranchSource('', item.name))}
>
<Plus size={15} color={colors.accentBlue} />
<Text style={styles.newBranchText} numberOfLines={1}>
Create new branch “{item.name}”
</Text>
</Pressable>
)
}
return (
<WorkspaceSourceResultRow
title={item.result.title}
subtitle={item.result.subtitle}
status={item.result.status}
onPress={() => selectThenClose(item.result.source)}
/>
)
}}
/>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
search: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.sm
},
tabRow: {
flexDirection: 'row',
gap: spacing.xs,
marginBottom: spacing.sm
},
tab: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
tabText: {
fontSize: 13,
color: colors.textSecondary
},
tabTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
notice: {
fontSize: 12,
color: colors.textMuted,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
errorNotice: {
fontSize: 12,
color: colors.statusRed,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
list: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden',
maxHeight: 420,
flexGrow: 0
},
loading: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
empty: {
paddingVertical: spacing.lg,
textAlign: 'center',
color: colors.textMuted,
fontSize: 13
},
newBranchRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
newBranchText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.accentBlue,
fontWeight: '600'
}
})
@@ -1,74 +0,0 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Props = {
title: string
subtitle?: string
status?: string
onPress: () => void
}
export function WorkspaceSourceResultRow({ title, subtitle, status, onPress }: Props) {
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.copy}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
{status ? (
<View style={styles.pill}>
<Text style={styles.pillText} numberOfLines={1}>
{status}
</Text>
</View>
) : null}
</Pressable>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
copy: {
flex: 1,
minWidth: 0
},
title: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
subtitle: {
fontSize: 12,
color: colors.textMuted,
marginTop: 1
},
pill: {
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: 2
},
pillText: {
fontSize: 11,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'capitalize'
}
})
@@ -0,0 +1,243 @@
import { describe, expect, it } from 'vitest'
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
import {
buildGitHubLinkedWorkItem,
buildGitLabLinkedWorkItem,
buildLinearLinkedWorkItem,
buildSmartNameSelection,
resolveComposerBranchPick,
resolveComposerCreateSelection,
resolveWorkItemAutoName,
shouldApplyAutoName
} from './composer-linked-work-item'
describe('linked work item builders', () => {
it('maps a GitHub PR into a linked work item', () => {
const linked = buildGitHubLinkedWorkItem({
type: 'pr',
number: 42,
title: 'Fix bug',
url: 'https://github.com/o/r/pull/42',
repoId: 'repo-1'
})
expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' })
})
it('maps a GitLab MR into a linked work item', () => {
const linked = buildGitLabLinkedWorkItem({
type: 'mr',
number: 7,
title: 'Add feature',
url: 'https://gitlab.com/g/p/-/merge_requests/7',
repoId: 'repo-2'
})
expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' })
})
it('maps a Linear issue with identifier, workspace, and org key', () => {
const linked = buildLinearLinkedWorkItem({
identifier: 'ENG-9',
title: 'Ship it',
url: 'https://linear.app/acme/issue/ENG-9',
workspaceId: 'ws-1'
})
expect(linked).toMatchObject({
provider: 'linear',
type: 'issue',
number: 0,
linearIdentifier: 'ENG-9',
linearWorkspaceId: 'ws-1',
linearOrganizationUrlKey: 'acme'
})
})
})
describe('shouldApplyAutoName', () => {
it('applies when the name is empty or the previous auto-name', () => {
expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true)
expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true)
})
it('applies when the name is a lookup query (URL / #N / linear id)', () => {
expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true)
expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(true)
})
it('keeps a deliberately typed name', () => {
expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe(
false
)
})
})
describe('resolveWorkItemAutoName', () => {
it('slugifies the title subject', () => {
expect(
resolveWorkItemAutoName({
type: 'issue',
number: 3,
title: 'Fix the Login Bug',
provider: 'github'
})
).toBe('fix-the-login-bug')
})
})
describe('buildSmartNameSelection', () => {
const base = (over: Record<string, unknown>) => ({
provider: 'github' as const,
type: 'pr' as const,
number: 12,
title: 'T',
url: 'u',
...over
})
it('maps GitHub PR / issue kinds and numbers the label', () => {
expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({
kind: 'github-pr',
label: '#12 T',
url: 'u'
})
expect(
buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined })
).toMatchObject({ kind: 'github-issue' })
})
it('maps GitLab MR / issue kinds', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-mr' })
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-issue' })
})
it('maps Linear with a bare title label', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }),
baseBranch: undefined
})
).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' })
})
it('falls back to a branch pill', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({
kind: 'branch',
label: 'main'
})
})
it('returns null when nothing is selected', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull()
})
})
describe('resolveComposerCreateSelection', () => {
const baseCreateArgs = {
branch: null,
reuseEligibleBranch: null,
reuseSelectedBranch: false,
branchCreateIntent: false,
name: ''
}
it('prefers a linked work item and passes resolved base fields', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: {
provider: 'github',
type: 'pr',
number: 5,
title: 'T',
url: 'u',
repoId: 'repo-1'
},
base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' }
})
expect(selection).toMatchObject({
kind: 'work-item',
baseBranch: 'main',
compareBaseRef: 'origin/main',
branchNameOverride: 'pr-5'
})
})
it('marks reuse when the eligible branch is toggled on', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: { baseBranch: 'feature', branchNameOverride: 'feature' },
branch: { refName: 'feature', localBranchName: 'feature' },
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true
})
expect(selection).toEqual({
kind: 'branch',
baseBranch: 'feature',
refName: 'feature',
localBranchName: 'feature',
reuse: true,
branchNameOverride: 'feature'
})
})
it('returns a new-branch selection when create-branch intent is set', () => {
expect(
resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: {},
branchCreateIntent: true,
name: 'feature/login'
})
).toEqual({ kind: 'new-branch', branchName: 'feature/login' })
})
it('returns null with no work item, no branch base, and no intent', () => {
expect(
resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} })
).toBeNull()
})
})
describe('resolveComposerBranchPick', () => {
it('auto-names and enables reuse for an unused local branch', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: []
})
expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' })
expect(pick).toMatchObject({
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true,
name: 'feature'
})
})
it('does not reuse a branch already checked out elsewhere', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: ['refs/heads/feature']
})
expect(pick.reuseEligibleBranch).toBeNull()
expect(pick.reuseSelectedBranch).toBe(false)
expect(pick.base.branchNameOverride).toBeUndefined()
})
})
// Keep the exported type aliases referenced so the module surface stays covered.
export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue]
@@ -0,0 +1,227 @@
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
import {
getLinearIssueWorkspaceName,
getLinkedWorkItemSuggestedName,
getLinkedWorkItemWorkspaceName
} from '../../../src/shared/workspace-name'
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../src/shared/linear-links'
import { isWorkItemLookupText } from './work-item-lookup-text'
import {
isBranchCheckedOutInWorktrees,
resolveComposerBranchReuse,
resolveComposerBranchSelection,
resolveComposerReuseOverride
} from '../../../src/shared/composer-branch-selection'
import type {
MobileComposerCreateSelection,
MobileLinkedWorkItem,
SmartNameSelection
} from './mobile-composer-source-types'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
export function buildGitHubLinkedWorkItem(item: {
type: 'issue' | 'pr'
number: number
title: string
url: string
repoId: string
}): MobileLinkedWorkItem {
return {
provider: 'github',
type: item.type,
number: item.number,
title: item.title,
url: item.url,
repoId: item.repoId
}
}
export function buildGitLabLinkedWorkItem(item: {
type: 'issue' | 'mr'
number: number
title: string
url: string
repoId: string
}): MobileLinkedWorkItem {
return {
provider: 'gitlab',
type: item.type,
number: item.number,
title: item.title,
url: item.url,
repoId: item.repoId
}
}
export function buildLinearLinkedWorkItem(issue: {
identifier: string
title: string
url: string
workspaceId?: string
}): MobileLinkedWorkItem {
const orgUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
return {
provider: 'linear',
type: 'issue',
number: 0,
title: issue.title,
url: issue.url,
linearIdentifier: issue.identifier,
...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}),
...(orgUrlKey ? { linearOrganizationUrlKey: orgUrlKey } : {})
}
}
// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name
// replaces the current field only when it's empty, still the last auto-name, or
// a lookup query — never a name the user deliberately typed.
export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean {
return (
!args.currentName.trim() ||
args.currentName === args.lastAutoName ||
isWorkItemLookupText(args.currentName)
)
}
export function resolveWorkItemAutoName(item: {
type: 'issue' | 'pr' | 'mr'
number: number
title: string
provider: 'github' | 'gitlab' | 'linear'
linearIdentifier?: string
}): string {
return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
}
export function resolveLinearAutoName(issue: { identifier: string; title: string }): string {
return getLinearIssueWorkspaceName(issue)
}
// Derives the pill descriptor from the linked item (or a plain branch base),
// mirroring desktop's smartNameSelection memo.
export function buildSmartNameSelection(args: {
linkedWorkItem: MobileLinkedWorkItem | null
baseBranch: string | undefined
}): SmartNameSelection | null {
const { linkedWorkItem, baseBranch } = args
if (linkedWorkItem) {
const isLinear = linkedWorkItem.provider === 'linear'
const kind: SmartNameSelection['kind'] = isLinear
? 'linear'
: linkedWorkItem.provider === 'gitlab'
? linkedWorkItem.type === 'mr'
? 'gitlab-mr'
: 'gitlab-issue'
: linkedWorkItem.type === 'pr'
? 'github-pr'
: 'github-issue'
return {
kind,
label:
isLinear || linkedWorkItem.number === 0
? linkedWorkItem.title
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
url: linkedWorkItem.url
}
}
if (baseBranch) {
return { kind: 'branch', label: baseBranch }
}
return null
}
// Derives the create-time selection from composer state: a linked work item wins
// (carrying its resolved base/push fields), else a picked branch, else null (a
// name-only/blank create).
export function resolveComposerCreateSelection(args: {
linkedWorkItem: MobileLinkedWorkItem | null
base: {
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
branchNameOverride?: string
}
branch: { refName: string; localBranchName: string } | null
reuseEligibleBranch: string | null
reuseSelectedBranch: boolean
branchCreateIntent: boolean
name: string
}): MobileComposerCreateSelection | null {
const { linkedWorkItem, base, branch, reuseEligibleBranch, reuseSelectedBranch } = args
if (linkedWorkItem) {
return {
kind: 'work-item',
item: linkedWorkItem,
baseBranch: base.baseBranch,
compareBaseRef: base.compareBaseRef,
pushTarget: base.pushTarget,
branchNameOverride: base.branchNameOverride
}
}
if (branch && base.baseBranch) {
return {
kind: 'branch',
baseBranch: base.baseBranch,
refName: branch.refName,
localBranchName: branch.localBranchName,
reuse: reuseSelectedBranch && reuseEligibleBranch === branch.localBranchName,
branchNameOverride: base.branchNameOverride
}
}
if (args.branchCreateIntent && args.name.trim()) {
return { kind: 'new-branch', branchName: args.name.trim() }
}
return null
}
export type ComposerBranchPick = {
base: { baseBranch: string; branchNameOverride?: string }
reuseEligibleBranch: string | null
reuseSelectedBranch: boolean
name?: string
lastAutoName?: string
}
// Pure port of desktop handleSmartBranchSelect's derivation: base + reuse
// eligibility/default + the auto-name to apply, from the shared branch helpers.
export function resolveComposerBranchPick(args: {
refName: string
localBranchName: string
currentName: string
lastAutoName: string
worktreeBranches: readonly string[]
}): ComposerBranchPick {
const { refName, localBranchName } = args
const selection = resolveComposerBranchSelection({
refName,
localBranchName,
currentName: args.currentName,
lastAutoName: args.lastAutoName
})
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
localBranchName,
args.worktreeBranches
)
const { reuseEligibleBranch, defaultReuse } = resolveComposerBranchReuse({
refName,
localBranchName,
selectionProducedOverride: selection.branchNameOverride !== undefined,
branchCheckedOutElsewhere
})
const effectiveOverride = resolveComposerReuseOverride({
refName,
localBranchName,
branchNameOverride: selection.branchNameOverride,
branchCheckedOutElsewhere
})
return {
base: { baseBranch: selection.baseBranch, branchNameOverride: effectiveOverride },
reuseEligibleBranch,
reuseSelectedBranch: defaultReuse,
...(selection.name !== undefined && selection.lastAutoName !== undefined
? { name: selection.name, lastAutoName: selection.lastAutoName }
: {})
}
}
export type { GitHubWorkItem, GitLabWorkItem, LinearIssue }
@@ -0,0 +1,78 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
// The resolved start point for a linked PR/MR: the base branch to create from
// plus the optional review-compare ref, push target, and exact branch name.
export type ComposerHostedBase = {
baseBranch: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
branchNameOverride?: string
}
type HostedBaseResult = ComposerHostedBase | { error: string }
// Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's
// select-time resolution. The runtime returns a soft { error } payload rather
// than an RPC error for provider failures.
export async function resolveComposerPrBase(args: {
client: RpcClient
repoId: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}): Promise<ComposerHostedBase> {
const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args
const response = await client.sendRequest(
'worktree.resolvePrBase',
{
repo: `id:${repoId}`,
prNumber,
...(headRefName ? { headRefName } : {}),
...(baseRefName ? { baseRefName } : {}),
...(isCrossRepository !== undefined ? { isCrossRepository } : {})
},
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as HostedBaseResult
if ('error' in result) {
throw new Error(result.error)
}
return result
}
// Resolves a GitLab MR's base via worktree.resolveMrBase.
export async function resolveComposerMrBase(args: {
client: RpcClient
repoId: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}): Promise<ComposerHostedBase> {
const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args
const response = await client.sendRequest(
'worktree.resolveMrBase',
{
repo: `id:${repoId}`,
mrIid,
...(sourceBranch ? { sourceBranch } : {}),
...(targetBranch ? { targetBranch } : {}),
...(isCrossRepository !== undefined ? { isCrossRepository } : {})
},
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as HostedBaseResult
if ('error' in result) {
throw new Error(result.error)
}
return result
}
@@ -0,0 +1,63 @@
import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
export type { SmartNameMode }
// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask
// superset): the one work item a Smart selection pins the workspace to. Linear
// items carry the workspace/org routing the runtime needs to relink the issue.
export type MobileLinkedWorkItem = {
provider: 'github' | 'gitlab' | 'linear'
type: 'issue' | 'pr' | 'mr'
number: number
title: string
url: string
repoId?: string
linearIdentifier?: string
linearWorkspaceId?: string
linearOrganizationUrlKey?: string
}
export type SmartNameSelectionKind =
| 'github-pr'
| 'github-issue'
| 'gitlab-mr'
| 'gitlab-issue'
| 'branch'
| 'linear'
// The pill descriptor the field renders once a source is selected. Same shape
// as desktop's `SmartWorkspaceNameSelection`.
export type SmartNameSelection = {
kind: SmartNameSelectionKind
label: string
url?: string
}
// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default
// is 'opened' (Open).
export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all'
// The resolved selection the create flow consumes. Work-item selections carry
// the base/push fields the composer resolved at select time; branch selections
// carry the ref + reuse intent.
export type MobileComposerCreateSelection =
| {
kind: 'work-item'
item: MobileLinkedWorkItem
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
branchNameOverride?: string
}
| {
kind: 'branch'
baseBranch: string
refName: string
localBranchName: string
reuse: boolean
branchNameOverride?: string
}
// A brand-new branch created by name (no ref picked); the branch is created off
// the repo's default base and the typed name is kept verbatim as the branch.
| { kind: 'new-branch'; branchName: string }
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_MR_STATE_FILTER,
MR_STATE_FILTER_OPTIONS,
normalizeSmartMode,
resolveAvailableSmartModes,
resolveDefaultSmartMode
} from './mobile-smart-source-modes'
const fullyAvailable = {
textOnly: false,
tasksSupported: true,
hasRepo: true,
githubAvailable: true,
gitlabAvailable: true,
linearAvailable: true
}
describe('resolveAvailableSmartModes', () => {
it('lists every mode in desktop order when all are available', () => {
expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([
'smart',
'github',
'linear',
'gitlab',
'branches',
'text'
])
})
it('collapses to Name for a non-git (text-only) repo', () => {
expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text'])
})
it('drops provider + smart modes when the tasks RPC surface is missing', () => {
expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([
'branches',
'text'
])
})
it('gates provider tabs on their availability and a selected repo', () => {
expect(
resolveAvailableSmartModes({
...fullyAvailable,
githubAvailable: false,
gitlabAvailable: false
})
).toEqual(['smart', 'linear', 'branches', 'text'])
expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([
'smart',
'linear',
'text'
])
})
})
describe('resolveDefaultSmartMode', () => {
it('defaults to smart for a git repo with search', () => {
expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart')
})
it('defaults to text for a non-git repo', () => {
expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text')
})
it('defaults to branches for a git repo without the tasks surface', () => {
expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches')
})
})
describe('normalizeSmartMode', () => {
it('keeps a valid mode and snaps an unavailable one back to default', () => {
expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab')
expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe(
'smart'
)
})
})
describe('MR state filters', () => {
it('exposes Open/Merged/Closed/All with an Open default', () => {
expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all'])
expect(DEFAULT_MR_STATE_FILTER).toBe('opened')
})
})
@@ -0,0 +1,93 @@
import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types'
// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand
// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand
// icons.
export type SmartModeIcon =
| { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' }
| { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' }
export type SmartModeOption = {
id: SmartNameMode
label: string
icon: SmartModeIcon
}
// Order + labels + icons mirror desktop getSmartWorkspaceNameModes():
// Smart · GitHub · Linear · GitLab · Branch · Name.
export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [
{ id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } },
{ id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } },
{ id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } },
{ id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } },
{ id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } },
{ id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } }
]
export type SmartModeAvailabilityInput = {
textOnly: boolean
tasksSupported: boolean
hasRepo: boolean
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
}
// Faithful port of the desktop availableModes filter. Non-git repos collapse to
// the Name tab; provider tabs gate on availability + a selected repo + the tasks
// RPC surface; branches only need a git repo (new-branch-by-name works without
// the search capability).
export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] {
if (input.textOnly) {
return ['text']
}
return SMART_MODE_OPTIONS.filter((option) => {
switch (option.id) {
case 'smart':
return input.tasksSupported
case 'github':
return input.tasksSupported && input.hasRepo && input.githubAvailable
case 'gitlab':
return input.tasksSupported && input.hasRepo && input.gitlabAvailable
case 'linear':
return input.tasksSupported && input.linearAvailable
case 'branches':
return input.hasRepo
case 'text':
return true
}
}).map((option) => option.id)
}
// Default mode when the picker opens: 'smart' for a git repo when search is
// available, else the first available mode (branches for git without tasks,
// 'text' for non-git).
export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode {
const available = resolveAvailableSmartModes(input)
if (available.includes('smart')) {
return 'smart'
}
return available[0] ?? 'text'
}
// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a
// non-git folder), mirroring desktop's snap-to-available effect.
export function normalizeSmartMode(
mode: SmartNameMode,
input: SmartModeAvailabilityInput
): SmartNameMode {
const available = resolveAvailableSmartModes(input)
return available.includes(mode) ? mode : resolveDefaultSmartMode(input)
}
export type MrStateFilterOption = { id: MrStateFilter; label: string }
// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'.
export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [
{ id: 'opened', label: 'Open' },
{ id: 'merged', label: 'Merged' },
{ id: 'closed', label: 'Closed' },
{ id: 'all', label: 'All' }
]
export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened'
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { fanOutSmartSearch } from './smart-source-fan-out'
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(byMethod: Record<string, unknown>, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
const result = byMethod[method]
if (result instanceof Error) {
return {
id: '1',
ok: false,
error: { code: 'x', message: result.message },
_meta: { runtimeId: 'r' }
}
}
return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
const smartArgs = {
mode: 'smart' as const,
query: 'bug',
repoId: 'repo-1',
githubAvailable: true,
gitlabAvailable: true,
linearAvailable: true,
mrStateFilter: 'opened' as const,
linearWorkspaceId: null
}
describe('fanOutSmartSearch', () => {
it('fans out to every provider in smart mode and stamps repoId', async () => {
const calls: Call[] = []
const client = fakeClient(
{
'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] },
'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] },
'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] },
'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] }
},
calls
)
const result = await fanOutSmartSearch({ client, ...smartArgs })
expect(calls.map((c) => c.method).sort()).toEqual([
'github.listWorkItems',
'gitlab.listWorkItems',
'linear.searchIssues',
'repo.searchRefs'
])
expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' })
expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' })
expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' })
expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }])
expect(result.error).toBe('')
})
it('swallows a single provider failure in smart mode (best-effort)', async () => {
const calls: Call[] = []
const client = fakeClient(
{
'github.listWorkItems': new Error('gh down'),
'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] },
'linear.searchIssues': { items: [] },
'repo.searchRefs': { refDetails: [] }
},
calls
)
const result = await fanOutSmartSearch({ client, ...smartArgs })
expect(result.error).toBe('')
expect(result.gitlabItems).toHaveLength(1)
})
it('surfaces the error for a single-provider mode', async () => {
const calls: Call[] = []
const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls)
const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client })
expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems'])
expect(result.error).toBe('gl boom')
})
it('only searches branches in smart mode when the query is non-empty', async () => {
const calls: Call[] = []
const client = fakeClient({}, calls)
await fanOutSmartSearch({ ...smartArgs, query: '', client })
expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs')
})
it('skips GitHub in smart mode when GitHub is unavailable', async () => {
const calls: Call[] = []
const client = fakeClient({}, calls)
await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client })
expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems')
})
})
+133
View File
@@ -0,0 +1,133 @@
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearIssue
} from '../../../src/shared/types'
import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { RpcClient } from '../transport/rpc-client'
import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items'
import type { MrStateFilter } from './mobile-composer-source-types'
import {
searchBranches,
searchGitHubItems,
searchGitLabItems,
searchLinearIssues
} from './smart-source-search-requests'
export type SmartFanOutResult = {
githubItems: GitHubWorkItem[]
gitlabItems: GitLabWorkItem[]
linearIssues: LinearIssue[]
branches: BaseRefSearchResult[]
needsGitHubRemote: boolean
error: string
}
const EMPTY: Omit<SmartFanOutResult, 'needsGitHubRemote' | 'error'> = {
githubItems: [],
gitlabItems: [],
linearIssues: [],
branches: []
}
function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean {
return githubAvailable && (mode === 'smart' || mode === 'github')
}
function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean {
return gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
}
function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean {
return linearAvailable && (mode === 'smart' || mode === 'linear')
}
function shouldSearchBranches(mode: SmartNameMode, query: string): boolean {
return mode === 'branches' || (mode === 'smart' && query.trim().length > 0)
}
type FanOutArgs = {
client: RpcClient
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId: string | null | undefined
}
// Runs every provider search the active mode needs, concurrently. Smart mode is
// best-effort (a single provider failure never blocks the others); single-provider
// modes surface the failure. No cross-provider ranking/dedup — the shared row
// builder concatenates in provider order.
export async function fanOutSmartSearch(args: FanOutArgs): Promise<SmartFanOutResult> {
const {
client,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter
} = args
const isSmart = mode === 'smart'
const tasks = {
github:
shouldSearchGitHub(mode, githubAvailable) && repoId
? searchGitHubItems(client, repoId, query)
: null,
gitlab:
shouldSearchGitLab(mode, gitlabAvailable) && repoId
? searchGitLabItems(client, repoId, query, mrStateFilter)
: null,
linear: shouldSearchLinear(mode, linearAvailable)
? searchLinearIssues(client, query, args.linearWorkspaceId)
: null,
branches:
shouldSearchBranches(mode, query) && repoId ? searchBranches(client, repoId, query) : null
}
const [github, gitlab, linear, branches] = await Promise.allSettled([
tasks.github ?? Promise.resolve<GitHubWorkItem[]>([]),
tasks.gitlab ?? Promise.resolve<GitLabWorkItem[]>([]),
tasks.linear ?? Promise.resolve<LinearIssue[]>([]),
tasks.branches ?? Promise.resolve<BaseRefSearchResult[]>([])
])
let needsGitHubRemote = false
let error = ''
const fail = (reason: unknown) => {
if (!isSmart) {
error = reason instanceof Error ? reason.message : 'Search failed'
}
}
if (github.status === 'rejected') {
if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) {
needsGitHubRemote = true
} else {
fail(github.reason)
}
}
if (gitlab.status === 'rejected') {
fail(gitlab.reason)
}
if (linear.status === 'rejected') {
fail(linear.reason)
}
if (branches.status === 'rejected') {
fail(branches.reason)
}
return {
...EMPTY,
githubItems: github.status === 'fulfilled' ? github.value : [],
gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [],
linearIssues: linear.status === 'fulfilled' ? linear.value : [],
branches: branches.status === 'fulfilled' ? branches.value : [],
needsGitHubRemote,
error
}
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
deriveRepoSlug,
findRepoMatchingSlug,
resolvePasteIntent,
type PasteRepoCandidate
} from './smart-source-paste-intent'
describe('resolvePasteIntent', () => {
it('classifies a GitHub issue/PR URL as a github-link', () => {
expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({
kind: 'github-link',
link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' }
})
})
it('classifies a bare #number as a github-number', () => {
expect(resolvePasteIntent('#42')).toEqual({ kind: 'github-number', number: 42 })
})
it('classifies a GitLab MR URL as a gitlab-link', () => {
const intent = resolvePasteIntent('https://gitlab.com/group/proj/-/merge_requests/8')
expect(intent?.kind).toBe('gitlab-link')
if (intent?.kind === 'gitlab-link') {
expect(intent.link).toMatchObject({ number: 8, type: 'mr' })
}
})
it('returns null for plain search text', () => {
expect(resolvePasteIntent('login bug')).toBeNull()
})
})
describe('deriveRepoSlug', () => {
it('prefers the upstream identity', () => {
expect(deriveRepoSlug({ upstream: { owner: 'up', repo: 'stream' } })).toEqual({
owner: 'up',
repo: 'stream'
})
})
it('parses an SSH remote URL', () => {
expect(
deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } })
).toEqual({ owner: 'acme', repo: 'widgets' })
})
it('parses an HTTPS remote URL', () => {
expect(
deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'https://github.com/acme/widgets' } })
).toEqual({ owner: 'acme', repo: 'widgets' })
})
it('returns null when no slug can be derived', () => {
expect(deriveRepoSlug({})).toBeNull()
})
})
describe('findRepoMatchingSlug', () => {
const repos: PasteRepoCandidate[] = [
{ id: 'a', displayName: 'A', slug: { owner: 'acme', repo: 'widgets' } },
{ id: 'b', displayName: 'B', slug: null }
]
it('matches case-insensitively', () => {
expect(findRepoMatchingSlug(repos, { owner: 'Acme', repo: 'Widgets' })?.id).toBe('a')
})
it('returns null when no repo matches', () => {
expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull()
})
})
@@ -0,0 +1,138 @@
import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types'
import {
normalizeGitHubLinkQuery,
parseGitHubIssueOrPRLink,
type GitHubIssueOrPRLink,
type RepoSlug
} from '../../../src/shared/new-workspace/github-links'
import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived
// best-effort from the repo's remote metadata.
export type PasteRepoCandidate = {
id: string
displayName: string
slug: RepoSlug | null
}
export type GitHubPasteIntent =
| { kind: 'github-link'; link: GitHubIssueOrPRLink }
| { kind: 'github-number'; number: number }
export type GitLabPasteIntent = {
kind: 'gitlab-link'
link: NonNullable<ReturnType<typeof parseGitLabIssueOrMRLink>>
}
export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null
// Pure: classify pasted text into a work-item lookup intent. A slug-bearing
// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number';
// a GitLab issue/MR URL becomes 'gitlab-link'.
export function resolvePasteIntent(query: string): PasteIntent {
const trimmed = query.trim()
if (!trimmed) {
return null
}
const ghLink = parseGitHubIssueOrPRLink(trimmed)
if (ghLink) {
return { kind: 'github-link', link: ghLink }
}
const normalizedGh = normalizeGitHubLinkQuery(trimmed)
if (normalizedGh.directNumber !== null && !/^https?:\/\//i.test(trimmed)) {
return { kind: 'github-number', number: normalizedGh.directNumber }
}
const glLink = parseGitLabIssueOrMRLink(trimmed)
if (glLink) {
return { kind: 'gitlab-link', link: glLink }
}
return null
}
// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted
// cross-repo URL can be matched to a locally known repo.
export function deriveRepoSlug(repo: {
upstream?: { owner: string; repo: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}): RepoSlug | null {
if (repo.upstream?.owner && repo.upstream.repo) {
return { owner: repo.upstream.owner, repo: repo.upstream.repo }
}
const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? ''
const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source)
if (match) {
return { owner: match[1], repo: match[2] }
}
return null
}
function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean {
if (!a || !b) {
return false
}
return (
a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase()
)
}
export function findRepoMatchingSlug(
repos: readonly PasteRepoCandidate[],
slug: RepoSlug
): PasteRepoCandidate | null {
return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null
}
export async function lookupGitHubItemByNumber(
client: RpcClient,
repoId: string,
number: number
): Promise<GitHubWorkItem | null> {
const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number })
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitHubWorkItem | null
return item ? { ...item, repoId } : null
}
export async function lookupGitHubItemByOwnerRepo(
client: RpcClient,
repoId: string,
slug: RepoSlug,
number: number,
type: 'issue' | 'pr'
): Promise<GitHubWorkItem | null> {
const response = await client.sendRequest('github.workItemByOwnerRepo', {
repo: `id:${repoId}`,
owner: slug.owner,
ownerRepo: slug.repo,
number,
type
})
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitHubWorkItem | null
return item ? { ...item, repoId } : null
}
export async function lookupGitLabItemByPath(
client: RpcClient,
repoId: string,
link: NonNullable<ReturnType<typeof parseGitLabIssueOrMRLink>>
): Promise<GitLabWorkItem | null> {
const response = await client.sendRequest('gitlab.workItemByPath', {
repo: `id:${repoId}`,
host: link.slug.host,
path: link.slug.path,
iid: link.number,
type: link.type
})
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitLabWorkItem | null
return item ? { ...item, repoId } : null
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests'
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(result: unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
describe('scopeGitHubQuery', () => {
it('passes the raw trimmed query so BOTH issues and PRs are returned', () => {
// Empty stays empty (runtime lists recent issues + PRs); no forced is:issue.
expect(scopeGitHubQuery('')).toBe('')
expect(scopeGitHubQuery(' login bug ')).toBe('login bug')
})
it('preserves an explicit is:pr / is:issue scope the user typed', () => {
expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth')
expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth')
})
})
describe('searchLinearIssues', () => {
it('lists assigned issues for an empty query (desktop default)', async () => {
const calls: Call[] = []
const client = fakeClient({ items: [] }, calls)
await searchLinearIssues(client, '', null)
expect(calls[0]!.method).toBe('linear.listIssues')
expect(calls[0]!.params).toMatchObject({ filter: 'assigned' })
})
it('searches when a query is present', async () => {
const calls: Call[] = []
const client = fakeClient({ items: [] }, calls)
await searchLinearIssues(client, 'bug', 'ws-1')
expect(calls[0]!.method).toBe('linear.searchIssues')
expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' })
})
})
@@ -0,0 +1,119 @@
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearIssue
} from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { extractLinearIssueReadItems } from './linear-mobile-issue-read'
import { PER_REPO_FETCH_LIMIT } from './mobile-work-items'
import type { MrStateFilter } from './mobile-composer-source-types'
const GITLAB_PER_PAGE = 50
const LINEAR_LIMIT = 50
const BRANCH_LIMIT = 20
// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's
// parseTaskQuery defaults scope 'all', and an empty query lists recent items of
// both types. So pass the raw trimmed query straight through (an explicit
// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty
// so the runtime lists recent issues + PRs.
export function scopeGitHubQuery(query: string): string {
return query.trim()
}
export async function searchGitHubItems(
client: RpcClient,
repoId: string,
query: string
): Promise<GitHubWorkItem[]> {
const response = await client.sendRequest('github.listWorkItems', {
repo: `id:${repoId}`,
limit: PER_REPO_FETCH_LIMIT,
query: scopeGitHubQuery(query)
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] }
// Stamp repoId so the shared row builder + create flow can attribute each item
// to the searched repo (the runtime omits it, like the desktop fetcher).
return (envelope.items ?? []).map((item) => ({ ...item, repoId }))
}
export async function searchGitLabItems(
client: RpcClient,
repoId: string,
query: string,
state: MrStateFilter
): Promise<GitLabWorkItem[]> {
const response = await client.sendRequest('gitlab.listWorkItems', {
repo: `id:${repoId}`,
state,
page: 1,
perPage: GITLAB_PER_PAGE,
query: query.trim() || undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as {
items: GitLabWorkItem[]
error?: { type?: string; message: string }
}
if (envelope.error?.type && envelope.error.type !== 'not_found') {
throw new Error(envelope.error.message)
}
return (envelope.items ?? []).map((item) => ({ ...item, repoId }))
}
export async function searchLinearIssues(
client: RpcClient,
query: string,
linearWorkspaceId: string | null | undefined
): Promise<LinearIssue[]> {
const trimmed = query.trim()
const response = trimmed
? await client.sendRequest('linear.searchIssues', {
query: trimmed,
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
: await client.sendRequest('linear.listIssues', {
// Empty query lists the viewer's assigned issues, matching desktop's
// Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')).
filter: 'assigned',
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
// extractLinearIssueReadItems yields the mobile issue-read shape; the fields the
// row builder/create flow read (id/identifier/title/url/state/team) are a subset.
return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[]
}
export async function searchBranches(
client: RpcClient,
repoId: string,
query: string
): Promise<BaseRefSearchResult[]> {
const response = await client.sendRequest(
'repo.searchRefs',
{ repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT },
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as {
refDetails?: BaseRefSearchResult[]
refs?: string[]
}
return (
result.refDetails ??
(result.refs ?? []).map((refName) => ({ refName, localBranchName: refName }))
)
}
+178 -178
View File
@@ -1,21 +1,15 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createWorkspaceFromSource } from './source-workspace-create'
import {
buildBranchSource,
buildGitHubTaskSource,
buildGitLabTaskSource,
buildLinearTaskSource,
buildNewBranchSource
} from './workspace-source-selection'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
type Call = { method: string; params: unknown }
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(script: (method: string) => unknown, calls: Call[]): RpcClient {
function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params })
const result = script(method)
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
const result = handle(method, calls.length)
if (result instanceof Error) {
return {
id: '1',
@@ -29,192 +23,198 @@ function fakeClient(script: (method: string) => unknown, calls: Call[]): RpcClie
} as unknown as RpcClient
}
describe('createWorkspaceFromSource', () => {
it('resolves the PR base then creates a workspace linked to the PR', async () => {
const agent = { choice: 'blank' as const, startupCommand: undefined }
const baseArgs = {
targetRepoId: 'repo-1',
setupDecision: 'inherit' as const,
agent,
workspaceName: undefined,
note: undefined
}
describe('createWorkspaceFromComposerSource', () => {
it('creates a GitHub issue workspace linking the issue to its own repo', async () => {
const calls: Call[] = []
const client = fakeClient((method) => {
if (method === 'worktree.resolvePrBase') {
return { baseBranch: 'main', pushTarget: { remoteName: 'origin', branchName: 'feat' } }
}
return { worktree: { id: 'wt-1' } }
}, calls)
const result = await createWorkspaceFromSource({
client,
source: buildGitHubTaskSource('repo-1', {
type: 'pr',
number: 42,
title: 'Fix login',
url: 'https://github.com/acme/app/pull/42',
branchName: 'feat',
isCrossRepository: false
}) as never,
targetRepoId: 'repo-1',
setupDecision: 'inherit',
agent: { choice: 'codex', startupCommand: undefined },
workspaceName: undefined,
note: undefined
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'pr-42' })
expect(calls[0]).toEqual({
method: 'worktree.resolvePrBase',
params: { repo: 'id:repo-1', prNumber: 42, headRefName: 'feat', isCrossRepository: false }
})
const createParams = calls[1]?.params as Record<string, unknown>
expect(calls[1]?.method).toBe('worktree.create')
expect(createParams.linkedPR).toBe(42)
expect(createParams.baseBranch).toBe('main')
expect(createParams.createdWithAgent).toBe('codex')
expect(createParams.startupDraft).toBe('https://github.com/acme/app/pull/42')
expect(createParams.pushTarget).toEqual({ remoteName: 'origin', branchName: 'feat' })
})
it('resolves the MR base then creates a workspace linked to the GitLab MR', async () => {
const calls: Call[] = []
const client = fakeClient((method) => {
if (method === 'worktree.resolveMrBase') {
return { baseBranch: 'develop', pushTarget: { remoteName: 'origin', branchName: 'fix' } }
}
return { worktree: { id: 'wt-mr' } }
}, calls)
const result = await createWorkspaceFromSource({
client,
source: buildGitLabTaskSource('repo-2', {
type: 'mr',
const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'github',
type: 'issue',
number: 7,
title: 'Fix logout',
url: 'https://gitlab.com/acme/app/-/merge_requests/7',
branchName: 'fix',
isCrossRepository: true
}) as never,
targetRepoId: 'repo-2',
setupDecision: 'inherit',
agent: { choice: 'blank', startupCommand: undefined },
workspaceName: undefined,
note: undefined
title: 'Bug',
url: 'u',
repoId: 'repo-9'
}
}
// The composer supplies the title-derived name as workspaceName; with none,
// buildTaskWorkspaceCreateParams falls back to the "<type>-<number>" slug.
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' })
expect(calls).toHaveLength(1)
expect(calls[0]!.method).toBe('worktree.create')
expect(calls[0]!.params).toMatchObject({
repo: 'id:repo-9',
linkedIssue: 7,
displayName: 'Bug'
})
expect(result).toEqual({ worktreeId: 'wt-mr', name: 'mr-7' })
expect(calls[0]).toEqual({
method: 'worktree.resolveMrBase',
params: { repo: 'id:repo-2', mrIid: 7, sourceBranch: 'fix', isCrossRepository: true }
})
expect(calls[1]?.method).toBe('worktree.create')
const createParams = calls[1]?.params as Record<string, unknown>
expect(createParams.linkedGitLabMR).toBe(7)
expect(createParams.baseBranch).toBe('develop')
expect(createParams.pushTarget).toEqual({ remoteName: 'origin', branchName: 'fix' })
})
it('creates a branch workspace with baseBranch + branchNameOverride and no resolve call', async () => {
it('passes composer-resolved PR base fields straight through (no re-resolve)', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls)
const result = await createWorkspaceFromSource({
client,
source: buildBranchSource('origin/main', 'main') as never,
targetRepoId: 'repo-9',
setupDecision: 'run',
agent: { choice: 'blank', startupCommand: undefined },
workspaceName: undefined,
note: undefined
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'github',
type: 'pr',
number: 3,
title: 'Feat',
url: 'u',
repoId: 'repo-1'
},
baseBranch: 'main',
compareBaseRef: 'origin/main',
pushTarget: { remoteName: 'origin', branchName: 'feat-3' },
branchNameOverride: 'feat-3'
}
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(calls.map((c) => c.method)).toEqual(['worktree.create'])
expect(calls[0]!.params).toMatchObject({
linkedPR: 3,
baseBranch: 'main',
compareBaseRef: 'origin/main',
branchNameOverride: 'feat-3',
pushTarget: { remoteName: 'origin', branchName: 'feat-3' }
})
expect(result).toEqual({ worktreeId: 'wt-2', name: 'main' })
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('worktree.create')
const params = calls[0]?.params as Record<string, unknown>
expect(params).toMatchObject({
repo: 'id:repo-9',
name: 'main',
baseBranch: 'origin/main',
branchNameOverride: 'main',
setupDecision: 'run'
})
expect(params.createdWithAgent).toBeUndefined()
})
it('links a Linear issue to the target repo without a resolve call', async () => {
it('resolves a PR base as a fallback when the selection carries none', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-3' } }), calls)
await createWorkspaceFromSource({
client,
source: buildLinearTaskSource({
identifier: 'ENG-42',
title: 'Onboarding',
url: 'https://linear.app/acme/issue/ENG-42'
}) as never,
targetRepoId: 'repo-5',
setupDecision: 'inherit',
agent: { choice: 'blank', startupCommand: undefined },
workspaceName: undefined,
note: undefined
})
expect(calls).toHaveLength(1)
const params = calls[0]?.params as Record<string, unknown>
expect(params.repo).toBe('id:repo-5')
expect(params.linkedLinearIssue).toBe('ENG-42')
const client = fakeClient(
(method) =>
method === 'worktree.resolvePrBase'
? { baseBranch: 'develop' }
: { worktree: { id: 'wt-3' } },
calls
)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: { provider: 'github', type: 'pr', number: 4, title: 'X', url: 'u', repoId: 'repo-1' }
}
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(calls.map((c) => c.method)).toEqual(['worktree.resolvePrBase', 'worktree.create'])
expect(calls[1]!.params).toMatchObject({ baseBranch: 'develop', linkedPR: 4 })
})
it('bumps the branch name (not just display) when a new branch collides', async () => {
it('creates a Linear workspace with workspace + org routing', async () => {
const calls: Call[] = []
let attempt = 0
const client = fakeClient((method) => {
if (method === 'worktree.create') {
attempt += 1
if (attempt === 1) {
return new Error('Branch feature already exists locally')
}
const client = fakeClient(() => ({ worktree: { id: 'wt-4' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'linear',
type: 'issue',
number: 0,
title: 'Ship it',
url: 'https://linear.app/acme/issue/ENG-9',
linearIdentifier: 'ENG-9',
linearWorkspaceId: 'ws-1',
linearOrganizationUrlKey: 'acme'
}
return { worktree: { id: 'wt-nb' } }
}, calls)
const result = await createWorkspaceFromSource({
client,
source: buildNewBranchSource('main', 'feature') as never,
targetRepoId: 'repo-1',
setupDecision: 'inherit',
agent: { choice: 'blank', startupCommand: undefined },
workspaceName: undefined,
note: undefined
}
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(calls[0]!.params).toMatchObject({
repo: 'id:repo-1',
linkedLinearIssue: 'ENG-9',
linkedLinearIssueWorkspaceId: 'ws-1',
linkedLinearIssueOrganizationUrlKey: 'acme'
})
expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature-2' })
const creates = calls.filter((call) => call.method === 'worktree.create')
expect(creates).toHaveLength(2)
const first = creates[0]?.params as Record<string, unknown>
const second = creates[1]?.params as Record<string, unknown>
expect(first.branchNameOverride).toBe('feature')
expect(second.branchNameOverride).toBe('feature-2')
expect(second.name).toBe('feature-2')
expect(second.baseBranch).toBe('main')
})
it('fails fast (no retry) when reusing an existing branch that collides', async () => {
it('reuses an existing branch with a single attempt (no suffix retry)', async () => {
const calls: Call[] = []
const client = fakeClient((method) => {
if (method === 'worktree.create') {
return new Error('Branch "main" already exists locally.')
}
return { worktree: { id: 'wt-4' } }
}, calls)
const result = await createWorkspaceFromSource({
client,
source: buildBranchSource('origin/main', 'main') as never,
targetRepoId: 'repo-1',
setupDecision: 'inherit',
agent: { choice: 'blank', startupCommand: undefined },
workspaceName: undefined,
note: undefined
const client = fakeClient(() => new Error('Branch "feature" already exists.'), calls)
const selection: MobileComposerCreateSelection = {
kind: 'branch',
baseBranch: 'feature',
refName: 'feature',
localBranchName: 'feature',
reuse: true,
branchNameOverride: 'feature'
}
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect('error' in result).toBe(true)
expect(calls).toHaveLength(1)
expect(calls[0]!.params).toMatchObject({
baseBranch: 'feature',
branchNameOverride: 'feature'
})
})
// branchNameOverride is fixed for reuse, so suffixing can't help — one attempt only.
expect(result).toEqual({ error: 'Branch "main" already exists locally.' })
expect(calls.filter((call) => call.method === 'worktree.create')).toHaveLength(1)
it('creates a brand-new branch by name, keeping a slashy name as the branch', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-nb' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'new-branch',
branchName: 'feature/login'
}
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature/login' })
expect(calls[0]!.params).toMatchObject({
repo: 'id:repo-1',
name: 'feature/login',
branchNameOverride: 'feature/login'
})
})
it('suppresses displayName when the name is user-edited (not auto-managed)', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'github',
type: 'issue',
number: 7,
title: 'Bug',
url: 'u',
repoId: 'repo-1'
}
}
await createWorkspaceFromComposerSource({
client,
selection,
...baseArgs,
workspaceName: 'my-name',
nameIsAutoManaged: false
})
expect(calls[0]!.params.displayName).toBeUndefined()
expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 })
})
it('creates a new branch off a ref, bumping the branch on collision', async () => {
const calls: Call[] = []
const client = fakeClient(
(_m, n) => (n === 1 ? new Error('already exists locally') : { worktree: { id: 'wt-5' } }),
calls
)
const selection: MobileComposerCreateSelection = {
kind: 'branch',
baseBranch: 'main',
refName: 'main',
localBranchName: 'topic',
reuse: false,
branchNameOverride: 'topic'
}
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(result).toEqual({ worktreeId: 'wt-5', name: 'topic-2' })
expect(calls).toHaveLength(2)
expect(calls[1]!.params).toMatchObject({
baseBranch: 'main',
branchNameOverride: 'topic-2',
name: 'topic-2'
})
})
})
+166 -98
View File
@@ -1,72 +1,137 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { shouldResolveHostedReviewStartPoint } from './hosted-review-start-point'
import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve'
import type {
MobileComposerCreateSelection,
MobileLinkedWorkItem
} from './mobile-composer-source-types'
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
import {
buildTaskWorkspaceCreateParams,
type WorkspaceCreateHostedStartPoint,
type WorkspaceCreateSetupDecision
type WorkspaceCreateSetupDecision,
type WorkspaceCreateTaskItem
} from './workspace-create-params'
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
import type { WorkspaceSource } from './workspace-source-selection'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
// The agent bundle the modal already resolved: the choice drives buildTaskWorkspaceCreateParams
// for task sources; the explicit launch command is used for branch sources (which
// have no work-item URL to seed the agent draft).
// The agent bundle the modal already resolved: the choice drives
// buildTaskWorkspaceCreateParams for work-item sources; the explicit launch
// command is used for branch sources (which have no work-item URL to seed the draft).
export type WorkspaceCreateAgentBundle = {
choice: WorkspaceAgentChoice
startupCommand: string | undefined
}
type NonBlankSource = Exclude<WorkspaceSource, { kind: 'blank' }>
export async function createWorkspaceFromSource(args: {
export type CreateWorkspaceFromComposerArgs = {
client: RpcClient
source: NonBlankSource
selection: MobileComposerCreateSelection
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
const { source } = args
if (source.kind === 'branch' || source.kind === 'new-branch') {
return createBranchWorkspace({ ...args, source })
}
return createTaskWorkspace({ ...args, source })
nameIsAutoManaged?: boolean
}
async function createTaskWorkspace(args: {
export async function createWorkspaceFromComposerSource(
args: CreateWorkspaceFromComposerArgs
): Promise<WorktreeCreateResult> {
if (args.selection.kind === 'branch') {
return createBranchWorkspace({ ...args, selection: args.selection })
}
if (args.selection.kind === 'new-branch') {
return createNewBranchWorkspace({ ...args, selection: args.selection })
}
return createWorkItemWorkspace({ ...args, selection: args.selection })
}
function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem {
if (item.provider === 'github') {
return {
provider: 'github',
source: {
type: item.type === 'pr' ? 'pr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
if (item.provider === 'gitlab') {
return {
provider: 'gitlab',
source: {
type: item.type === 'mr' ? 'mr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
return {
provider: 'linear',
source: {
identifier: item.linearIdentifier ?? '',
title: item.title,
url: item.url,
...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}),
...(item.linearOrganizationUrlKey
? { organizationUrlKey: item.linearOrganizationUrlKey }
: {})
}
}
}
async function createWorkItemWorkspace(args: {
client: RpcClient
source: Extract<NonBlankSource, { kind: 'task' }>
selection: Extract<MobileComposerCreateSelection, { kind: 'work-item' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
}): Promise<WorktreeCreateResult> {
const { client, source, targetRepoId, setupDecision, agent, workspaceName, note } = args
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const item = selection.item
const taskItem = toTaskItem(item, targetRepoId)
let hostedStartPoint: WorkspaceCreateHostedStartPoint | undefined
if (
(source.hostedType === 'pr' || source.hostedType === 'mr') &&
shouldResolveHostedReviewStartPoint({ type: source.hostedType })
) {
hostedStartPoint = await resolveHostedStartPoint(client, source)
// The composer resolves PR/MR base at select time; only re-resolve as a
// fallback when a linked PR/MR reached create without one.
let baseBranch = selection.baseBranch
let compareBaseRef = selection.compareBaseRef
let pushTarget = selection.pushTarget
let branchNameOverride = selection.branchNameOverride
if (!baseBranch && item.provider !== 'linear' && (item.type === 'pr' || item.type === 'mr')) {
const repoId = item.repoId ?? targetRepoId
const resolved =
item.type === 'pr'
? await resolveComposerPrBase({ client, repoId, prNumber: item.number }).catch(() => null)
: await resolveComposerMrBase({ client, repoId, mrIid: item.number }).catch(() => null)
if (resolved) {
baseBranch = resolved.baseBranch
compareBaseRef = resolved.compareBaseRef
pushTarget = resolved.pushTarget
branchNameOverride = resolved.branchNameOverride ?? branchNameOverride
}
}
const params = buildTaskWorkspaceCreateParams({
item: source.item,
item: taskItem,
targetRepoId,
setupDecision,
agent: agent.choice,
workspaceName,
note,
hostedStartPoint
baseBranch,
compareBaseRef,
branchNameOverride,
pushTarget,
nameIsAutoManaged: args.nameIsAutoManaged
})
// buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base so
// collisions still append -2, -3, ... like the blank path does.
// buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base
// so collisions still append -2, -3, ... like the blank path does.
const baseName = String(params.name)
return createWorktreeWithNameRetry({
client,
@@ -75,51 +140,16 @@ async function createTaskWorkspace(args: {
})
}
// Faithful port of the PR/MR base-resolve in mobile/app/h/[hostId]/tasks.tsx: the
// runtime returns either a start point or a soft { error } payload (not an RPC error).
async function resolveHostedStartPoint(
client: RpcClient,
source: Extract<NonBlankSource, { kind: 'task' }>
): Promise<WorkspaceCreateHostedStartPoint> {
const item = source.item
if (item.provider === 'linear') {
// Unreachable: only github/gitlab pr/mr sources resolve a hosted base.
throw new Error('Linear sources do not resolve a hosted base branch')
}
const method = item.provider === 'github' ? 'worktree.resolvePrBase' : 'worktree.resolveMrBase'
const numberKey = item.provider === 'github' ? 'prNumber' : 'mrIid'
const branchKey = item.provider === 'github' ? 'headRefName' : 'sourceBranch'
const params: Record<string, unknown> = {
repo: `id:${item.source.repoId}`,
[numberKey]: item.source.number,
...(source.branchName ? { [branchKey]: source.branchName } : {}),
...(source.isCrossRepository !== undefined
? { isCrossRepository: source.isCrossRepository }
: {})
}
const response = await client.sendRequest(method, params, { timeoutMs: 30_000 })
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as
| WorkspaceCreateHostedStartPoint
| { error: string }
if ('error' in result) {
throw new Error(result.error)
}
return result
}
async function createBranchWorkspace(args: {
client: RpcClient
source: Extract<NonBlankSource, { kind: 'branch' | 'new-branch' }>
selection: Extract<MobileComposerCreateSelection, { kind: 'branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
const { client, source, targetRepoId, setupDecision, agent, workspaceName, note } = args
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const comment = note?.trim()
const applyCommon = (params: Record<string, unknown>): Record<string, unknown> => {
@@ -132,51 +162,89 @@ async function createBranchWorkspace(args: {
return params
}
if (source.kind === 'new-branch') {
// New branch: the retry base is the branch name so a collision bumps the
// branch itself (the colliding constraint), not just the display name. An
// empty baseRef leaves baseBranch unset so the runtime picks the default base.
const baseBranch = source.baseRefName
const displayNameOverride = workspaceName?.trim()
if (selection.reuse) {
// Reusing a fixed existing branch: branchNameOverride is pinned to the reused
// branch, so a branch collision can't be cleared by suffixing the display
// name — fail fast instead of burning the retry budget.
const baseName = resolveMobileWorkspaceCreateName({
draft: workspaceName,
fallback: selection.localBranchName
})
return createWorktreeWithNameRetry({
client,
baseName: source.branchName,
buildParams: (candidateBranch) => {
const params: Record<string, unknown> = {
baseName,
maxAttempts: 1,
buildParams: (name) =>
applyCommon({
repo: `id:${targetRepoId}`,
name: displayNameOverride || candidateBranch,
name,
setupDecision,
branchNameOverride: candidateBranch,
baseBranch: selection.refName,
branchNameOverride: selection.localBranchName,
startupCommand: agent.startupCommand
}
if (baseBranch) {
params.baseBranch = baseBranch
}
return applyCommon(params)
}
})
})
}
// Existing branch: reuse the branch off its ref. branchNameOverride is fixed to
// the reused branch, so a branch collision (e.g. it's checked out elsewhere)
// can't be cleared by suffixing the display name — fail fast instead of
// retrying 25 identical creates.
// New branch off the selected ref. The retry base is the branch name so a
// collision bumps the branch itself.
const baseName = resolveMobileWorkspaceCreateName({
draft: workspaceName,
fallback: source.localBranchName
fallback: selection.branchNameOverride || selection.localBranchName
})
return createWorktreeWithNameRetry({
client,
baseName,
maxAttempts: 1,
buildParams: (name) =>
applyCommon({
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
name,
name: candidate,
setupDecision,
baseBranch: source.refName,
branchNameOverride: source.localBranchName,
baseBranch: selection.baseBranch,
startupCommand: agent.startupCommand
})
}
if (selection.branchNameOverride) {
params.branchNameOverride = candidate
}
return applyCommon(params)
}
})
}
async function createNewBranchWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'new-branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const comment = note?.trim()
// A brand-new branch off the repo's default base. The typed name is kept as the
// git branch (via branchNameOverride) so a slash like `feature/login` survives;
// the runtime sanitizes the worktree folder from the same name. The retry base is
// the branch name so a collision bumps the branch (and folder) together.
return createWorktreeWithNameRetry({
client,
baseName: selection.branchName,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
name: candidate,
setupDecision,
branchNameOverride: candidate,
startupCommand: agent.startupCommand
}
if (createdWithAgentId) {
params.createdWithAgent = createdWithAgentId
}
if (comment) {
params.comment = comment
}
return params
}
})
}
@@ -0,0 +1,334 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection'
import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity'
import type { RpcClient } from '../transport/rpc-client'
import {
buildGitHubLinkedWorkItem,
buildGitLabLinkedWorkItem,
buildLinearLinkedWorkItem,
buildSmartNameSelection,
resolveComposerBranchPick,
resolveComposerCreateSelection,
resolveLinearAutoName,
resolveWorkItemAutoName,
shouldApplyAutoName
} from './composer-linked-work-item'
import {
resolveComposerMrBase,
resolveComposerPrBase,
type ComposerHostedBase
} from './composer-source-base-resolve'
import type {
MobileComposerCreateSelection,
MobileLinkedWorkItem,
SmartNameSelection
} from './mobile-composer-source-types'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
type ComposerBaseState = {
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
branchNameOverride?: string
}
const EMPTY_BASE: ComposerBaseState = {}
export type UseMobileComposerSourceArgs = {
client: RpcClient | null
selectedRepoId: string | null
worktreeBranches?: readonly string[]
onError?: (message: string) => void
}
export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
const { client, selectedRepoId, worktreeBranches = [], onError } = args
const [name, setNameState] = useState('')
const [linkedWorkItem, setLinkedWorkItem] = useState<MobileLinkedWorkItem | null>(null)
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
const [forkPushWarning, setForkPushWarning] = useState<string | null>(null)
const [resolvingBase, setResolvingBase] = useState(false)
// Set when the "Create branch <name>" row is picked, so the typed name (which
// may contain slashes) is kept verbatim as the git branch (folder is sanitized).
const [branchCreateIntent, setBranchCreateIntent] = useState(false)
const lastAutoNameRef = useRef('')
const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null)
// Guards async base resolution: only the latest selection applies its result.
const resolveTokenRef = useRef(0)
const setName = useCallback((value: string) => setNameState(value), [])
const applyAutoName = useCallback((suggested: string, currentName: string) => {
if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
}, [])
const clearBaseAndBranch = useCallback(() => {
branchSelectionRef.current = null
setBranchCreateIntent(false)
setBase(EMPTY_BASE)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
// Why: a superseding selection bumps the resolve token, so an in-flight base
// resolve's token-gated finally can no longer clear this — reset it here so
// resolvingBase never sticks true after switching sources.
setResolvingBase(false)
}, [])
// Applies an async PR/MR base resolution guarded by the current token so only
// the latest selection wins; failures clear the base and surface the error.
const runBaseResolve = useCallback(
(token: number, resolve: Promise<ComposerHostedBase>) => {
setResolvingBase(true)
void resolve
.then((result) => {
if (resolveTokenRef.current !== token) {
return
}
setBase({
baseBranch: result.baseBranch,
compareBaseRef: result.compareBaseRef,
pushTarget: result.pushTarget,
branchNameOverride: result.branchNameOverride
})
})
.catch((error: unknown) => {
if (resolveTokenRef.current !== token) {
return
}
setBase(EMPTY_BASE)
onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.')
})
.finally(() => {
if (resolveTokenRef.current === token) {
setResolvingBase(false)
}
})
},
[onError]
)
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem) => {
const token = (resolveTokenRef.current += 1)
const identity = resolveGitHubWorkItemIdentity(item)
// Resolve the PR base against the item's OWN repo — a cross-repo accept
// switches repos then selects synchronously, so selectedRepoId is stale.
const repoId = item.repoId || selectedRepoId
setLinkedWorkItem(
buildGitHubLinkedWorkItem({
type: identity.type,
number: identity.number,
title: item.title,
url: item.url,
repoId: item.repoId
})
)
applyAutoName(
resolveWorkItemAutoName({ ...identity, title: item.title, provider: 'github' }),
name
)
clearBaseAndBranch()
if (identity.type !== 'pr' || !client || !repoId) {
return
}
runBaseResolve(
token,
resolveComposerPrBase({
client,
repoId,
prNumber: identity.number,
...(item.branchName ? { headRefName: item.branchName } : {}),
...(item.baseRefName ? { baseRefName: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
)
},
[applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId]
)
const handleSmartGitLabItemSelect = useCallback(
(item: GitLabWorkItem) => {
const token = (resolveTokenRef.current += 1)
// Resolve the MR base against the item's OWN repo (see the GitHub handler).
const repoId = item.repoId || selectedRepoId
setLinkedWorkItem(
buildGitLabLinkedWorkItem({
type: item.type,
number: item.number,
title: item.title,
url: item.url,
repoId: item.repoId
})
)
applyAutoName(
resolveWorkItemAutoName({
type: item.type,
number: item.number,
title: item.title,
provider: 'gitlab'
}),
name
)
clearBaseAndBranch()
if (item.type !== 'mr' || !client || !repoId) {
return
}
runBaseResolve(
token,
resolveComposerMrBase({
client,
repoId,
mrIid: item.number,
...(item.branchName ? { sourceBranch: item.branchName } : {}),
...(item.baseRefName ? { targetBranch: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
)
},
[applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId]
)
const handleSmartLinearIssueSelect = useCallback(
(issue: LinearIssue) => {
resolveTokenRef.current += 1
setLinkedWorkItem(buildLinearLinkedWorkItem(issue))
const suggested = resolveLinearAutoName(issue)
const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase()
if (
suggested &&
(identifierTyped ||
shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current }))
) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
clearBaseAndBranch()
},
[clearBaseAndBranch, name]
)
const handleSmartBranchSelect = useCallback(
(refName: string, localBranchName: string) => {
resolveTokenRef.current += 1
setLinkedWorkItem(null)
setForkPushWarning(null)
setBranchCreateIntent(false)
setResolvingBase(false)
const pick = resolveComposerBranchPick({
refName,
localBranchName,
currentName: name,
lastAutoName: lastAutoNameRef.current,
worktreeBranches
})
setReuseEligibleBranch(pick.reuseEligibleBranch)
setReuseSelectedBranch(pick.reuseSelectedBranch)
setBase(pick.base)
branchSelectionRef.current = { refName, localBranchName }
if (pick.name !== undefined) {
setNameState(pick.name)
lastAutoNameRef.current = pick.lastAutoName ?? ''
}
},
[name, worktreeBranches]
)
// Picking "Create branch <name>": name the workspace and mark a new-branch
// intent so the typed (possibly slashy) name is kept verbatim as the git branch.
const handleSmartCreateBranch = useCallback(
(branchName: string) => {
resolveTokenRef.current += 1
setLinkedWorkItem(null)
clearBaseAndBranch()
setNameState(branchName)
lastAutoNameRef.current = branchName
setBranchCreateIntent(true)
},
[clearBaseAndBranch]
)
const handleClearSmartNameSelection = useCallback(() => {
resolveTokenRef.current += 1
setLinkedWorkItem(null)
clearBaseAndBranch()
setResolvingBase(false)
if (name === lastAutoNameRef.current) {
setNameState('')
lastAutoNameRef.current = ''
}
}, [clearBaseAndBranch, name])
const handleBranchNameOverrideChange = useCallback((value: string) => {
setBase((prev) => {
const next = resolveComposerManualBranchNameChange({
value,
pushTarget: prev.pushTarget,
forkPushWarning: null
})
return {
...prev,
branchNameOverride: next.branchNameOverride,
pushTarget: next.pushTarget
}
})
}, [])
const smartNameSelection = useMemo<SmartNameSelection | null>(
() => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }),
[base.baseBranch, linkedWorkItem]
)
const createSelection = useMemo<MobileComposerCreateSelection | null>(
() =>
resolveComposerCreateSelection({
linkedWorkItem,
base,
branch: branchSelectionRef.current,
reuseEligibleBranch,
reuseSelectedBranch,
branchCreateIntent,
name
}),
[base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch]
)
// Auto-managed until the user edits the name away from the last derived value;
// desktop suppresses the workspace displayName once the name is user-edited.
const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current
return {
name,
setName,
linkedWorkItem,
branchNameOverride: base.branchNameOverride,
handleBranchNameOverrideChange,
reuseEligibleBranch,
reuseSelectedBranch,
setReuseSelectedBranch,
forkPushWarning,
resolvingBase,
isNameAutoManaged,
smartNameSelection,
createSelection,
handleSmartGitHubItemSelect,
handleSmartGitLabItemSelect,
handleSmartLinearIssueSelect,
handleSmartBranchSelect,
handleSmartCreateBranch,
handleClearSmartNameSelection
}
}
export type MobileComposerSource = ReturnType<typeof useMobileComposerSource>
@@ -0,0 +1,225 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types'
import {
buildSmartWorkspaceSourceRows,
getSmartWorkspaceEmptyHint,
type SmartNameMode,
type SmartWorkspaceSourceRow
} from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { RpcClient } from '../transport/rpc-client'
import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out'
import type { MrStateFilter } from './mobile-composer-source-types'
import {
findRepoMatchingSlug,
lookupGitHubItemByNumber,
lookupGitHubItemByOwnerRepo,
lookupGitLabItemByPath,
resolvePasteIntent,
type PasteRepoCandidate
} from './smart-source-paste-intent'
const DEBOUNCE_MS = 200
const RESULT_LIMIT = 36
export type SmartCrossRepoPrompt = {
link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' }
matchingRepo: PasteRepoCandidate
}
export type UseSmartWorkspaceSourceArgs = {
client: RpcClient | null
enabled: boolean
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId?: string | null
repos: readonly PasteRepoCandidate[]
}
const EMPTY_FAN: SmartFanOutResult = {
githubItems: [],
gitlabItems: [],
linearIssues: [],
branches: [],
needsGitHubRemote: false,
error: ''
}
type PasteResolved = { github: GitHubWorkItem | null; gitlab: GitLabWorkItem | null }
export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) {
const {
client,
enabled,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos
} = args
const [fan, setFan] = useState<SmartFanOutResult>(EMPTY_FAN)
const [paste, setPaste] = useState<PasteResolved>({ github: null, gitlab: null })
const [loading, setLoading] = useState(false)
const [crossRepoPrompt, setCrossRepoPrompt] = useState<SmartCrossRepoPrompt | null>(null)
// Why: preserve results across keystrokes (debounce) but drop them the moment
// the mode/repo changes so one provider's rows never render under another tab.
const scopeRef = useRef('')
const dismissedPasteRef = useRef<string>('')
useEffect(() => {
if (!client || !enabled || mode === 'text') {
setFan(EMPTY_FAN)
setPaste({ github: null, gitlab: null })
setLoading(false)
setCrossRepoPrompt(null)
return
}
const scope = `${mode}:${repoId ?? ''}`
const scopeChanged = scopeRef.current !== scope
scopeRef.current = scope
if (scopeChanged) {
setFan(EMPTY_FAN)
setPaste({ github: null, gitlab: null })
setCrossRepoPrompt(null)
}
setLoading(true)
let stale = false
const timer = setTimeout(() => {
void runSmartSearch({
client,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos,
dismissedPasteRef
})
.then((result) => {
if (stale) {
return
}
setFan(result.fan)
setPaste(result.paste)
setCrossRepoPrompt(result.crossRepoPrompt)
setLoading(false)
})
.catch(() => {
if (!stale) {
setLoading(false)
}
})
}, DEBOUNCE_MS)
return () => {
stale = true
clearTimeout(timer)
}
}, [
client,
enabled,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos
])
const rows = useMemo<SmartWorkspaceSourceRow[]>(
() =>
buildSmartWorkspaceSourceRows({
branches: fan.branches,
githubItems: paste.github ? [paste.github] : fan.githubItems,
gitlabAvailable,
gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems,
linearAvailable,
linearIssues: fan.linearIssues,
mode,
resultLimit: RESULT_LIMIT,
value: query
}),
[fan, gitlabAvailable, linearAvailable, mode, paste, query]
)
const dismissCrossRepoPrompt = useCallback(() => {
dismissedPasteRef.current = query.trim()
setCrossRepoPrompt(null)
}, [query])
return {
rows,
loading,
error: fan.error,
needsGitHubRemote: fan.needsGitHubRemote,
emptyHint: getSmartWorkspaceEmptyHint(mode),
crossRepoPrompt,
dismissCrossRepoPrompt
}
}
async function runSmartSearch(args: {
client: RpcClient
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId: string | null | undefined
repos: readonly PasteRepoCandidate[]
dismissedPasteRef: { current: string }
}): Promise<{
fan: SmartFanOutResult
paste: PasteResolved
crossRepoPrompt: SmartCrossRepoPrompt | null
}> {
const { client, mode, query, repoId, repos, dismissedPasteRef } = args
const fan = await fanOutSmartSearch(args)
const paste: PasteResolved = { github: null, gitlab: null }
let crossRepoPrompt: SmartCrossRepoPrompt | null = null
const intent =
mode === 'branches' || dismissedPasteRef.current === query.trim()
? null
: resolvePasteIntent(query)
if (intent && repoId) {
try {
if (intent.kind === 'github-number') {
paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number)
} else if (intent.kind === 'github-link') {
const matchingRepo = findRepoMatchingSlug(repos, intent.link.slug)
if (matchingRepo && matchingRepo.id !== repoId) {
crossRepoPrompt = { link: intent.link, matchingRepo }
} else {
paste.github = await lookupGitHubItemByOwnerRepo(
client,
repoId,
intent.link.slug,
intent.link.number,
intent.link.type
)
}
} else if (intent.kind === 'gitlab-link') {
paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link)
}
} catch {
// Best-effort paste resolution; fall back to the fan-out results.
}
}
return { fan, paste, crossRepoPrompt }
}
@@ -1,301 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import type { BaseRefSearchResult } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { extractLinearIssueReadItems } from './linear-mobile-issue-read'
import { isGitHubWorkItemsSshRemoteRequiredError, PER_REPO_FETCH_LIMIT } from './mobile-work-items'
import {
buildBranchSource,
buildGitHubTaskSource,
buildGitLabTaskSource,
buildLinearTaskSource,
type WorkspaceSource,
type WorkspaceSourceTab
} from './workspace-source-selection'
const DEBOUNCE_MS = 250
const GITLAB_PER_PAGE = 50
const LINEAR_LIMIT = 50
const BRANCH_LIMIT = 20
export type WorkspaceSourceSearchResult = {
id: string
title: string
subtitle: string
status?: string
source: WorkspaceSource
}
export type WorkspaceSourceSearchState = {
results: WorkspaceSourceSearchResult[]
loading: boolean
error: string
needsGitHubRemote: boolean
}
type GitHubSearchItem = {
type: 'issue' | 'pr'
number: number
title: string
url: string
state?: string
branchName?: string
isCrossRepository?: boolean
}
type GitLabSearchItem = {
type: 'issue' | 'mr'
number: number
title: string
url: string
state?: string
branchName?: string
isCrossRepository?: boolean
}
// Mirrors mobile Tasks' default issue scoping; a user can type `is:pr` to search
// pull requests instead.
function scopeGitHubQuery(query: string): string {
const trimmed = query.trim()
if (!trimmed) {
return 'is:issue is:open'
}
if (/\bis:(?:issue|pr)\b/i.test(trimmed)) {
return trimmed
}
return `is:issue ${trimmed}`
}
async function searchGitHub(
client: RpcClient,
repoId: string,
query: string
): Promise<WorkspaceSourceSearchResult[]> {
const response = await client.sendRequest('github.listWorkItems', {
repo: `id:${repoId}`,
limit: PER_REPO_FETCH_LIMIT,
query: scopeGitHubQuery(query)
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as { items: GitHubSearchItem[] }
return envelope.items.map((item) => ({
id: `github:${item.type}:${item.number}`,
title: item.title,
subtitle: `${item.type === 'pr' ? 'PR' : 'Issue'} #${item.number}`,
status: item.state,
source: buildGitHubTaskSource(repoId, {
type: item.type,
number: item.number,
title: item.title,
url: item.url,
branchName: item.branchName,
isCrossRepository: item.isCrossRepository
})
}))
}
async function searchGitLab(
client: RpcClient,
repoId: string,
query: string
): Promise<WorkspaceSourceSearchResult[]> {
const response = await client.sendRequest('gitlab.listWorkItems', {
repo: `id:${repoId}`,
state: 'opened',
page: 1,
perPage: GITLAB_PER_PAGE,
query: query.trim() || undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as {
items: GitLabSearchItem[]
error?: { type?: string; message: string }
}
if (envelope.error?.type && envelope.error.type !== 'not_found') {
throw new Error(envelope.error.message)
}
return envelope.items.map((item) => ({
id: `gitlab:${item.type}:${item.number}`,
title: item.title,
subtitle: `${item.type === 'mr' ? 'MR !' : 'Issue #'}${item.number}`,
status: item.state,
source: buildGitLabTaskSource(repoId, {
type: item.type,
number: item.number,
title: item.title,
url: item.url,
branchName: item.branchName,
isCrossRepository: item.isCrossRepository
})
}))
}
async function searchLinear(
client: RpcClient,
query: string,
linearWorkspaceId: string | null | undefined
): Promise<WorkspaceSourceSearchResult[]> {
const trimmed = query.trim()
const response = trimmed
? await client.sendRequest('linear.searchIssues', {
query: trimmed,
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
: await client.sendRequest('linear.listIssues', {
filter: 'all',
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
const issues = extractLinearIssueReadItems((response as RpcSuccess).result)
return issues.map((issue) => ({
id: `linear:${issue.identifier}`,
title: issue.title,
subtitle: `${issue.identifier} · ${issue.team?.key ?? 'Linear'}`,
status: issue.state?.name,
source: buildLinearTaskSource({
identifier: issue.identifier,
title: issue.title,
url: issue.url
})
}))
}
async function searchBranches(
client: RpcClient,
repoId: string,
query: string
): Promise<WorkspaceSourceSearchResult[]> {
const response = await client.sendRequest(
'repo.searchRefs',
{ repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT },
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as {
refDetails?: BaseRefSearchResult[]
refs?: string[]
}
const refs =
result.refDetails ??
(result.refs ?? []).map((refName) => ({ refName, localBranchName: refName }))
return refs.map((ref) => ({
id: `branch:${ref.refName}`,
title: ref.localBranchName || ref.refName,
subtitle: ref.refName,
source: buildBranchSource(ref.refName, ref.localBranchName)
}))
}
function runSearch(args: {
client: RpcClient
activeTab: WorkspaceSourceTab
query: string
repoId: string | null
linearWorkspaceId: string | null | undefined
}): Promise<WorkspaceSourceSearchResult[]> {
const { client, activeTab, query, repoId, linearWorkspaceId } = args
if (activeTab === 'linear') {
return searchLinear(client, query, linearWorkspaceId)
}
if (!repoId) {
return Promise.resolve([])
}
if (activeTab === 'github') {
return searchGitHub(client, repoId, query)
}
if (activeTab === 'gitlab') {
return searchGitLab(client, repoId, query)
}
return searchBranches(client, repoId, query)
}
// Runs a debounced, single-repo provider search for the workspace source picker.
// A stale-closure guard drops out-of-order results when the tab/query changes.
export function useWorkspaceSourceSearch(args: {
client: RpcClient | null
enabled: boolean
activeTab: WorkspaceSourceTab
query: string
repoId: string | null
linearWorkspaceId?: string | null
}): WorkspaceSourceSearchState {
const { client, enabled, activeTab, query, repoId, linearWorkspaceId } = args
const [state, setState] = useState<WorkspaceSourceSearchState>({
results: [],
loading: false,
error: '',
needsGitHubRemote: false
})
// Why: preserve results across keystrokes (avoids flicker under debounce) but
// drop them the moment the tab or repo changes, so one provider's rows never
// render under another provider's tab.
const scopeRef = useRef('')
useEffect(() => {
if (!client || !enabled) {
setState({ results: [], loading: false, error: '', needsGitHubRemote: false })
return
}
// Repo-scoped tabs can't search without a repo; branch search needs a query.
if (activeTab !== 'linear' && !repoId) {
setState({ results: [], loading: false, error: '', needsGitHubRemote: false })
return
}
if (activeTab === 'branch' && !query.trim()) {
setState({ results: [], loading: false, error: '', needsGitHubRemote: false })
return
}
const scope = `${activeTab}:${repoId ?? ''}`
const scopeChanged = scopeRef.current !== scope
scopeRef.current = scope
let stale = false
setState((prev) => ({
results: scopeChanged ? [] : prev.results,
loading: true,
error: '',
needsGitHubRemote: false
}))
const timer = setTimeout(() => {
void runSearch({ client, activeTab, query, repoId, linearWorkspaceId })
.then((results) => {
if (!stale) {
setState({ results, loading: false, error: '', needsGitHubRemote: false })
}
})
.catch((err) => {
if (stale) {
return
}
if (activeTab === 'github' && isGitHubWorkItemsSshRemoteRequiredError(err)) {
setState({ results: [], loading: false, error: '', needsGitHubRemote: true })
return
}
setState({
results: [],
loading: false,
error: err instanceof Error ? err.message : 'Search failed',
needsGitHubRemote: false
})
})
}, DEBOUNCE_MS)
return () => {
stale = true
clearTimeout(timer)
}
}, [client, enabled, activeTab, query, repoId, linearWorkspaceId])
return state
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { isWorkItemLookupText } from './work-item-lookup-text'
describe('isWorkItemLookupText', () => {
it('treats references as lookup text, not names', () => {
expect(isWorkItemLookupText('#42')).toBe(true)
expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true)
expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true)
expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true)
expect(isWorkItemLookupText('ENG-9')).toBe(true)
})
it('treats plain names as non-lookup text', () => {
expect(isWorkItemLookupText('')).toBe(false)
expect(isWorkItemLookupText('fix the login bug')).toBe(false)
})
})
+24
View File
@@ -0,0 +1,24 @@
import { parseGitHubIssueOrPRNumber } from '../../../src/shared/new-workspace/github-links'
import { parseGitLabIssueOrMRNumber } from '../../../src/shared/new-workspace/gitlab-links'
const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i
const LINEAR_IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
// Why: text typed to *find* a work item — a GitHub/GitLab URL, "#123", or a
// Linear link/identifier — is a lookup query, never a deliberate name. Selection
// handlers use this to decide the resolved item's title-derived name may replace
// the field content; otherwise the pasted reference silently survives behind the
// pill and the workspace gets a slugified-URL name. Mirrors desktop's
// isWorkItemLookupText using the shared link parsers.
export function isWorkItemLookupText(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) {
return false
}
return (
parseGitHubIssueOrPRNumber(trimmed) !== null ||
parseGitLabIssueOrMRNumber(trimmed) !== null ||
LINEAR_ISSUE_URL_RE.test(trimmed) ||
LINEAR_IDENTIFIER_RE.test(trimmed)
)
}
+22 -5
View File
@@ -48,6 +48,8 @@ type WorkspaceCreateLinearItem = {
identifier: string
title: string
url: string
workspaceId?: string
organizationUrlKey?: string
}
}
@@ -66,9 +68,12 @@ export function buildTaskWorkspaceCreateParams(args: {
workspaceName?: string
note?: string
baseBranch?: string
compareBaseRef?: string
branchNameOverride?: string
pushTarget?: WorkspaceCreateGitPushTarget
sparseCheckout?: WorkspaceCreateSparseCheckout
hostedStartPoint?: WorkspaceCreateHostedStartPoint
nameIsAutoManaged?: boolean
}): WorkspaceCreateParams {
const {
item,
@@ -78,22 +83,30 @@ export function buildTaskWorkspaceCreateParams(args: {
workspaceName,
note,
baseBranch,
compareBaseRef,
branchNameOverride,
pushTarget,
sparseCheckout,
hostedStartPoint
hostedStartPoint,
nameIsAutoManaged = true
} = args
const shouldLaunchAgent = agent !== 'blank'
const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined
const comment = note?.trim()
const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch
const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget
// Why: desktop only sends displayName while the name is still auto-derived; a
// user-edited name suppresses it so the runtime keeps the user's chosen name.
const displayName = nameIsAutoManaged ? { displayName: item.source.title } : {}
const common = {
setupDecision,
activate: true,
...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}),
...(compareBaseRef ? { compareBaseRef } : {}),
...(branchNameOverride ? { branchNameOverride } : {}),
...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}),
...(selectedPushTarget ? { pushTarget: selectedPushTarget } : {}),
...(sparseCheckout ? { sparseCheckout } : {}),
...(comment ? { comment } : {})
}
@@ -103,7 +116,7 @@ export function buildTaskWorkspaceCreateParams(args: {
return {
repo: `id:${item.source.repoId}`,
name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }),
displayName: item.source.title,
...displayName,
...common,
...(item.source.type === 'issue'
? { linkedIssue: item.source.number }
@@ -116,7 +129,7 @@ export function buildTaskWorkspaceCreateParams(args: {
return {
repo: `id:${item.source.repoId}`,
name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }),
displayName: item.source.title,
...displayName,
...common,
...(item.source.type === 'issue'
? { linkedGitLabIssue: item.source.number }
@@ -130,8 +143,12 @@ export function buildTaskWorkspaceCreateParams(args: {
draft: workspaceName,
fallback: item.source.identifier.toLowerCase()
}),
displayName: item.source.title,
...displayName,
linkedLinearIssue: item.source.identifier,
...(item.source.workspaceId ? { linkedLinearIssueWorkspaceId: item.source.workspaceId } : {}),
...(item.source.organizationUrlKey
? { linkedLinearIssueOrganizationUrlKey: item.source.organizationUrlKey }
: {}),
...common
}
}
@@ -1,138 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildBranchSource,
buildGitHubTaskSource,
buildGitLabTaskSource,
buildLinearTaskSource,
buildNewBranchSource,
describeWorkspaceSource,
isRepoBoundSource,
resolveSourceRepoId
} from './workspace-source-selection'
describe('workspace source selection', () => {
it('builds a GitHub PR task source pinned to its repo', () => {
const source = buildGitHubTaskSource('repo-1', {
type: 'pr',
number: 42,
title: 'Fix login',
url: 'https://github.com/acme/app/pull/42',
branchName: 'feature/login',
isCrossRepository: true
})
expect(source).toEqual({
kind: 'task',
hostedType: 'pr',
branchName: 'feature/login',
isCrossRepository: true,
item: {
provider: 'github',
source: {
type: 'pr',
repoId: 'repo-1',
number: 42,
title: 'Fix login',
url: 'https://github.com/acme/app/pull/42'
}
}
})
expect(resolveSourceRepoId(source)).toBe('repo-1')
})
it('omits optional resolve fields when absent on a GitHub issue', () => {
const source = buildGitHubTaskSource('repo-1', {
type: 'issue',
number: 7,
title: 'Bug',
url: 'https://github.com/acme/app/issues/7'
})
if (source.kind !== 'task') {
throw new Error('expected task source')
}
expect(source.hostedType).toBe('issue')
expect('branchName' in source).toBe(false)
expect('isCrossRepository' in source).toBe(false)
})
it('builds a GitLab MR task source with mr hostedType', () => {
const source = buildGitLabTaskSource('repo-2', {
type: 'mr',
number: 9,
title: 'Refactor',
url: 'https://gitlab.com/acme/app/-/merge_requests/9'
})
if (source.kind !== 'task') {
throw new Error('expected task source')
}
expect(source.hostedType).toBe('mr')
expect(source.item.provider).toBe('gitlab')
expect(resolveSourceRepoId(source)).toBe('repo-2')
})
it('builds a Linear task source that defers repo to the modal', () => {
const source = buildLinearTaskSource({
identifier: 'ENG-42',
title: 'Improve onboarding',
url: 'https://linear.app/acme/issue/ENG-42'
})
if (source.kind !== 'task') {
throw new Error('expected task source')
}
expect(source.hostedType).toBe('linear')
expect(resolveSourceRepoId(source)).toBeNull()
})
it('builds branch and new-branch sources deferring repo to the modal', () => {
expect(resolveSourceRepoId(buildBranchSource('origin/main', 'main'))).toBeNull()
expect(resolveSourceRepoId(buildNewBranchSource('main', 'feature-x'))).toBeNull()
})
it('treats branch/new-branch and GitHub/GitLab tasks as repo-bound, not Linear or blank', () => {
expect(isRepoBoundSource({ kind: 'blank' })).toBe(false)
expect(isRepoBoundSource(buildBranchSource('origin/main', 'main'))).toBe(true)
expect(isRepoBoundSource(buildNewBranchSource('', 'feature-x'))).toBe(true)
expect(
isRepoBoundSource(
buildGitHubTaskSource('r', { type: 'issue', number: 1, title: 't', url: 'u' })
)
).toBe(true)
expect(
isRepoBoundSource(buildGitLabTaskSource('r', { type: 'mr', number: 1, title: 't', url: 'u' }))
).toBe(true)
expect(
isRepoBoundSource(buildLinearTaskSource({ identifier: 'ENG-1', title: 't', url: 'u' }))
).toBe(false)
})
it('describes each source kind for the collapsed field', () => {
expect(describeWorkspaceSource({ kind: 'blank' })).toEqual({ label: 'Blank workspace' })
expect(describeWorkspaceSource(buildBranchSource('origin/main', 'main'))).toEqual({
label: 'Branch: main',
providerIconId: 'branch'
})
expect(describeWorkspaceSource(buildNewBranchSource('main', 'feature-x'))).toEqual({
label: 'New branch: feature-x',
providerIconId: 'branch'
})
expect(
describeWorkspaceSource(
buildGitHubTaskSource('r', {
type: 'issue',
number: 3,
title: 'Crash',
url: 'u'
})
)
).toEqual({ label: '#3 Crash', providerIconId: 'github' })
expect(
describeWorkspaceSource(
buildGitLabTaskSource('r', { type: 'mr', number: 5, title: 'Speed', url: 'u' })
)
).toEqual({ label: '!5 Speed', providerIconId: 'gitlab' })
expect(
describeWorkspaceSource(
buildLinearTaskSource({ identifier: 'ENG-1', title: 'Ship', url: 'u' })
)
).toEqual({ label: 'ENG-1 Ship', providerIconId: 'linear' })
})
})
@@ -1,166 +0,0 @@
import type { WorkspaceCreateTaskItem } from './workspace-create-params'
// The tabs a user can start a workspace from, mirroring the desktop composer's
// smart-name source modes (branch / GitHub / GitLab / Linear). "Blank" is the
// implicit default and isn't a tab.
export type WorkspaceSourceTab = 'branch' | 'github' | 'gitlab' | 'linear'
// Slim inputs: only the fields the create flow needs from each provider's work
// item. The picker builds these from the richer search-result envelopes.
export type GitHubWorkItemInput = {
type: 'issue' | 'pr'
number: number
title: string
url: string
branchName?: string
isCrossRepository?: boolean
}
export type GitLabWorkItemInput = {
type: 'issue' | 'mr'
number: number
title: string
url: string
branchName?: string
isCrossRepository?: boolean
}
export type LinearIssueInput = {
identifier: string
title: string
url: string
}
// Why: branchName/isCrossRepository ride alongside the trimmed
// WorkspaceCreateTaskItem because worktree.resolvePrBase/resolveMrBase need them
// but they aren't part of the create-item source shape.
export type WorkspaceSource =
| { kind: 'blank' }
| { kind: 'branch'; refName: string; localBranchName: string }
| { kind: 'new-branch'; baseRefName: string; branchName: string }
| {
kind: 'task'
item: WorkspaceCreateTaskItem
hostedType: 'issue' | 'pr' | 'mr' | 'linear'
branchName?: string
isCrossRepository?: boolean
}
export type WorkspaceSourceDescription = {
label: string
providerIconId?: WorkspaceSourceTab
}
export function buildGitHubTaskSource(repoId: string, item: GitHubWorkItemInput): WorkspaceSource {
return {
kind: 'task',
hostedType: item.type,
...(item.branchName ? { branchName: item.branchName } : {}),
...(item.isCrossRepository !== undefined ? { isCrossRepository: item.isCrossRepository } : {}),
item: {
provider: 'github',
source: {
type: item.type,
repoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
}
export function buildGitLabTaskSource(repoId: string, item: GitLabWorkItemInput): WorkspaceSource {
return {
kind: 'task',
hostedType: item.type,
...(item.branchName ? { branchName: item.branchName } : {}),
...(item.isCrossRepository !== undefined ? { isCrossRepository: item.isCrossRepository } : {}),
item: {
provider: 'gitlab',
source: {
type: item.type,
repoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
}
export function buildLinearTaskSource(item: LinearIssueInput): WorkspaceSource {
return {
kind: 'task',
hostedType: 'linear',
item: {
provider: 'linear',
source: {
identifier: item.identifier,
title: item.title,
url: item.url
}
}
}
}
export function buildBranchSource(refName: string, localBranchName: string): WorkspaceSource {
return { kind: 'branch', refName, localBranchName }
}
export function buildNewBranchSource(baseRefName: string, branchName: string): WorkspaceSource {
return { kind: 'new-branch', baseRefName, branchName }
}
export function describeWorkspaceSource(source: WorkspaceSource): WorkspaceSourceDescription {
switch (source.kind) {
case 'blank':
return { label: 'Blank workspace' }
case 'branch':
return {
label: `Branch: ${source.localBranchName || source.refName}`,
providerIconId: 'branch'
}
case 'new-branch':
return { label: `New branch: ${source.branchName}`, providerIconId: 'branch' }
case 'task': {
if (source.item.provider === 'github') {
const item = source.item.source
return { label: `#${item.number} ${item.title}`, providerIconId: 'github' }
}
if (source.item.provider === 'gitlab') {
const item = source.item.source
const prefix = item.type === 'mr' ? '!' : '#'
return { label: `${prefix}${item.number} ${item.title}`, providerIconId: 'gitlab' }
}
const item = source.item.source
return { label: `${item.identifier} ${item.title}`, providerIconId: 'linear' }
}
}
}
// The repo a source pins to: GitHub/GitLab work items dictate their own repo;
// Linear/branch/new-branch/blank defer to the modal's selected repo (null).
export function resolveSourceRepoId(source: WorkspaceSource): string | null {
if (
source.kind === 'task' &&
(source.item.provider === 'github' || source.item.provider === 'gitlab')
) {
return source.item.source.repoId
}
return null
}
// Whether a source is tied to a specific repo, so switching the modal's repo
// invalidates it. Branches are searched per-repo; GitHub/GitLab items pin a repo.
// Linear issues are repo-agnostic (the target repo is chosen separately), and
// blank has no repo dependency.
export function isRepoBoundSource(source: WorkspaceSource): boolean {
if (source.kind === 'branch' || source.kind === 'new-branch') {
return true
}
if (source.kind === 'task') {
return source.item.provider !== 'linear'
}
return false
}
+6
View File
@@ -230,11 +230,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'github.updatePRState',
'github.repoSlug',
'github.workItem',
// Cross-repo GitHub work-item lookup: lets the mobile create-workspace Smart
// picker resolve a pasted github.com URL that points at a different repo.
'github.workItemByOwnerRepo',
'github.workItemDetails',
'gitlab.createIssue',
'gitlab.addIssueComment',
'gitlab.addMRComment',
'gitlab.listWorkItems',
// Mobile create-workspace Smart picker: resolve a pasted GitLab URL to an exact
// issue/MR. (MR listing reuses gitlab.listWorkItems, which returns issues + MRs.)
'gitlab.workItemByPath',
'gitlab.mergeMR',
'gitlab.resolveMRDiscussion',
'gitlab.todos',
@@ -1,54 +1,2 @@
export type SmartWorkspaceCommandRowKind =
| 'use-name'
| 'create-branch'
| 'github'
| 'gitlab'
| 'branch'
| 'linear'
export type SmartWorkspaceCommandRow = {
kind: SmartWorkspaceCommandRowKind
value: string
}
export type SmartWorkspaceSourceIntent = 'github' | 'gitlab' | 'linear' | null
export function resolveSmartWorkspaceCommandValue({
currentValue,
rows,
isQueryStale,
sourceIntent
}: {
currentValue: string
rows: readonly SmartWorkspaceCommandRow[]
isQueryStale: boolean
sourceIntent: SmartWorkspaceSourceIntent
}): string {
if (rows.length === 0) {
return currentValue
}
if (isQueryStale) {
const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch')
return typedTextRow?.value ?? ''
}
if (sourceIntent === 'github') {
const githubRow = rows.find((row) => row.kind === 'github')
if (githubRow) {
return githubRow.value
}
} else if (sourceIntent === 'gitlab') {
const gitlabRow = rows.find((row) => row.kind === 'gitlab')
if (gitlabRow) {
return gitlabRow.value
}
} else if (sourceIntent === 'linear') {
const linearRow = rows.find((row) => row.kind === 'linear')
if (linearRow) {
return linearRow.value
}
}
return rows.some((row) => row.value === currentValue) ? currentValue : rows[0].value
}
// Re-export shim: the implementation moved to src/shared so mobile can share it.
export * from '../../../../shared/new-workspace/smart-workspace-command-value'
@@ -1,190 +1,2 @@
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearCollectionResult,
LinearIssue
} from '../../../../shared/types'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text'
export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048
export type SmartWorkspaceSourceRow =
| { kind: 'use-name'; value: string; name: string }
| { kind: 'create-branch'; value: string; name: string }
| { kind: 'github'; value: string; item: GitHubWorkItem }
| { kind: 'gitlab'; value: string; item: GitLabWorkItem }
| { kind: 'branch'; value: string; refName: string; localBranchName: string }
| { kind: 'linear'; value: string; issue: LinearIssue }
type LinearIssueSourceInput = LinearIssue[] | LinearCollectionResult<LinearIssue> | null | undefined
const EMPTY_HINT_BY_MODE: Record<SmartNameMode, string> = {
smart: 'Start typing to create a name or find a source.',
github: 'Start typing to search GitHub PRs and issues.',
gitlab: 'Start typing to search GitLab MRs and issues.',
branches: 'No matching branches.',
linear: 'Start typing to search Linear issues.',
text: ''
}
export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string {
return EMPTY_HINT_BY_MODE[mode]
}
export function isSmartWorkspaceSourceQueryWithinLimit(
query: string,
maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES
): boolean {
return !isClipboardTextByteLengthOverLimit(query, maxBytes)
}
export function getBranchSearchRequest({
branchesEnabled,
disabled,
textOnly,
mode,
selectedRepoId,
query,
limit
}: {
branchesEnabled?: boolean
disabled: boolean
textOnly: boolean
mode: SmartNameMode
selectedRepoId: string | null
query: string
limit: number
}): { repoId: string; query: string; limit: number } | null {
if (
branchesEnabled === false ||
disabled ||
textOnly ||
!isSmartWorkspaceSourceQueryWithinLimit(query) ||
!selectedRepoId
) {
return null
}
const trimmedQuery = query.trim()
const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0)
if (!shouldSearchBranches) {
return null
}
return { repoId: selectedRepoId, query: trimmedQuery, limit }
}
export function getVisibleBranchResults({
branches,
mode,
resultRepoId,
resultQuery,
selectedRepoId,
value
}: {
branches: BaseRefSearchResult[]
mode: SmartNameMode
resultRepoId: string | null
resultQuery: string | null
selectedRepoId: string | null
value: string
}): BaseRefSearchResult[] {
if (!isSmartWorkspaceSourceQueryWithinLimit(value)) {
return []
}
const currentQuery = value.trim()
if (mode !== 'branches' && mode !== 'smart') {
return []
}
if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) {
return []
}
return branches
}
export function buildSmartWorkspaceSourceRows({
branches,
githubItems,
gitlabAvailable,
gitlabItems,
linearAvailable,
linearIssues,
mode,
resultLimit,
value
}: {
branches: BaseRefSearchResult[]
githubItems: GitHubWorkItem[]
gitlabAvailable: boolean
gitlabItems: GitLabWorkItem[]
linearAvailable: boolean
linearIssues: LinearIssueSourceInput
mode: SmartNameMode
resultLimit: number
value: string
}): SmartWorkspaceSourceRow[] {
if (!isSmartWorkspaceSourceQueryWithinLimit(value)) {
return []
}
const trimmed = value.trim()
const nextRows: SmartWorkspaceSourceRow[] = []
if (trimmed && mode === 'smart') {
nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed })
}
if (mode === 'text') {
return nextRows
}
if (mode === 'smart' || mode === 'github') {
nextRows.push(
...githubItems.map((item) => ({
kind: 'github' as const,
value: `github-${item.repoId}-${item.type}-${item.number}`,
item
}))
)
}
if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) {
nextRows.push(
...gitlabItems.map((item) => ({
kind: 'gitlab' as const,
value: `gitlab-${item.repoId}-${item.type}-${item.number}`,
item
}))
)
}
const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0)
if (shouldShowBranches) {
const branchExactMatch = branches.some(
(branch) => branch.refName === trimmed || branch.localBranchName === trimmed
)
if (trimmed && mode === 'branches' && !branchExactMatch) {
nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed })
}
nextRows.push(
...branches.map((branch) => ({
kind: 'branch' as const,
value: `branch-${branch.refName}`,
refName: branch.refName,
localBranchName: branch.localBranchName
}))
)
}
if (linearAvailable && (mode === 'smart' || mode === 'linear')) {
// Why: mixed-version runtime responses may briefly carry the paginated
// collection shape into this render path; rendering must stay recoverable.
const resolvedLinearIssues = Array.isArray(linearIssues)
? linearIssues
: Array.isArray(linearIssues?.items)
? linearIssues.items
: []
nextRows.push(
...resolvedLinearIssues.map((issue) => ({
kind: 'linear' as const,
value: `linear-${issue.id}`,
issue
}))
)
}
return nextRows.slice(0, resultLimit + 1)
}
// Re-export shim: the implementation moved to src/shared so mobile can share it.
export * from '../../../../shared/new-workspace/smart-workspace-source-results'
+2 -142
View File
@@ -1,142 +1,2 @@
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
const HTTP_URL_PREFIX_RE = /^https?:\/\//i
export type RepoSlug = {
owner: string
repo: string
}
export type GitHubIssueOrPRLink = {
slug: RepoSlug
number: number
type: 'issue' | 'pr'
}
export type GitHubLinkQuery = {
query: string
directNumber: number | null
directLink?: GitHubIssueOrPRLink
tooLarge?: boolean
}
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
if (!slug?.owner || !slug.repo) {
return null
}
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
}
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
}
function parseGitHubItemNumber(value: string): number | null {
const parsed = Number.parseInt(value, 10)
return parsed > 0 ? parsed : null
}
/**
* Parses a GitHub issue/PR reference from plain input.
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
*/
export function parseGitHubIssueOrPRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return parseGitHubItemNumber(numeric)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return parseGitHubItemNumber(match[4])
}
/**
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
*/
export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
const number = parseGitHubItemNumber(match[4])
if (number === null) {
return null
}
return {
slug: { owner: match[1], repo: match[2] },
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
number
}
}
/**
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
* URLs resolve to a usable query + direct-number lookup.
*/
export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery {
if (isWorkItemLinkQueryTooLarge(raw)) {
return { query: '', directNumber: null, tooLarge: true }
}
const trimmed = raw.trim()
if (!trimmed) {
return { query: '', directNumber: null }
}
const direct = parseGitHubIssueOrPRNumber(trimmed)
if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) {
return { query: trimmed, directNumber: direct }
}
const link = parseGitHubIssueOrPRLink(trimmed)
if (!link) {
return { query: trimmed, directNumber: null }
}
// Why: any GitHub-shaped issue/pull URL is accepted by number regardless of
// slug, since fork checkouts can legitimately target upstream issues whose
// slug differs from the origin remote.
return {
query: trimmed,
directNumber: link.number,
directLink: link
}
}
// Re-export shim: the implementation moved to src/shared so mobile can share it.
export * from '../../../shared/new-workspace/github-links'
@@ -1,20 +1,2 @@
import { parseGitHubIssueOrPRLink } from '@/lib/github-links'
export type GitHubWorkItemIdentity = {
type: 'issue' | 'pr'
number: number
}
export function resolveGitHubWorkItemIdentity(item: {
type: 'issue' | 'pr'
number: number
url?: string | null
}): GitHubWorkItemIdentity {
const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null
if (link) {
// Why: stale cached work-item payloads can disagree with a pasted URL. The
// URL path is the user-visible intent, so it decides issue-vs-PR launches.
return { type: link.type, number: link.number }
}
return { type: item.type, number: item.number }
}
// Re-export shim: the implementation moved to src/shared so mobile can share it.
export * from '../../../shared/new-workspace/github-work-item-identity'
+2 -138
View File
@@ -1,138 +1,2 @@
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
// Why: GitLab project paths can include nested groups, and the host may
// be self-hosted (gitlab.example.com), so the URL pattern uses the
// project-internal `/-/` separator as the GitLab-specific signal rather
// than locking to gitlab.com. Anything matching `/<path>/-/(issues|
// work_items|merge_requests)/<digits>` is treated as a GitLab item URL
// regardless of host. Modern GitLab emits issue URLs as
// `/-/work_items/<iid>`; treat that as an issue work item, same as the
// legacy `/-/issues/<iid>` form.
const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i
const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i
export type ProjectSlug = {
/** GitLab hostname, preserving self-hosted instances from pasted URLs. */
host: string
/** Full GitLab project path including any nested groups. */
path: string
}
export type GitLabLinkQuery = {
query: string
directNumber: number | null
tooLarge?: boolean
}
/**
* Parse a GitLab issue or MR reference from plain input. Accepts:
* - bare numbers ("42")
* - hash-prefixed numbers ("#42")
* - exclamation-prefixed numbers ("!42") — GitLab convention for MRs
* - full GitLab URLs (any host) for issues or merge_requests
*/
export function parseGitLabIssueOrMRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
// Why: GitLab references issues with `#` and MRs with `!` in markdown
// and copy-paste contexts. Accept both prefixes so users can drop in
// either form.
const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return Number.parseInt(numeric, 10)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
const match = GL_ITEM_PATH_RE.exec(url.pathname)
if (!match) {
return null
}
// Why: the basic pattern matches plain GitHub URLs too (e.g.
// /owner/repo/issues/123). Require the `/-/` separator that's
// unique to GitLab to avoid mis-classifying a GitHub URL.
if (!url.pathname.includes('/-/')) {
return null
}
return Number.parseInt(match[1], 10)
}
/**
* Parse a GitLab URL into project path + iid + type. Returns null for
* anything that isn't a recognizable GitLab issue or merge-request URL.
*/
export function parseGitLabIssueOrMRLink(input: string): {
slug: ProjectSlug
number: number
type: 'issue' | 'mr'
} | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname)
if (!match) {
return null
}
const path = match[1]
// Why: a project path needs at least one slash (group/project). A
// single-segment path is the user/group root, not a project.
if (!path.includes('/')) {
return null
}
return {
slug: { host: url.host, path },
type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue',
number: Number.parseInt(match[3], 10)
}
}
/**
* Normalize link-picker input so both raw issue/MR numbers and full
* GitLab URLs resolve to a usable query + direct-number lookup.
*/
export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery {
if (isWorkItemLinkQueryTooLarge(raw)) {
return { query: '', directNumber: null, tooLarge: true }
}
const trimmed = raw.trim()
if (!trimmed) {
return { query: '', directNumber: null }
}
const direct = parseGitLabIssueOrMRNumber(trimmed)
if (direct !== null && !trimmed.startsWith('http')) {
return { query: trimmed, directNumber: direct }
}
const link = parseGitLabIssueOrMRLink(trimmed)
if (!link) {
return { query: trimmed, directNumber: null }
}
// Why: any GitLab issue/MR URL is accepted by number regardless of
// project slug, mirroring the GitHub-side behavior — fork checkouts
// can legitimately target an upstream's issue numbers.
return {
query: trimmed,
directNumber: link.number
}
}
// Re-export shim: the implementation moved to src/shared so mobile can share it.
export * from '../../../shared/new-workspace/gitlab-links'
@@ -1,10 +1,2 @@
import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text'
export const WORK_ITEM_LINK_QUERY_MAX_BYTES = 2 * 1024
export function isWorkItemLinkQueryTooLarge(
query: string,
maxBytes = WORK_ITEM_LINK_QUERY_MAX_BYTES
): boolean {
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
// Re-export shim: the implementation moved to src/shared so mobile can share it.
export * from '../../../shared/new-workspace/work-item-link-query-bounds'
+142
View File
@@ -0,0 +1,142 @@
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
const HTTP_URL_PREFIX_RE = /^https?:\/\//i
export type RepoSlug = {
owner: string
repo: string
}
export type GitHubIssueOrPRLink = {
slug: RepoSlug
number: number
type: 'issue' | 'pr'
}
export type GitHubLinkQuery = {
query: string
directNumber: number | null
directLink?: GitHubIssueOrPRLink
tooLarge?: boolean
}
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
if (!slug?.owner || !slug.repo) {
return null
}
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
}
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
}
function parseGitHubItemNumber(value: string): number | null {
const parsed = Number.parseInt(value, 10)
return parsed > 0 ? parsed : null
}
/**
* Parses a GitHub issue/PR reference from plain input.
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
*/
export function parseGitHubIssueOrPRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return parseGitHubItemNumber(numeric)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return parseGitHubItemNumber(match[4])
}
/**
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
*/
export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
const number = parseGitHubItemNumber(match[4])
if (number === null) {
return null
}
return {
slug: { owner: match[1], repo: match[2] },
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
number
}
}
/**
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
* URLs resolve to a usable query + direct-number lookup.
*/
export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery {
if (isWorkItemLinkQueryTooLarge(raw)) {
return { query: '', directNumber: null, tooLarge: true }
}
const trimmed = raw.trim()
if (!trimmed) {
return { query: '', directNumber: null }
}
const direct = parseGitHubIssueOrPRNumber(trimmed)
if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) {
return { query: trimmed, directNumber: direct }
}
const link = parseGitHubIssueOrPRLink(trimmed)
if (!link) {
return { query: trimmed, directNumber: null }
}
// Why: any GitHub-shaped issue/pull URL is accepted by number regardless of
// slug, since fork checkouts can legitimately target upstream issues whose
// slug differs from the origin remote.
return {
query: trimmed,
directNumber: link.number,
directLink: link
}
}
@@ -0,0 +1,20 @@
import { parseGitHubIssueOrPRLink } from './github-links'
export type GitHubWorkItemIdentity = {
type: 'issue' | 'pr'
number: number
}
export function resolveGitHubWorkItemIdentity(item: {
type: 'issue' | 'pr'
number: number
url?: string | null
}): GitHubWorkItemIdentity {
const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null
if (link) {
// Why: stale cached work-item payloads can disagree with a pasted URL. The
// URL path is the user-visible intent, so it decides issue-vs-PR launches.
return { type: link.type, number: link.number }
}
return { type: item.type, number: item.number }
}
+138
View File
@@ -0,0 +1,138 @@
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
// Why: GitLab project paths can include nested groups, and the host may
// be self-hosted (gitlab.example.com), so the URL pattern uses the
// project-internal `/-/` separator as the GitLab-specific signal rather
// than locking to gitlab.com. Anything matching `/<path>/-/(issues|
// work_items|merge_requests)/<digits>` is treated as a GitLab item URL
// regardless of host. Modern GitLab emits issue URLs as
// `/-/work_items/<iid>`; treat that as an issue work item, same as the
// legacy `/-/issues/<iid>` form.
const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i
const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i
export type ProjectSlug = {
/** GitLab hostname, preserving self-hosted instances from pasted URLs. */
host: string
/** Full GitLab project path including any nested groups. */
path: string
}
export type GitLabLinkQuery = {
query: string
directNumber: number | null
tooLarge?: boolean
}
/**
* Parse a GitLab issue or MR reference from plain input. Accepts:
* - bare numbers ("42")
* - hash-prefixed numbers ("#42")
* - exclamation-prefixed numbers ("!42") — GitLab convention for MRs
* - full GitLab URLs (any host) for issues or merge_requests
*/
export function parseGitLabIssueOrMRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
// Why: GitLab references issues with `#` and MRs with `!` in markdown
// and copy-paste contexts. Accept both prefixes so users can drop in
// either form.
const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return Number.parseInt(numeric, 10)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
const match = GL_ITEM_PATH_RE.exec(url.pathname)
if (!match) {
return null
}
// Why: the basic pattern matches plain GitHub URLs too (e.g.
// /owner/repo/issues/123). Require the `/-/` separator that's
// unique to GitLab to avoid mis-classifying a GitHub URL.
if (!url.pathname.includes('/-/')) {
return null
}
return Number.parseInt(match[1], 10)
}
/**
* Parse a GitLab URL into project path + iid + type. Returns null for
* anything that isn't a recognizable GitLab issue or merge-request URL.
*/
export function parseGitLabIssueOrMRLink(input: string): {
slug: ProjectSlug
number: number
type: 'issue' | 'mr'
} | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname)
if (!match) {
return null
}
const path = match[1]
// Why: a project path needs at least one slash (group/project). A
// single-segment path is the user/group root, not a project.
if (!path.includes('/')) {
return null
}
return {
slug: { host: url.host, path },
type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue',
number: Number.parseInt(match[3], 10)
}
}
/**
* Normalize link-picker input so both raw issue/MR numbers and full
* GitLab URLs resolve to a usable query + direct-number lookup.
*/
export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery {
if (isWorkItemLinkQueryTooLarge(raw)) {
return { query: '', directNumber: null, tooLarge: true }
}
const trimmed = raw.trim()
if (!trimmed) {
return { query: '', directNumber: null }
}
const direct = parseGitLabIssueOrMRNumber(trimmed)
if (direct !== null && !trimmed.startsWith('http')) {
return { query: trimmed, directNumber: direct }
}
const link = parseGitLabIssueOrMRLink(trimmed)
if (!link) {
return { query: trimmed, directNumber: null }
}
// Why: any GitLab issue/MR URL is accepted by number regardless of
// project slug, mirroring the GitHub-side behavior — fork checkouts
// can legitimately target an upstream's issue numbers.
return {
query: trimmed,
directNumber: link.number
}
}
@@ -0,0 +1,54 @@
export type SmartWorkspaceCommandRowKind =
| 'use-name'
| 'create-branch'
| 'github'
| 'gitlab'
| 'branch'
| 'linear'
export type SmartWorkspaceCommandRow = {
kind: SmartWorkspaceCommandRowKind
value: string
}
export type SmartWorkspaceSourceIntent = 'github' | 'gitlab' | 'linear' | null
export function resolveSmartWorkspaceCommandValue({
currentValue,
rows,
isQueryStale,
sourceIntent
}: {
currentValue: string
rows: readonly SmartWorkspaceCommandRow[]
isQueryStale: boolean
sourceIntent: SmartWorkspaceSourceIntent
}): string {
if (rows.length === 0) {
return currentValue
}
if (isQueryStale) {
const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch')
return typedTextRow?.value ?? ''
}
if (sourceIntent === 'github') {
const githubRow = rows.find((row) => row.kind === 'github')
if (githubRow) {
return githubRow.value
}
} else if (sourceIntent === 'gitlab') {
const gitlabRow = rows.find((row) => row.kind === 'gitlab')
if (gitlabRow) {
return gitlabRow.value
}
} else if (sourceIntent === 'linear') {
const linearRow = rows.find((row) => row.kind === 'linear')
if (linearRow) {
return linearRow.value
}
}
return rows.some((row) => row.value === currentValue) ? currentValue : rows[0].value
}
@@ -0,0 +1,190 @@
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearCollectionResult,
LinearIssue
} from '../types'
import { isClipboardTextByteLengthOverLimit } from '../clipboard-text'
export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text'
export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048
export type SmartWorkspaceSourceRow =
| { kind: 'use-name'; value: string; name: string }
| { kind: 'create-branch'; value: string; name: string }
| { kind: 'github'; value: string; item: GitHubWorkItem }
| { kind: 'gitlab'; value: string; item: GitLabWorkItem }
| { kind: 'branch'; value: string; refName: string; localBranchName: string }
| { kind: 'linear'; value: string; issue: LinearIssue }
type LinearIssueSourceInput = LinearIssue[] | LinearCollectionResult<LinearIssue> | null | undefined
const EMPTY_HINT_BY_MODE: Record<SmartNameMode, string> = {
smart: 'Start typing to create a name or find a source.',
github: 'Start typing to search GitHub PRs and issues.',
gitlab: 'Start typing to search GitLab MRs and issues.',
branches: 'No matching branches.',
linear: 'Start typing to search Linear issues.',
text: ''
}
export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string {
return EMPTY_HINT_BY_MODE[mode]
}
export function isSmartWorkspaceSourceQueryWithinLimit(
query: string,
maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES
): boolean {
return !isClipboardTextByteLengthOverLimit(query, maxBytes)
}
export function getBranchSearchRequest({
branchesEnabled,
disabled,
textOnly,
mode,
selectedRepoId,
query,
limit
}: {
branchesEnabled?: boolean
disabled: boolean
textOnly: boolean
mode: SmartNameMode
selectedRepoId: string | null
query: string
limit: number
}): { repoId: string; query: string; limit: number } | null {
if (
branchesEnabled === false ||
disabled ||
textOnly ||
!isSmartWorkspaceSourceQueryWithinLimit(query) ||
!selectedRepoId
) {
return null
}
const trimmedQuery = query.trim()
const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0)
if (!shouldSearchBranches) {
return null
}
return { repoId: selectedRepoId, query: trimmedQuery, limit }
}
export function getVisibleBranchResults({
branches,
mode,
resultRepoId,
resultQuery,
selectedRepoId,
value
}: {
branches: BaseRefSearchResult[]
mode: SmartNameMode
resultRepoId: string | null
resultQuery: string | null
selectedRepoId: string | null
value: string
}): BaseRefSearchResult[] {
if (!isSmartWorkspaceSourceQueryWithinLimit(value)) {
return []
}
const currentQuery = value.trim()
if (mode !== 'branches' && mode !== 'smart') {
return []
}
if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) {
return []
}
return branches
}
export function buildSmartWorkspaceSourceRows({
branches,
githubItems,
gitlabAvailable,
gitlabItems,
linearAvailable,
linearIssues,
mode,
resultLimit,
value
}: {
branches: BaseRefSearchResult[]
githubItems: GitHubWorkItem[]
gitlabAvailable: boolean
gitlabItems: GitLabWorkItem[]
linearAvailable: boolean
linearIssues: LinearIssueSourceInput
mode: SmartNameMode
resultLimit: number
value: string
}): SmartWorkspaceSourceRow[] {
if (!isSmartWorkspaceSourceQueryWithinLimit(value)) {
return []
}
const trimmed = value.trim()
const nextRows: SmartWorkspaceSourceRow[] = []
if (trimmed && mode === 'smart') {
nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed })
}
if (mode === 'text') {
return nextRows
}
if (mode === 'smart' || mode === 'github') {
nextRows.push(
...githubItems.map((item) => ({
kind: 'github' as const,
value: `github-${item.repoId}-${item.type}-${item.number}`,
item
}))
)
}
if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) {
nextRows.push(
...gitlabItems.map((item) => ({
kind: 'gitlab' as const,
value: `gitlab-${item.repoId}-${item.type}-${item.number}`,
item
}))
)
}
const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0)
if (shouldShowBranches) {
const branchExactMatch = branches.some(
(branch) => branch.refName === trimmed || branch.localBranchName === trimmed
)
if (trimmed && mode === 'branches' && !branchExactMatch) {
nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed })
}
nextRows.push(
...branches.map((branch) => ({
kind: 'branch' as const,
value: `branch-${branch.refName}`,
refName: branch.refName,
localBranchName: branch.localBranchName
}))
)
}
if (linearAvailable && (mode === 'smart' || mode === 'linear')) {
// Why: mixed-version runtime responses may briefly carry the paginated
// collection shape into this render path; rendering must stay recoverable.
const resolvedLinearIssues = Array.isArray(linearIssues)
? linearIssues
: Array.isArray(linearIssues?.items)
? linearIssues.items
: []
nextRows.push(
...resolvedLinearIssues.map((issue) => ({
kind: 'linear' as const,
value: `linear-${issue.id}`,
issue
}))
)
}
return nextRows.slice(0, resultLimit + 1)
}
@@ -0,0 +1,10 @@
import { isClipboardTextByteLengthOverLimit } from '../clipboard-text'
export const WORK_ITEM_LINK_QUERY_MAX_BYTES = 2 * 1024
export function isWorkItemLinkQueryTooLarge(
query: string,
maxBytes = WORK_ITEM_LINK_QUERY_MAX_BYTES
): boolean {
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}