Merge branch 'main' of https://github.com/stablyai/orca into being-able-to-reply-to-any-comment-from-bot

This commit is contained in:
Jinjing
2026-07-13 17:33:43 -07:00
215 changed files with 12669 additions and 1794 deletions
+9 -7
View File
@@ -3924,14 +3924,14 @@
"remote-runtime",
"ssh"
],
"coverageNotes": "Deterministic tests cover a four-child healthy runtime pool plus at most four bounded quarantine children, one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH now bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.",
"coverageNotes": "Deterministic tests cover a four-child healthy runtime pool, a one-child healthy SSH relay pool, and at most four bounded quarantine children, with one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/5308",
"https://github.com/stablyai/orca/issues/7547",
"https://github.com/stablyai/orca/issues/8212"
],
"invariant": "Local runtime and SSH relay roots use at most four shared watcher children while healthy and at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and the SSH relay, PTYs, Git, and other filesystem operations never load the watcher native addon or share its fault boundary.",
"oracle": "Fill the four-child healthy pool with multiple roots, inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.",
"invariant": "Local runtime roots use at most four shared watcher children while healthy, while each SSH relay process uses at most one; either pool uses at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and each SSH target's relay, PTYs, Git, and other filesystem operations remain isolated from other targets and never load the watcher native addon or share its fault boundary.",
"oracle": "Fill the four-child healthy runtime pool with multiple roots, and map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor. Inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts",
"pnpm run build:electron-vite",
@@ -4043,6 +4043,8 @@
{
"file": "src/relay/relay-filesystem-watch-registry.test.ts",
"assertions": [
"each SSH host keeps its own healthy watcher supervisor while a standard repository's three distinct roots share that one child",
"shared-child failure moves those roots into separate quarantine supervisors with overflow and resumed events",
"replacement emits overflow before later ordered events",
"terminal shard failure preserves same-root clients through recovery",
"pending setup aborts only after the last same-root client leaves",
@@ -4155,11 +4157,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Red tests reproduced the reviewed failures: IPC disconnect-before-exit orphaned existing roots, queue-admission deadlines cascaded shard replacement, a fused shard created one child per root, destructive desktop cleanup waited indefinitely for native setup, and synchronous renderer retry joined a terminal stream. Earlier red coverage also reproduced missing-entry fail-open, terminal recovery loss, stale same-root lease deletion, and unbounded per-child stat lanes. The bounded quarantine and cancellation rewrite passes those contracts, and the real-supervisor built-entry harness survives SIGSEGV with automatic replacement. CI/soak history is still absent."
"evidence": "Red tests reproduced the reviewed failures: IPC disconnect-before-exit orphaned existing roots, queue-admission deadlines cascaded shard replacement, a fused shard created one child per root, a standard SSH repository created three healthy watcher supervisors, destructive desktop cleanup waited indefinitely for native setup, and synchronous renderer retry joined a terminal stream. Earlier red coverage also reproduced missing-entry fail-open, terminal recovery loss, stale same-root lease deletion, and unbounded per-child stat lanes. The bounded quarantine and cancellation rewrite passes those contracts, the relay-specific healthy width test reduces the three-root case to one supervisor, and the real-supervisor built-entry harness survives SIGSEGV with automatic replacement. CI/soak history is still absent."
},
"performanceBudget": {
"required": true,
"evidence": "Healthy operation is capped at four shared child processes instead of one child per root; based on the prior 51.9 MiB one-child measurement this bounds the unremeasured eight-root healthy ceiling near 207.6 MiB rather than the rejected 388.7 MiB per-root design. Degraded operation adds at most four quarantine children, distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most 32 healthy or 64 total logical stat operations), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added."
"evidence": "Local runtime operation remains capped at four shared child processes, while each SSH relay is capped at one healthy child instead of spreading a standard repository's three roots across three children. Based on the measured 51.9 MiB per child, normal SSH watcher overhead is bounded near 51.9 MiB rather than 155.7 MiB before relay and PTY overhead. Degraded operation adds at most four quarantine children, distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most 32 healthy or 64 total logical stat operations for local runtime; 8 healthy or 40 total per SSH relay), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added."
},
"promotionCriteria": [
"Run the built-entry SIGSEGV harness in soak for at least 100 consecutive passes or 14 days on macOS.",
@@ -4168,11 +4170,11 @@
],
"knownGaps": [
"The fault harness bundles the real source supervisor and drives the built child, but it does not launch a packaged orca serve pairing session or exercise app.asar path resolution.",
"The four-child healthy and four-child quarantine memory ceilings are inferred from the earlier one-child measurement; direct degraded-state RSS plus sustained six-writer event-loop-delay and CPU soak evidence is still absent.",
"The one-child SSH healthy and four-child quarantine memory ceilings are inferred from the earlier one-child measurement; direct degraded-state RSS plus sustained six-writer event-loop-delay and CPU soak evidence is still absent.",
"Linux native watcher fault containment is source-equivalent but not live fault-injected in this evidence run.",
"Windows runtime watches bypass this path; SSH registration ownership is deterministic-contract tested but not live-relay fault-injected."
],
"demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four runtime watcher children, if total healthy plus quarantine operation exceeds eight, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process."
"demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four local runtime watcher children or one child in any SSH relay, if total healthy plus quarantine operation exceeds eight locally or five in an SSH relay, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process."
}
]
}
+1
View File
@@ -1428,6 +1428,7 @@ export function HostScreen({
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={worktrees}
onVisibleChange={(visible) => {
newWorktreeModalVisibleRef.current = visible
}}
+1 -1
View File
@@ -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
+97 -39
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
esbuild: true
overrides:
xcode>uuid: 11.1.1
+310 -286
View File
@@ -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<Repo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [showRepoPicker, setShowRepoPicker] = useState(false)
const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true)
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const createInFlightRef = useRef(false)
const setupTrustActionInFlightRef = useRef(false)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
null
)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
const [tasksSupported, setTasksSupported] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
@@ -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<NewWorktreeDrawerView, 'transition'>): 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<PasteRepoCandidate[]>(
() =>
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<RpcResponse>): 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<void> {
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<string, unknown> = {
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<void> {
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 (
<>
<BottomDrawer visible={visible} onClose={onClose}>
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
@@ -702,7 +839,7 @@ function NewWorktreeModalContent({
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
setShowRepoPicker(true)
transitionDrawer('repo')
}}
>
{selectedRepo ? (
@@ -720,6 +857,18 @@ function NewWorktreeModalContent({
</Pressable>
</View>
<SmartWorkspaceSourceField
composer={composer}
label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
disabled={sshGate.requiresConnection}
onBeforeOpen={() => setError('')}
onOpenDrawer={() => transitionDrawer('source')}
/>
{composer.forkPushWarning ? (
<Text style={styles.sourceWarning}>{composer.forkPushWarning}</Text>
) : null}
{selectedRepoConnectionId ? (
<View style={styles.field}>
<Text style={styles.label}>SSH Connection</Text>
@@ -763,28 +912,6 @@ function NewWorktreeModalContent({
</View>
) : null}
<View style={styles.field}>
<Text style={styles.label}>
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<MobileWorkspaceNameInput
style={styles.input}
value={name}
onChangeText={(t) => {
setName(t)
setError('')
}}
placeholderTextColor={colors.textMuted}
shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0}
returnKeyType="done"
onSubmitEditing={() => {
if (canCreate) {
void handleCreate()
}
}}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Agent</Text>
<Pressable
@@ -792,7 +919,7 @@ function NewWorktreeModalContent({
disabled={sshGate.requiresConnection}
onPress={() => {
prepareSelectionPickerOpen()
setShowAgentPicker(true)
transitionDrawer('agent')
}}
>
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
@@ -814,6 +941,11 @@ function NewWorktreeModalContent({
{showAdvanced && (
<>
<SmartWorkspaceAdvancedFields
composer={composer}
selectedRepoIsGit={selectedRepoIsGit}
/>
<View style={styles.field}>
<Text style={styles.label}>Note</Text>
<TextInput
@@ -903,22 +1035,39 @@ function NewWorktreeModalContent({
)}
</BottomDrawer>
{/* 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. */}
<SmartWorkspaceSourceDrawer
visible={visible && drawerView === 'source'}
client={client}
composer={composer}
availability={sourceAvailability}
repoId={selectedRepo?.id ?? null}
repos={pasteRepos}
sshReady={!sshGate.requiresConnection}
onRepoChange={(repoId) => {
const nextRepo = repos.find((repo) => repo.id === repoId)
if (nextRepo) {
setSelectedRepo(nextRepo)
}
}}
onClose={() => transitionDrawer('form')}
/>
<PickerListDrawer
visible={visible && showRepoPicker}
visible={visible && drawerView === 'repo'}
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => setSelectedRepo(item.repo)}
onClose={() => setShowRepoPicker(false)}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={(item) => {
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
}}
/>
<PickerListDrawer
visible={visible && showAgentPicker}
visible={visible && drawerView === 'agent'}
title="Agent"
items={pickerAgentOptions}
selectedId={selectedAgent.id}
@@ -926,104 +1075,19 @@ function NewWorktreeModalContent({
setAgentOverridden(true)
setSelectedAgent(agent)
}}
onClose={() => setShowAgentPicker(false)}
onClose={() => transitionDrawer('form')}
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
/>
<BottomDrawer
visible={visible && setupTrustPrompt != null}
onClose={() => setSetupTrustPrompt(null)}
>
{setupTrustPrompt ? (
<View>
<View style={styles.trustHeader}>
<Text style={styles.title}>
{setupTrustPrompt.previouslyApproved
? `${setupTrustPrompt.repoName}'s setup script changed`
: `Run setup from ${setupTrustPrompt.repoName}?`}
</Text>
<Text style={styles.subtitle}>
This repository's orca.yaml runs before the workspace starts. Only run it if you
trust this repository.
</Text>
</View>
<View style={styles.trustScriptBox}>
<Text style={styles.trustScriptLabel}>
{setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'}
</Text>
<Text style={styles.trustScriptText}>{setupTrustPrompt.scriptContent}</Text>
</View>
<View style={styles.trustActionGroup}>
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() =>
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.')
}
})()
}
>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Run hooks</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() =>
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.')
}
})()
}
>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Always trust and run</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() => {
setSetupTrustPrompt(null)
void handleCreate({ setupOverride: 'skip' })
}}
>
<Text style={styles.trustActionText}>Don't run</Text>
</Pressable>
</View>
</View>
) : null}
</BottomDrawer>
<SetupHookTrustDrawer
visible={visible && drawerView === 'trust' && setupTrustPrompt != null}
prompt={setupTrustPrompt}
busy={creating}
onRunOnce={() => 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',
@@ -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<NewWorktreeModalControllerH
client,
hostId,
existingWorktreePaths,
existingWorktrees,
onVisibleChange,
onRouteVisibleChange,
onCreated
@@ -58,6 +60,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
onCreated={onCreated}
onClose={close}
/>
@@ -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 (
<BottomDrawer visible={visible && prompt != null} onClose={onClose}>
{prompt ? (
<View>
<View style={styles.trustHeader}>
<Text style={styles.title}>
{prompt.previouslyApproved
? `${prompt.repoName}'s setup script changed`
: `Run setup from ${prompt.repoName}?`}
</Text>
<Text style={styles.subtitle}>
This repository's orca.yaml runs before the workspace starts. Only run it if you trust
this repository.
</Text>
</View>
<View style={styles.trustScriptBox}>
<Text style={styles.trustScriptLabel}>
{prompt.previouslyApproved ? 'New setup script' : 'Setup script'}
</Text>
<Text style={styles.trustScriptText}>{prompt.scriptContent}</Text>
</View>
<View style={styles.trustActionGroup}>
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onRunOnce}>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Run hooks</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onAlwaysTrust}>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Always trust and run</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onDontRun}>
<Text style={styles.trustActionText}>Don't run</Text>
</Pressable>
</View>
</View>
) : null}
</BottomDrawer>
)
}
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
}
})
@@ -0,0 +1,18 @@
import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes'
import { TaskProviderLogo } from './TaskProviderLogo'
// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes,
// lucide glyphs for the neutral modes.
export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) {
if (icon.type === 'provider') {
return <TaskProviderLogo provider={icon.provider} size={14} color={color} />
}
if (icon.name === 'sparkles') {
return <Sparkles size={14} color={color} />
}
if (icon.name === 'git-branch') {
return <GitBranch size={14} color={color} />
}
return <CaseSensitive size={14} color={color} />
}
@@ -0,0 +1,102 @@
import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Props = {
composer: MobileComposerSource
selectedRepoIsGit: boolean
}
// The Advanced-section source controls: the editable Name appears once a source
// pill is shown (the field itself is no longer the name input); the branch-name
// override and reuse toggle mirror the desktop composer's advanced branch fields.
export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) {
const selection = composer.smartNameSelection
const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch')
return (
<>
{selection ? (
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
value={composer.name}
onChangeText={composer.setName}
placeholder="Workspace name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{showBranchOverride ? (
<View style={styles.field}>
<Text style={styles.label}>Branch name</Text>
<TextInput
style={styles.input}
value={composer.branchNameOverride ?? ''}
onChangeText={composer.handleBranchNameOverrideChange}
placeholder="Derived from name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{composer.reuseEligibleBranch ? (
<View style={styles.field}>
<View style={styles.reuseRow}>
<Text style={styles.reuseLabel} numberOfLines={1}>
Reuse branch “{composer.reuseEligibleBranch}”
</Text>
<Switch
value={composer.reuseSelectedBranch}
onValueChange={composer.setReuseSelectedBranch}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
style={styles.reuseSwitch}
/>
</View>
</View>
) : null}
</>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reuseRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
reuseLabel: {
flex: 1,
fontSize: 13,
color: colors.textSecondary
},
reuseSwitch: {
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
}
})
@@ -0,0 +1,425 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import {
MR_STATE_FILTER_OPTIONS,
resolveAvailableSmartModes,
resolveDefaultSmartMode,
SMART_MODE_OPTIONS,
type SmartModeAvailabilityInput,
type SmartModeOption
} from '../tasks/mobile-smart-source-modes'
import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types'
import {
lookupGitHubItemByOwnerRepo,
type PasteRepoCandidate
} from '../tasks/smart-source-paste-intent'
import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { SmartSourceModeIcon } from './SmartSourceModeIcon'
import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow'
type Props = {
visible: boolean
client: RpcClient | null
composer: MobileComposerSource
availability: SmartModeAvailabilityInput
repoId: string | null
repos: readonly PasteRepoCandidate[]
linearWorkspaceId?: string | null
sshReady: boolean
onRepoChange: (repoId: string) => void
onClose: () => void
}
export function SmartWorkspaceSourceDrawer({
visible,
client,
composer,
availability,
repoId,
repos,
linearWorkspaceId,
sshReady,
onRepoChange,
onClose
}: Props) {
const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability])
const [mode, setMode] = useState<SmartNameMode>(() => resolveDefaultSmartMode(availability))
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
// Why: read latest availability inside the open effect without making it a
// reactive dep (the object is recreated each render), so re-seeding happens
// only on open, not on every availability recompute.
const availabilityRef = useRef(availability)
availabilityRef.current = availability
// Reset to the default mode each time the drawer opens.
useEffect(() => {
if (visible) {
setMode(resolveDefaultSmartMode(availabilityRef.current))
}
}, [visible])
// Snap the chosen mode back into the available set if availability changes.
const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text')
// Linear searches without a repo; every other provider/branch search needs a
// connected repo-backed target.
const searchEnabled = visible && (effectiveMode === 'linear' || sshReady)
const {
rows,
loading,
error,
needsGitHubRemote,
emptyHint,
crossRepoPrompt,
dismissCrossRepoPrompt
} = useSmartWorkspaceSource({
client,
enabled: searchEnabled,
mode: effectiveMode,
query: composer.name,
repoId,
githubAvailable: availability.githubAvailable,
gitlabAvailable: availability.gitlabAvailable,
linearAvailable: availability.linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos
})
function closeSoon(): void {
setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS)
}
function handleSelectRow(row: SourceRow): void {
switch (row.kind) {
case 'use-name':
composer.setName(row.name)
break
case 'create-branch':
composer.handleSmartCreateBranch(row.name)
break
case 'github':
composer.handleSmartGitHubItemSelect(row.item)
break
case 'gitlab':
composer.handleSmartGitLabItemSelect(row.item)
break
case 'branch':
composer.handleSmartBranchSelect(row.refName, row.localBranchName)
break
case 'linear':
composer.handleSmartLinearIssueSelect(row.issue)
break
}
onClose()
}
async function handleAcceptCrossRepo(): Promise<void> {
if (!client || !crossRepoPrompt) {
return
}
const { link, matchingRepo } = crossRepoPrompt
try {
const item = await lookupGitHubItemByOwnerRepo(
client,
matchingRepo.id,
link.slug,
link.number,
link.type
)
if (item) {
onRepoChange(matchingRepo.id)
composer.handleSmartGitHubItemSelect(item)
onClose()
}
} catch {
dismissCrossRepoPrompt()
}
}
const showEmpty =
!loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0
return (
<BottomDrawer
visible={visible}
onClose={onClose}
dragContentToDismiss={false}
contentScrollable={false}
>
<View style={styles.header}>
<Text style={styles.title}>Name or 'Create From'</Text>
<Pressable onPress={closeSoon} hitSlop={8}>
<Text style={styles.done}>Done</Text>
</Pressable>
</View>
<TextInput
style={styles.search}
value={composer.name}
onChangeText={composer.setName}
placeholder="Type a name or search a source"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<View style={styles.tabRow}>
{SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
availableModes.includes(option.id)
).map((option) => {
const selected = option.id === effectiveMode
const tint = selected ? colors.textPrimary : colors.textSecondary
return (
<Pressable
key={option.id}
style={[styles.tab, selected && styles.tabSelected]}
onPress={() => setMode(option.id)}
>
<SmartSourceModeIcon icon={option.icon} color={tint} />
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
{effectiveMode === 'gitlab' ? (
<View style={styles.chipRow}>
{MR_STATE_FILTER_OPTIONS.map((option) => {
const selected = option.id === mrStateFilter
return (
<Pressable
key={option.id}
style={[styles.chip, selected && styles.chipSelected]}
onPress={() => setMrStateFilter(option.id)}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
) : null}
{crossRepoPrompt ? (
<View style={styles.crossRepo}>
<Text style={styles.crossRepoText}>
This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
</Text>
<View style={styles.crossRepoActions}>
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
<Text style={styles.crossRepoDismissText}>Cancel</Text>
</Pressable>
<Pressable style={styles.crossRepoSwitch} onPress={() => void handleAcceptCrossRepo()}>
<Text style={styles.crossRepoSwitchText}>
Switch to {crossRepoPrompt.matchingRepo.displayName}
</Text>
</Pressable>
</View>
</View>
) : null}
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
<Text style={styles.notice}>Connect the repository to search sources.</Text>
) : needsGitHubRemote ? (
<Text style={styles.notice}>
This SSH repo needs a GitHub remote to list issues and PRs.
</Text>
) : error ? (
<Text style={styles.errorNotice}>{error}</Text>
) : null}
<FlatList
data={rows}
keyExtractor={(row) => row.value}
style={styles.list}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
ListFooterComponent={
loading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : showEmpty ? (
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
) : null
}
renderItem={({ item }) => (
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
)}
/>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
done: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.accentBlue
},
search: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.sm
},
tabRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginBottom: spacing.sm
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
tabText: {
fontSize: 13,
color: colors.textSecondary
},
tabTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
chipRow: {
flexDirection: 'row',
gap: spacing.xs,
marginBottom: spacing.sm
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
chipSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
chipText: {
fontSize: 12,
color: colors.textSecondary
},
chipTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
crossRepo: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.sm,
gap: spacing.sm
},
crossRepoText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm
},
crossRepoDismiss: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
crossRepoDismissText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoSwitch: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.textSecondary
},
crossRepoSwitchText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
notice: {
fontSize: 12,
color: colors.textMuted,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
errorNotice: {
fontSize: 12,
color: colors.statusRed,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
list: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden',
maxHeight: 420,
flexGrow: 0
},
loading: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
empty: {
paddingVertical: spacing.lg,
textAlign: 'center',
color: colors.textMuted,
fontSize: 13
}
})
@@ -0,0 +1,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 <GitPullRequest size={15} color={colors.textSecondary} />
}
if (kind === 'gitlab-mr') {
return <GitMerge size={15} color={colors.textSecondary} />
}
if (kind === 'github-issue' || kind === 'gitlab-issue') {
return <CircleDot size={15} color={colors.textSecondary} />
}
if (kind === 'branch') {
return <GitBranch size={15} color={colors.textSecondary} />
}
return <TaskProviderLogo provider="linear" size={15} color={colors.textSecondary} />
}
export function SmartWorkspaceSourceField({
composer,
label,
disabled,
onBeforeOpen,
onOpenDrawer
}: Props) {
const selection = composer.smartNameSelection
function openDrawer(): void {
if (disabled) {
return
}
onBeforeOpen?.()
onOpenDrawer()
}
return (
<View style={styles.field}>
<Text style={styles.label}>
{label} <Text style={styles.labelHint}>[Optional]</Text>
</Text>
{selection ? (
<View style={styles.pill}>
<SelectionIcon kind={selection.kind} />
<Text style={styles.pillLabel} numberOfLines={1}>
{selection.label}
</Text>
{selection.url ? (
<Pressable
hitSlop={6}
onPress={() => selection.url && void Linking.openURL(selection.url).catch(() => {})}
>
<ExternalLink size={15} color={colors.textMuted} />
</Pressable>
) : null}
<Pressable hitSlop={6} onPress={composer.handleClearSmartNameSelection}>
<X size={15} color={colors.textMuted} />
</Pressable>
</View>
) : (
<Pressable
style={[styles.input, disabled && styles.disabled]}
disabled={disabled}
onPress={openDrawer}
>
<Text
style={[styles.inputText, !composer.name && styles.inputPlaceholder]}
numberOfLines={1}
>
{composer.name || 'Type a name or search a source'}
</Text>
</Pressable>
)}
</View>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
labelHint: {
fontWeight: '400',
color: colors.textMuted
},
input: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderWidth: 1,
borderColor: colors.borderSubtle
},
disabled: {
opacity: 0.55
},
inputText: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
inputPlaceholder: {
color: colors.textMuted
},
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle
},
pillLabel: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
}
})
@@ -0,0 +1,134 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { TaskProviderLogo } from './TaskProviderLogo'
type Props = {
row: SourceRow
onPress: () => void
}
type RowContent = {
icon: React.ReactNode
title: string
subtitle?: string
status?: string
}
function resolveRowContent(row: SourceRow): RowContent {
switch (row.kind) {
case 'use-name':
return {
icon: <Sparkles size={16} color={colors.textSecondary} />,
title: `Use "${row.name}"`,
subtitle: 'Name this workspace'
}
case 'create-branch':
return {
icon: <GitBranch size={16} color={colors.accentBlue} />,
title: `Create branch "${row.name}"`,
subtitle: 'New branch'
}
case 'github':
return {
icon: <TaskProviderLogo provider="github" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'gitlab':
return {
icon: <TaskProviderLogo provider="gitlab" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'branch':
return {
icon: <GitBranch size={16} color={colors.textSecondary} />,
title: row.localBranchName || row.refName,
subtitle: row.refName
}
case 'linear':
return {
icon: <TaskProviderLogo provider="linear" size={16} color={colors.textSecondary} />,
title: row.issue.title,
subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`,
status: row.issue.state?.name
}
default:
return { icon: <CaseSensitive size={16} color={colors.textSecondary} />, title: '' }
}
}
export function SmartWorkspaceSourceRow({ row, onPress }: Props) {
const content = resolveRowContent(row)
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.icon}>{content.icon}</View>
<View style={styles.copy}>
<Text style={styles.title} numberOfLines={1}>
{content.title}
</Text>
{content.subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{content.subtitle}
</Text>
) : null}
</View>
{content.status ? (
<View style={styles.pill}>
<Text style={styles.pillText} numberOfLines={1}>
{content.status}
</Text>
</View>
) : null}
</Pressable>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
icon: {
width: 18,
alignItems: 'center'
},
copy: {
flex: 1,
minWidth: 0
},
title: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
subtitle: {
fontSize: 12,
color: colors.textMuted,
marginTop: 1
},
pill: {
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: 2
},
pillText: {
fontSize: 11,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'capitalize'
}
})
@@ -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<string, unknown>
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<string, unknown>
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)
})
})
@@ -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<WorktreeCreateResult> {
return createWorktreeWithNameRetry({
client: args.client,
baseName: args.baseName,
buildParams: (name) => {
const params: Record<string, unknown> = {
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
}
})
}
@@ -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<string, unknown>) => ({
provider: 'github' as const,
type: 'pr' as const,
number: 12,
title: 'T',
url: 'u',
...over
})
it('maps GitHub PR / issue kinds and numbers the label', () => {
expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({
kind: 'github-pr',
label: '#12 T',
url: 'u'
})
expect(
buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined })
).toMatchObject({ kind: 'github-issue' })
})
it('maps GitLab MR / issue kinds', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-mr' })
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-issue' })
})
it('maps Linear with a bare title label', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }),
baseBranch: undefined
})
).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' })
})
it('falls back to a branch pill', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({
kind: 'branch',
label: 'main'
})
})
it('returns null when nothing is selected', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull()
})
})
describe('resolveComposerCreateSelection', () => {
const baseCreateArgs = {
branch: null,
reuseEligibleBranch: null,
reuseSelectedBranch: false,
branchCreateIntent: false,
name: ''
}
it('prefers a linked work item and passes resolved base fields', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: {
provider: 'github',
type: 'pr',
number: 5,
title: 'T',
url: 'u',
repoId: 'repo-1'
},
base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' }
})
expect(selection).toMatchObject({
kind: 'work-item',
baseBranch: 'main',
compareBaseRef: 'origin/main',
branchNameOverride: 'pr-5'
})
})
it('marks reuse when the eligible branch is toggled on', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: { baseBranch: 'feature', branchNameOverride: 'feature' },
branch: { refName: 'feature', localBranchName: 'feature' },
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true
})
expect(selection).toEqual({
kind: 'branch',
baseBranch: 'feature',
refName: 'feature',
localBranchName: 'feature',
reuse: true,
branchNameOverride: 'feature'
})
})
it('returns a new-branch selection when create-branch intent is set', () => {
expect(
resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: {},
branchCreateIntent: true,
name: 'feature/login'
})
).toEqual({ kind: 'new-branch', branchName: 'feature/login' })
})
it('returns null with no work item, no branch base, and no intent', () => {
expect(
resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} })
).toBeNull()
})
})
describe('resolveComposerBranchPick', () => {
it('auto-names and enables reuse for an unused local branch', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: []
})
expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' })
expect(pick).toMatchObject({
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true,
name: 'feature'
})
})
it('does not reuse a branch already checked out elsewhere', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: ['refs/heads/feature']
})
expect(pick.reuseEligibleBranch).toBeNull()
expect(pick.reuseSelectedBranch).toBe(false)
expect(pick.base.branchNameOverride).toBeUndefined()
})
})
// Keep the exported type aliases referenced so the module surface stays covered.
export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue]
@@ -0,0 +1,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 }
@@ -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<GitHubPrStartPoint> {
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<ComposerHostedBase> {
const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args
const response = await client.sendRequest(
'worktree.resolveMrBase',
{
repo: `id:${repoId}`,
mrIid,
...(sourceBranch ? { sourceBranch } : {}),
...(targetBranch ? { targetBranch } : {}),
...(isCrossRepository !== undefined ? { isCrossRepository } : {})
},
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as HostedBaseResult
if ('error' in result) {
throw new Error(result.error)
}
return result
}
@@ -0,0 +1,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<WorkspaceSourceLinkedItem, 'provider'> & {
provider: Exclude<WorkspaceSourceLinkedItem['provider'], 'jira'>
}
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<WorkspaceSourceSelection, 'kind'> & {
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 }
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_MR_STATE_FILTER,
MR_STATE_FILTER_OPTIONS,
normalizeSmartMode,
resolveAvailableSmartModes,
resolveDefaultSmartMode
} from './mobile-smart-source-modes'
const fullyAvailable = {
textOnly: false,
tasksSupported: true,
hasRepo: true,
githubAvailable: true,
gitlabAvailable: true,
linearAvailable: true
}
describe('resolveAvailableSmartModes', () => {
it('lists every mode in desktop order when all are available', () => {
expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([
'smart',
'github',
'linear',
'gitlab',
'branches',
'text'
])
})
it('collapses to Name for a non-git (text-only) repo', () => {
expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text'])
})
it('drops provider + smart modes when the tasks RPC surface is missing', () => {
expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([
'branches',
'text'
])
})
it('gates provider tabs on their availability and a selected repo', () => {
expect(
resolveAvailableSmartModes({
...fullyAvailable,
githubAvailable: false,
gitlabAvailable: false
})
).toEqual(['smart', 'linear', 'branches', 'text'])
expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([
'smart',
'linear',
'text'
])
})
})
describe('resolveDefaultSmartMode', () => {
it('defaults to smart for a git repo with search', () => {
expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart')
})
it('defaults to text for a non-git repo', () => {
expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text')
})
it('defaults to branches for a git repo without the tasks surface', () => {
expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches')
})
})
describe('normalizeSmartMode', () => {
it('keeps a valid mode and snaps an unavailable one back to default', () => {
expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab')
expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe(
'smart'
)
})
})
describe('MR state filters', () => {
it('exposes Open/Merged/Closed/All with an Open default', () => {
expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all'])
expect(DEFAULT_MR_STATE_FILTER).toBe('opened')
})
})
@@ -0,0 +1,93 @@
import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types'
// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand
// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand
// icons.
export type SmartModeIcon =
| { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' }
| { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' }
export type SmartModeOption = {
id: SmartNameMode
label: string
icon: SmartModeIcon
}
// Order + labels + icons mirror desktop getSmartWorkspaceNameModes():
// Smart · GitHub · Linear · GitLab · Branch · Name.
export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [
{ id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } },
{ id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } },
{ id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } },
{ id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } },
{ id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } },
{ id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } }
]
export type SmartModeAvailabilityInput = {
textOnly: boolean
tasksSupported: boolean
hasRepo: boolean
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
}
// Faithful port of the desktop availableModes filter. Non-git repos collapse to
// the Name tab; provider tabs gate on availability + a selected repo + the tasks
// RPC surface; branches only need a git repo (new-branch-by-name works without
// the search capability).
export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] {
if (input.textOnly) {
return ['text']
}
return SMART_MODE_OPTIONS.filter((option) => {
switch (option.id) {
case 'smart':
return input.tasksSupported
case 'github':
return input.tasksSupported && input.hasRepo && input.githubAvailable
case 'gitlab':
return input.tasksSupported && input.hasRepo && input.gitlabAvailable
case 'linear':
return input.tasksSupported && input.linearAvailable
case 'branches':
return input.hasRepo
case 'text':
return true
}
}).map((option) => option.id)
}
// Default mode when the picker opens: 'smart' for a git repo when search is
// available, else the first available mode (branches for git without tasks,
// 'text' for non-git).
export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode {
const available = resolveAvailableSmartModes(input)
if (available.includes('smart')) {
return 'smart'
}
return available[0] ?? 'text'
}
// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a
// non-git folder), mirroring desktop's snap-to-available effect.
export function normalizeSmartMode(
mode: SmartNameMode,
input: SmartModeAvailabilityInput
): SmartNameMode {
const available = resolveAvailableSmartModes(input)
return available.includes(mode) ? mode : resolveDefaultSmartMode(input)
}
export type MrStateFilterOption = { id: MrStateFilter; label: string }
// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'.
export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [
{ id: 'opened', label: 'Open' },
{ id: 'merged', label: 'Merged' },
{ id: 'closed', label: 'Closed' },
{ id: 'all', label: 'All' }
]
export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened'
@@ -0,0 +1,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'
+23
View File
@@ -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(
+16
View File
@@ -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<PersistedTrustedOrcaHooks> {
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 {
@@ -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<string, unknown> }
function fakeClient(byMethod: Record<string, unknown>, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
const result = byMethod[method]
if (result instanceof Error) {
return {
id: '1',
ok: false,
error: { code: 'x', message: result.message },
_meta: { runtimeId: 'r' }
}
}
return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
const smartArgs = {
mode: 'smart' as const,
query: 'bug',
repoId: 'repo-1',
githubAvailable: true,
gitlabAvailable: true,
linearAvailable: true,
mrStateFilter: 'opened' as const,
linearWorkspaceId: null
}
describe('fanOutSmartSearch', () => {
it('fans out to every provider in smart mode and stamps repoId', async () => {
const calls: Call[] = []
const client = fakeClient(
{
'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] },
'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] },
'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] },
'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] }
},
calls
)
const result = await fanOutSmartSearch({ client, ...smartArgs })
expect(calls.map((c) => c.method).sort()).toEqual([
'github.listWorkItems',
'gitlab.listWorkItems',
'linear.searchIssues',
'repo.searchRefs'
])
expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' })
expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' })
expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' })
expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }])
expect(result.error).toBe('')
})
it('swallows a single provider failure in smart mode (best-effort)', async () => {
const calls: Call[] = []
const client = fakeClient(
{
'github.listWorkItems': new Error('gh down'),
'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] },
'linear.searchIssues': { items: [] },
'repo.searchRefs': { refDetails: [] }
},
calls
)
const result = await fanOutSmartSearch({ client, ...smartArgs })
expect(result.error).toBe('')
expect(result.gitlabItems).toHaveLength(1)
})
it('surfaces the error for a single-provider mode', async () => {
const calls: Call[] = []
const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls)
const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client })
expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems'])
expect(result.error).toBe('gl boom')
})
it('only searches branches in smart mode when the query is non-empty', async () => {
const calls: Call[] = []
const client = fakeClient({}, calls)
await fanOutSmartSearch({ ...smartArgs, query: '', client })
expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs')
})
it('skips GitHub in smart mode when GitHub is unavailable', async () => {
const calls: Call[] = []
const client = fakeClient({}, calls)
await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client })
expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems')
})
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: []
})
})
})
+141
View File
@@ -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<SmartFanOutResult, 'needsGitHubRemote' | 'error'> = {
githubItems: [],
gitlabItems: [],
linearIssues: [],
branches: []
}
function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean {
return githubAvailable && (mode === 'smart' || mode === 'github')
}
function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean {
return gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
}
function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean {
return linearAvailable && (mode === 'smart' || mode === 'linear')
}
function shouldSearchBranches(mode: SmartNameMode, query: string): boolean {
return mode === 'branches' || (mode === 'smart' && query.trim().length > 0)
}
type FanOutArgs = {
client: RpcClient
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId: string | null | undefined
}
// Runs every provider search the active mode needs, concurrently. Smart mode is
// best-effort (a single provider failure never blocks the others); single-provider
// modes surface the failure. No cross-provider ranking/dedup — the shared row
// builder concatenates in provider order.
export async function fanOutSmartSearch(args: FanOutArgs): Promise<SmartFanOutResult> {
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<GitHubWorkItem[]>([]),
tasks.gitlab ?? Promise.resolve<GitLabWorkItem[]>([]),
tasks.linear ?? Promise.resolve<LinearIssue[]>([]),
tasks.branches ?? Promise.resolve<BaseRefSearchResult[]>([])
])
let needsGitHubRemote = false
let error = ''
const fail = (reason: unknown) => {
if (!isSmart) {
error = reason instanceof Error ? reason.message : 'Search failed'
}
}
if (github.status === 'rejected') {
if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) {
needsGitHubRemote = true
} else {
fail(github.reason)
}
}
if (gitlab.status === 'rejected') {
fail(gitlab.reason)
}
if (linear.status === 'rejected') {
fail(linear.reason)
}
if (branches.status === 'rejected') {
fail(branches.reason)
}
return {
...EMPTY,
githubItems: github.status === 'fulfilled' ? github.value : [],
gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [],
linearIssues: linear.status === 'fulfilled' ? linear.value : [],
branches: branches.status === 'fulfilled' ? branches.value : [],
needsGitHubRemote,
error
}
}
@@ -0,0 +1,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<string, { owner: string; repo: string } | null>()
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()
})
})
@@ -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<ReturnType<typeof parseGitLabIssueOrMRLink>>
}
export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null
// Pure: classify pasted text into a work-item lookup intent. A slug-bearing
// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number';
// a GitLab issue/MR URL becomes 'gitlab-link'.
export function resolvePasteIntent(query: string): PasteIntent {
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<string, RepoSlug | null>
): Promise<PasteRepoCandidate | null> {
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<GitHubWorkItem | null> {
const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number })
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitHubWorkItem | null
return item ? { ...item, repoId } : null
}
export async function lookupGitHubItemByOwnerRepo(
client: RpcClient,
repoId: string,
slug: RepoSlug,
number: number,
type: 'issue' | 'pr'
): Promise<GitHubWorkItem | null> {
const response = await client.sendRequest('github.workItemByOwnerRepo', {
repo: `id:${repoId}`,
owner: slug.owner,
ownerRepo: slug.repo,
number,
type
})
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitHubWorkItem | null
return item ? { ...item, repoId } : null
}
export async function lookupGitLabItemByPath(
client: RpcClient,
repoId: string,
link: NonNullable<ReturnType<typeof parseGitLabIssueOrMRLink>>
): Promise<GitLabWorkItem | null> {
const response = await client.sendRequest('gitlab.workItemByPath', {
repo: `id:${repoId}`,
host: link.slug.host,
path: link.slug.path,
iid: link.number,
type: link.type
})
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitLabWorkItem | null
return item ? { ...item, repoId } : null
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests'
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(result: unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
describe('scopeGitHubQuery', () => {
it('passes the raw trimmed query so BOTH issues and PRs are returned', () => {
// Empty stays empty (runtime lists recent issues + PRs); no forced is:issue.
expect(scopeGitHubQuery('')).toBe('')
expect(scopeGitHubQuery(' login bug ')).toBe('login bug')
})
it('preserves an explicit is:pr / is:issue scope the user typed', () => {
expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth')
expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth')
})
})
describe('searchLinearIssues', () => {
it('lists assigned issues for an empty query (desktop default)', async () => {
const calls: Call[] = []
const client = fakeClient({ items: [] }, calls)
await searchLinearIssues(client, '', null)
expect(calls[0]!.method).toBe('linear.listIssues')
expect(calls[0]!.params).toMatchObject({ filter: 'assigned' })
})
it('searches when a query is present', async () => {
const calls: Call[] = []
const client = fakeClient({ items: [] }, calls)
await searchLinearIssues(client, 'bug', 'ws-1')
expect(calls[0]!.method).toBe('linear.searchIssues')
expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' })
})
})
@@ -0,0 +1,119 @@
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearIssue
} from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { extractLinearIssueReadItems } from './linear-mobile-issue-read'
import { PER_REPO_FETCH_LIMIT } from './mobile-work-items'
import type { MrStateFilter } from './mobile-composer-source-types'
const GITLAB_PER_PAGE = 50
const LINEAR_LIMIT = 50
const BRANCH_LIMIT = 20
// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's
// parseTaskQuery defaults scope 'all', and an empty query lists recent items of
// both types. So pass the raw trimmed query straight through (an explicit
// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty
// so the runtime lists recent issues + PRs.
export function scopeGitHubQuery(query: string): string {
return query.trim()
}
export async function searchGitHubItems(
client: RpcClient,
repoId: string,
query: string
): Promise<GitHubWorkItem[]> {
const response = await client.sendRequest('github.listWorkItems', {
repo: `id:${repoId}`,
limit: PER_REPO_FETCH_LIMIT,
query: scopeGitHubQuery(query)
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] }
// Stamp repoId so the shared row builder + create flow can attribute each item
// to the searched repo (the runtime omits it, like the desktop fetcher).
return (envelope.items ?? []).map((item) => ({ ...item, repoId }))
}
export async function searchGitLabItems(
client: RpcClient,
repoId: string,
query: string,
state: MrStateFilter
): Promise<GitLabWorkItem[]> {
const response = await client.sendRequest('gitlab.listWorkItems', {
repo: `id:${repoId}`,
state,
page: 1,
perPage: GITLAB_PER_PAGE,
query: query.trim() || undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as {
items: GitLabWorkItem[]
error?: { type?: string; message: string }
}
if (envelope.error?.type && envelope.error.type !== 'not_found') {
throw new Error(envelope.error.message)
}
return (envelope.items ?? []).map((item) => ({ ...item, repoId }))
}
export async function searchLinearIssues(
client: RpcClient,
query: string,
linearWorkspaceId: string | null | undefined
): Promise<LinearIssue[]> {
const trimmed = query.trim()
const response = trimmed
? await client.sendRequest('linear.searchIssues', {
query: trimmed,
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
: await client.sendRequest('linear.listIssues', {
// Empty query lists the viewer's assigned issues, matching desktop's
// Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')).
filter: 'assigned',
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
// extractLinearIssueReadItems yields the mobile issue-read shape; the fields the
// row builder/create flow read (id/identifier/title/url/state/team) are a subset.
return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[]
}
export async function searchBranches(
client: RpcClient,
repoId: string,
query: string
): Promise<BaseRefSearchResult[]> {
const response = await client.sendRequest(
'repo.searchRefs',
{ repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT },
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as {
refDetails?: BaseRefSearchResult[]
refs?: string[]
}
return (
result.refDetails ??
(result.refs ?? []).map((refName) => ({ refName, localBranchName: refName }))
)
}
@@ -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<string, unknown> }
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<string, unknown> })
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 "<type>-<number>" slug.
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' })
expect(calls).toHaveLength(1)
expect(calls[0]!.method).toBe('worktree.create')
expect(calls[0]!.params).toMatchObject({
repo: 'id:repo-9',
linkedIssue: 7,
displayName: 'Bug'
})
})
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'
})
})
})
+250
View File
@@ -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<WorktreeCreateResult> {
if (args.selection.kind === 'branch') {
return createBranchWorkspace({ ...args, selection: args.selection })
}
if (args.selection.kind === 'new-branch') {
return createNewBranchWorkspace({ ...args, selection: args.selection })
}
return createWorkItemWorkspace({ ...args, selection: args.selection })
}
function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem {
if (item.provider === 'github') {
return {
provider: 'github',
source: {
type: item.type === 'pr' ? 'pr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
if (item.provider === 'gitlab') {
return {
provider: 'gitlab',
source: {
type: item.type === 'mr' ? 'mr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
return {
provider: 'linear',
source: {
identifier: item.linearIdentifier ?? '',
title: item.title,
url: item.url,
...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}),
...(item.linearOrganizationUrlKey
? { organizationUrlKey: item.linearOrganizationUrlKey }
: {})
}
}
}
async function createWorkItemWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'work-item' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
}): Promise<WorktreeCreateResult> {
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<MobileComposerCreateSelection, { kind: 'branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const comment = note?.trim()
const applyCommon = (params: Record<string, unknown>): Record<string, unknown> => {
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<string, unknown> = {
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<MobileComposerCreateSelection, { kind: 'new-branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const comment = note?.trim()
// A brand-new branch off the repo's default base. The typed name is kept as the
// git branch (via branchNameOverride) so a slash like `feature/login` survives;
// the runtime sanitizes the worktree folder from the same name. The retry base is
// the branch name so a collision bumps the branch (and folder) together.
return createWorktreeWithNameRetry({
client,
baseName: selection.branchName,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
name: candidate,
setupDecision,
branchNameOverride: candidate,
startupCommand: agent.startupCommand
}
if (createdWithAgentId) {
params.createdWithAgent = createdWithAgentId
}
if (comment) {
params.comment = comment
}
return params
}
})
}
@@ -0,0 +1,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<MobileLinkedWorkItem | null>(null)
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
const [forkPushWarning, setForkPushWarning] = useState<string | null>(null)
const [resolvingBase, setResolvingBase] = useState(false)
// Set when the "Create branch <name>" row is picked, so the typed name (which
// may contain slashes) is kept verbatim as the git branch (folder is sanitized).
const [branchCreateIntent, setBranchCreateIntent] = useState(false)
const lastAutoNameRef = useRef('')
const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null)
// Guards async base resolution: only the latest selection applies its result.
const resolveTokenRef = useRef(0)
const setName = useCallback((value: string) => setNameState(value), [])
const applyAutoName = useCallback((suggested: string, currentName: string) => {
if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
}, [])
const clearBaseAndBranch = useCallback(() => {
branchSelectionRef.current = null
setBranchCreateIntent(false)
setBase(EMPTY_BASE)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
// Why: a superseding selection bumps the resolve token, so an in-flight base
// resolve's token-gated finally can no longer clear this — reset it here so
// resolvingBase never sticks true after switching sources.
setResolvingBase(false)
}, [])
// Applies an async PR/MR base resolution guarded by the current token so only
// the latest selection wins; failures clear the base and surface the error.
const runBaseResolve = useCallback(
(token: number, resolve: Promise<ComposerHostedBase>) => {
setResolvingBase(true)
void resolve
.then((result) => {
if (resolveTokenRef.current !== token) {
return
}
setBase({
baseBranch: result.baseBranch,
compareBaseRef: result.compareBaseRef,
pushTarget: result.pushTarget,
branchNameOverride: result.branchNameOverride
})
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>": 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<SmartNameSelection | null>(
() => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }),
[base.baseBranch, linkedWorkItem]
)
const createSelection = useMemo<MobileComposerCreateSelection | null>(
() =>
resolveComposerCreateSelection({
linkedWorkItem,
base,
branch: branchSelectionRef.current,
reuseEligibleBranch,
reuseSelectedBranch,
branchCreateIntent,
name
}),
[base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch]
)
// Auto-managed until the user edits the name away from the last derived value;
// desktop suppresses the workspace displayName once the name is user-edited.
const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current
return {
name,
setName,
linkedWorkItem,
branchNameOverride: base.branchNameOverride,
handleBranchNameOverrideChange,
reuseEligibleBranch,
reuseSelectedBranch,
setReuseSelectedBranch,
forkPushWarning,
resolvingBase,
isNameAutoManaged,
smartNameSelection,
createSelection,
handleSmartGitHubItemSelect,
handleSmartGitLabItemSelect,
handleSmartLinearIssueSelect,
handleSmartBranchSelect,
handleSmartCreateBranch,
handleClearSmartNameSelection
}
}
export type MobileComposerSource = ReturnType<typeof useMobileComposerSource>
@@ -0,0 +1,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<SmartFanOutResult>(EMPTY_FAN)
const [paste, setPaste] = useState<PasteResolved>({ github: null, gitlab: null })
const [loading, setLoading] = useState(false)
const [crossRepoPrompt, setCrossRepoPrompt] = useState<SmartCrossRepoPrompt | null>(null)
// Why: preserve results across keystrokes (debounce) but drop them the moment
// the mode/repo changes so one provider's rows never render under another tab.
const scopeRef = useRef('')
const dismissedPasteRef = useRef<string>('')
const repoSlugCacheRef = useRef<Map<string, { owner: string; repo: string } | null>>(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<SmartWorkspaceSourceRow[]>(
() =>
buildSmartWorkspaceSourceRows({
branches: fan.branches,
githubItems: paste.github ? [paste.github] : fan.githubItems,
gitlabAvailable,
gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems,
linearAvailable,
linearIssues: fan.linearIssues,
mode,
resultLimit: RESULT_LIMIT,
value: query
}),
[fan, gitlabAvailable, linearAvailable, mode, paste, query]
)
const dismissCrossRepoPrompt = useCallback(() => {
dismissedPasteRef.current = query.trim()
setCrossRepoPrompt(null)
}, [query])
return {
rows,
loading,
error: fan.error,
needsGitHubRemote: fan.needsGitHubRemote,
emptyHint: getSmartWorkspaceEmptyHint(mode),
crossRepoPrompt,
dismissCrossRepoPrompt
}
}
async function runSmartSearch(args: {
client: RpcClient
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId: string | null | undefined
repos: readonly PasteRepoCandidate[]
dismissedPasteRef: { current: string }
repoSlugCache: Map<string, { owner: string; repo: string } | null>
}): 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 }
}
@@ -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)
})
})
@@ -0,0 +1 @@
export * from '../../../src/shared/new-workspace/work-item-lookup-text'
@@ -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'
+43 -18
View File
@@ -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
}
}
+46
View File
@@ -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<string, unknown>
maxAttempts?: number
}): Promise<WorktreeCreateResult> {
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' }
}
@@ -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' })
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.138-rc.6",
"version": "1.4.139-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+5 -10
View File
@@ -1,4 +1,4 @@
import { isAbsolute, relative, resolve as resolvePath } from 'node:path'
import { resolve as resolvePath } from 'node:path'
import type {
ComputerAppQuery,
RuntimeWorktreeListResult,
@@ -43,14 +43,6 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient)
)
}
function isWithinPath(parentPath: string, childPath: string): boolean {
if (isPathInsideOrEqual(parentPath, childPath)) {
return true
}
const relativePath = relative(parentPath, childPath)
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
export async function resolveCurrentWorktreeSelector(
cwd: string,
client: RuntimeClient
@@ -65,7 +57,10 @@ export async function resolveCurrentWorktreeSelector(
let enclosingPathLength = -1
for (const worktree of worktrees.result.worktrees) {
const worktreePath = resolvePath(worktree.path)
if (!isWithinPath(worktreePath, currentPath) || worktreePath.length <= enclosingPathLength) {
if (
!isPathInsideOrEqual(worktreePath, currentPath) ||
worktreePath.length <= enclosingPathLength
) {
continue
}
enclosingWorktree = worktree
@@ -185,6 +185,19 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('runs Git against the backing folder for a folder-workspace instance id', async () => {
// Why: instance ids carry a synthetic `::workspace:<uuid>` suffix that is not
// a real directory. The Git cwd must resolve to the folder or `rev-parse`
// spawns against a nonexistent path (ENOENT).
const instanceId = `${WORKTREE_ID}::workspace:123e4567-e89b-12d3-a456-426614174000`
const { deps } = makeDeps({ resolveWorktreeIdForTab: () => instanceId })
await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.objectContaining({ cwd: '/repo/wt' })
)
})
it('skips when no worktree can be resolved for the tab', async () => {
const { deps } = makeDeps({ resolveWorktreeIdForTab: () => undefined })
await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps)
@@ -4,7 +4,7 @@
// owns the orchestration: gate on the signal, enforce the safety guardrails,
// summarize the prompt via the configured agent, and rename.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { parsePaneKey } from '../../shared/stable-pane-id'
import {
@@ -188,7 +188,10 @@ async function runAutoRename(
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
const parsed = splitWorktreeId(worktreeId)
// Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs
// carry a synthetic `::workspace:<uuid>` suffix that is not a real directory,
// so resolve to the backing folder or Git spawns against a nonexistent cwd.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
if (!repo || !parsed) {
return stop('unresolved repo or worktree id')
}
@@ -8,6 +8,7 @@ import {
const REPO = { id: 'repo1', path: '/repos/orca', connectionId: null } as unknown as Repo
const SETTINGS = { nestWorkspaces: false, workspaceDir: '/ws' } as unknown as GlobalSettings
const OLD_ID = 'repo1::/ws/cunner'
const FOLDER_WORKSPACE_ID = 'repo1::/ws/cunner::workspace:12345678-1234-1234-1234-123456789abc'
function makeDeps(overrides: Partial<FirstWorkFolderRenameDeps> = {}): FirstWorkFolderRenameDeps {
return {
@@ -50,6 +51,25 @@ describe('renameWorktreeFolderOnFirstWork', () => {
)
})
it('preserves the folder-workspace instance suffix in the migrated identity', async () => {
const deps = makeDeps()
const result = await renameWorktreeFolderOnFirstWork(
FOLDER_WORKSPACE_ID,
'worktree-creation-spinner',
deps
)
expect(result).toBe(true)
expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith(
FOLDER_WORKSPACE_ID,
'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc'
)
expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith(
'repo1',
FOLDER_WORKSPACE_ID,
'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc'
)
})
it('skips (no move) when the destination already exists', async () => {
const deps = makeDeps({ pathExists: vi.fn(async () => true) })
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'taken', deps)).toBe(false)
@@ -6,7 +6,11 @@
// best-effort and local-only — remote/Windows/locked/dest-taken all degrade to
// "folder kept" without disturbing the rename that already succeeded.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
import {
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
getRepoIdFromWorktreeId,
splitWorktreeIdForFilesystem
} from '../../shared/worktree-id'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target'
@@ -35,7 +39,10 @@ export async function renameWorktreeFolderOnFirstWork(
deps: FirstWorkFolderRenameDeps
): Promise<boolean> {
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
const parsed = splitWorktreeId(worktreeId)
// Why: oldWorktreePath feeds an on-disk folder move. Resolve the synthetic
// `::workspace:<uuid>` suffix to the backing folder; identity is migrated
// separately via the untouched worktreeId.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
if (!repo || !parsed) {
return false
}
@@ -43,6 +50,9 @@ export async function renameWorktreeFolderOnFirstWork(
repoId: repo.id,
repoPath: repo.path,
oldWorktreePath: parsed.worktreePath,
worktreeIdSuffix: worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)
? `${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${worktreeId.split(FOLDER_WORKSPACE_INSTANCE_SEPARATOR).at(-1)}`
: undefined,
newLeaf,
settings: deps.getSettings(),
platform: process.platform,
+31 -2
View File
@@ -194,8 +194,15 @@ describe('WslCliInstaller', () => {
)
expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript())
const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"'))
expect(installCommand).toBeDefined()
expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'")
expect(installCommand).toContain('rm -f "$legacy_command_path"')
// Why: the new bridge accepts the old launcher's positional arguments, so
// publishing it first keeps interrupted upgrades usable.
const bridgePublishIndex = installCommand?.indexOf('mv -f "$bridge_tmp"') ?? -1
const launcherPublishIndex = installCommand?.indexOf('mv -f "$command_tmp"') ?? -1
expect(bridgePublishIndex).toBeGreaterThan(-1)
expect(bridgePublishIndex).toBeLessThan(launcherPublishIndex)
expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]')
})
@@ -296,12 +303,34 @@ describe('WslCliInstaller', () => {
'Orca WSL CLI requires Windows interop and could not find powershell.exe.'
)
expect(launcher).toContain('"$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File')
expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"')
expect(launcher).toContain('ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || {')
expect(launcher).toContain('ORCA_WSL_CWD=/')
expect(launcher).toContain('cd /')
expect(launcher).toContain('ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD")')
expect(launcher.indexOf('ORCA_WSL_CWD=$(pwd -P')).toBeLessThan(
launcher.indexOf('ORCA_BRIDGE_PS1_WIN=$(wslpath')
)
expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"')
expect(launcher).not.toContain('-Command')
expect(bridge).toContain('[CmdletBinding(PositionalBinding=$false)]')
expect(bridge).toContain('[Parameter(Mandatory=$true, Position=0)]')
expect(bridge).toContain('[string]$WslCwd')
expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]')
expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))')
expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd')
expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)')
expect(bridge).toContain('& $OrcaLauncher @ForwardArgs')
const nullExitCodeBranch = bridge.indexOf('if ($null -eq $LASTEXITCODE)')
const invocationFailureBranch = bridge.indexOf('if (-not $?)')
expect(nullExitCodeBranch).toBeGreaterThan(-1)
// Why: native launchers can set a non-zero LASTEXITCODE while $? is false;
// checking the native status first preserves that specific exit code.
expect(nullExitCodeBranch).toBeLessThan(invocationFailureBranch)
expect(bridge).toContain('$exitCode = $LASTEXITCODE')
expect(bridge).toContain('Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue')
expect(bridge).toContain('catch')
expect(bridge).toContain('exit 1')
expect(bridge).toContain('$exitCode = 1')
expect(bridge).toContain('exit $exitCode')
})
it('wraps WSL bash scripts as a single encoded command line', () => {
+28 -8
View File
@@ -20,34 +20,54 @@ else
echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2
exit 1
fi
# Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and
# help usable, and repair cwd before any WSL interop tool tries to resolve it.
ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || {
ORCA_WSL_CWD=/
cd /
}
ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD")
exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"
`
}
export function buildWslBridgeScript(): string {
return `${BRIDGE_MANAGED_MARKER}
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$true)]
[Parameter(Mandatory=$true, Position=0)]
[string]$OrcaLauncher,
[string]$WslCwd,
[Parameter(ValueFromRemainingArguments=$true)]
[string[]]$ForwardArgs
)
$exitCode = 0
try {
if ([string]::IsNullOrEmpty($WslCwd)) {
Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue
} else {
$env:ORCA_CLI_CWD = $WslCwd
}
Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)
& $OrcaLauncher @ForwardArgs
if (-not $?) {
exit 1
}
if ($null -eq $LASTEXITCODE) {
exit 0
if (-not $?) {
$exitCode = 1
} else {
$exitCode = 0
}
} else {
$exitCode = $LASTEXITCODE
}
exit $LASTEXITCODE
} catch {
Write-Error $_
exit 1
$exitCode = 1
}
exit $exitCode
`
}
+84
View File
@@ -363,6 +363,90 @@ describe('git remote operations', () => {
])
})
it('retries a divergent pull as a merge when no strategy is configured', async () => {
const divergentError = new Error(
'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.'
)
gitExecFileAsyncMock
// First attempt: plain pull rejects with git's reconciliation error.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(divergentError)
// Fallback attempt: pull --no-rebase (merge) succeeds.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPull('/repo')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }],
[['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }],
[['pull'], { cwd: '/repo' }],
[['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }],
[['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }],
[['pull', '--no-rebase'], { cwd: '/repo' }]
])
})
it('does not retry a fast-forward-only pull that fails on divergence', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(
new Error('Command failed: git pull\nfatal: Not possible to fast-forward, aborting.')
)
await expect(gitFastForward('/repo')).rejects.toThrow('Not possible to fast-forward')
// No fallback attempt: only the three probe/pull calls ran.
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(3)
})
it('retries a divergent pushTarget pull as a merge when no strategy is configured', async () => {
const divergentError = new Error(
'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.'
)
gitExecFileAsyncMock
// First attempt: validate the target, then the plain pull rejects.
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(divergentError)
// Fallback attempt: re-validate, then pull --no-rebase (merge) succeeds.
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await gitPull('/repo', { remoteName: 'fork', branchName: 'feature/fix' })
// The merge flag is spliced ahead of the positional remote/branch args.
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['pull', 'fork', 'feature/fix'], { cwd: '/repo' }],
[['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }],
[['pull', '--no-rebase', 'fork', 'feature/fix'], { cwd: '/repo' }]
])
})
it('surfaces a normalized error and does not loop when the merge fallback itself fails', async () => {
const divergentError = new Error(
'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.'
)
const mergeConflictError = new Error(
'Command failed: git pull --no-rebase\nCONFLICT (content): Merge conflict in file.txt'
)
gitExecFileAsyncMock
// First attempt fails with the reconciliation error.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(divergentError)
// The single merge fallback then fails on a real conflict.
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' })
.mockRejectedValueOnce(mergeConflictError)
await expect(gitPull('/repo')).rejects.toThrow()
// At-most-once retry: probe+pull, then probe+fallback-pull — no further attempts.
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6)
})
it('pulls the same-name origin branch for legacy base-tracking worktrees', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' })
+12 -5
View File
@@ -1,4 +1,7 @@
import { normalizeGitErrorMessage } from '../../shared/git-remote-error'
import {
normalizeGitErrorMessage,
runPullWithDivergenceFallback
} from '../../shared/git-remote-error'
import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream'
import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name'
import { resolveGitRemoteRebaseSource } from '../../shared/git-rebase-source'
@@ -214,11 +217,11 @@ async function gitPullWithArgs(
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
try {
const runPull = async (effectiveArgs: string[]): Promise<void> => {
if (pushTarget) {
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
await gitExecFileAsync(
['pull', ...pullArgs, target.remoteName, target.branchName],
['pull', ...effectiveArgs, target.remoteName, target.branchName],
gitOptionsForWorktree(worktreePath, options)
)
return
@@ -230,13 +233,17 @@ async function gitPullWithArgs(
// Why: legacy Orca branches may still track origin/main while pushes
// target origin/<branch>. Pull the same effective branch the UI reports.
await gitExecFileAsync(
['pull', ...pullArgs, upstream.remoteName, upstream.branchName],
['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName],
gitOptionsForWorktree(worktreePath, options)
)
return
}
await gitExecFileAsync(['pull', ...pullArgs], gitOptionsForWorktree(worktreePath, options))
await gitExecFileAsync(['pull', ...effectiveArgs], gitOptionsForWorktree(worktreePath, options))
}
try {
await runPullWithDivergenceFallback(pullArgs, runPull)
} catch (error) {
throw new Error(normalizeGitErrorMessage(error, 'pull'))
}
+17 -3
View File
@@ -229,6 +229,10 @@ const recoveryReloadInFlight = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
// Why: the serve barrier fails open at its budget, so headless clients need the
// reconciliation state at ready time to know a WSL PTY launch may still race an
// un-migrated registration. 'settled' covers the off-Windows no-op fast path.
let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled'
// Why: GPU child crashes clustered right after launch indicate a broken driver;
// track them so Orca can move this build onto software rendering.
const gpuLaunchTimeMs = Date.now()
@@ -1384,6 +1388,9 @@ async function printServeReady(options: ServeOptions): Promise<void> {
type: 'orca_server_ready',
runtimeId: runtime.getRuntimeId(),
endpoint,
// Why: the WSL reconciliation barrier fails open, so 'pending' warns clients
// it outlived the startup budget and a WSL PTY launch may still race a repair.
managedWslCliReconciliation: managedWslCliReconciliationStatus,
pairing: pairing.available
? {
url: pairing.pairingUrl,
@@ -1610,6 +1617,7 @@ app.whenReady().then(async () => {
// Why: managed WSL launchers live outside the Windows app bundle, so keep
// their launcher and bridge contract synchronized across app updates.
managedWslCliReconciliationStatus = 'pending'
managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({
isPackaged: app.isPackaged,
userDataPath: getCanonicalUserDataPath(),
@@ -1625,8 +1633,10 @@ app.whenReady().then(async () => {
console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`)
}
}
managedWslCliReconciliationStatus = 'settled'
})
.catch((error) => {
managedWslCliReconciliationStatus = 'failed'
console.warn(
'[wsl-cli] Managed registration reconciliation discovery failed:',
error instanceof Error ? error.message : String(error)
@@ -2090,9 +2100,13 @@ app.whenReady().then(async () => {
}
if (serveOptions) {
// Why: headless serve has no renderer startup barrier, so settle managed
// WSL command reconciliation before exposing its runtime transport.
await managedWslCliReconciliationReady
// Why: give managed WSL launchers a brief chance to migrate before headless
// PTYs become reachable without letting slow repairs withhold all RPC readiness.
logStartupMilestone('wsl-cli-barrier-start')
await managedWslCliStartupBarrierReady
logStartupMilestone('wsl-cli-barrier-resolved', {
reconciliation: managedWslCliReconciliationStatus
})
await startServeAgentHookServer()
registerHeadlessPtyRuntime(
runtime,
+31 -9
View File
@@ -1702,6 +1702,7 @@ describe('registerPtyHandlers', () => {
// (the GNOME screen reader) inside Orca-managed terminals (#7904).
expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0)
expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin'))
expect(env.ORCA_CLI_COMMAND).toBeUndefined()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
@@ -1801,10 +1802,13 @@ describe('registerPtyHandlers', () => {
// Why: runtime-created spawns (e.g. the mobile-create materialize path)
// must thread the same {tabId, leafId} so the catch-path rescue can find
// and keep their live PTY (#7587).
expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-runtime', null, {
tabId: 'tab-1',
leafId
})
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'wt-runtime',
null,
{ tabId: 'tab-1', leafId },
false
)
})
it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => {
@@ -1859,6 +1863,13 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.shellOverride).toBe('wsl.exe')
expect(spawnOptions.terminalWindowsWslDistro).toBe('Ubuntu')
expect(spawnOptions.terminalWindowsPowerShellImplementation).toBe('auto')
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'repo-1::C:\\repo',
null,
undefined,
true
)
})
})
@@ -4558,10 +4569,13 @@ describe('registerPtyHandlers', () => {
// Why: this is the load-bearing wiring for #7587 — the runtime can only back a
// stalled mobile create from a live spawn if the spawn threads {tabId, leafId}.
expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, {
tabId: 'tab-1',
leafId
})
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'wt-1',
null,
{ tabId: 'tab-1', leafId },
false
)
})
it('omits the pane identity from registerPty when the leafId is not a terminal leaf (#7587)', async () => {
@@ -4590,7 +4604,13 @@ describe('registerPtyHandlers', () => {
// Why: legacy numeric pane ids (`pane:N`) are not terminal leaf ids, so the
// spawn seam must not fabricate a binding for them (registerPty would ignore
// it anyway); this pins that the seam passes a clean `undefined`.
expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, undefined)
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'wt-1',
null,
undefined,
false
)
})
it('refreshes native Agent Teams env when captured teammate mode lives in launch args', async () => {
@@ -6069,10 +6089,12 @@ describe('registerPtyHandlers', () => {
expect(spawnCall[0]).toBe('wsl.exe')
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
expect(env.ORCA_CLI_COMMAND).toBe('orca-ide')
expect(env.WSLENV?.split(':')).toEqual(
expect.arrayContaining([
'ORCA_TERMINAL_HANDLE/u',
'ORCA_USER_DATA_PATH/p',
'ORCA_CLI_COMMAND/u',
'ORCA_AGENT_HOOK_PORT/u',
'ORCA_AGENT_HOOK_TOKEN/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',
+14 -2
View File
@@ -980,8 +980,14 @@ export function buildPtyHostEnv(
// Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance.
if (opts.isWsl) {
baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath
} else if (!opts.isPackaged) {
baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath
// Why: managed WSL registration deliberately uses `orca-ide`; exposing
// that literal keeps agent guidance scoped to WSL without a bare-orca shim.
baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev'
} else {
if (!opts.isPackaged) {
baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath
}
delete baseEnv.ORCA_CLI_COMMAND
}
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
@@ -3234,6 +3240,9 @@ export function registerPtyHandlers(
args.tabId.length <= 512 &&
metadataLeafId !== null
? { tabId: args.tabId, leafId: metadataLeafId }
: undefined,
!args.connectionId
? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd)
: undefined
)
}
@@ -4282,6 +4291,9 @@ export function registerPtyHandlers(
args.tabId.length <= 512 &&
metadataLeafId !== null
? { tabId: args.tabId, leafId: metadataLeafId }
: undefined,
!args.connectionId
? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd)
: undefined
)
}
@@ -13,35 +13,55 @@ export type WorktreeBaseCollectedChanges = {
overflow: boolean
structureRepoIds: string[]
gitStatusRepoIds: string[]
headIdentityRepoIds: string[]
}
type ChangeBuckets = {
structureRepoIds: Set<string>
gitStatusRepoIds: Set<string>
headIdentityRepoIds: Set<string>
}
function emptyBuckets(): ChangeBuckets {
return {
structureRepoIds: new Set<string>(),
gitStatusRepoIds: new Set<string>(),
headIdentityRepoIds: new Set<string>()
}
}
function emptyChanges(): WorktreeBaseCollectedChanges {
return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] }
return {
overflow: false,
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: []
}
}
function addMatchingChange(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent,
structureRepoIds: Set<string>,
gitStatusRepoIds: Set<string>
buckets: ChangeBuckets
): void {
const change = classifyWorktreeBaseChange(target, event)
for (const repoId of change.structureRepoIds) {
structureRepoIds.add(repoId)
buckets.structureRepoIds.add(repoId)
}
for (const repoId of change.gitStatusRepoIds) {
gitStatusRepoIds.add(repoId)
buckets.gitStatusRepoIds.add(repoId)
}
for (const repoId of change.headIdentityRepoIds) {
buckets.headIdentityRepoIds.add(repoId)
}
}
function toCollectedChanges(
structureRepoIds: Set<string>,
gitStatusRepoIds: Set<string>
): WorktreeBaseCollectedChanges {
function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChanges {
return {
overflow: false,
structureRepoIds: [...structureRepoIds],
gitStatusRepoIds: [...gitStatusRepoIds]
structureRepoIds: [...buckets.structureRepoIds],
gitStatusRepoIds: [...buckets.gitStatusRepoIds],
headIdentityRepoIds: [...buckets.headIdentityRepoIds]
}
}
@@ -49,47 +69,30 @@ export function collectLocalWorktreeBaseChanges(
target: WorktreeBaseWatchTarget,
events: WorktreeBaseWatcherEvent[]
): WorktreeBaseCollectedChanges {
const structureRepoIds = new Set<string>()
const gitStatusRepoIds = new Set<string>()
const buckets = emptyBuckets()
for (const event of events) {
addMatchingChange(target, event, structureRepoIds, gitStatusRepoIds)
addMatchingChange(target, event, buckets)
}
return toCollectedChanges(structureRepoIds, gitStatusRepoIds)
return toCollectedChanges(buckets)
}
export function collectRemoteWorktreeBaseChanges(
target: WorktreeBaseWatchTarget,
events: FsChangeEvent[]
): WorktreeBaseCollectedChanges {
const structureRepoIds = new Set<string>()
const gitStatusRepoIds = new Set<string>()
const buckets = emptyBuckets()
for (const event of events) {
if (event.kind === 'overflow') {
return { ...emptyChanges(), overflow: true }
}
if (event.kind === 'rename') {
if (event.oldAbsolutePath) {
addMatchingChange(
target,
{ type: 'delete', path: event.oldAbsolutePath },
structureRepoIds,
gitStatusRepoIds
)
addMatchingChange(target, { type: 'delete', path: event.oldAbsolutePath }, buckets)
}
addMatchingChange(
target,
{ type: 'create', path: event.absolutePath },
structureRepoIds,
gitStatusRepoIds
)
addMatchingChange(target, { type: 'create', path: event.absolutePath }, buckets)
continue
}
addMatchingChange(
target,
{ type: event.kind, path: event.absolutePath },
structureRepoIds,
gitStatusRepoIds
)
addMatchingChange(target, { type: event.kind, path: event.absolutePath }, buckets)
}
return toCollectedChanges(structureRepoIds, gitStatusRepoIds)
return toCollectedChanges(buckets)
}
@@ -25,13 +25,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: ['repo-1'],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
expect(
classifyWorktreeBaseChange(target, {
type: 'create',
path: join(COMMON_DIR, 'worktrees', 'wt-b')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: ['repo-1'],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'delete',
@@ -44,12 +52,26 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => {
const target = makeGitCommonTarget()
for (const file of ['HEAD', 'packed-refs']) {
expect(
classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, file) })
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, file)
})
).toEqual({
structureRepoIds: ['repo-1'],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
}
expect(
classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'index') })
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'index')
})
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: ['repo-1'],
headIdentityRepoIds: []
})
})
it('classifies linked-worktree index as status-only', () => {
@@ -59,16 +81,24 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: ['repo-1'],
headIdentityRepoIds: []
})
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index.lock')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
})
it('classifies HEAD reflog appends as status-only for linked and primary checkouts', () => {
it('classifies HEAD reflog appends as head-identity triggers for linked and primary checkouts', () => {
const target = makeGitCommonTarget()
// commit --amend / reset --soft move HEAD without touching index or HEAD.
expect(
@@ -76,26 +106,42 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: ['repo-1']
})
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'logs', 'HEAD')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: ['repo-1']
})
// Per-ref reflogs churn on fetches and stay ignored.
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'logs', 'refs', 'heads', 'main')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'refs', 'heads', 'main')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
})
it('classifies worktree-scoped config as structural for sparse-flag freshness', () => {
@@ -105,13 +151,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'config.worktree')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: ['repo-1'],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
expect(
classifyWorktreeBaseChange(target, {
type: 'create',
path: join(COMMON_DIR, 'config.worktree')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: ['repo-1'],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
})
it('classifies Windows-shaped linked metadata paths', () => {
@@ -126,13 +180,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => {
type: 'update',
path: win32.join(commonDir, 'worktrees', 'wt a', 'gitdir')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
).toEqual({
structureRepoIds: ['repo-1'],
gitStatusRepoIds: [],
headIdentityRepoIds: []
})
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: win32.join(commonDir, 'worktrees', 'wt a', 'index')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
).toEqual({
structureRepoIds: [],
gitStatusRepoIds: ['repo-1'],
headIdentityRepoIds: []
})
})
it('ignores non-status common-dir churn', () => {
@@ -11,6 +11,10 @@ type WorktreeBaseWatcherEvent = {
export type WorktreeBaseChangeClass = {
structureRepoIds: string[]
gitStatusRepoIds: string[]
// logs/HEAD and other true head-move triggers: notify Source Control like
// status churn, but distinct so only these re-read head identities. An index
// rewrite cannot move HEAD, so it must never land here.
headIdentityRepoIds: string[]
}
export type WorktreeBaseWatchKind = 'base' | 'git-common'
@@ -98,9 +102,10 @@ const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index'])
const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree'])
const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index'])
// `logs/HEAD` is the status-only trigger for head moves that rewrite no
// `logs/HEAD` is the head-identity trigger for head moves that rewrite no
// watched leaf (commit --amend, reset --soft): every ref update through a
// checkout appends there, while `git status` churn never touches it.
// checkout appends there, while `git status` churn never touches it. It is
// kept separate from index churn so only these events re-read head identities.
function isHeadLogParts(parts: string[], offset: number): boolean {
return parts.length === offset + 2 && parts[offset] === 'logs' && parts[offset + 1] === 'HEAD'
}
@@ -109,6 +114,36 @@ function allRepoIds(target: WorktreeBaseWatchTarget): string[] {
return [...target.repos.keys()]
}
const NO_CHANGE: WorktreeBaseChangeClass = {
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: []
}
function structuralChange(repoIds: string[]): WorktreeBaseChangeClass {
return {
structureRepoIds: repoIds,
gitStatusRepoIds: [],
headIdentityRepoIds: []
}
}
function gitStatusChange(repoIds: string[]): WorktreeBaseChangeClass {
return {
structureRepoIds: [],
gitStatusRepoIds: repoIds,
headIdentityRepoIds: []
}
}
function headIdentityChange(repoIds: string[]): WorktreeBaseChangeClass {
return {
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: repoIds
}
}
// Why: Git records linked worktrees under the common dir's `worktrees`
// metadata, which is lower churn than watching checkout contents.
function classifyGitCommonEvent(
@@ -117,54 +152,49 @@ function classifyGitCommonEvent(
): WorktreeBaseChangeClass {
const parts = pathRelativeToWorktreeWatchRoot(target.path, event.path)
if (!parts) {
return { structureRepoIds: [], gitStatusRepoIds: [] }
return NO_CHANGE
}
const repoIds = allRepoIds(target)
if (parts.length === 1) {
if (parts[0] === 'worktrees') {
return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
return structuralChange(repoIds)
}
if (GIT_COMMON_PRIMARY_STRUCTURAL_FILES.has(parts[0])) {
return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
return structuralChange(repoIds)
}
if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
return gitStatusChange(repoIds)
}
return { structureRepoIds: [], gitStatusRepoIds: [] }
return NO_CHANGE
}
if (parts[0] !== 'worktrees') {
if (isHeadLogParts(parts, 0)) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
return headIdentityChange(repoIds)
}
return { structureRepoIds: [], gitStatusRepoIds: [] }
return NO_CHANGE
}
if (parts.length === 2) {
return event.type === 'update'
? { structureRepoIds: [], gitStatusRepoIds: [] }
: { structureRepoIds: repoIds, gitStatusRepoIds: [] }
return event.type === 'update' ? NO_CHANGE : structuralChange(repoIds)
}
if (parts.length === 3) {
if (GIT_COMMON_LINKED_STRUCTURAL_FILES.has(parts[2])) {
return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
return structuralChange(repoIds)
}
if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
return gitStatusChange(repoIds)
}
}
if (isHeadLogParts(parts, 2)) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
return headIdentityChange(repoIds)
}
return { structureRepoIds: [], gitStatusRepoIds: [] }
return NO_CHANGE
}
function classifyBaseEvent(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent
): WorktreeBaseChangeClass {
return {
structureRepoIds: matchingBaseRepoIds(target, event.path, event.type),
gitStatusRepoIds: []
}
return structuralChange(matchingBaseRepoIds(target, event.path, event.type))
}
export function classifyWorktreeBaseChange(
@@ -116,7 +116,10 @@ describe('worktree base directory watcher', () => {
emit(WORKTREE_ROOT, [
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') },
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') }
{
type: 'create',
path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git')
}
])
await vi.advanceTimersByTimeAsync(300)
@@ -133,7 +136,10 @@ describe('worktree base directory watcher', () => {
)
emit(WORKTREE_ROOT, [
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') }
{
type: 'create',
path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git')
}
])
destroyed = true
await vi.advanceTimersByTimeAsync(300)
@@ -145,7 +151,10 @@ describe('worktree base directory watcher', () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(WORKTREE_ROOT, [
{ type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') }
{
type: 'update',
path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts')
}
])
await vi.advanceTimersByTimeAsync(300)
@@ -156,7 +165,10 @@ describe('worktree base directory watcher', () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') }
{
type: 'create',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir')
}
])
await vi.advanceTimersByTimeAsync(300)
@@ -167,9 +179,18 @@ describe('worktree base directory watcher', () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') },
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') },
{ type: 'delete', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
{
type: 'create',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index')
},
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index')
},
{
type: 'delete',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index')
}
])
await vi.advanceTimersByTimeAsync(300)
@@ -182,8 +203,14 @@ describe('worktree base directory watcher', () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') },
{ type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') }
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD')
},
{
type: 'create',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked')
}
])
await vi.advanceTimersByTimeAsync(300)
@@ -192,17 +219,25 @@ describe('worktree base directory watcher', () => {
expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled()
})
it('emits head identities for status-only head moves without structural fanout', async () => {
it('emits head identities for a linked reflog head move without structural fanout', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
{
worktreePath: linkedWorktree,
head: 'aaa111',
branch: 'refs/heads/feature'
}
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
// External commit --amend: only logs/HEAD moves, no index write.
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' }
{
worktreePath: linkedWorktree,
head: 'bbb222',
branch: 'refs/heads/feature'
}
])
emit(PROJECT_GIT_COMMON_DIR, [
{
@@ -217,50 +252,181 @@ describe('worktree base directory watcher', () => {
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [
{ worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' }
{
worktreePath: linkedWorktree,
head: 'bbb222',
branch: 'refs/heads/feature'
}
])
})
it('does not emit head identities when a status-only burst leaves heads unchanged', async () => {
it('makes zero head-identity reads on an index-only burst across linked and primary checkouts', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
{
worktreePath: linkedWorktree,
head: 'aaa111',
branch: 'refs/heads/feature'
}
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
// Subscribe baselines heads once; the index burst must add no further reads.
vi.mocked(readGitCommonHeadIdentities).mockClear()
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
{
type: 'create',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index')
},
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index')
},
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'index') }
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
// An index rewrite cannot move HEAD, so the linked-worktree scan is skipped.
expect(readGitCommonHeadIdentities).not.toHaveBeenCalled()
expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
// Source Control still refreshes for the status churn, coalesced to one call.
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
})
it('emits head identities for a primary-checkout reflog head move', async () => {
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: PROJECT_ROOT, head: 'aaa111', branch: 'refs/heads/main' }
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: PROJECT_ROOT, head: 'bbb222', branch: 'refs/heads/main' }
])
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'logs', 'HEAD') }
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [
{
worktreePath: PROJECT_ROOT,
head: 'bbb222',
branch: 'refs/heads/main'
}
])
})
it('coalesces an index and reflog burst into one head read and one status refresh', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{
worktreePath: linkedWorktree,
head: 'aaa111',
branch: 'refs/heads/feature'
}
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
vi.mocked(readGitCommonHeadIdentities).mockClear()
// reset --soft rewrites the index and appends logs/HEAD in the same burst.
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{
worktreePath: linkedWorktree,
head: 'bbb222',
branch: 'refs/heads/feature'
}
])
emit(PROJECT_GIT_COMMON_DIR, [
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index')
},
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD')
}
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
})
it('debounces successive reflog events into a single head read', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{
worktreePath: linkedWorktree,
head: 'aaa111',
branch: 'refs/heads/feature'
}
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
vi.mocked(readGitCommonHeadIdentities).mockClear()
const reflog = {
type: 'update' as const,
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD')
}
emit(PROJECT_GIT_COMMON_DIR, [reflog])
// Second burst arrives before the debounce window elapses and resets it.
await vi.advanceTimersByTimeAsync(100)
emit(PROJECT_GIT_COMMON_DIR, [reflog])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
})
it('re-baselines head identities silently on structural notifications', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
{
worktreePath: linkedWorktree,
head: 'aaa111',
branch: 'refs/heads/feature'
}
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
// Branch switch: structural path owns the refresh via the full listing.
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'ccc333', branch: 'refs/heads/other' }
{
worktreePath: linkedWorktree,
head: 'ccc333',
branch: 'refs/heads/other'
}
])
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') }
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD')
}
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
// A later status-only event diffs against the re-baselined heads.
// A later reflog event diffs against the re-baselined heads, so an
// unchanged head is not re-reported.
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD')
}
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
+57 -45
View File
@@ -25,6 +25,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
notifyTimer: ReturnType<typeof setTimeout> | null
pendingStructureRepoIds: Set<string>
pendingGitStatusRepoIds: Set<string>
pendingHeadIdentityRepoIds: Set<string>
headIdentityRefresh: WorktreeHeadIdentityRefreshState
disposed: boolean
}
@@ -35,13 +36,17 @@ let syncGeneration = 0
let scheduledSync: ReturnType<typeof setTimeout> | null = null
let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null
function scheduleNotification(
watch: ActiveWatch,
changes: { structureRepoIds?: readonly string[]; gitStatusRepoIds?: readonly string[] }
): void {
function clearPendingRepoIds(watch: ActiveWatch): void {
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
watch.pendingHeadIdentityRepoIds.clear()
}
type PendingNotificationInput = Partial<Omit<WorktreeBaseCollectedChanges, 'overflow'>>
function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationInput): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
clearPendingRepoIds(watch)
return
}
for (const repoId of changes.structureRepoIds ?? []) {
@@ -50,37 +55,41 @@ function scheduleNotification(
for (const repoId of changes.gitStatusRepoIds ?? []) {
watch.pendingGitStatusRepoIds.add(repoId)
}
for (const repoId of changes.headIdentityRepoIds ?? []) {
watch.pendingHeadIdentityRepoIds.add(repoId)
}
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
watch.notifyTimer = setTimeout(() => {
watch.notifyTimer = null
if (watch.disposed || watch.mainWindow.isDestroyed()) {
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
clearPendingRepoIds(watch)
return
}
const pendingStructure = [...watch.pendingStructureRepoIds]
const pendingGitStatus = [...watch.pendingGitStatusRepoIds].filter(
(repoId) => !watch.pendingStructureRepoIds.has(repoId)
const hasHeadIdentity = watch.pendingHeadIdentityRepoIds.size > 0
// Source Control refreshes on both index churn and head moves; structural
// repos already refresh via the authoritative listing, so drop them here.
const sourceControlRepoIds = new Set(
[...watch.pendingGitStatusRepoIds, ...watch.pendingHeadIdentityRepoIds].filter(
(repoId) => !watch.pendingStructureRepoIds.has(repoId)
)
)
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
// Structural ticks refresh silently (emit=false): the authoritative listing
// already reported them, so this only re-baselines ahead of later head diffs.
const emitHeadIdentities = pendingStructure.length === 0
clearPendingRepoIds(watch)
for (const repoId of pendingStructure) {
notifyWorktreesChanged(watch.mainWindow, repoId)
}
for (const repoId of pendingGitStatus) {
for (const repoId of sourceControlRepoIds) {
notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId)
}
if (supportsHeadIdentityRefresh(watch)) {
// Emit only when no structural notification fired this tick: structural
// paths already run the authoritative worktree listing; the silent pass
// just re-baselines so later status-only diffs stay accurate.
void refreshWorktreeHeadIdentities(
watch,
watch.headIdentityRefresh,
pendingStructure.length === 0
)
// Only re-read head identities for true head triggers: an index rewrite
// cannot move HEAD, so status-only bursts skip the linked-worktree scan.
if (supportsHeadIdentityRefresh(watch) && (pendingStructure.length > 0 || hasHeadIdentity)) {
void refreshWorktreeHeadIdentities(watch, watch.headIdentityRefresh, emitHeadIdentities)
}
}, WATCH_DEBOUNCE_MS)
}
@@ -92,7 +101,9 @@ function supportsHeadIdentityRefresh(watch: ActiveWatch): boolean {
}
function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean {
return changes.structureRepoIds.length > 0 || changes.gitStatusRepoIds.length > 0
return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some(
(ids) => ids.length > 0
)
}
function handleLocalWatchEvents(
@@ -131,6 +142,24 @@ function handleRemoteWatchEvents(
}
}
function createActiveWatch(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow,
subscription: ActiveWatch['subscription']
): ActiveWatch {
return {
...target,
mainWindow,
subscription,
notifyTimer: null,
pendingStructureRepoIds: new Set(),
pendingGitStatusRepoIds: new Set(),
pendingHeadIdentityRepoIds: new Set(),
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
}
async function subscribeTarget(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow
@@ -148,16 +177,9 @@ async function subscribeTarget(
}
handleRemoteWatchEvents(currentWatch, events)
})
activeWatch = {
...target,
mainWindow,
subscription: { unsubscribe: async () => unwatch() },
notifyTimer: null,
pendingStructureRepoIds: new Set(),
pendingGitStatusRepoIds: new Set(),
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
activeWatch = createActiveWatch(target, mainWindow, {
unsubscribe: async () => unwatch()
})
return activeWatch
}
@@ -176,16 +198,7 @@ async function subscribeTarget(
handleLocalWatchEvents(currentWatch, null, events)
}
)
activeWatch = {
...target,
mainWindow,
subscription,
notifyTimer: null,
pendingStructureRepoIds: new Set(),
pendingGitStatusRepoIds: new Set(),
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
activeWatch = createActiveWatch(target, mainWindow, subscription)
if (supportsHeadIdentityRefresh(activeWatch)) {
// Baseline eagerly so the first status-only signal — possibly hours after
// subscribe — diffs against subscribe-time heads instead of silently
@@ -231,8 +244,7 @@ async function removeWatch(key: string): Promise<void> {
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
clearPendingRepoIds(watch)
await watch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
})
@@ -26,6 +26,7 @@ export function planWorktreeFolderRename(args: {
repoId: string
repoPath: string
oldWorktreePath: string
worktreeIdSuffix?: string
newLeaf: string
settings: WorktreePathSettings
platform: NodeJS.Platform
@@ -49,6 +50,8 @@ export function planWorktreeFolderRename(args: {
return {
oldPath: args.oldWorktreePath,
newPath,
newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}`
// Why: folder workspaces can have multiple live instances backed by the
// same folder path, so preserve any synthetic instance suffix when re-keying.
newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}${args.worktreeIdSuffix ?? ''}`
}
}
@@ -110,6 +110,19 @@ describe('readNativeChatTranscriptCached', () => {
expect('error' in result && result.error).toBeTruthy()
})
// Why: a just-created session's transcript can take up to minutes to exist on
// disk (#8401) — the miss must be marked notFound so watch/renderer callers
// retry instead of settling into a permanent error, and it must never be
// cached (a real error already isn't cached; this locks in the same for a miss).
it('marks a resolve miss as notFound and does not cache it', async () => {
await seedSession('present-2', 1)
const first = await readNativeChatTranscriptCached('claude', 'absent-2')
expect('error' in first && first.notFound).toBe(true)
expect(readSpy).not.toHaveBeenCalled()
const second = await readNativeChatTranscriptCached('claude', 'absent-2')
expect(second).not.toBe(first)
})
// Why: two worktrees can present the SAME (agent, sessionId) via different
// transcript files — e.g. the same session resumed into a second worktree,
// which writes a new transcript file. Keying the cache by sessionId let one
@@ -91,7 +91,9 @@ export async function readNativeChatTranscriptCached(
): Promise<ReadTranscriptResult> {
const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
if (!filePath) {
return { error: `No transcript found for ${agent} session ${sessionId}` }
// Not cached (see below): a not-yet-flushed transcript should be re-checked
// on the next call, not pinned as a settled miss (#8401).
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
}
const key = cacheKey(agent, filePath)
+24 -2
View File
@@ -242,19 +242,41 @@ describe('readNativeChatTranscript (codex)', () => {
})
describe('readNativeChatTranscript (errors)', () => {
it('returns an error for an unreadable/missing file without throwing', async () => {
// Why: ENOENT after a successful resolve is the same first-flush/rotation
// race as an unresolved path (#8401) — it must stay retry-worthy.
it('marks an ENOENT on a directly-passed path as notFound (vanished after resolve)', async () => {
const result = await readNativeChatTranscript('claude', 'sess', {
filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl')
})
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.notFound).toBe(true)
}
})
it('returns an error when no transcript can be resolved', async () => {
it('returns a real read error (no notFound) when the path exists but is unreadable', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unreadable-'))
tempRoots.push(root)
// A directory instead of a file fails the read with a non-ENOENT error.
const result = await readNativeChatTranscript('claude', 'sess', { filePath: root })
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.notFound).toBeUndefined()
}
})
// Why: a just-created Claude Code session's transcript can take up to minutes
// to exist on disk (#8401) — the miss must be marked retry-worthy so callers
// above (cache, watch, renderer) don't settle into a permanent error.
it('marks an unresolved session as notFound so callers know to retry', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-'))
tempRoots.push(root)
const result = await readNativeChatTranscript('claude', 'missing', {
claudeProjectsDir: join(root, 'empty')
})
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.notFound).toBe(true)
}
})
})
+11 -2
View File
@@ -9,7 +9,11 @@ import {
} from './transcript-line-decoders'
import { decodeTranscriptStream } from './transcript-stream-lines'
export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string }
export type ReadTranscriptResult =
| { messages: NativeChatMessage[] }
// notFound marks a retry-worthy miss (transcript not flushed to disk yet,
// #8401) as opposed to a real parse/IO error callers surface immediately.
| { error: string; notFound?: true }
export type ReadTranscriptOptions = ResolveSessionFileOptions & {
/** Resolve directly to this file, skipping path discovery (used by tests). */
@@ -30,7 +34,7 @@ export async function readNativeChatTranscript(
): Promise<ReadTranscriptResult> {
const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
if (!filePath) {
return { error: `No transcript found for ${agent} session ${sessionId}` }
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
}
try {
if (agent === 'claude') {
@@ -44,6 +48,11 @@ export async function readNativeChatTranscript(
}
return { error: `Unsupported agent for native chat transcript: ${agent}` }
} catch (err) {
// Why: ENOENT after a successful resolve is the same first-flush/rotation
// race as an unresolved path — keep it retry-worthy (#8401).
if ((err as NodeJS.ErrnoException | null)?.code === 'ENOENT') {
return { error: errorMessage(err), notFound: true }
}
return { error: errorMessage(err) }
}
}
@@ -24,6 +24,14 @@ async function tempFile(initial: string): Promise<string> {
return filePath
}
// A path inside a fresh temp dir with nothing written yet — simulates a
// just-created session whose agent hasn't flushed its first JSONL line (#8401).
async function pendingFilePath(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-pending-'))
tempRoots.push(root)
return join(root, 'rollout.jsonl')
}
function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string {
return `${JSON.stringify({
type: role,
@@ -237,3 +245,77 @@ describe('subscribeNativeChatTranscript', () => {
sub.unsubscribe()
})
})
// Regression for #8401: Claude Code (and other agents) can take from ~3s to
// minutes to flush a brand-new session's first JSONL line, so the file
// genuinely doesn't exist when native chat subscribes. Before this fix,
// subscribeNativeChatTranscript returned a permanent no-op the instant the
// file was missing and never recovered once it appeared.
describe('subscribeNativeChatTranscript (resolve-poll for a not-yet-created file, #8401)', () => {
it('keeps retrying resolve+install and tails the file once it is created', async () => {
const filePath = await pendingFilePath()
const seen: NativeChatMessage[] = []
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: (messages) => seen.push(...messages),
debounceMs: 5,
resolvePollIntervalMs: 20
})
// Nothing installed yet — the file doesn't exist on disk.
expect(getActiveNativeChatWatcherCount()).toBe(0)
// The agent flushes its first turn well after subscribe.
await new Promise((resolve) => setTimeout(resolve, 50))
await writeFile(filePath, claudeLine('u-1', 'user', 'hello'))
await waitFor(() => seen.some((m) => m.id === 'u-1'))
expect(getActiveNativeChatWatcherCount()).toBe(1)
sub.unsubscribe()
expect(getActiveNativeChatWatcherCount()).toBe(0)
})
it('returns a no-op (no resolve poll) for a blank session id with no explicit file', async () => {
const before = getActiveNativeChatWatcherCount()
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: ' ',
onAppend: () => {},
debounceMs: 5,
resolvePollIntervalMs: 10
})
// An unresolvable target must not spin the resolve poll forever.
await new Promise((resolve) => setTimeout(resolve, 60))
expect(getActiveNativeChatWatcherCount()).toBe(before)
sub.unsubscribe()
sub.unsubscribe()
expect(getActiveNativeChatWatcherCount()).toBe(before)
})
it('unsubscribing during the poll phase leaves no watcher or timer alive', async () => {
const filePath = await pendingFilePath()
const before = getActiveNativeChatWatcherCount()
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: () => {},
debounceMs: 5,
resolvePollIntervalMs: 20
})
// Unsubscribe while still polling — the file is never created in this test.
sub.unsubscribe()
expect(getActiveNativeChatWatcherCount()).toBe(before)
// Give any stray timer a chance to fire; it must not install a watcher.
await new Promise((resolve) => setTimeout(resolve, 100))
expect(getActiveNativeChatWatcherCount()).toBe(before)
})
})
+150 -23
View File
@@ -29,6 +29,10 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
filePath?: string
/** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */
debounceMs?: number
/** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests
* don't wait out the production backoff. Production ignores this and backs
* off from 500ms to a 5s cap. */
resolvePollIntervalMs?: number
}
export type NativeChatTranscriptSubscription = {
@@ -115,26 +119,17 @@ async function readAppendedMessages(
}
/**
* Subscribe to live appends on an agent's transcript file. Returns an
* unsubscribe fn that tears the watcher down completely.
*
* Handles file rotation/replacement: when the file shrinks (a new session id
* resolved to a smaller/newer file, or the file was truncated), the offset is
* reset to 0 so the replacement's content is read from the top.
* Install a live-tail watcher on an already-resolved file path. Returns null
* (rather than throwing) when `watch()` fails — e.g. the file vanished between
* resolve and install — so the caller can fall back to the resolve-poll below
* instead of surfacing a hard error.
*/
export async function subscribeNativeChatTranscript(
args: SubscribeNativeChatTranscriptArgs
): Promise<NativeChatTranscriptSubscription> {
const { agent, sessionId, onAppend, debounceMs } = args
const decode = lineDecoderForAgent(agent)
const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args))
if (!filePath || !decode) {
// Nothing watchable — return a no-op teardown so callers can unconditionally
// unsubscribe without null-checks.
return { unsubscribe: () => {} }
}
function installTranscriptWatcher(
filePath: string,
decode: (line: string, fallbackId: string) => NativeChatMessage | null,
onAppend: (messages: NativeChatMessage[]) => void,
debounceMs?: number
): NativeChatTranscriptSubscription | null {
// Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This
// closes the read/subscribe race — a turn appended between the caller's
// readSession EOF and the watcher install is still emitted. Re-emitted lines
@@ -161,12 +156,12 @@ export async function subscribeNativeChatTranscript(
do {
pendingReadRequested = false
try {
const currentSize = await fileSize(filePath!)
const currentSize = await fileSize(filePath)
if (currentSize < offset) {
// Rotation/replacement/truncation: re-read from the top.
offset = 0
}
const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!)
const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode)
offset = consumedTo
if (!closed && messages.length > 0) {
onAppend(messages)
@@ -200,8 +195,8 @@ export async function subscribeNativeChatTranscript(
try {
watcher = watch(filePath, scheduleDrain)
} catch {
// File vanished between resolve and watch — return a no-op teardown.
return { unsubscribe: () => {} }
// File vanished between resolve and watch.
return null
}
activeWatcherCount++
@@ -225,3 +220,135 @@ export async function subscribeNativeChatTranscript(
}
}
}
/** One resolve+install attempt. Returns null when the file isn't resolvable
* yet, or vanished between resolve and `watch()` — either case is retried by
* the resolve-poll rather than treated as a hard failure. */
async function attemptInstall(
args: SubscribeNativeChatTranscriptArgs,
decode: (line: string, fallbackId: string) => NativeChatMessage | null
): Promise<NativeChatTranscriptSubscription | null> {
const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args))
if (!filePath) {
return null
}
return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs)
}
// Why: Claude Code (and other agents) can take from ~3s to minutes to flush a
// brand-new session's first JSONL line (#8401) — resolveSessionFilePath
// genuinely has nothing to find yet. Poll for it instead of going deaf; each
// attempt is a cheap glob, so the cost of polling is negligible next to the
// gap it covers.
const INITIAL_RESOLVE_POLL_MS = 500
const MAX_RESOLVE_POLL_MS = 5_000
/**
* Background retry loop for a transcript that hasn't been resolvable yet.
* Returns a subscription immediately (per subscribeNativeChatTranscript's
* contract); the loop keeps retrying resolve+install until it succeeds or
* unsubscribe() cancels it.
*/
function subscribeViaResolvePoll(
args: SubscribeNativeChatTranscriptArgs,
decode: (line: string, fallbackId: string) => NativeChatMessage | null
): NativeChatTranscriptSubscription {
let closed = false
let installed: NativeChatTranscriptSubscription | null = null
let pollTimer: ReturnType<typeof setTimeout> | null = null
let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS
function scheduleAttempt(): void {
if (closed) {
return
}
pollTimer = setTimeout(() => {
pollTimer = null
void runAttempt()
}, delay)
// Why: never hold the event loop open (headless `orca serve` shutdown) for
// a session that may genuinely never resolve.
pollTimer.unref?.()
// Only back off in production; a test-supplied interval stays fixed so
// tests resolve in bounded, predictable time.
if (args.resolvePollIntervalMs === undefined) {
delay = Math.min(delay * 2, MAX_RESOLVE_POLL_MS)
}
}
async function runAttempt(): Promise<void> {
if (closed) {
return
}
let result: NativeChatTranscriptSubscription | null
try {
result = await attemptInstall(args, decode)
} catch {
// Why: a transient resolve failure (EACCES/EIO during the glob) must not
// kill the poll loop with an unhandled rejection — retry like a miss.
result = null
}
if (closed) {
// unsubscribe() ran while this attempt was in flight.
result?.unsubscribe()
return
}
if (result) {
installed = result
return
}
scheduleAttempt()
}
scheduleAttempt()
return {
unsubscribe: () => {
if (closed) {
return
}
closed = true
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
installed?.unsubscribe()
installed = null
}
}
}
/**
* Subscribe to live appends on an agent's transcript file. Returns an
* unsubscribe fn that tears the watcher down completely.
*
* Handles file rotation/replacement: when the file shrinks (a new session id
* resolved to a smaller/newer file, or the file was truncated), the offset is
* reset to 0 so the replacement's content is read from the top.
*
* When the transcript isn't resolvable yet (a just-created session whose
* agent hasn't flushed its first JSONL line, #8401), returns the subscription
* immediately and keeps retrying resolve+install in the background rather
* than returning a no-op that never recovers.
*/
export async function subscribeNativeChatTranscript(
args: SubscribeNativeChatTranscriptArgs
): Promise<NativeChatTranscriptSubscription> {
const decode = lineDecoderForAgent(args.agent)
if (!decode) {
// Nothing watchable — return a no-op teardown so callers can unconditionally
// unsubscribe without null-checks.
return { unsubscribe: () => {} }
}
// Why: a blank session id (and no explicit file) can never resolve — bail out
// instead of resolve-polling an unresolvable target forever.
if (!args.filePath && !args.sessionId.trim()) {
return { unsubscribe: () => {} }
}
const installed = await attemptInstall(args, decode)
if (installed) {
return installed
}
return subscribeViaResolvePoll(args, decode)
}
+6 -2
View File
@@ -15,7 +15,7 @@ import { resolveProcessCwd } from './process-cwd'
import { existsSync } from 'node:fs'
import * as pty from 'node-pty'
import { parseWslPath, isWslAvailable } from '../wsl'
import { splitWorktreeId } from '../../shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import {
injectHistoryEnv,
updateHistFileForFallback,
@@ -154,7 +154,11 @@ function runPtyCleanup(id: string): void {
function getWslContextFromWorktreeId(
worktreeId: string | undefined
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
// Why: strip any synthetic `::workspace:<uuid>` folder-workspace suffix so WSL
// detection parses the real path, not a nonexistent identifier.
const worktreePath = worktreeId
? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
: undefined
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined
}
+2
View File
@@ -24,6 +24,7 @@ describe('addOrcaWslInteropEnv', () => {
const env: Record<string, string> = {
ORCA_TERMINAL_HANDLE: 'term_wsl',
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
ORCA_CLI_COMMAND: 'orca-ide',
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
ORCA_PANE_KEY: 'tab-1:leaf-1',
ORCA_TAB_ID: 'tab-1',
@@ -38,6 +39,7 @@ describe('addOrcaWslInteropEnv', () => {
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
+1
View File
@@ -24,6 +24,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
const passthroughEntries = [
'ORCA_TERMINAL_HANDLE/u',
'ORCA_USER_DATA_PATH/p',
'ORCA_CLI_COMMAND/u',
'ORCA_PANE_KEY/u',
'ORCA_TAB_ID/u',
'ORCA_WORKTREE_ID/u',
+58 -8
View File
@@ -17066,6 +17066,16 @@ describe('OrcaRuntimeService', () => {
it('creates mobile session terminals in a headless runtime server', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' })
const runtime = new OrcaRuntimeService(store)
const persistViewMode = vi.spyOn(
runtime as unknown as {
persistHeadlessSessionTabProps: (
worktreeId: string,
tabId: string,
props: { viewMode: 'terminal' | 'chat' }
) => void
},
'persistHeadlessSessionTabProps'
)
runtime.setPtyController({
spawn,
write: () => true,
@@ -17074,7 +17084,9 @@ describe('OrcaRuntimeService', () => {
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`)
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
viewMode: 'chat'
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -17090,8 +17102,12 @@ describe('OrcaRuntimeService', () => {
type: 'terminal',
status: 'ready',
terminal: expect.stringMatching(/^term_/),
viewMode: 'chat',
isActive: true
})
expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, {
viewMode: 'chat'
})
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs).toEqual([
@@ -20138,7 +20154,8 @@ describe('OrcaRuntimeService', () => {
})
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: false
activate: false,
viewMode: 'chat'
})
expect(send).toHaveBeenCalledWith(
@@ -20146,7 +20163,8 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({
worktreeId: TEST_WORKTREE_ID,
activate: false,
source: 'runtime-session'
source: 'runtime-session',
viewMode: 'chat'
})
)
expect(focusTerminal).not.toHaveBeenCalled()
@@ -20504,7 +20522,8 @@ describe('OrcaRuntimeService', () => {
})
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: true
activate: true,
viewMode: 'terminal'
})
let settled = false
const settledCreate = create.finally(() => {
@@ -20550,6 +20569,7 @@ describe('OrcaRuntimeService', () => {
leafId: pendingLeafId,
status: 'ready',
terminal: expect.stringMatching(/^term_/),
viewMode: 'terminal',
isActive: true
})
expect(spawn).toHaveBeenCalledWith(
@@ -20567,7 +20587,8 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({
ptyId: 'pty-materialized',
tabId: 'tab-pending',
leafId: pendingLeafId
leafId: pendingLeafId,
viewMode: 'terminal'
})
)
expect(closeTerminal).not.toHaveBeenCalled()
@@ -20761,7 +20782,8 @@ describe('OrcaRuntimeService', () => {
})
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: true
activate: true,
viewMode: 'chat'
})
let settled = false
const settledCreate = create.finally(() => {
@@ -20769,8 +20791,35 @@ describe('OrcaRuntimeService', () => {
})
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1))
// A shell-only renderer snapshot can win the first race but still omit
// launch props. The later PTY rescue must fill the explicit mode.
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer-shell',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `tab-alive::${leafId}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `tab-alive::${leafId}`,
parentTabId: 'tab-alive',
leafId,
title: 'Terminal',
isActive: true
}
]
}
]
})
// The renderer's own PTY spawn registers with the tab binding — the same
// call the pty IPC layer now makes — without any mobileSessionTabs sync.
// call the pty IPC layer now makes — after the shell-only snapshot.
runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, {
tabId: 'tab-alive',
leafId
@@ -20786,7 +20835,8 @@ describe('OrcaRuntimeService', () => {
parentTabId: 'tab-alive',
leafId,
status: 'ready',
terminal: expect.stringMatching(/^term_/)
terminal: expect.stringMatching(/^term_/),
viewMode: 'chat'
})
expect(closeTerminal).not.toHaveBeenCalled()
} finally {
+89 -8
View File
@@ -469,7 +469,9 @@ import {
resolveLocalProjectRuntimeForRepo,
resolveLocalProjectRuntimesForRepos
} from '../project-runtime-git-options'
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command'
import {
getLocalWorktreePathAccess,
removeLocalWorktreePath,
@@ -976,6 +978,9 @@ type RuntimePtyWorktreeRecord = {
ptyId: string
worktreeId: string
connectionId: string | null
// Why: a Windows host can own both native and WSL panes; preamble command
// selection must follow the pane that executes it, not process.platform.
isWsl: boolean | null
// Why: background CLI PTYs can outlive a failed renderer reveal. Preserve the
// spawn-time tab/pane identity so later reveals can adopt under the env key.
tabId: string | null
@@ -1015,6 +1020,7 @@ type TerminalCreateOptions = {
launchConfig?: WorktreeStartupLaunch['launchConfig']
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
telemetry?: WorktreeStartupLaunch['telemetry']
title?: string
@@ -1313,6 +1319,7 @@ type RuntimeNotifier = {
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
activate?: boolean
presentation?: RuntimeTerminalPresentation
tabId?: string
@@ -2134,7 +2141,11 @@ export class OrcaRuntimeService {
// creates so ordinary renderer spawns never publish here.
private pendingMobileTerminalCreatesByKey = new Map<
string,
{ activate: boolean; selectIfNoActiveTab: boolean }
{
activate: boolean
selectIfNoActiveTab: boolean
viewMode?: 'terminal' | 'chat'
}
>()
private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>()
// Why: coalesces title/status-driven session.tabs emits so spinner churn
@@ -3658,6 +3669,7 @@ export class OrcaRuntimeService {
activate: boolean
selectIfNoActiveTab?: boolean
startupCwd?: string
viewMode?: 'terminal' | 'chat'
split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' }
}
): void {
@@ -3689,6 +3701,17 @@ export class OrcaRuntimeService {
baseLayout,
args.split
)
// Why: a main-side PTY rescue or split publication must not erase the
// host's explicit tab mode before the renderer graph catches up.
const viewMode =
args.viewMode ??
existingTab?.viewMode ??
existing?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' &&
candidate.parentTabId === args.tabId &&
candidate.viewMode !== undefined
)?.viewMode
const tab: RuntimeMobileSessionTerminalTab = {
type: 'terminal',
id: `${args.tabId}::${args.leafId}`,
@@ -3698,6 +3721,7 @@ export class OrcaRuntimeService {
title,
...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}),
...(args.startupCwd ? { startupCwd: args.startupCwd } : {}),
...(viewMode ? { viewMode } : {}),
parentLayout,
isActive:
args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null)
@@ -5529,7 +5553,8 @@ export class OrcaRuntimeService {
ptyId: string,
worktreeId: string,
connectionId: string | null = null,
binding?: { tabId: string; leafId: string }
binding?: { tabId: string; leafId: string },
isWsl?: boolean
): void {
// Why: record the renderer pane identity at spawn time so a stalled graph
// sync can't hide that a live PTY already backs a pending mobile create.
@@ -5540,6 +5565,7 @@ export class OrcaRuntimeService {
this.recordPtyWorktree(ptyId, worktreeId, {
connected: true,
connectionId,
...(isWsl !== undefined ? { isWsl } : {}),
...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {})
})
// Why: the renderer's own PTY spawn is the reliable signal that the pending
@@ -7234,6 +7260,27 @@ export class OrcaRuntimeService {
return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null
}
getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' {
let pty: RuntimePtyWorktreeRecord | null = null
try {
const ptyId = this.resolveLeafForHandle(handle)?.ptyId
pty = ptyId ? (this.ptysById.get(ptyId) ?? null) : null
} catch {
return 'orca'
}
if (!pty) {
return 'orca'
}
return resolveTerminalOrchestrationCliCommand({
connectionId: pty.connectionId,
isWsl: pty.isWsl,
worktreeId: pty.worktreeId,
projectRuntime: this.store
? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId)
: undefined
})
}
hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean {
const ptyId = this.resolveLeafForHandle(handle)?.ptyId
const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId) : null
@@ -17690,6 +17737,7 @@ export class OrcaRuntimeService {
// Why: explicit background presentation may carry legacy activate
// metadata from an already-owned renderer pane; don't select it on mobile.
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
})
}
@@ -17708,6 +17756,7 @@ export class OrcaRuntimeService {
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
tabId,
@@ -17783,6 +17832,7 @@ export class OrcaRuntimeService {
...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}),
...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
startupCommandDelivery: launchOpts.startupCommandDelivery,
title: launchOpts.title,
activate: presentation === 'focused',
@@ -17841,6 +17891,7 @@ export class OrcaRuntimeService {
agent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
activate?: boolean
clientMutationId?: string
signal?: AbortSignal
@@ -17885,6 +17936,7 @@ export class OrcaRuntimeService {
agent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
activate?: boolean
clientMutationId?: string
signal?: AbortSignal
@@ -17918,6 +17970,7 @@ export class OrcaRuntimeService {
env: startupCommand.env,
startupCommandDelivery: startupCommand.startupCommandDelivery,
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig
}
@@ -17966,6 +18019,7 @@ export class OrcaRuntimeService {
...(startupCommand.env ? { env: startupCommand.env } : {}),
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: startupCommand.startupCommandDelivery,
source: 'runtime-session',
activate: opts.activate
@@ -17984,7 +18038,8 @@ export class OrcaRuntimeService {
// requested group, so any wrong-group placement is cosmetic and stall-window-only.
this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, {
activate: opts.activate !== false,
selectIfNoActiveTab: true
selectIfNoActiveTab: true,
...(opts.viewMode ? { viewMode: opts.viewMode } : {})
})
try {
// Why: the PTY spawn and the tabCreate reply race on independent IPC
@@ -18029,6 +18084,7 @@ export class OrcaRuntimeService {
startupCommandDelivery: startupCommand.startupCommandDelivery,
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig
}
@@ -18152,6 +18208,7 @@ export class OrcaRuntimeService {
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
identity?: { tabId: string; leafId: string; sessionId?: string }
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
launchConfig?: SleepingAgentLaunchConfig
} = {}
@@ -18169,6 +18226,7 @@ export class OrcaRuntimeService {
env: opts.env,
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: opts.startupCommandDelivery,
...(opts.identity
? {
@@ -18190,6 +18248,11 @@ export class OrcaRuntimeService {
}
const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}`
const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID()
if (opts.viewMode) {
// Why: the runtime-owned binding must survive a serve restart with the
// same initial mode, not fall back to a later client's local default.
this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode })
}
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const existingSurface =
existing?.tabs.find(
@@ -18212,6 +18275,7 @@ export class OrcaRuntimeService {
title: terminal.title ?? livePty.pty.title ?? 'Terminal',
...(cwd ? { startupCwd: cwd } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
parentLayout,
isActive: activate
}
@@ -18358,21 +18422,27 @@ export class OrcaRuntimeService {
return null
}
const existing = this.findMobileTerminalSurface(worktreeId, tabId)
if (existing) {
// Why: the renderer's own publication already landed; stay idempotent.
if (
existing &&
this.isReadyMobileTerminalSurface(existing) &&
(pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode)
) {
// Why: the renderer's ready publication already landed with the intended
// mode; only a pending shell still needs the main-side PTY rescue.
return existing
}
const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId)
const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined
if (!pty || !leafId) {
return null
return existing
}
this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, {
tabId,
leafId,
title: null,
activate: pending.activate,
selectIfNoActiveTab: pending.selectIfNoActiveTab
selectIfNoActiveTab: pending.selectIfNoActiveTab,
...(pending.viewMode ? { viewMode: pending.viewMode } : {})
})
// Why: waitForMobileTerminalSurface's check closures are drained only inside
// syncWindowGraph; a main-side publish must drain them too or the pending
@@ -20057,7 +20127,14 @@ export class OrcaRuntimeService {
state: Partial<
Pick<
RuntimePtyWorktreeRecord,
'connected' | 'lastOutputAt' | 'preview' | 'tabId' | 'paneKey' | 'title' | 'connectionId'
| 'connected'
| 'lastOutputAt'
| 'preview'
| 'tabId'
| 'paneKey'
| 'title'
| 'connectionId'
| 'isWsl'
>
> = {}
): RuntimePtyWorktreeRecord {
@@ -20068,6 +20145,7 @@ export class OrcaRuntimeService {
ptyId,
worktreeId,
connectionId: state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null,
isWsl: state.isWsl ?? null,
tabId: state.tabId ?? null,
paneKey: state.paneKey ?? null,
launchConfig: null,
@@ -20109,6 +20187,9 @@ export class OrcaRuntimeService {
if (state.connectionId !== undefined) {
pty.connectionId = state.connectionId
}
if (state.isWsl !== undefined) {
pty.isWsl = state.isWsl
}
if (state.tabId !== undefined) {
pty.tabId = state.tabId
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalOrchestrationCliCommand } from './cli-command'
describe('resolveTerminalOrchestrationCliCommand', () => {
it('uses orca-ide for a pane recorded as WSL', () => {
expect(
resolveTerminalOrchestrationCliCommand({
connectionId: null,
isWsl: true,
worktreeId: 'repo::C:\\repo'
})
).toBe('orca-ide')
})
it('uses project runtime and WSL paths when restored pane metadata is unavailable', () => {
expect(
resolveTerminalOrchestrationCliCommand({
connectionId: null,
isWsl: null,
worktreeId: 'repo::C:\\repo',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'project',
distro: 'Ubuntu',
reason: 'project-override',
cacheKey: 'project:wsl:Ubuntu'
}
}
})
).toBe('orca-ide')
expect(
resolveTerminalOrchestrationCliCommand({
connectionId: null,
isWsl: null,
worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo'
})
).toBe('orca-ide')
})
it('preserves native and SSH bare-orca commands', () => {
expect(
resolveTerminalOrchestrationCliCommand({
connectionId: null,
isWsl: false,
worktreeId: 'repo::/home/alice/repo'
})
).toBe('orca')
expect(
resolveTerminalOrchestrationCliCommand({
connectionId: 'ssh-1',
isWsl: null,
worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo'
})
).toBe('orca')
})
})
@@ -0,0 +1,25 @@
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import { isWslUncPath } from '../../../shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../../shared/worktree-id'
export type OrchestrationCliCommand = 'orca' | 'orca-ide'
export function resolveTerminalOrchestrationCliCommand(args: {
connectionId: string | null
isWsl: boolean | null | undefined
worktreeId: string
projectRuntime?: ProjectExecutionRuntimeResolution
}): OrchestrationCliCommand {
if (args.connectionId) {
return 'orca'
}
if (args.isWsl !== null && args.isWsl !== undefined) {
return args.isWsl ? 'orca-ide' : 'orca'
}
if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') {
return 'orca-ide'
}
const worktreePath = splitWorktreeIdForFilesystem(args.worktreeId)?.worktreePath
return worktreePath && isWslUncPath(worktreePath) ? 'orca-ide' : 'orca'
}
@@ -21,6 +21,7 @@ function createMockRuntime(): CoordinatorRuntime & {
createdTerminalOptions: { title?: string }[]
probeDriftCalls: string[]
probeDriftResult: DriftResult
cliCommand: 'orca' | 'orca-ide'
setProbeDrift(result: DriftResult): void
throwProbeDrift: Error | null
} {
@@ -36,6 +37,7 @@ function createMockRuntime(): CoordinatorRuntime & {
createdTerminalOptions: [] as { title?: string }[],
probeDriftCalls: [] as string[],
probeDriftResult: null as DriftResult,
cliCommand: 'orca' as 'orca' | 'orca-ide',
throwProbeDrift: null as Error | null,
setProbeDrift(result: DriftResult): void {
mock.probeDriftResult = result
@@ -63,6 +65,9 @@ function createMockRuntime(): CoordinatorRuntime & {
throw mock.throwProbeDrift
}
return mock.probeDriftResult
},
getTerminalOrchestrationCliCommand() {
return mock.cliCommand
}
}
return mock
@@ -121,6 +126,7 @@ describe('Coordinator', () => {
it('dispatches a ready task to an available terminal', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
runtime.cliCommand = 'orca-ide'
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
const task = db.createTask({ spec: 'implement feature' })
@@ -147,6 +153,7 @@ describe('Coordinator', () => {
expect(result.status).toBe('completed')
expect(result.completedTasks).toContain(task.id)
expect(runtime.sentMessages.length).toBeGreaterThan(0)
expect(runtime.sentMessages[0].text).toContain('orca-ide orchestration send')
})
it('records the assignee pane key when the runtime can resolve one', async () => {
@@ -32,6 +32,9 @@ export type CoordinatorRuntime = {
// Why: optional so lightweight runtime fakes keep compiling; when present,
// dispatch records the remint-stable pane identity of the assignee.
getTerminalPaneKey?(handle: string): string | null
// Why: Windows can host native and WSL workers concurrently, so the
// worker pane—not the coordinator process—selects the packaged CLI name.
getTerminalOrchestrationCliCommand?(handle: string): 'orca' | 'orca-ide'
}
// Why (§3.1): single threshold, no warn/refuse split. Coordinator picked 20
@@ -486,6 +489,9 @@ export class Coordinator {
coordinatorHandle: this.opts.coordinatorHandle,
workerHandle: targetHandle,
devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev'),
...(this.runtime.getTerminalOrchestrationCliCommand
? { cliCommand: this.runtime.getTerminalOrchestrationCliCommand(targetHandle) }
: {}),
// Why (§3.2): drift section fires only when behind > 0. The preamble
// builder gates on this itself; passing the object unconditionally lets
// the coordinator stay dumb about the display rule.
@@ -159,7 +159,7 @@ describe('buildDispatchPreamble', () => {
})
it('uses orca-dev CLI when devMode is true', () => {
const result = buildDispatchPreamble(baseParams({ devMode: true }))
const result = buildDispatchPreamble(baseParams({ devMode: true, cliCommand: 'orca-ide' }))
expect(result).toContain('orca-dev orchestration send')
expect(result).toContain('orca-dev orchestration check')
expect(result).toContain('orca-dev orchestration ask')
@@ -175,6 +175,15 @@ describe('buildDispatchPreamble', () => {
expect(result).toContain('orca orchestration check')
})
it('uses the exact orca-ide command for packaged WSL workers', () => {
const result = buildDispatchPreamble(baseParams({ cliCommand: 'orca-ide' }))
expect(result).toContain('orca-ide orchestration send')
expect(result).toContain('orca-ide orchestration check')
expect(result).toContain('orca-ide orchestration ask')
expect(result).not.toMatch(/(^|\s)orca orchestration/m)
})
it('appends a BASE DRIFT section when baseDrift.behind > 0', () => {
const result = buildDispatchPreamble({
taskId: 'task_x',
+6 -1
View File
@@ -1,3 +1,5 @@
import type { OrchestrationCliCommand } from './cli-command'
export type PreambleParams = {
taskId: string
// Why: completion and heartbeat payloads attribute activity to a specific
@@ -10,6 +12,9 @@ export type PreambleParams = {
coordinatorHandle: string
workerHandle: string
devMode?: boolean
// Why: packaged WSL panes install the scoped launcher as `orca-ide`;
// other execution hosts keep their existing bare `orca` bridge.
cliCommand?: OrchestrationCliCommand
// Why: populated by the coordinator's dispatch pre-flight (§3.1) only
// when the target worktree is behind its tracking remote. When absent
// or when `behind === 0`, the preamble emits no drift section. Callers
@@ -42,7 +47,7 @@ export function buildDispatchPreamble(params: PreambleParams): string {
// Why: in dev mode, agents must use orca-dev to connect to the dev runtime's
// socket. Without this, agents inside the dev Electron app would call the
// production CLI and talk to the wrong Orca instance (Section 6.4).
const cli = params.devMode ? 'orca-dev' : 'orca'
const cli = params.devMode ? 'orca-dev' : (params.cliCommand ?? 'orca')
const postDoneInstructions = buildPostWorkerDoneInstructions({
cli,
workerKind: params.workerKind ?? 'prompt-returning-agent'
@@ -1369,6 +1369,22 @@ describe('orchestration RPC methods', () => {
)
})
it('uses the target pane CLI command for the returned preamble', async () => {
setup()
const task = db.createTask({ spec: 'work' })
vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca-ide')
const result = (await call('orchestration.dispatch', {
task: task.id,
to: 'term_wsl',
returnPreamble: true
})) as { preamble: string }
expect(runtime.getTerminalOrchestrationCliCommand).toHaveBeenCalledWith('term_wsl')
expect(result.preamble).toContain('orca-ide orchestration send')
expect(result.preamble).not.toMatch(/(^|\s)orca orchestration/m)
})
it('injects preamble through the agent prompt path instead of raw terminal send', async () => {
setup()
const task = db.createTask({ spec: 'line one\nline two' })
+10 -4
View File
@@ -495,7 +495,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
workerHandle: params.to ?? 'worker',
devMode: params.devMode
devMode: params.devMode,
...(params.to
? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) }
: {})
})
return { dispatch: null, injected: false, dryRun: true, preamble }
}
@@ -540,7 +543,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
workerHandle: to,
devMode: params.devMode
devMode: params.devMode,
cliCommand: runtime.getTerminalOrchestrationCliCommand(to)
})
let injected = false
@@ -583,6 +587,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
if (!task) {
throw new Error(`Task not found: ${params.task}`)
}
const workerHandle = ctx?.assignee_handle ?? 'worker'
const preamble = buildDispatchPreamble({
taskId: task.id,
// Why: prefer the existing dispatch context's id if we have one
@@ -591,8 +596,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
dispatchId: ctx?.id ?? 'ctx_preview',
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
workerHandle: ctx?.assignee_handle ?? 'worker',
devMode: params.devMode
workerHandle,
devMode: params.devMode,
...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {})
})
return { dispatch: ctx ?? null, preamble }
}
@@ -130,6 +130,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({
message: 'Unknown launch agent'
})
.optional(),
viewMode: z.enum(['terminal', 'chat']).optional(),
activate: z.boolean().optional(),
// Why: idempotency key so a retried create (double-tap, reconnect replay)
// returns the in-flight operation instead of spawning a duplicate terminal.
@@ -148,6 +148,7 @@ describe('session tab RPC methods', () => {
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchAgent: 'codex',
viewMode: 'chat',
activate: true
})
)
@@ -167,6 +168,7 @@ describe('session tab RPC methods', () => {
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchAgent: 'codex',
viewMode: 'chat',
activate: true
})
})
@@ -53,6 +53,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.viewMode ? { viewMode: params.viewMode } : {}),
activate: params.activate,
clientMutationId: params.clientMutationId,
// Why: a dead client connection must cancel the surface wait instead
+33
View File
@@ -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'
})
})
})
+6
View File
@@ -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',
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('desktop startup ordering', () => {
describe('startup ordering', () => {
it('passes the startup barrier into PTY handlers without blocking window creation', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const attachStart = source.indexOf('attachMainWindowServices(')
@@ -24,7 +24,7 @@ describe('desktop startup ordering', () => {
expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0)
})
it('shows the desktop window without waiting for WSL registration reconciliation', () => {
it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'")
const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart)
@@ -43,12 +43,32 @@ describe('desktop startup ordering', () => {
expect(serveStart).toBeGreaterThan(reconciliationStart)
expect(serveEnd).toBeGreaterThan(serveStart)
expect(desktopWindowStart).toBeGreaterThan(reconciliationStart)
expect(serveStartup).toContain('await managedWslCliReconciliationReady')
expect(serveStartup).toContain('await managedWslCliStartupBarrierReady')
expect(serveStartup).not.toContain('await managedWslCliReconciliationReady')
expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan(
serveStartup.indexOf('await runtimeRpc.start()')
)
expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady')
expect(barrier).toContain('managedWslCliStartupBarrierReady')
expect(barrier).not.toContain('managedWslCliReconciliationReady')
})
it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
// Why: the barrier fails open, so the serve-ready payload must carry the
// reconciliation state and the bounded wait must be traceable via a milestone.
const readyStart = source.indexOf("type: 'orca_server_ready'")
const readyEnd = source.indexOf('pairing: pairing.available', readyStart)
const readyPayload = source.slice(readyStart, readyEnd)
expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus')
expect(source).toContain("managedWslCliReconciliationStatus = 'pending'")
expect(source).toContain("managedWslCliReconciliationStatus = 'settled'")
expect(source).toContain("managedWslCliReconciliationStatus = 'failed'")
expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'")
})
it('does not run the rate-limit quota fetch before the first window can show results', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const attachIndex = source.indexOf('rateLimits.attach(window)')
@@ -30,13 +30,13 @@ describe('createWslCliReconciliationStartupBarrier', () => {
}
})
it('fails open when reconciliation exceeds the startup budget', async () => {
it('fails open immediately when reconciliation rejects before the budget expires', async () => {
vi.useFakeTimers()
let resolveReconciliation!: () => void
let rejectReconciliation!: (error: Error) => void
try {
const reconciliation = new Promise<void>((resolve) => {
resolveReconciliation = resolve
const reconciliation = new Promise<void>((_resolve, reject) => {
rejectReconciliation = reject
})
const barrier = createWslCliReconciliationStartupBarrier(reconciliation)
let barrierSettled = false
@@ -44,38 +44,73 @@ describe('createWslCliReconciliationStartupBarrier', () => {
barrierSettled = true
})
await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1)
await vi.advanceTimersByTimeAsync(1)
expect(barrierSettled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
// Why: a fast WSL discovery failure should release the barrier via its catch
// branch without waiting out the budget, and must clear the pending timer.
rejectReconciliation(new Error('WSL discovery failed'))
await expect(barrier).resolves.toBeUndefined()
resolveReconciliation()
await reconciliation
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('leaves reconciliation running after the startup budget expires', async () => {
it('lets serve reach RPC readiness at budget while reconciliation remains pending', async () => {
vi.useFakeTimers()
let resolveWork!: () => void
let completed = false
let resolveReconciliation!: () => void
let reconciliationCompleted = false
try {
const work = new Promise<void>((resolve) => {
resolveWork = resolve
const reconciliation = new Promise<void>((resolve) => {
resolveReconciliation = resolve
}).then(() => {
completed = true
reconciliationCompleted = true
})
const barrier = createWslCliReconciliationStartupBarrier(work, { timeoutMs: 10 })
const barrier = createWslCliReconciliationStartupBarrier(reconciliation)
let rpcReady = false
const serveRpcReadiness = barrier.then(() => {
rpcReady = true
})
await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1)
expect(rpcReady).toBe(false)
expect(reconciliationCompleted).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await expect(serveRpcReadiness).resolves.toBeUndefined()
expect(rpcReady).toBe(true)
expect(reconciliationCompleted).toBe(false)
resolveReconciliation()
await reconciliation
expect(reconciliationCompleted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('preserves eventual reconciliation error reporting after the budget expires', async () => {
vi.useFakeTimers()
let rejectReconciliation!: (error: Error) => void
const reportedErrors: string[] = []
try {
const reconciliation = new Promise<void>((_resolve, reject) => {
rejectReconciliation = reject
}).catch((error) => {
reportedErrors.push(error instanceof Error ? error.message : String(error))
})
const barrier = createWslCliReconciliationStartupBarrier(reconciliation, { timeoutMs: 10 })
await vi.advanceTimersByTimeAsync(10)
await expect(barrier).resolves.toBeUndefined()
expect(completed).toBe(false)
expect(reportedErrors).toEqual([])
resolveWork()
await work
expect(completed).toBe(true)
rejectReconciliation(new Error('WSL discovery failed'))
await reconciliation
expect(reportedErrors).toEqual(['WSL discovery failed'])
} finally {
vi.useRealTimers()
}
@@ -5,7 +5,7 @@ type WslCliReconciliationStartupBarrierOptions = {
}
/**
* Briefly gates restored terminals while managed WSL registrations reconcile.
* Briefly gates terminal startup while managed WSL registrations reconcile.
*/
export function createWslCliReconciliationStartupBarrier(
reconciliation: Promise<unknown>,
@@ -22,8 +22,8 @@ export function createWslCliReconciliationStartupBarrier(
}
})
// Why: reconciliation may outlive a slow or unavailable WSL distro; restored
// terminals should wait briefly without turning WSL discovery into an app hang.
// Why: reconciliation may outlive a slow or unavailable WSL distro; terminal
// startup should wait briefly without turning WSL discovery into an app hang.
return Promise.race([
settled,
new Promise<void>((resolve) => {
@@ -631,13 +631,20 @@ describe('attachMainWindowServices', () => {
const notifier = runtime.setNotifier.mock.calls[0][0] as {
revealTerminalSession: (
worktreeId: string,
opts: { ptyId: string; title?: string; cwd?: string; activate?: boolean }
opts: {
ptyId: string
title?: string
cwd?: string
viewMode?: 'terminal' | 'chat'
activate?: boolean
}
) => Promise<{ tabId: string; title?: string }>
}
const revealPromise = notifier.revealTerminalSession('wt-1', {
ptyId: 'pty-1',
title: 'SSH tmux',
cwd: '/repo/packages/web'
cwd: '/repo/packages/web',
viewMode: 'chat'
})
const sentPayload = sendMock.mock.calls.find(
([channel]) => channel === 'ui:createTerminal'
@@ -645,7 +652,7 @@ describe('attachMainWindowServices', () => {
const handler = onMock.mock.calls.find(
([channel]) => channel === 'terminal:tabCreateReply'
)?.[1]
expect(sentPayload.cwd).toBe('/repo/packages/web')
expect(sentPayload).toMatchObject({ cwd: '/repo/packages/web', viewMode: 'chat' })
handler?.(
{ sender: { send: vi.fn() } },
@@ -328,6 +328,7 @@ function registerRuntimeWindowLifecycle(
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
activate: opts.activate !== false,
...(opts.presentation ? { presentation: opts.presentation } : {}),
// Why: pre-minted tabId from main keeps the renderer's tab id aligned
+6 -1
View File
@@ -813,7 +813,11 @@ export type AiVaultApi = {
onWindowFocused: (callback: () => void) => () => void
}
export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string }
// notFound marks a miss caused by the transcript not existing on disk yet
// (retry-worthy), as opposed to a real read/parse error (#8401).
export type NativeChatReadSessionResult =
| { messages: NativeChatMessage[] }
| { error: string; notFound?: true }
/** Messages appended to a live-tailed transcript since the previous emit. */
export type NativeChatAppendedMessages = NativeChatMessage[]
@@ -2796,6 +2800,7 @@ export type PreloadApi = {
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
title?: string
ptyId?: string
activate?: boolean
+2
View File
@@ -3445,6 +3445,7 @@ const api = {
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
title?: string
ptyId?: string
activate?: boolean
@@ -3467,6 +3468,7 @@ const api = {
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
title?: string
ptyId?: string
activate?: boolean
@@ -86,13 +86,33 @@ describe('GitHandler pull reconciliation', () => {
return consumerDir
}
it('explains how to configure a pull policy when divergent branches have no strategy', async () => {
it('falls back to a merge when divergent branches have no configured strategy', async () => {
const consumerDir = createDivergentFixture()
await expect(dispatcher.callRequest('git.pull', { worktreePath: consumerDir })).rejects.toThrow(
'Pull needs a Git pull policy for divergent branches. Configure one for this repository'
)
await expect(
dispatcher.callRequest('git.pull', { worktreePath: consumerDir })
).resolves.not.toThrow()
// Merge reconciliation yields a two-parent commit and both sides' files.
const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/)
expect(parentRefs).toHaveLength(2)
expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true)
expect(existsSync(path.join(consumerDir, 'local.txt'))).toBe(true)
expect(execGit(consumerDir, ['status', '--short'])).toBe('')
}, 15_000)
it('preserves configured fast-forward-only pull semantics on divergent branches', async () => {
const consumerDir = createDivergentFixture()
execGit(consumerDir, ['config', 'pull.ff', 'only'])
// Why: an explicit ff-only policy must still fail on divergence rather than
// getting silently reconciled by the merge fallback.
await expect(
dispatcher.callRequest('git.pull', { worktreePath: consumerDir })
).rejects.toThrow()
const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/)
expect(parentRefs).toHaveLength(1)
expect(execGit(consumerDir, ['status', '--short'])).toBe('')
}, 15_000)
+30 -22
View File
@@ -44,7 +44,11 @@ import { gitExecMutatesRepository } from '../shared/git-exec-mutation'
import { detectConflictOperation, getStatusOp } from './git-handler-status-ops'
import { checkIgnoredPathsOp } from './git-handler-check-ignore'
import { resolveRelayPushTarget } from './git-handler-push-target'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error'
import {
isNoUpstreamError,
normalizeGitErrorMessage,
runPullWithDivergenceFallback
} from '../shared/git-remote-error'
import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status'
import { assertGitPushTargetShape } from '../shared/git-push-target-validation'
import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status'
@@ -1021,29 +1025,33 @@ export class GitHandler {
private async pullWithArgs(params: Record<string, unknown>, pullArgs: string[]) {
this.clearGitMutationReadCaches()
const worktreePath = params.worktreePath as string
const runPull = async (effectiveArgs: string[]): Promise<void> => {
if (params.pushTarget !== undefined) {
assertGitPushTargetShape(params.pushTarget)
const pushTarget = params.pushTarget as GitPushTarget
await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath)
await this.git(
['pull', ...effectiveArgs, pushTarget.remoteName, pushTarget.branchName],
worktreePath
)
return
}
const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath))
if (upstream && !upstream.isConfiguredUpstream) {
// Why: legacy Orca branches may still track origin/main while pushes
// target origin/<branch>. Pull the same effective branch the UI reports.
await this.git(
['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName],
worktreePath
)
return
}
await this.git(['pull', ...effectiveArgs], worktreePath)
}
try {
try {
if (params.pushTarget !== undefined) {
assertGitPushTargetShape(params.pushTarget)
const pushTarget = params.pushTarget as GitPushTarget
await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath)
await this.git(
['pull', ...pullArgs, pushTarget.remoteName, pushTarget.branchName],
worktreePath
)
return
}
const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath))
if (upstream && !upstream.isConfiguredUpstream) {
// Why: legacy Orca branches may still track origin/main while pushes
// target origin/<branch>. Pull the same effective branch the UI reports.
await this.git(
['pull', ...pullArgs, upstream.remoteName, upstream.branchName],
worktreePath
)
return
}
await this.git(['pull', ...pullArgs], worktreePath)
await runPullWithDivergenceFallback(pullArgs, runPull)
} catch (error) {
// Why: mirror the local gitPull normalization so SSH users see the same
// actionable messages instead of raw git stderr.
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
@@ -17,6 +18,29 @@ type InstalledWatch = {
unsubscribe: ReturnType<typeof vi.fn<() => Promise<void>>>
}
type InstalledSupervisorWatch = InstalledWatch & { dir: string }
function stubRelayWatcherSupervisors() {
const installed = new Map<WatcherProcessSupervisor, InstalledSupervisorWatch[]>()
vi.spyOn(WatcherProcessSupervisor.prototype, 'subscribe').mockImplementation(function (
this: WatcherProcessSupervisor,
dir,
callback,
_options,
hooks = {}
) {
const watches = installed.get(this) ?? []
const unsubscribe = vi.fn(async () => undefined)
watches.push({ dir, callback, hooks, unsubscribe })
installed.set(this, watches)
return Promise.resolve({ unsubscribe })
})
const dispose = vi
.spyOn(WatcherProcessSupervisor.prototype, 'dispose')
.mockImplementation(() => undefined)
return { installed, dispose }
}
class FakeWatcherPool {
readonly installed: InstalledWatch[] = []
readonly dispose = vi.fn()
@@ -148,6 +172,81 @@ describe('RelayFilesystemWatchRegistry', () => {
})
describe('createRelayWatcherProcessPool', () => {
afterEach(() => vi.restoreAllMocks())
it('uses one healthy watcher process per SSH host for each standard repo', async () => {
const { installed } = stubRelayWatcherSupervisors()
const firstHostPool = createRelayWatcherProcessPool()
const secondHostPool = createRelayWatcherProcessPool()
const repoBase = join(tmpdir(), 'ssh-repo')
const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')]
try {
for (const root of roots) {
await firstHostPool.subscribe(root, vi.fn(), {}, {})
await secondHostPool.subscribe(root, vi.fn(), {}, {})
}
expect(
Array.from(installed.values()).map((watches) => watches.map(({ dir }) => dir))
).toEqual([roots, roots])
} finally {
firstHostPool.dispose()
secondHostPool.dispose()
}
})
it('quarantines and recovers each standard-repo root after the shared child fails', async () => {
const { installed, dispose } = stubRelayWatcherSupervisors()
const pool = createRelayWatcherProcessPool()
const dispatcher = createDispatcher()
const registry = new RelayFilesystemWatchRegistry(
dispatcher as unknown as RelayDispatcher,
pool
)
const repoBase = join(tmpdir(), 'ssh-repo-recovery')
const roots = [repoBase, join(repoBase, '.git'), join(repoBase, 'worktree')]
try {
for (const root of roots) {
await registry.watch(root, context(1))
}
const healthyWatches = Array.from(installed.values())[0]
const failure = new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
for (const watch of healthyWatches) {
watch.hooks.onTerminalError?.(failure)
}
await Promise.resolve()
const recoveredWatches = Array.from(installed.values()).slice(1)
expect(dispose).toHaveBeenCalledTimes(1)
expect(recoveredWatches.map((watches) => watches.map(({ dir }) => dir))).toEqual(
roots.map((root) => [root])
)
expect(dispatcher.notify.mock.calls.slice(0, roots.length)).toEqual(
roots.map((root) => ['fs.changed', { events: [{ kind: 'overflow', absolutePath: root }] }])
)
for (const watches of recoveredWatches) {
const [{ callback, dir }] = watches
callback(null, [{ type: 'update', path: join(dir, 'recovered.txt') }])
}
expect(dispatcher.notify.mock.calls.slice(roots.length)).toEqual(
roots.map((root) => [
'fs.changed',
{
events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }]
}
])
)
} finally {
registry.dispose()
}
})
it('fails closed instead of loading the native watcher in the relay process', async () => {
const previousVitest = process.env.VITEST
process.env.VITEST = 'true'
+3
View File
@@ -15,6 +15,9 @@ export function createRelayWatcherProcessPool(
entryPath = getRelayWatcherProcessEntryPath()
): RelayWatcherProcessPool {
return new RuntimeWatcherProcessPool({
// Why: one SSH workspace normally installs three roots, and each remote
// watcher child carries substantial Node/native-addon process overhead.
maxSharedSupervisors: 1,
createSupervisor: () =>
new WatcherProcessSupervisor({
entryPath,
@@ -104,6 +104,9 @@ import {
getCmdJQuickActions,
CREATE_WORKSPACE_QUICK_ACTION_ID
} from '@/components/cmd-j/quick-actions'
import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index'
import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs'
import { getRepoHostIdentity } from '@/store/slices/repo-host-identity'
import {
getComposerEligibleRepos,
resolveComposerGitRepoId
@@ -382,8 +385,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const agentStatusEpoch = useAppStore((s) =>
visible || statusInputsLingering ? s.agentStatusEpoch : 0
)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const { prCache, issueCache, hostedReviewCache } = useAppStore(
useShallow((s) => selectWorktreePaletteCacheInputs(s, visible || statusInputsLingering))
)
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeTabType = useAppStore((s) => s.activeTabType)
@@ -439,6 +443,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const preserveCreateLookupOnCloseRef = useRef(false)
const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos])
const repoByHostIdentity = useMemo(
() => new Map(repos.map((repo) => [getRepoHostIdentity(repo), repo])),
[repos]
)
const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
// Why: host badges only appear when more than one execution host exists; reuse
// the same registry the sidebar host-scope strip builds so labels stay in sync.
@@ -622,6 +630,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[browserSortedWorktrees]
)
const checksReviewByWorktree = useMemo(
() =>
buildWorktreeChecksReviewIndex({
worktrees: allWorktrees,
repoByHostIdentity,
prCache,
hostedReviewCache,
settings
}),
[allWorktrees, hostedReviewCache, prCache, repoByHostIdentity, settings]
)
const worktreeMatches = useMemo(
() =>
searchWorktrees(
@@ -630,9 +650,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
repoMap,
prCache,
issueCache,
getWorkspacePortsByWorktreeId(workspacePortScan)
getWorkspacePortsByWorktreeId(workspacePortScan),
checksReviewByWorktree
),
[sortedWorktrees, deferredQuery, repoMap, prCache, issueCache, workspacePortScan]
[
sortedWorktrees,
deferredQuery,
repoMap,
prCache,
issueCache,
workspacePortScan,
checksReviewByWorktree
]
)
const browserPageEntries = useMemo<SearchableBrowserPage[]>(() => {
@@ -2364,5 +2393,7 @@ function getPaletteSupportingTextLabel(
return translate('worktreeJumpPalette.matchLabel.port', 'Port')
case 'pr':
return translate('worktreeJumpPalette.matchLabel.pr', 'PR')
case 'mr':
return translate('worktreeJumpPalette.matchLabel.mr', 'MR')
}
}
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { getRepoHostIdentity } from '@/store/slices/repo-host-identity'
import { buildWorktreeChecksReviewIndex } from './worktree-checks-review-index'
const repo: Repo = {
id: 'repo-1',
path: '/remote/orca',
displayName: 'orca',
badgeColor: '#000000',
addedAt: 0,
executionHostId: 'ssh:staging'
}
const worktree: Worktree = {
id: 'worktree-1',
repoId: repo.id,
path: '/remote/orca-worktrees/search',
head: 'abc123',
branch: 'refs/heads/feature/search',
isBare: false,
isMainWorktree: false,
displayName: 'Search reviews',
comment: '',
linkedIssue: null,
linkedPR: 42,
linkedLinearIssue: null,
hostId: 'ssh:staging',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
function makePR(): PRInfo {
return {
number: 42,
title: 'Search worktrees by their pull requests',
state: 'open',
url: 'https://github.com/acme/orca/pull/42',
checksStatus: 'success',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'MERGEABLE'
}
}
function makeGitLabReview(): HostedReviewInfo {
return {
provider: 'gitlab',
number: 17,
title: 'Search worktrees by merge request',
state: 'open',
url: 'https://gitlab.com/acme/orca/-/merge_requests/17',
status: 'pending',
updatedAt: '2026-07-12T00:00:00Z',
mergeable: 'UNKNOWN'
}
}
describe('buildWorktreeChecksReviewIndex', () => {
it('reads the same host-scoped GitHub PR cache entry as Checks', () => {
const key = getGitHubPRCacheKey(
repo.path,
repo.id,
'feature/search',
null,
repo.connectionId,
repo.executionHostId,
true
)
const reviews = buildWorktreeChecksReviewIndex({
worktrees: [worktree],
repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]),
prCache: { [key]: { data: makePR(), fetchedAt: 1 } },
hostedReviewCache: {},
settings: null
})
expect(reviews.get(worktree)).toMatchObject({
provider: 'github',
number: 42,
title: 'Search worktrees by their pull requests'
})
})
it('uses the GitLab review selected by Checks instead of stale GitHub metadata', () => {
const gitLabWorktree = { ...worktree, linkedGitLabMR: 17 }
const prKey = getGitHubPRCacheKey(
repo.path,
repo.id,
'feature/search',
null,
repo.connectionId,
repo.executionHostId,
true
)
const reviewKey = getHostedReviewCacheKey(
repo.path,
'feature/search',
null,
repo.id,
repo.connectionId,
repo.executionHostId,
true
)
const gitLabReview = makeGitLabReview()
const reviews = buildWorktreeChecksReviewIndex({
worktrees: [gitLabWorktree],
repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]),
prCache: { [prKey]: { data: makePR(), fetchedAt: 1 } },
hostedReviewCache: { [reviewKey]: { data: gitLabReview, fetchedAt: 1 } },
settings: null
})
expect(reviews.get(gitLabWorktree)).toBe(gitLabReview)
})
it('records when a non-GitHub link suppresses stale GitHub metadata before its review loads', () => {
const gitLabWorktree = { ...worktree, linkedGitLabMR: 17 }
const prKey = getGitHubPRCacheKey(
repo.path,
repo.id,
'feature/search',
null,
repo.connectionId,
repo.executionHostId,
true
)
const reviews = buildWorktreeChecksReviewIndex({
worktrees: [gitLabWorktree],
repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]),
prCache: { [prKey]: { data: makePR(), fetchedAt: 1 } },
hostedReviewCache: {},
settings: null
})
expect(reviews.has(gitLabWorktree)).toBe(true)
expect(reviews.get(gitLabWorktree)).toBeNull()
})
it('keeps same-id worktrees isolated across execution hosts', () => {
const localRepo: Repo = {
...repo,
path: '/local/orca',
executionHostId: 'local'
}
const localWorktree: Worktree = { ...worktree, hostId: 'local' }
const sshWorktree: Worktree = { ...worktree, hostId: 'ssh:staging' }
const sshKey = getGitHubPRCacheKey(
repo.path,
repo.id,
'feature/search',
null,
repo.connectionId,
repo.executionHostId,
true
)
const reviews = buildWorktreeChecksReviewIndex({
worktrees: [localWorktree, sshWorktree],
repoByHostIdentity: new Map([
[getRepoHostIdentity(localRepo), localRepo],
[getRepoHostIdentity(repo), repo]
]),
prCache: { [sshKey]: { data: makePR(), fetchedAt: 1 } },
hostedReviewCache: {},
settings: null
})
expect(reviews.has(localWorktree)).toBe(false)
expect(reviews.get(sshWorktree)).toMatchObject({ provider: 'github', number: 42 })
})
})
@@ -0,0 +1,84 @@
import { branchName } from '@/lib/git-utils'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity'
import type { AppState } from '@/store/types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Repo, Worktree } from '../../../../shared/types'
import { selectChecksPanelReview } from '../right-sidebar/checks-panel-review'
type WorktreeChecksReviewIndexArgs = {
worktrees: readonly Worktree[]
repoByHostIdentity: ReadonlyMap<string, Repo>
prCache: AppState['prCache'] | null
hostedReviewCache: AppState['hostedReviewCache'] | null
settings: AppState['settings']
}
export function buildWorktreeChecksReviewIndex({
worktrees,
repoByHostIdentity,
prCache,
hostedReviewCache,
settings
}: WorktreeChecksReviewIndexArgs): Map<Worktree, HostedReviewInfo | null> {
const reviews = new Map<Worktree, HostedReviewInfo | null>()
if (!prCache || !hostedReviewCache) {
return reviews
}
for (const worktree of worktrees) {
const repo = repoByHostIdentity.get(
getRepoHostIdentityForParts(worktree.repoId, worktree.hostId ?? LOCAL_EXECUTION_HOST_ID)
)
if (!repo) {
continue
}
const branch = branchName(worktree.branch)
const prKey = getGitHubPRCacheKey(
repo.path,
repo.id,
branch,
settings,
repo.connectionId,
repo.executionHostId,
true
)
const hostedReviewKey = getHostedReviewCacheKey(
repo.path,
branch,
settings,
repo.id,
repo.connectionId,
repo.executionHostId,
true
)
// Why: Cmd+J should expose exactly the review metadata Checks has already
// resolved, without starting another provider lookup from the search path.
const review = selectChecksPanelReview({
hostedReview: hostedReviewCache[hostedReviewKey]?.data,
pr: prCache[prKey]?.data,
linkedGitLabMR: worktree.linkedGitLabMR ?? null,
linkedBitbucketPR: worktree.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: worktree.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: worktree.linkedGiteaPR ?? null
})
if (review) {
// Why: persisted IDs can be identical across execution hosts; the search
// scope preserves these object references while sorting and filtering.
reviews.set(worktree, review)
} else if (
worktree.linkedGitLabMR != null ||
worktree.linkedBitbucketPR != null ||
worktree.linkedAzureDevOpsPR != null ||
worktree.linkedGiteaPR != null
) {
// Why: an empty Checks selection for a non-GitHub link is authoritative;
// omitting it would let Cmd+J surface stale GitHub metadata as a fallback.
reviews.set(worktree, null)
}
}
return reviews
}

Some files were not shown because too many files have changed in this diff Show More