mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into being-able-to-reply-to-any-comment-from-bot
This commit is contained in:
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1428,6 +1428,7 @@ export function HostScreen({
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={worktrees}
|
||||
onVisibleChange={(visible) => {
|
||||
newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+97
-39
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
overrides:
|
||||
xcode>uuid: 11.1.1
|
||||
@@ -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'
|
||||
@@ -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(
|
||||
|
||||
@@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user