diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 01a3cc95a..b8db4e3a2 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1428,6 +1428,7 @@ export function HostScreen({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={worktrees} onVisibleChange={(visible) => { newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index fea23f017..c61d01559 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -61,6 +61,7 @@ import { } from '../../../src/session/mobile-file-syntax' import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { filterWorkspaceAgents, isWorkspaceAgentEnabled, @@ -858,7 +859,6 @@ const GITHUB_REPO_CONCURRENCY = 3 const MAX_RENDERED_PR_DIFF_LINES = 400 const GITLAB_PER_PAGE = 50 const LINEAR_LIMIT = 50 -const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' // Why: task detail drawers can launch child sheets; children must layer above // the still-mounted parent while its dismissal animation/state remains alive. const TASK_SECONDARY_DRAWER_Z_INDEX = 1100 diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 224511d38..d28e9ac1d 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1935,48 +1935,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==} @@ -2049,48 +2057,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==} @@ -2552,36 +2568,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==} @@ -4881,24 +4903,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==} @@ -7025,22 +7051,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: @@ -7050,7 +7076,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: @@ -7085,12 +7111,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: @@ -7105,42 +7131,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: @@ -8604,7 +8630,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -8771,7 +8797,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': 26.1.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9529,24 +9555,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: @@ -9561,7 +9587,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/hammerjs@2.0.46': {} @@ -9639,7 +9665,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -9659,7 +9685,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -9676,6 +9702,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9685,6 +9720,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9714,6 +9753,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -9972,7 +10026,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 @@ -10273,7 +10327,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10282,7 +10336,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10678,7 +10732,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: @@ -10776,11 +10830,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -10804,7 +10858,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -10827,7 +10881,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10838,7 +10892,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11515,7 +11569,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: {} @@ -12087,7 +12141,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.6.0 + '@types/node': 26.1.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12242,7 +12296,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12287,7 +12341,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -14008,6 +14062,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml new file mode 100644 index 000000000..35f983a34 --- /dev/null +++ b/mobile/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + +overrides: + xcode>uuid: 11.1.1 diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 961a62a7e..75b18fcd1 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -10,21 +10,19 @@ import { ActivityIndicator, Keyboard } from 'react-native' -import { ChevronDown, ChevronUp, Check } from 'lucide-react-native' +import { ChevronDown, ChevronUp } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import type { RpcResponse, RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' -import { BottomDrawer } from './BottomDrawer' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } 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 { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' import { isSetupHookTrusted, normalizeSetupHookTrust, - trustedOrcaHooksWithSetupApproval, + persistSetupHookTrustApproval, wasSetupHookPreviouslyApproved, type SetupHookTrust } from '../tasks/setup-hook-trust' @@ -49,6 +47,24 @@ import { refreshMobileNewWorkspaceDialogSelectedRepo, resolveMobileNewWorkspaceDialogRepoId } from '../worktree/new-workspace-dialog-repo-selection' +import { createBlankWorkspace } from '../tasks/blank-workspace-create' +import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' +import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability' +import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + type TaskProvider +} from '../tasks/mobile-task-providers' +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 { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' +import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' +import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' +import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' +import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' +import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' type Repo = { id: string @@ -56,6 +72,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' @@ -91,13 +110,11 @@ type CreateOptions = { approvedSetupContentHash?: string } -type SetupTrustPrompt = { - repoId: string - repoName: string - scriptContent: string - contentHash: string - previouslyApproved: boolean -} +type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust' + +// Why: iOS cannot reliably present a second native modal until the first drawer's +// exit commits; one extra frame keeps transitions sequential on slower devices. +const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16 function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] @@ -124,6 +141,7 @@ type Props = { // on the on-disk directory basename, so paths (not displayNames) are // what the suggestion logic must dedupe against. existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onCreated: (worktreeId: string, name: string) => void onClose: () => void } @@ -133,6 +151,7 @@ export function NewWorktreeModal({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { @@ -157,6 +176,7 @@ export function NewWorktreeModal({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={existingWorktrees} onCreated={onCreated} onClose={onClose} /> @@ -168,25 +188,28 @@ function NewWorktreeModalContent({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null)) const [repos, setRepos] = useState(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) - const [showRepoPicker, setShowRepoPicker] = useState(false) - const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true) + const [drawerView, setDrawerView] = useState('form') + const drawerTransitionTimerRef = useRef | null>(null) + const createInFlightRef = useRef(false) + const setupTrustActionInFlightRef = useRef(false) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( null ) const [agentOverriddenState, setAgentOverridden] = useState(false) - const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [name, setName] = useState('') const [note, setNote] = useState('') + const [availableProviders, setAvailableProviders] = useState([]) + const [tasksSupported, setTasksSupported] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -200,12 +223,40 @@ function NewWorktreeModalContent({ const [error, setError] = useState('') const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + const selectedRepoWorktreeBranches = useMemo( + () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), + [existingWorktrees, selectedRepo] + ) - // 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. + useEffect(() => { + return () => { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + } + }, []) + + function transitionDrawer(nextView: Exclude): void { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + setDrawerView('transition') + drawerTransitionTimerRef.current = setTimeout(() => { + drawerTransitionTimerRef.current = null + setDrawerView(nextView) + }, NEW_WORKTREE_DRAWER_TRANSITION_MS) + } + + // 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, + worktreeBranches: selectedRepoWorktreeBranches, + onError: setError + }) const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const sshGate = deriveWorkspaceSshGate({ @@ -242,6 +293,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( + () => + repos.map((repo) => ({ + id: repo.id, + displayName: repo.displayName, + slug: deriveRepoSlug(repo) + })), + [repos] + ) + useEffect(() => { if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { return @@ -298,27 +368,68 @@ function NewWorktreeModalContent({ }) void (async () => { - try { - const [settingsResponse, uiResponse] = await Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('ui.get') - ]) - if (stale) { - return - } - if (settingsResponse.ok) { - const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } - setRuntimeSettings(result.settings) - } - if (uiResponse.ok) { - const result = (uiResponse as RpcSuccess).result as { - ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } - } - setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {}) - } - } catch { - // Non-critical; repo.list owns the visible loading state. + // Why: settle each RPC independently so a flaky availability probe (e.g. a + // linear.status timeout, which rejects rather than resolving {ok:false}) + // can't discard the already-resolved critical settings/ui results. + const probes = Promise.allSettled([ + client.sendRequest('status.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + const okResult = (entry: PromiseSettledResult): RpcSuccess | null => + entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null + // Why: hydrate settings/trust the moment their own RPCs settle — gating them + // on the probes (a first-open preflight.check can take seconds) widens the + // window where an already-trusted setup hook spuriously re-prompts on create. + const [settingsRes, uiRes] = await Promise.allSettled([ + client.sendRequest('settings.get'), + client.sendRequest('ui.get') + ]) + if (stale) { + return } + + const settingsResult = okResult(settingsRes) + const settingsValue = settingsResult + ? ( + settingsResult.result as { + settings: RuntimeSettings & { visibleTaskProviders?: unknown } + } + ).settings + : null + if (settingsValue) { + setRuntimeSettings(settingsValue) + } + const uiResult = okResult(uiRes) + if (uiResult) { + const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui + setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) + } + + const [statusRes, preflightRes, linearRes] = await probes + if (stale) { + return + } + // Tasks is an additive RPC surface, so older paired desktops without the + // capability fall back to branch + blank sources only. + const statusResult = okResult(statusRes) + const capabilities = + (statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? [] + setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY)) + const glabInstalled = + (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab + ?.installed === true + const linearConnected = + (okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true + const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) + setAvailableProviders( + // Drop filterAvailableTaskProviders' forced 'github' fallback when the user + // hid GitHub; the Branch tab always guarantees at least one tab remains. + filterAvailableTaskProviders(visibleProviders, { + gitlabInstalled: glabInstalled, + linearConnected + }).filter((provider) => visibleProviders.includes(provider)) + ) })() return () => { stale = true @@ -486,31 +597,11 @@ function NewWorktreeModalContent({ } } - async function persistSetupHookTrust( - repoId: string, - contentHash: string, - alwaysTrust: boolean - ): Promise { - if (!client) { - return - } - const next = trustedOrcaHooksWithSetupApproval({ - trust: trustedOrcaHooks, - repoId, - contentHash, - alwaysTrust - }) - const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) - if (!response.ok) { - throw new Error(response.error.message) - } - setTrustedOrcaHooks(next) - } - async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo) { + if (!client || !selectedRepo || createInFlightRef.current) { return } + createInFlightRef.current = true setCreating(true) setError('') @@ -555,24 +646,9 @@ 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 ?? []) - // Why: mirrors src/renderer/src/store/slices/worktrees.ts - // (createWorktree retry loop). Server-side checks (Branch X already - // exists locally / on a remote / already has PR #N) can fire even - // after the pre-flight basename dedupe — branches outlive worktrees - // in git, and remote branches/PRs aren't visible from worktree.ps. - // Retry up to 25 times by appending -2, -3, ... before surfacing - // the error. The desktop applies this to user-typed names too, so - // mobile follows suit for parity. - const retryablePatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /already has pr #\d+/i - ] - const candidateFor = (attempt: number): string => - attempt === 0 ? baseName : `${baseName}-${attempt + 1}` let setupDecision: SetupDecision = 'inherit' if (setupCommand) { if (options.setupOverride) { @@ -602,44 +678,46 @@ function NewWorktreeModalContent({ contentHash: setupTrust.contentHash, previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) }) + transitionDrawer('trust') return } - let lastError: string | null = null - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = candidateFor(attempt) - const params: Record = { - repo: `id:${selectedRepo.id}`, - startupCommand: command, - setupDecision, - name: candidateName - } - if (selectedAgent.id !== '__blank__') { - params.createdWithAgent = selectedAgent.id - } - if (note.trim()) { - params.comment = note.trim() - } - - const response = await client.sendRequest('worktree.create', params, { - timeoutMs: WORKTREE_CREATE_TIMEOUT_MS - }) - if (response.ok) { - const result = (response as RpcSuccess).result as { worktree: { id: string } } - onClose() - onCreated(result.worktree.id, candidateName) - return - } - - lastError = response.error.message - if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) { - break - } + const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined + const trimmedNote = note.trim() || undefined + 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 } - setError(lastError ?? 'Failed to create workspace') + onClose() + onCreated(result.worktreeId, result.name) } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create workspace') } finally { + createInFlightRef.current = false setCreating(false) } } @@ -670,15 +748,74 @@ 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 handleRepoSelected(repo: Repo): void { + const repoChanged = repo.id !== selectedRepo?.id + setSelectedRepo(repo) + // Branch and provider-backed sources are repo-scoped; Linear/Jira are global + // work context and survive choosing a different implementation repo. + if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) { + composer.handleClearSmartNameSelection() + } + } + + async function approveSetupTrust(alwaysTrust: boolean): Promise { + if ( + !client || + !setupTrustPrompt || + setupTrustActionInFlightRef.current || + createInFlightRef.current + ) { + return + } + setupTrustActionInFlightRef.current = true + setCreating(true) + try { + const nextTrust = await persistSetupHookTrustApproval({ + client, + trust: trustedOrcaHooks, + repoId: setupTrustPrompt.repoId, + contentHash: setupTrustPrompt.contentHash, + alwaysTrust + }) + setTrustedOrcaHooks(nextTrust) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + transitionDrawer('form') + await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } finally { + setupTrustActionInFlightRef.current = false + if (!createInFlightRef.current) { + setCreating(false) + } + } + } + + function closeSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + setSetupTrustPrompt(null) + transitionDrawer('form') + } + + function skipSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + closeSetupTrust() + void handleCreate({ setupOverride: 'skip' }) + } + return ( <> - + Create Workspace @@ -702,7 +839,7 @@ function NewWorktreeModalContent({ style={styles.fieldButton} onPress={() => { prepareSelectionPickerOpen() - setShowRepoPicker(true) + transitionDrawer('repo') }} > {selectedRepo ? ( @@ -720,6 +857,18 @@ function NewWorktreeModalContent({ + setError('')} + onOpenDrawer={() => transitionDrawer('source')} + /> + + {composer.forkPushWarning ? ( + {composer.forkPushWarning} + ) : null} + {selectedRepoConnectionId ? ( SSH Connection @@ -763,28 +912,6 @@ function NewWorktreeModalContent({ ) : null} - - - Workspace Name [Optional] - - { - setName(t) - setError('') - }} - placeholderTextColor={colors.textMuted} - shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0} - returnKeyType="done" - onSubmitEditing={() => { - if (canCreate) { - void handleCreate() - } - }} - /> - - Agent { prepareSelectionPickerOpen() - setShowAgentPicker(true) + transitionDrawer('agent') }} > @@ -814,6 +941,11 @@ function NewWorktreeModalContent({ {showAdvanced && ( <> + + Note - {/* Sub-modals for pickers — rendered outside the main modal so they - layer on top and scroll without touch conflicts. */} + {/* Why: list drawers stay outside the form's ScrollView, and the transition + state prevents overlapping native modals from swallowing iOS taps. */} + { + const nextRepo = repos.find((repo) => repo.id === repoId) + if (nextRepo) { + setSelectedRepo(nextRepo) + } + }} + onClose={() => transitionDrawer('form')} + /> + setSelectedRepo(item.repo)} - onClose={() => setShowRepoPicker(false)} + onSelect={(item) => handleRepoSelected(item.repo)} + onClose={() => transitionDrawer('form')} renderIcon={(item) => { return }} /> setShowAgentPicker(false)} + onClose={() => transitionDrawer('form')} renderIcon={(agent) => } /> - setSetupTrustPrompt(null)} - > - {setupTrustPrompt ? ( - - - - {setupTrustPrompt.previouslyApproved - ? `${setupTrustPrompt.repoName}'s setup script changed` - : `Run setup from ${setupTrustPrompt.repoName}?`} - - - This repository's orca.yaml runs before the workspace starts. Only run it if you - trust this repository. - - - - - - {setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} - - {setupTrustPrompt.scriptContent} - - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - false - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Run hooks - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - true - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Always trust and run - - - { - setSetupTrustPrompt(null) - void handleCreate({ setupOverride: 'skip' }) - }} - > - Don't run - - - - ) : null} - + void approveSetupTrust(false)} + onAlwaysTrust={() => void approveSetupTrust(true)} + onDontRun={skipSetupTrust} + onClose={closeSetupTrust} + /> ) } @@ -1164,6 +1228,12 @@ const styles = StyleSheet.create({ fontSize: 13, marginBottom: spacing.md }, + sourceWarning: { + marginTop: -spacing.sm, + marginBottom: spacing.md, + fontSize: 12, + color: colors.statusAmber + }, advancedToggle: { flexDirection: 'row', alignItems: 'center', @@ -1247,52 +1317,6 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, color: colors.textPrimary }, - trustHeader: { - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - trustScriptBox: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - borderWidth: 1, - borderColor: colors.borderSubtle, - padding: spacing.md, - marginBottom: spacing.md - }, - trustScriptLabel: { - fontSize: 12, - fontWeight: '600', - color: colors.textSecondary, - marginBottom: spacing.sm - }, - trustScriptText: { - fontSize: 13, - fontFamily: typography.monoFamily, - color: colors.textPrimary - }, - trustActionGroup: { - backgroundColor: colors.bgPanel, - borderRadius: radii.input, - overflow: 'hidden' - }, - trustActionRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md - }, - trustActionText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.textPrimary, - fontWeight: '500' - }, - trustActionSeparator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index df4226429..9a062692d 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -12,6 +12,7 @@ type Props = { client: RpcClient | null hostId?: string existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void onCreated: (worktreeId: string, name: string) => void @@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef diff --git a/mobile/src/components/SetupHookTrustDrawer.tsx b/mobile/src/components/SetupHookTrustDrawer.tsx new file mode 100644 index 000000000..6095aaa17 --- /dev/null +++ b/mobile/src/components/SetupHookTrustDrawer.tsx @@ -0,0 +1,137 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type SetupTrustPrompt = { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean +} + +type Props = { + visible: boolean + prompt: SetupTrustPrompt | null + busy: boolean + onRunOnce: () => void + onAlwaysTrust: () => void + onDontRun: () => void + onClose: () => void +} + +// The repo-owned orca.yaml setup-hook trust prompt, shown before a workspace +// create that would run an untrusted setup script. Extracted from NewWorktreeModal +// to keep that file focused; the async persist/create logic stays with the caller. +export function SetupHookTrustDrawer({ + visible, + prompt, + busy, + onRunOnce, + onAlwaysTrust, + onDontRun, + onClose +}: Props) { + return ( + + {prompt ? ( + + + + {prompt.previouslyApproved + ? `${prompt.repoName}'s setup script changed` + : `Run setup from ${prompt.repoName}?`} + + + This repository's orca.yaml runs before the workspace starts. Only run it if you trust + this repository. + + + + + + {prompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + {prompt.scriptContent} + + + + + + Run hooks + + + + + Always trust and run + + + + Don't run + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + trustHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + trustScriptBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md + }, + trustScriptLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.sm + }, + trustScriptText: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + trustActionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + overflow: 'hidden' + }, + trustActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + }, + trustActionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '500' + }, + trustActionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/components/SmartSourceModeIcon.tsx b/mobile/src/components/SmartSourceModeIcon.tsx new file mode 100644 index 000000000..3d066369e --- /dev/null +++ b/mobile/src/components/SmartSourceModeIcon.tsx @@ -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 + } + if (icon.name === 'sparkles') { + return + } + if (icon.name === 'git-branch') { + return + } + return +} diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx new file mode 100644 index 000000000..4af0ad682 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -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 ? ( + + Name + + + ) : null} + + {showBranchOverride ? ( + + Branch name + + + ) : null} + + {composer.reuseEligibleBranch ? ( + + + + Reuse branch “{composer.reuseEligibleBranch}” + + + + + ) : 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 }] + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx new file mode 100644 index 000000000..c5f1a6c47 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx @@ -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(() => resolveDefaultSmartMode(availability)) + const [mrStateFilter, setMrStateFilter] = useState('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 { + 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 ( + + + Name or 'Create From' + + Done + + + + + + + {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 ( + setMode(option.id)} + > + + + {option.label} + + + ) + })} + + + {effectiveMode === 'gitlab' ? ( + + {MR_STATE_FILTER_OPTIONS.map((option) => { + const selected = option.id === mrStateFilter + return ( + setMrStateFilter(option.id)} + > + + {option.label} + + + ) + })} + + ) : null} + + {crossRepoPrompt ? ( + + + This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}. + + + + Cancel + + void handleAcceptCrossRepo()}> + + Switch to {crossRepoPrompt.matchingRepo.displayName} + + + + + ) : null} + + {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? ( + Connect the repository to search sources. + ) : needsGitHubRemote ? ( + + This SSH repo needs a GitHub remote to list issues and PRs. + + ) : error ? ( + {error} + ) : null} + + row.value} + style={styles.list} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled + ListFooterComponent={ + loading ? ( + + + + ) : showEmpty ? ( + {emptyHint || 'No results found.'} + ) : null + } + renderItem={({ item }) => ( + handleSelectRow(item)} /> + )} + /> + + ) +} + +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 + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx new file mode 100644 index 000000000..eb08ddca6 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceField.tsx @@ -0,0 +1,145 @@ +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { + CircleDot, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + X +} from 'lucide-react-native' +import type { SmartNameSelection } from '../tasks/mobile-composer-source-types' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + composer: MobileComposerSource + label: string + disabled?: boolean + onBeforeOpen?: () => void + onOpenDrawer: () => void +} + +function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) { + if (kind === 'github-pr') { + return + } + if (kind === 'gitlab-mr') { + return + } + if (kind === 'github-issue' || kind === 'gitlab-issue') { + return + } + if (kind === 'branch') { + return + } + return +} + +export function SmartWorkspaceSourceField({ + composer, + label, + disabled, + onBeforeOpen, + onOpenDrawer +}: Props) { + const selection = composer.smartNameSelection + + function openDrawer(): void { + if (disabled) { + return + } + onBeforeOpen?.() + onOpenDrawer() + } + + return ( + + + {label} [Optional] + + {selection ? ( + + + + {selection.label} + + {selection.url ? ( + selection.url && void Linking.openURL(selection.url).catch(() => {})} + > + + + ) : null} + + + + + ) : ( + + + {composer.name || 'Type a name or search a source'} + + + )} + + ) +} + +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 + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceRow.tsx b/mobile/src/components/SmartWorkspaceSourceRow.tsx new file mode 100644 index 000000000..ee601c2eb --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceRow.tsx @@ -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: , + title: `Use "${row.name}"`, + subtitle: 'Name this workspace' + } + case 'create-branch': + return { + icon: , + title: `Create branch "${row.name}"`, + subtitle: 'New branch' + } + case 'github': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'gitlab': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'branch': + return { + icon: , + title: row.localBranchName || row.refName, + subtitle: row.refName + } + case 'linear': + return { + icon: , + title: row.issue.title, + subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`, + status: row.issue.state?.name + } + default: + return { icon: , title: '' } + } +} + +export function SmartWorkspaceSourceRow({ row, onPress }: Props) { + const content = resolveRowContent(row) + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + > + {content.icon} + + + {content.title} + + {content.subtitle ? ( + + {content.subtitle} + + ) : null} + + {content.status ? ( + + + {content.status} + + + ) : null} + + ) +} + +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' + } +}) diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts new file mode 100644 index 000000000..1d35d29f7 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createBlankWorkspace } from './blank-workspace-create' + +type Call = { method: string; params: unknown } + +function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params }) + const result = script(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('createBlankWorkspace', () => { + it('assembles exactly the params the modal historically sent, omitting empty extras', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + method: 'worktree.create', + params: { + repo: 'id:repo-1', + startupCommand: undefined, + setupDecision: 'inherit', + name: 'octopus' + } + }) + const params = calls[0]?.params as Record + expect('createdWithAgent' in params).toBe(false) + expect('comment' in params).toBe(false) + }) + + it('includes createdWithAgent and comment only when provided', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + startupCommand: 'claude', + createdWithAgentId: 'claude', + comment: 'spike', + setupDecision: 'run' + }) + + expect(calls[0]?.params).toMatchObject({ + repo: 'id:repo-2', + name: 'manatee', + startupCommand: 'claude', + setupDecision: 'run', + createdWithAgent: 'claude', + comment: 'spike' + }) + }) + + it('retries with a numeric suffix on a branch-collision error', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists locally. Pick a different branch name.') + } + return { worktree: { id: 'wt-3' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + const retryParams = calls[1]?.params as Record + expect(retryParams.name).toBe('octopus-2') + }) + + it('retries on the bare older-runtime collision message', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists.') + } + return { worktree: { id: 'wt-4' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + }) + + it('surfaces a non-collision error without retrying', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('SSH connection is not available'), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'skip' + }) + + expect(result).toEqual({ error: 'SSH connection is not available' }) + expect(calls).toHaveLength(1) + }) +}) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts new file mode 100644 index 000000000..014af5581 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -0,0 +1,37 @@ +import type { TuiAgent } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorkspaceCreateSetupDecision } from './workspace-create-params' + +// The blank/named create path, extracted from NewWorktreeModal so the modal keeps +// only the UI-coupled setup-trust flow. Assembles worktree.create params and +// applies the shared name-collision retry. +export async function createBlankWorkspace(args: { + client: RpcClient + repoId: string + baseName: string + startupCommand: string | undefined + createdWithAgentId: TuiAgent | undefined + comment: string | undefined + setupDecision: WorkspaceCreateSetupDecision +}): Promise { + return createWorktreeWithNameRetry({ + client: args.client, + baseName: args.baseName, + buildParams: (name) => { + const params: Record = { + repo: `id:${args.repoId}`, + startupCommand: args.startupCommand, + setupDecision: args.setupDecision, + name + } + if (args.createdWithAgentId) { + params.createdWithAgent = args.createdWithAgentId + } + if (args.comment) { + params.comment = args.comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts new file mode 100644 index 000000000..d08d1388a --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -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)', () => { + expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false) + }) + + 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) => ({ + 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] diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts new file mode 100644 index 000000000..ed2bc4dbf --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -0,0 +1,153 @@ +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name' +import { + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + shouldApplyWorkspaceSourceAutoName +} from '../../../src/shared/new-workspace/workspace-source' +import { resolveComposerBranchPick as resolveSharedComposerBranchPick } 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 buildGitHubWorkspaceSource(item) +} + +export function buildGitLabLinkedWorkItem(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitLabWorkspaceSource(item) +} + +export function buildLinearLinkedWorkItem(issue: { + identifier: string + title: string + url: string + workspaceId?: string +}): MobileLinkedWorkItem { + return buildLinearWorkspaceSource(issue) +} + +// 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 shouldApplyWorkspaceSourceAutoName(args) +} + +export function resolveWorkItemAutoName(item: { + type: 'issue' | 'pr' | 'mr' + number: number + title: string + provider: 'github' | 'gitlab' | 'linear' + linearIdentifier?: string +}): string { + return getWorkspaceSourceName({ ...item, url: '' }).seedName +} + +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 { + return buildWorkspaceSourceSelection(args) as SmartNameSelection | 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 selection = resolveSharedComposerBranchPick(args) + return { + base: { + baseBranch: selection.baseBranch, + branchNameOverride: selection.branchNameOverride + }, + reuseEligibleBranch: selection.reuseEligibleBranch, + reuseSelectedBranch: selection.defaultReuse, + ...(selection.name !== undefined && selection.lastAutoName !== undefined + ? { name: selection.name, lastAutoName: selection.lastAutoName } + : {}) + } +} + +export type { GitHubWorkItem, GitLabWorkItem, LinearIssue } diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts new file mode 100644 index 000000000..73c7eaa18 --- /dev/null +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -0,0 +1,76 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { GitHubPrStartPoint } from '../../../src/shared/types' + +// 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 = Pick< + GitHubPrStartPoint, + 'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify' +> + +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 { + 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 GitHubPrStartPoint | { error: string } + 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 { + 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 +} diff --git a/mobile/src/tasks/mobile-composer-source-types.ts b/mobile/src/tasks/mobile-composer-source-types.ts new file mode 100644 index 000000000..d36215189 --- /dev/null +++ b/mobile/src/tasks/mobile-composer-source-types.ts @@ -0,0 +1,64 @@ +import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { + WorkspaceSourceLinkedItem, + WorkspaceSourceSelection +} from '../../../src/shared/new-workspace/workspace-source' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export type { SmartNameMode } + +export type ComposerBaseState = { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + +// 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 = Omit & { + provider: Exclude +} + +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 = Omit & { + kind: SmartNameSelectionKind +} + +// 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 } diff --git a/mobile/src/tasks/mobile-smart-source-modes.test.ts b/mobile/src/tasks/mobile-smart-source-modes.test.ts new file mode 100644 index 000000000..dc7265690 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.test.ts @@ -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') + }) +}) diff --git a/mobile/src/tasks/mobile-smart-source-modes.ts b/mobile/src/tasks/mobile-smart-source-modes.ts new file mode 100644 index 000000000..911219d28 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.ts @@ -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' diff --git a/mobile/src/tasks/mobile-tasks-capability.ts b/mobile/src/tasks/mobile-tasks-capability.ts new file mode 100644 index 000000000..f8498bfd9 --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-capability.ts @@ -0,0 +1,5 @@ +// Runtime capability the desktop advertises when it supports the mobile Tasks RPC +// surface (github/gitlab/linear work items + repo.searchRefs). Older paired +// desktops omit it, so mobile must degrade to blank/new-branch sources only. +// Mirrors the 'mobile.tasks.v1' entry in src/shared/protocol-version.ts. +export const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' diff --git a/mobile/src/tasks/setup-hook-trust.test.ts b/mobile/src/tasks/setup-hook-trust.test.ts index b09bdd0d4..0e2d91bb7 100644 --- a/mobile/src/tasks/setup-hook-trust.test.ts +++ b/mobile/src/tasks/setup-hook-trust.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { isSetupHookTrusted, normalizeSetupHookTrust, + persistSetupHookTrustApproval, trustedOrcaHooksWithSetupApproval, wasSetupHookPreviouslyApproved } from './setup-hook-trust' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' describe('setup hook trust', () => { it('trusts a setup script only when the approved hash matches', () => { @@ -71,6 +73,27 @@ describe('setup hook trust', () => { }) }) + it('persists and returns the approved trust state', async () => { + let persisted: unknown + const client = { + sendRequest: async (_method: string, params: unknown) => { + persisted = params + return { ok: true, result: null } + } + } as unknown as RpcClient + + const next = await persistSetupHookTrustApproval({ + client, + trust: {}, + repoId: 'repo-1', + contentHash: 'setup-hash', + alwaysTrust: false + }) + + expect(persisted).toEqual({ trustedOrcaHooks: next }) + expect(isSetupHookTrusted(next, 'repo-1', 'setup-hash')).toBe(true) + }) + it('detects previous setup approval and ignores incomplete trust payloads', () => { expect( wasSetupHookPreviouslyApproved( diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 847420f1e..04c52152f 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -1,4 +1,5 @@ import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' export type SetupHookTrust = { contentHash: string @@ -36,6 +37,21 @@ export function trustedOrcaHooksWithSetupApproval(args: { return { ...args.trust, [args.repoId]: nextRepo } } +export async function persistSetupHookTrustApproval(args: { + client: RpcClient + trust: PersistedTrustedOrcaHooks + repoId: string + contentHash: string + alwaysTrust: boolean +}): Promise { + const next = trustedOrcaHooksWithSetupApproval(args) + const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!response.ok) { + throw new Error(response.error.message) + } + return next +} + export function normalizeSetupHookTrust( setupTrust: SetupHookTrust | null | undefined ): SetupHookTrust | null { diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts new file mode 100644 index 000000000..cffdc287e --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -0,0 +1,112 @@ +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 } + +function fakeClient(byMethod: Record, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + 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') + }) + + it('does not send oversized source queries to any provider', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client }) + expect(calls).toEqual([]) + expect(result).toMatchObject({ + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] + }) + }) +}) diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts new file mode 100644 index 000000000..e2217597a --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -0,0 +1,141 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import { + isSmartWorkspaceSourceQueryWithinLimit, + 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 = { + 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 { + if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) { + // Why: the source limit is an outbound-request boundary, not only a render + // limit; pasted payloads must never fan out to provider CLIs or SSH hosts. + return { ...EMPTY, needsGitHubRemote: false, error: '' } + } + 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([]), + tasks.gitlab ?? Promise.resolve([]), + tasks.linear ?? Promise.resolve([]), + tasks.branches ?? Promise.resolve([]) + ]) + + 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 + } +} diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts new file mode 100644 index 000000000..f7c724e76 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + deriveRepoSlug, + findRepoMatchingSlug, + findRepoMatchingSlugForPaste, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' +import type { RpcClient } from '../transport/rpc-client' + +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() + }) + + it('rejects oversized paste intents before any exact lookup', () => { + expect( + resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`) + ).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() + }) + + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { + const calls: string[] = [] + const client = { + sendRequest: async (_method: string, params: unknown) => { + const repo = (params as { repo: string }).repo + calls.push(repo) + return { + ok: true, + result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + } + } + } as unknown as RpcClient + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toMatchObject({ id: 'b' }) + expect(calls).toEqual(['id:a', 'id:b']) + }) + + it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => { + let calls = 0 + const client = { + sendRequest: async () => { + calls += 1 + return { + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' } + } + } + } as unknown as RpcClient + const cache = new Map() + + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'widgets' }, cache) + ).resolves.toBeNull() + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'other' }, cache) + ).resolves.toBeNull() + expect(calls).toBe(1) + }) + + it('keeps local matching usable when the optional repo slug lookup rejects', async () => { + const client = { + sendRequest: async () => { + throw new Error('connection closed') + } + } as unknown as RpcClient + + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toBeNull() + }) +}) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts new file mode 100644 index 000000000..72c23a373 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -0,0 +1,178 @@ +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 { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' +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> +} + +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 { + if (!isSmartWorkspaceSourceQueryWithinLimit(query)) { + return null + } + 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 findRepoMatchingSlugForPaste( + client: RpcClient, + repos: readonly PasteRepoCandidate[], + slug: RepoSlug, + cache: Map +): Promise { + const projected = findRepoMatchingSlug(repos, slug) + if (projected) { + return projected + } + // Why: projected remote metadata is incomplete for SSH and GitHub Enterprise; + // ask each repo's owning runtime instead of assuming github.com URL syntax. + for (const repo of repos) { + let resolved = cache.get(repo.id) + if (!cache.has(repo.id)) { + try { + const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` }) + if (!response.ok && response.error.code === 'method_not_found') { + // Why: RPC availability is host-wide; avoid repeating an unsupported + // probe for every repo or on the next paste attempt. + repos.forEach((candidate) => cache.set(candidate.id, null)) + return null + } + resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null + } catch { + resolved = null + } + cache.set(repo.id, resolved ?? null) + } + if (slugsEqual(resolved ?? null, slug)) { + return repo + } + } + return null +} + +export async function lookupGitHubItemByNumber( + client: RpcClient, + repoId: string, + number: number +): Promise { + 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 { + 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> +): Promise { + 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 +} diff --git a/mobile/src/tasks/smart-source-search-requests.test.ts b/mobile/src/tasks/smart-source-search-requests.test.ts new file mode 100644 index 000000000..ecf029feb --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.test.ts @@ -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 } + +function fakeClient(result: unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + 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' }) + }) +}) diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts new file mode 100644 index 000000000..5875cd4d6 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -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 { + 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 { + 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 { + 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 { + 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 })) + ) +} diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts new file mode 100644 index 000000000..d23e0c663 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createWorkspaceFromComposerSource } from './source-workspace-create' +import type { MobileComposerCreateSelection } from './mobile-composer-source-types' + +type Call = { method: string; params: Record } + +function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = handle(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +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(() => ({ worktree: { id: 'wt-1' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-9' + } + } + // The composer supplies the title-derived name as workspaceName; with none, + // buildTaskWorkspaceCreateParams falls back to the "-" 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' + }) + }) + + 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 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' } + }) + }) + + it('resolves a PR base as a fallback when the selection carries none', async () => { + const calls: Call[] = [] + 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('creates a Linear workspace with workspace + org routing', async () => { + const calls: Call[] = [] + 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' + } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + linkedLinearIssue: 'ENG-9', + linkedLinearIssueWorkspaceId: 'ws-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) + + it('reuses an existing branch with a single attempt (no suffix retry)', async () => { + const calls: Call[] = [] + 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' + }) + }) + + 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' + }) + }) +}) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts new file mode 100644 index 000000000..d16cc1741 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.ts @@ -0,0 +1,250 @@ +import type { RpcClient } from '../transport/rpc-client' +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 WorkspaceCreateSetupDecision, + type WorkspaceCreateTaskItem +} from './workspace-create-params' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' + +// 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 +} + +export type CreateWorkspaceFromComposerArgs = { + client: RpcClient + selection: MobileComposerCreateSelection + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +} + +export async function createWorkspaceFromComposerSource( + args: CreateWorkspaceFromComposerArgs +): Promise { + 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 + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const item = selection.item + const taskItem = toTaskItem(item, targetRepoId) + + // 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: taskItem, + targetRepoId, + setupDecision, + agent: agent.choice, + workspaceName, + note, + 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. + const baseName = String(params.name) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (name) => ({ ...params, name }) + }) +} + +async function createBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + 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): Record => { + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + + 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, + maxAttempts: 1, + buildParams: (name) => + applyCommon({ + repo: `id:${targetRepoId}`, + name, + setupDecision, + baseBranch: selection.refName, + branchNameOverride: selection.localBranchName, + startupCommand: agent.startupCommand + }) + }) + } + + // 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: selection.branchNameOverride || selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + baseBranch: selection.baseBranch, + startupCommand: agent.startupCommand + } + if (selection.branchNameOverride) { + params.branchNameOverride = candidate + } + return applyCommon(params) + } + }) +} + +async function createNewBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + 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 = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + branchNameOverride: candidate, + startupCommand: agent.startupCommand + } + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts new file mode 100644 index 000000000..6785e2fe3 --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -0,0 +1,330 @@ +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 { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning' +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 { + ComposerBaseState, + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +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(null) + const [base, setBase] = useState(EMPTY_BASE) + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) + const [forkPushWarning, setForkPushWarning] = useState(null) + const [resolvingBase, setResolvingBase] = useState(false) + // Set when the "Create branch " 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) => { + setResolvingBase(true) + void resolve + .then((result) => { + if (resolveTokenRef.current !== token) { + return + } + setBase({ + baseBranch: result.baseBranch, + compareBaseRef: result.compareBaseRef, + pushTarget: result.pushTarget, + branchNameOverride: result.branchNameOverride + }) + setForkPushWarning(getForkPushWarning(result)) + }) + .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 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) => { + const next = resolveComposerManualBranchNameChange({ + value, + pushTarget: base.pushTarget, + forkPushWarning + }) + setBase({ + ...base, + branchNameOverride: next.branchNameOverride, + pushTarget: next.pushTarget + }) + setForkPushWarning(next.forkPushWarning) + }, + [base, forkPushWarning] + ) + + const smartNameSelection = useMemo( + () => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }), + [base.baseBranch, linkedWorkItem] + ) + + const createSelection = useMemo( + () => + 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 diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts new file mode 100644 index 000000000..1d0c88daf --- /dev/null +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -0,0 +1,233 @@ +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 { + findRepoMatchingSlugForPaste, + 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(EMPTY_FAN) + const [paste, setPaste] = useState({ github: null, gitlab: null }) + const [loading, setLoading] = useState(false) + const [crossRepoPrompt, setCrossRepoPrompt] = useState(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('') + const repoSlugCacheRef = useRef>(new Map()) + + 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, + repoSlugCache: repoSlugCacheRef.current + }) + .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( + () => + 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 } + repoSlugCache: Map +}): Promise<{ + fan: SmartFanOutResult + paste: PasteResolved + crossRepoPrompt: SmartCrossRepoPrompt | null +}> { + const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = 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 = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) + 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 } +} diff --git a/mobile/src/tasks/work-item-lookup-text.test.ts b/mobile/src/tasks/work-item-lookup-text.test.ts new file mode 100644 index 000000000..bd37f46d6 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.test.ts @@ -0,0 +1,18 @@ +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(false) + }) + + it('treats plain names as non-lookup text', () => { + expect(isWorkItemLookupText('')).toBe(false) + expect(isWorkItemLookupText('fix the login bug')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) + }) +}) diff --git a/mobile/src/tasks/work-item-lookup-text.ts b/mobile/src/tasks/work-item-lookup-text.ts new file mode 100644 index 000000000..b130292e8 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.ts @@ -0,0 +1 @@ +export * from '../../../src/shared/new-workspace/work-item-lookup-text' diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index f15c7afce..e53c79651 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -144,7 +144,7 @@ describe('task workspace create params', () => { ).toMatchObject({ repo: 'id:repo-linear', name: 'eng-42', - displayName: 'Ship Linear parity', + displayName: 'ENG-42 Ship Linear parity', linkedLinearIssue: 'ENG-42', startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', createdWithAgent: 'grok' diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index ec945a118..c66674702 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -1,19 +1,16 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { + CreateSparseCheckoutRequest, + GitPushTarget, + SetupDecision, + TuiAgent +} from '../../../src/shared/types' +import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' -export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip' - -export type WorkspaceCreateSparseCheckout = { - directories: string[] - presetId?: string -} - -export type WorkspaceCreateGitPushTarget = { - remoteName: string - branchName: string - remoteUrl?: string -} +export type WorkspaceCreateSetupDecision = SetupDecision +export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest +export type WorkspaceCreateGitPushTarget = GitPushTarget export type WorkspaceCreateHostedStartPoint = { baseBranch: string @@ -48,6 +45,8 @@ type WorkspaceCreateLinearItem = { identifier: string title: string url: string + workspaceId?: string + organizationUrlKey?: string } } @@ -66,9 +65,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 +80,41 @@ 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 sourceName = + item.provider === 'linear' + ? getWorkspaceSourceName({ + provider: 'linear', + type: 'issue', + number: 0, + title: item.source.title, + url: item.source.url, + linearIdentifier: item.source.identifier + }) + : getWorkspaceSourceName({ provider: item.provider, ...item.source }) + const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {} 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 +124,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 +137,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 +151,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 } } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts new file mode 100644 index 000000000..4eb749f5c --- /dev/null +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -0,0 +1,46 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' + +// Why: server-side collision checks (branch already exists locally / on a remote +// / already has PR #N) can fire even after a pre-flight basename dedupe — +// branches outlive worktrees in git, and remote branches/PRs aren't visible from +// worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree +// loop in src/renderer/src/store/slices/worktrees.ts. +export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } + +// Creates a worktree, retrying with a numeric suffix on a name-collision error. +// buildParams receives the candidate name so callers can assemble source-specific +// params (linked issue/PR, base branch, etc.) around it. Callers that can't clear +// a collision by re-suffixing (e.g. reusing a fixed existing branch) pass +// maxAttempts: 1 to fail fast instead of burning the full retry budget. +export async function createWorktreeWithNameRetry(args: { + client: RpcClient + baseName: string + buildParams: (name: string) => Record + maxAttempts?: number +}): Promise { + const { client, baseName, buildParams } = args + const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS + let lastError: string | null = null + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(baseName, attempt) + const response = await client.sendRequest('worktree.create', buildParams(candidateName), { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + return { worktreeId: result.worktree.id, name: candidateName } + } + lastError = response.error.message + if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { + break + } + } + return { error: lastError ?? 'Failed to create workspace' } +} diff --git a/mobile/src/transport/protocol-compat.test.ts b/mobile/src/transport/protocol-compat.test.ts new file mode 100644 index 000000000..d70ffd61b --- /dev/null +++ b/mobile/src/transport/protocol-compat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { evaluateCompat } from './protocol-compat' + +describe('evaluateCompat', () => { + it('allows the current mobile app to connect before a protocol-2 desktop updates', () => { + expect( + evaluateCompat({ + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + ).toEqual({ kind: 'ok' }) + }) +}) diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 9712f1c23..010ef2bea 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => { }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) + + it('accepts worktree.create payloads sent by the previous mobile protocol', () => { + const create = methodParams(WORKTREE_METHODS, 'worktree.create') + + expectParses(create, { + repo: 'id:repo-github', + name: 'fix-mobile-tasks', + displayName: 'Fix mobile tasks', + setupDecision: 'run', + activate: true, + startupDraft: 'https://github.com/acme/app/pull/123', + createdWithAgent: 'codex', + linkedPR: 123, + baseBranch: 'origin/main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feature/mobile-tasks', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + }) + expectParses(create, { + repo: 'id:repo-gitlab', + name: 'mr-7', + linkedGitLabMR: 7, + sparseCheckout: { directories: ['mobile'], presetId: 'mobile' }, + comment: 'keep mobile parity' + }) + expectParses(create, { + repo: 'id:repo-linear', + name: 'eng-42', + linkedLinearIssue: 'ENG-42', + linkedLinearIssueWorkspaceId: 'workspace-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index ace969138..67261b221 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -232,11 +232,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', diff --git a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts index 58d0e4289..273c92b8d 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts @@ -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' diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts index abbbfb040..afa2d1065 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts @@ -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 | null | undefined - -const EMPTY_HINT_BY_MODE: Record = { - 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' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index b9a527d17..c6b920bd9 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -1,9 +1,12 @@ -import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { - getLinkedWorkItemProvider, - getLinkedWorkItemWorkspaceName, - type LinkedWorkItemSummary -} from '@/lib/new-workspace' + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + getWorkspaceSourceProvider +} from '../../../../shared/new-workspace/workspace-source' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoExecutionHostId, @@ -63,7 +66,7 @@ export function toFolderWorkspaceLinkedTask( if (!item) { return null } - const provider = getLinkedWorkItemProvider(item) + const provider = getWorkspaceSourceProvider(item) return { provider, type: item.type, @@ -79,60 +82,23 @@ export function toFolderWorkspaceLinkedTask( export function getSmartNameSelection( linkedWorkItem: LinkedWorkItemSummary | null ): SmartWorkspaceNameSelection | null { - if (!linkedWorkItem) { - return null - } - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const kind: SmartWorkspaceNameSelection['kind'] = - provider === 'linear' - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } + return buildWorkspaceSourceSelection({ linkedWorkItem }) as SmartWorkspaceNameSelection | null } export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null { - return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null) + return getWorkspaceSourceName(item).displayName || null } export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'github', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitHubWorkspaceSource(item) } export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'gitlab', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitLabWorkspaceSource(item) } export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - return buildLinearIssueLinkedWorkItem(issue) + return buildLinearWorkspaceSource(issue) } export function getFolderWorkspacePrimaryActionLabel(): string { diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index cf4e8fb7c..e19aad2a0 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + getComposerRepoWorktreeBranches, isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, @@ -209,6 +210,20 @@ describe('isBranchCheckedOutInWorktrees', () => { }) }) +describe('getComposerRepoWorktreeBranches', () => { + it('supplies only the selected repo branches to reuse eligibility', () => { + expect( + getComposerRepoWorktreeBranches( + [ + { repoId: 'repo-a', branch: 'feature/a' }, + { repoId: 'repo-b', branch: 'feature/b' } + ], + 'repo-a' + ) + ).toEqual(['feature/a']) + }) +}) + describe('resolveComposerReuseOverride', () => { it('keeps the selection override for a reusable (non-busy) local branch', () => { expect( diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index 02f2dfce7..935c1689e 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -1,9 +1,12 @@ export { isBranchCheckedOutInWorktrees, + getComposerRepoWorktreeBranches, + resolveComposerBranchPick, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, resolveComposerBranchSelection, resolveComposerManualBranchNameChange, resolveComposerReuseOverride, + type ComposerBranchPick, type ComposerBranchSelection } from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/fork-push-warning.ts b/src/renderer/src/hooks/fork-push-warning.ts index 27ab7af80..a52854142 100644 --- a/src/renderer/src/hooks/fork-push-warning.ts +++ b/src/renderer/src/hooks/fork-push-warning.ts @@ -1,21 +1 @@ -import type { GitHubPrStartPoint } from '../../../shared/types' - -export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING = - 'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.' - -// Why: only warn for fork PRs where the push target points away from origin and -// whose author left "Allow edits from maintainers" off. That's the one case -// where our push to the contributor's fork can be rejected by GitHub. Returns -// the warning text to show, or null when no warning applies. -export function getForkPushWarning( - result: Pick -): string | null { - if ( - result.maintainerCanModify === false && - result.pushTarget !== undefined && - result.pushTarget.remoteName !== 'origin' - ) { - return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING - } - return null -} +export * from '../../../shared/new-workspace/fork-push-warning' diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 922f12628..eecc7a6c9 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -147,6 +147,10 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import { getForkPushWarning } from './fork-push-warning' +import { + buildWorkspaceSourceSelection, + shouldApplyWorkspaceSourceAutoName +} from '../../../shared/new-workspace/workspace-source' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' @@ -164,12 +168,10 @@ import { } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { - isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchReuse, - resolveComposerBranchSelection, + resolveComposerBranchPick, resolveComposerManualBranchNameChange, - resolveComposerReuseOverride + getComposerRepoWorktreeBranches } from './composer-branch-selection' import { isCurrentComposerDropOwner } from './composer-drop-owner' import { @@ -2072,7 +2074,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // name or it silently becomes a slugified-URL workspace name. if ( suggestedName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(suggestedName) lastAutoNameRef.current = suggestedName @@ -2307,7 +2312,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = titleName?.seedName ?? suggestedName if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -2909,7 +2917,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3013,7 +3024,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3110,11 +3124,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleSmartBranchSelect = useCallback( (refName: string, localBranchName: string): void => { smartGitHubPrStartPointSelectionRef.current = null - const selection = resolveComposerBranchSelection({ + const selection = resolveComposerBranchPick({ refName, localBranchName, currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + worktreeBranches: getComposerRepoWorktreeBranches(worktreesByRepo[repoId] ?? [], repoId) }) setBaseBranch(selection.baseBranch) setCompareBaseRef(undefined) @@ -3130,34 +3145,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Note: worktreesByRepo only covers visible worktrees; a branch busy only // in a hidden external worktree falls through to the backend conflict // check, which rejects it with a clear "already exists locally" error. - const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( - localBranchName, - (worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch) - ) - const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = - resolveComposerBranchReuse({ - refName, - localBranchName, - selectionProducedOverride: selection.branchNameOverride !== undefined, - branchCheckedOutElsewhere - }) + const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = selection setReuseEligibleBranch(nextReuseEligibleBranch) setReuseSelectedBranch(defaultReuse) setBranchNameOverridePreservesNameEdits(defaultReuse) - const effectiveOverride = resolveComposerReuseOverride({ - refName, - localBranchName, - branchNameOverride: selection.branchNameOverride, - branchCheckedOutElsewhere - }) if (selection.name !== undefined && selection.lastAutoName !== undefined) { setName(selection.name) lastAutoNameRef.current = selection.lastAutoName - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' - setBranchNameOverride(effectiveOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) } else { - setBranchNameOverride(effectiveOverride) - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' } }, [name, worktreesByRepo, repoId] @@ -3194,9 +3193,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const suggestedName = getLinkedItemDisplayName(linkedItem) ?? getLinearIssueWorkspaceName(issue) if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3213,9 +3213,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: same lookup-text rule as applyLinkedWorkItem, plus the typed // Linear identifier ("STA-123") that matched this issue. if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3262,33 +3263,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (isProjectGroupTarget) { return getFolderSmartNameSelection(linkedWorkItem) } - if (linkedWorkItem) { - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const isLinear = provider === 'linear' - const kind: SmartWorkspaceNameSelection['kind'] = isLinear - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - isLinear || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } - } - if (baseBranch) { - return { kind: 'branch', label: baseBranch } - } - return null + return buildWorkspaceSourceSelection({ + linkedWorkItem, + baseBranch + }) as SmartWorkspaceNameSelection | null }, [baseBranch, isProjectGroupTarget, linkedWorkItem]) const handleOpenAgentSettings = useCallback((): void => { diff --git a/src/renderer/src/lib/github-work-item-identity.ts b/src/renderer/src/lib/github-work-item-identity.ts index c9b24e316..3f964159c 100644 --- a/src/renderer/src/lib/github-work-item-identity.ts +++ b/src/renderer/src/lib/github-work-item-identity.ts @@ -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' diff --git a/src/renderer/src/lib/gitlab-links.ts b/src/renderer/src/lib/gitlab-links.ts index 1bd36c137..d2e537d7a 100644 --- a/src/renderer/src/lib/gitlab-links.ts +++ b/src/renderer/src/lib/gitlab-links.ts @@ -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 `//-/(issues| -// work_items|merge_requests)/` is treated as a GitLab item URL -// regardless of host. Modern GitLab emits issue URLs as -// `/-/work_items/`; treat that as an issue work item, same as the -// legacy `/-/issues/` 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' diff --git a/src/renderer/src/lib/linear-linked-work-item.ts b/src/renderer/src/lib/linear-linked-work-item.ts index 530a3c010..5bb212f2b 100644 --- a/src/renderer/src/lib/linear-linked-work-item.ts +++ b/src/renderer/src/lib/linear-linked-work-item.ts @@ -1,6 +1,6 @@ import type { LinearIssue } from '../../../shared/types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear-links' +import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source' export function isLinearLinkedWorkItem( item: Pick | null | undefined @@ -9,21 +9,5 @@ export function isLinearLinkedWorkItem( } export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) - return { - type: 'issue', - provider: 'linear', - // Why: Linear issue prose must not enter prompt metadata; keep only the - // string identifier/link and leave numeric issue metadata empty. - number: 0, - title: issue.title, - url: issue.url, - linearIdentifier: issue.identifier, - ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), - ...(organizationUrlKey - ? { - linearOrganizationUrlKey: organizationUrlKey - } - : {}) - } + return buildLinearWorkspaceSource(issue) } diff --git a/src/renderer/src/lib/linked-work-item-provider.ts b/src/renderer/src/lib/linked-work-item-provider.ts index 7ce731622..62918eac5 100644 --- a/src/renderer/src/lib/linked-work-item-provider.ts +++ b/src/renderer/src/lib/linked-work-item-provider.ts @@ -1,50 +1,4 @@ -import type { LinkedWorkItemSummary } from './new-workspace' - -// Why: self-hosted GitLab issue URLs may not contain "gitlab", and modern -// GitLab emits issue URLs as `/-/work_items/` as well as the legacy -// `/-/issues/`. Recognize both forms. -const GL_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i - -export function isGitLabIssueUrl(url: string): boolean { - try { - return GL_ISSUE_PATH_RE.test(new URL(url).pathname) - } catch { - return GL_ISSUE_PATH_RE.test(url) - } -} - -function isJiraIssueUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ( - /\.atlassian\.net$/i.test(parsed.hostname) || - /\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname) - ) - } catch { - return false - } -} - -export function getLinkedWorkItemProvider( - item: LinkedWorkItemSummary -): NonNullable { - if (item.provider) { - return item.provider - } - if (item.linearIdentifier) { - return 'linear' - } - if (item.jiraIdentifier || isJiraIssueUrl(item.url)) { - return 'jira' - } - if (item.type === 'mr') { - return 'gitlab' - } - if (isGitLabIssueUrl(item.url)) { - return 'gitlab' - } - if (item.number === 0 && !item.url.includes('github.com')) { - return 'linear' - } - return 'github' -} +export { + getWorkspaceSourceProvider as getLinkedWorkItemProvider, + isGitLabIssueUrl +} from '../../../shared/new-workspace/workspace-source' diff --git a/src/renderer/src/lib/work-item-link-query-bounds.ts b/src/renderer/src/lib/work-item-link-query-bounds.ts index 6cbb407da..831c942ef 100644 --- a/src/renderer/src/lib/work-item-link-query-bounds.ts +++ b/src/renderer/src/lib/work-item-link-query-bounds.ts @@ -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' diff --git a/src/renderer/src/lib/work-item-lookup-text.test.ts b/src/renderer/src/lib/work-item-lookup-text.test.ts index 3ced0ea23..fbdf4779e 100644 --- a/src/renderer/src/lib/work-item-lookup-text.test.ts +++ b/src/renderer/src/lib/work-item-lookup-text.test.ts @@ -37,5 +37,6 @@ describe('isWorkItemLookupText', () => { expect(isWorkItemLookupText('fix-2')).toBe(false) expect(isWorkItemLookupText('terminal scrollbar polish')).toBe(false) expect(isWorkItemLookupText('https://example.com/some/page')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) }) }) diff --git a/src/renderer/src/lib/work-item-lookup-text.ts b/src/renderer/src/lib/work-item-lookup-text.ts index e8c412d7e..8ef5ae530 100644 --- a/src/renderer/src/lib/work-item-lookup-text.ts +++ b/src/renderer/src/lib/work-item-lookup-text.ts @@ -1,24 +1 @@ -import { getSmartGitHubSubmitIntent } from './smart-github-submit' -import { parseGitLabIssueOrMRLink } from './gitlab-links' - -const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i - -/** - * Why: text typed into the smart name field to *find* a work item — a GitHub - * or GitLab URL, "#123", or a linear.app link — is a lookup query, never a - * deliberate workspace name. Selection handlers use this to decide that the - * resolved item's title-derived name may replace the field content; otherwise - * the pasted URL silently survives behind the selection pill and the - * workspace gets a slugified-URL name. - */ -export function isWorkItemLookupText(value: string): boolean { - const trimmed = value.trim() - if (!trimmed) { - return false - } - return ( - getSmartGitHubSubmitIntent(trimmed) !== null || - parseGitLabIssueOrMRLink(trimmed) !== null || - LINEAR_ISSUE_URL_RE.test(trimmed) - ) -} +export * from '../../../shared/new-workspace/work-item-lookup-text' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index dfd32303e..55611abee 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,11 @@ import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../../shared/new-workspace/worktree-create-retry-policy' import { classifyWorktreeForceDeleteReason, getLockedWorktreeRemovalReason, @@ -2954,22 +2959,13 @@ export const createWorktreeSlice: StateCreator options ) => { const automationProvenanceRequest = options?.automationProvenanceRequest - const retryableConflictPatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /^Branch ".+" already exists\./i, - /already has pr #\d+/i - ] - const nextCandidateName = (current: string, attempt: number): string => - attempt === 0 ? current : `${current}-${attempt + 1}` - try { - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = nextCandidateName(name, attempt) + for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(name, attempt) // Why: older runtimes may still reject exact PR branch overrides on // collision, so the renderer retries both branch and worktree names. const candidateBranchNameOverride = branchNameOverride - ? nextCandidateName(branchNameOverride, attempt) + ? getClientWorktreeCreateCandidate(branchNameOverride, attempt) : undefined try { // Why: Manual sort is user-authored order. Stamp new workspaces @@ -3130,8 +3126,8 @@ export const createWorktreeSlice: StateCreator return result } catch (error) { const message = error instanceof Error ? error.message : String(error) - const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message)) - if (!shouldRetry || attempt === 24) { + const shouldRetry = isRetryableWorktreeCreateConflict(message) + if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) { throw error } } diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index 0ee66f0a2..9342f18bf 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -50,6 +50,15 @@ export function isBranchCheckedOutInWorktrees( return worktreeBranches.some((ref) => ref.replace(/^refs\/heads\//, '') === branchName) } +export function getComposerRepoWorktreeBranches( + worktrees: readonly { repoId: string; branch: string }[], + repoId: string | null +): string[] { + return repoId + ? worktrees.filter((worktree) => worktree.repoId === repoId).map((worktree) => worktree.branch) + : [] +} + /** * Issue #5181: decide whether a picked branch row is an existing LOCAL branch * that can be reused (checked out) instead of branched off, and whether reuse @@ -99,6 +108,41 @@ export function resolveComposerReuseOverride(args: { return args.branchNameOverride } +export type ComposerBranchPick = ComposerBranchSelection & { + reuseEligibleBranch: string | null + defaultReuse: boolean +} + +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const selection = resolveComposerBranchSelection(args) + const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( + args.localBranchName, + args.worktreeBranches + ) + const reuse = resolveComposerBranchReuse({ + refName: args.refName, + localBranchName: args.localBranchName, + selectionProducedOverride: selection.branchNameOverride !== undefined, + branchCheckedOutElsewhere + }) + return { + ...selection, + branchNameOverride: resolveComposerReuseOverride({ + refName: args.refName, + localBranchName: args.localBranchName, + branchNameOverride: selection.branchNameOverride, + branchCheckedOutElsewhere + }), + ...reuse + } +} + /** * The branch-name override to apply when creating a worktree from the composer. * diff --git a/src/shared/new-workspace/fork-push-warning.ts b/src/shared/new-workspace/fork-push-warning.ts new file mode 100644 index 000000000..5c3a7c29e --- /dev/null +++ b/src/shared/new-workspace/fork-push-warning.ts @@ -0,0 +1,16 @@ +import type { GitHubPrStartPoint } from '../types' + +export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING = + 'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.' + +// Why: this is the one fork target where Orca can prepare the workspace but a +// later push may still be rejected by GitHub permissions. +export function getForkPushWarning( + result: Pick +): string | null { + return result.maintainerCanModify === false && + result.pushTarget !== undefined && + result.pushTarget.remoteName !== 'origin' + ? FORK_PUSH_NO_MAINTAINER_EDIT_WARNING + : null +} diff --git a/src/shared/new-workspace/github-links.ts b/src/shared/new-workspace/github-links.ts new file mode 100644 index 000000000..dc6cbd050 --- /dev/null +++ b/src/shared/new-workspace/github-links.ts @@ -0,0 +1,50 @@ +import { + type GitHubIssueOrPRLink, + parseGitHubIssueOrPRLink, + parseGitHubIssueOrPRNumber +} from '../github-links' +import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' + +export * from '../github-links' + +const HTTP_URL_PREFIX_RE = /^https?:\/\//i + +export type GitHubLinkQuery = { + query: string + directNumber: number | null + directLink?: GitHubIssueOrPRLink + tooLarge?: boolean +} + +/** + * 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 + } +} diff --git a/src/shared/new-workspace/github-work-item-identity.ts b/src/shared/new-workspace/github-work-item-identity.ts new file mode 100644 index 000000000..933a436da --- /dev/null +++ b/src/shared/new-workspace/github-work-item-identity.ts @@ -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 } +} diff --git a/src/shared/new-workspace/gitlab-links.ts b/src/shared/new-workspace/gitlab-links.ts new file mode 100644 index 000000000..1bd36c137 --- /dev/null +++ b/src/shared/new-workspace/gitlab-links.ts @@ -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 `//-/(issues| +// work_items|merge_requests)/` is treated as a GitLab item URL +// regardless of host. Modern GitLab emits issue URLs as +// `/-/work_items/`; treat that as an issue work item, same as the +// legacy `/-/issues/` 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 + } +} diff --git a/src/shared/new-workspace/smart-workspace-command-value.ts b/src/shared/new-workspace/smart-workspace-command-value.ts new file mode 100644 index 000000000..58d0e4289 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-command-value.ts @@ -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 +} diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts new file mode 100644 index 000000000..680419909 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -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 | null | undefined + +const EMPTY_HINT_BY_MODE: Record = { + 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) +} diff --git a/src/shared/new-workspace/work-item-link-query-bounds.ts b/src/shared/new-workspace/work-item-link-query-bounds.ts new file mode 100644 index 000000000..1e16d95ce --- /dev/null +++ b/src/shared/new-workspace/work-item-link-query-bounds.ts @@ -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) +} diff --git a/src/shared/new-workspace/work-item-lookup-text.ts b/src/shared/new-workspace/work-item-lookup-text.ts new file mode 100644 index 000000000..78e660a0d --- /dev/null +++ b/src/shared/new-workspace/work-item-lookup-text.ts @@ -0,0 +1,30 @@ +import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from './github-links' +import { parseGitLabIssueOrMRLink } from './gitlab-links' + +const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/[^/\s]+\/issue\/[^/\s]+(?:\/\S*)?$/i +const GITHUB_ITEM_URL_IN_TEXT_RE = + /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/(?:issues|pull)\/\d+[^\s]*/i +const TRAILING_URL_PUNCTUATION_RE = /[),.;:!?]+$/ + +function hasGitHubLookup(value: string): boolean { + if (parseGitHubIssueOrPRNumber(value) !== null || parseGitHubIssueOrPRLink(value) !== null) { + return true + } + const embedded = GITHUB_ITEM_URL_IN_TEXT_RE.exec(value)?.[0] + return embedded + ? parseGitHubIssueOrPRLink(embedded.replace(TRAILING_URL_PUNCTUATION_RE, '')) !== null + : false +} + +/** Lookup references may be replaced by an auto-name; deliberate names may not. */ +export function isWorkItemLookupText(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) { + return false + } + return ( + hasGitHubLookup(trimmed) || + parseGitLabIssueOrMRLink(trimmed) !== null || + LINEAR_ISSUE_URL_RE.test(trimmed) + ) +} diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts new file mode 100644 index 000000000..93e46c4db --- /dev/null +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + getWorkspaceSourceProvider, + shouldApplyWorkspaceSourceAutoName, + shouldPreserveWorkspaceSourceOnRepoChange +} from './workspace-source' + +describe('workspace source policy', () => { + const linear = buildLinearWorkspaceSource({ + identifier: 'ENG-42', + title: 'Ship mobile parity', + url: 'https://linear.app/acme/issue/ENG-42/ship-mobile-parity', + workspaceId: 'workspace-1' + }) + + it('builds one Linear identity for desktop and mobile create flows', () => { + expect(linear).toMatchObject({ + provider: 'linear', + number: 0, + linearIdentifier: 'ENG-42', + linearWorkspaceId: 'workspace-1', + linearOrganizationUrlKey: 'acme' + }) + expect(getWorkspaceSourceName(linear)).toEqual({ + seedName: 'eng-42-ship-mobile-parity', + displayName: 'ENG-42 Ship mobile parity' + }) + }) + + it('preserves global work-item sources across repo changes', () => { + expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true) + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + provider: 'github', + type: 'issue', + number: 1, + title: 'Repo scoped', + url: 'https://github.com/o/r/issues/1' + }) + ).toBe(false) + }) + + it('shares provider inference, selection labels, and auto-name gates', () => { + const legacyGitLab = { + type: 'issue' as const, + number: 7, + title: 'Self hosted', + url: 'https://gitlab.example.com/g/p/-/work_items/7' + } + expect(getWorkspaceSourceProvider(legacyGitLab)).toBe('gitlab') + expect(buildWorkspaceSourceSelection({ linkedWorkItem: legacyGitLab })).toMatchObject({ + kind: 'gitlab-issue', + label: '#7 Self hosted' + }) + expect(shouldApplyWorkspaceSourceAutoName({ currentName: '#42', lastAutoName: 'old' })).toBe( + true + ) + expect( + shouldApplyWorkspaceSourceAutoName({ currentName: 'my workspace', lastAutoName: 'old' }) + ).toBe(false) + }) +}) diff --git a/src/shared/new-workspace/workspace-source.ts b/src/shared/new-workspace/workspace-source.ts new file mode 100644 index 000000000..b8c3048e9 --- /dev/null +++ b/src/shared/new-workspace/workspace-source.ts @@ -0,0 +1,195 @@ +import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear-links' +import type { FolderWorkspaceLinkedTask, LinearIssue } from '../types' +import { + getLinkedWorkItemSuggestedName, + getLinkedWorkItemWorkspaceName, + type WorkspaceIntentWorkItem +} from '../workspace-name' +import { isWorkItemLookupText } from './work-item-lookup-text' + +export type WorkspaceSourceProvider = FolderWorkspaceLinkedTask['provider'] + +export type WorkspaceSourceLinkedItem = FolderWorkspaceLinkedTask & { + linearWorkspaceId?: string + linearOrganizationUrlKey?: string +} + +export type GitHubWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'github' + type: 'issue' | 'pr' +} + +export type GitLabWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'gitlab' + type: 'issue' | 'mr' +} + +export type LinearWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'linear' + type: 'issue' +} + +export type WorkspaceSourceItemLike = Omit & { + provider?: WorkspaceSourceProvider +} + +export type WorkspaceSourceSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + | 'jira' + +export type WorkspaceSourceSelection = { + kind: WorkspaceSourceSelectionKind + label: string + url?: string +} + +const GITLAB_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i + +export function isGitLabIssueUrl(url: string): boolean { + try { + return GITLAB_ISSUE_PATH_RE.test(new URL(url).pathname) + } catch { + return GITLAB_ISSUE_PATH_RE.test(url) + } +} + +function isJiraIssueUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ( + /\.atlassian\.net$/i.test(parsed.hostname) || + /\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname) + ) + } catch { + return false + } +} + +export function getWorkspaceSourceProvider(item: WorkspaceSourceItemLike): WorkspaceSourceProvider { + if (item.provider) { + return item.provider + } + if (item.linearIdentifier) { + return 'linear' + } + if (item.jiraIdentifier || isJiraIssueUrl(item.url)) { + return 'jira' + } + if (item.type === 'mr' || isGitLabIssueUrl(item.url)) { + return 'gitlab' + } + if (item.number === 0 && !item.url.includes('github.com')) { + return 'linear' + } + return 'github' +} + +export function buildGitHubWorkspaceSource(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId?: string +}): GitHubWorkspaceSource { + return { provider: 'github', ...item } +} + +export function buildGitLabWorkspaceSource(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId?: string +}): GitLabWorkspaceSource { + return { provider: 'gitlab', ...item } +} + +export function buildLinearWorkspaceSource( + issue: Pick +): LinearWorkspaceSource { + const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) + return { + provider: 'linear', + type: 'issue', + // Why: Linear uses a string identifier; numeric issue metadata must stay empty. + number: 0, + title: issue.title, + url: issue.url, + linearIdentifier: issue.identifier, + ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), + ...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {}) + } +} + +export function shouldApplyWorkspaceSourceAutoName(args: { + currentName: string + lastAutoName: string +}): boolean { + return ( + !args.currentName.trim() || + args.currentName === args.lastAutoName || + isWorkItemLookupText(args.currentName) + ) +} + +function toWorkspaceIntentItem(item: WorkspaceSourceItemLike): WorkspaceIntentWorkItem { + return { ...item, provider: getWorkspaceSourceProvider(item) } +} + +export function getWorkspaceSourceName(item: WorkspaceSourceItemLike): { + seedName: string + displayName: string +} { + const normalized = toWorkspaceIntentItem(item) + const resolved = getLinkedWorkItemWorkspaceName(normalized) + return { + seedName: resolved?.seedName ?? getLinkedWorkItemSuggestedName(normalized), + displayName: resolved?.displayName ?? item.title.trim() + } +} + +export function buildWorkspaceSourceSelection(args: { + linkedWorkItem: WorkspaceSourceItemLike | null + baseBranch?: string +}): WorkspaceSourceSelection | null { + const { linkedWorkItem, baseBranch } = args + if (!linkedWorkItem) { + return baseBranch ? { kind: 'branch', label: baseBranch } : null + } + const provider = getWorkspaceSourceProvider(linkedWorkItem) + const kind: WorkspaceSourceSelectionKind = + provider === 'linear' + ? 'linear' + : provider === 'jira' + ? 'jira' + : provider === 'gitlab' + ? linkedWorkItem.type === 'mr' + ? 'gitlab-mr' + : 'gitlab-issue' + : linkedWorkItem.type === 'pr' + ? 'github-pr' + : 'github-issue' + return { + kind, + label: + provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 + ? linkedWorkItem.title + : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, + url: linkedWorkItem.url + } +} + +export function shouldPreserveWorkspaceSourceOnRepoChange( + item: WorkspaceSourceItemLike | null +): boolean { + if (!item) { + return false + } + const provider = getWorkspaceSourceProvider(item) + return provider === 'linear' || provider === 'jira' +} diff --git a/src/shared/new-workspace/worktree-create-retry-policy.test.ts b/src/shared/new-workspace/worktree-create-retry-policy.test.ts new file mode 100644 index 000000000..47cc5e79e --- /dev/null +++ b/src/shared/new-workspace/worktree-create-retry-policy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from './worktree-create-retry-policy' + +describe('client worktree create retry policy', () => { + it('uses the same suffix sequence for every client', () => { + expect(getClientWorktreeCreateCandidate('feature', 0)).toBe('feature') + expect(getClientWorktreeCreateCandidate('feature', 1)).toBe('feature-2') + }) + + it('retries only known branch and review conflicts', () => { + expect(isRetryableWorktreeCreateConflict('Branch already exists locally')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Branch "x" already exists.')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Branch already has PR #42')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Permission denied')).toBe(false) + }) +}) diff --git a/src/shared/new-workspace/worktree-create-retry-policy.ts b/src/shared/new-workspace/worktree-create-retry-policy.ts new file mode 100644 index 000000000..295dd3c78 --- /dev/null +++ b/src/shared/new-workspace/worktree-create-retry-policy.ts @@ -0,0 +1,18 @@ +export const CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS = 25 + +// Why: mixed-version runtimes can still return these legacy conflicts instead +// of performing their own suffix retry, so every client needs one policy. +const RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS = [ + /already exists locally/i, + /already exists on a remote/i, + /^Branch ".+" already exists\./i, + /already has pr #\d+/i +] + +export function getClientWorktreeCreateCandidate(value: string, attempt: number): string { + return attempt === 0 ? value : `${value}-${attempt + 1}` +} + +export function isRetryableWorktreeCreateConflict(message: string): boolean { + return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message)) +} diff --git a/src/shared/protocol-compat.test.ts b/src/shared/protocol-compat.test.ts index 79b70c2d0..8b9734c77 100644 --- a/src/shared/protocol-compat.test.ts +++ b/src/shared/protocol-compat.test.ts @@ -45,6 +45,28 @@ describe('evaluateCompat', () => { expect(verdict).toEqual({ kind: 'ok' }) }) + it('allows desktop protocol 3 to roll out before mobile protocol 2 updates', () => { + const verdict = evaluateCompat({ + mobileProtocolVersion: 2, + minCompatibleDesktopVersion: 2, + desktopProtocolVersion: 3, + desktopMinCompatibleMobileVersion: 2 + }) + + expect(verdict).toEqual({ kind: 'ok' }) + }) + + it('allows mobile protocol 3 to roll out before desktop protocol 2 updates', () => { + const verdict = evaluateCompat({ + mobileProtocolVersion: 3, + minCompatibleDesktopVersion: 2, + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + + expect(verdict).toEqual({ kind: 'ok' }) + }) + it('blocks with mobile-too-old when desktop requires a newer mobile', () => { const verdict = evaluateCompat({ mobileProtocolVersion: MOBILE_V,