diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index fc02e5fb135..6a9ba8db139 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." } ] } diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 01a3cc95acd..b8db4e3a292 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1428,6 +1428,7 @@ export function HostScreen({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={worktrees} onVisibleChange={(visible) => { newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index fea23f01764..c61d01559cd 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -61,6 +61,7 @@ import { } from '../../../src/session/mobile-file-syntax' import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { filterWorkspaceAgents, isWorkspaceAgentEnabled, @@ -858,7 +859,6 @@ const GITHUB_REPO_CONCURRENCY = 3 const MAX_RENDERED_PR_DIFF_LINES = 400 const GITLAB_PER_PAGE = 50 const LINEAR_LIMIT = 50 -const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' // Why: task detail drawers can launch child sheets; children must layer above // the still-mounted parent while its dismissal animation/state remains alive. const TASK_SECONDARY_DRAWER_Z_INDEX = 1100 diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 224511d38c5..d28e9ac1d40 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1935,48 +1935,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -2049,48 +2057,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2552,36 +2568,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -4881,24 +4903,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -7025,22 +7051,22 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7050,7 +7076,7 @@ snapshots: '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7085,12 +7111,12 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7105,42 +7131,42 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: @@ -8604,7 +8630,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -8771,7 +8797,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9529,24 +9555,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -9561,7 +9587,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/hammerjs@2.0.46': {} @@ -9639,7 +9665,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -9659,7 +9685,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -9676,6 +9702,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9685,6 +9720,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9714,6 +9753,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -9972,7 +10026,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 5.2.1 @@ -10273,7 +10327,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10282,7 +10336,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10678,7 +10732,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: @@ -10776,11 +10830,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -10804,7 +10858,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -10827,7 +10881,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10838,7 +10892,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11515,7 +11569,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -12087,7 +12141,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.6.0 + '@types/node': 26.1.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12242,7 +12296,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12287,7 +12341,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -14008,6 +14062,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml new file mode 100644 index 00000000000..35f983a345a --- /dev/null +++ b/mobile/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + +overrides: + xcode>uuid: 11.1.1 diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 961a62a7eaa..75b18fcd1fb 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -10,21 +10,19 @@ import { ActivityIndicator, Keyboard } from 'react-native' -import { ChevronDown, ChevronUp, Check } from 'lucide-react-native' +import { ChevronDown, ChevronUp } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import type { RpcResponse, RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' -import { BottomDrawer } from './BottomDrawer' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' import { PickerListDrawer } from './PickerListDrawer' import { MobileAgentIcon } from './MobileAgentIcon' -import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' -import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' import { isSetupHookTrusted, normalizeSetupHookTrust, - trustedOrcaHooksWithSetupApproval, + persistSetupHookTrustApproval, wasSetupHookPreviouslyApproved, type SetupHookTrust } from '../tasks/setup-hook-trust' @@ -49,6 +47,24 @@ import { refreshMobileNewWorkspaceDialogSelectedRepo, resolveMobileNewWorkspaceDialogRepoId } from '../worktree/new-workspace-dialog-repo-selection' +import { createBlankWorkspace } from '../tasks/blank-workspace-create' +import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' +import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability' +import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + type TaskProvider +} from '../tasks/mobile-task-providers' +import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' +import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' +import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' +import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' +import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' +import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' +import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' type Repo = { id: string @@ -56,6 +72,9 @@ type Repo = { path: string badgeColor?: string connectionId?: string | null + kind?: 'git' | 'folder' + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null } type SetupDecision = 'inherit' | 'run' | 'skip' @@ -91,13 +110,11 @@ type CreateOptions = { approvedSetupContentHash?: string } -type SetupTrustPrompt = { - repoId: string - repoName: string - scriptContent: string - contentHash: string - previouslyApproved: boolean -} +type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust' + +// Why: iOS cannot reliably present a second native modal until the first drawer's +// exit commits; one extra frame keeps transitions sequential on slower devices. +const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16 function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] @@ -124,6 +141,7 @@ type Props = { // on the on-disk directory basename, so paths (not displayNames) are // what the suggestion logic must dedupe against. existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onCreated: (worktreeId: string, name: string) => void onClose: () => void } @@ -133,6 +151,7 @@ export function NewWorktreeModal({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { @@ -157,6 +176,7 @@ export function NewWorktreeModal({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={existingWorktrees} onCreated={onCreated} onClose={onClose} /> @@ -168,25 +188,28 @@ function NewWorktreeModalContent({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null)) const [repos, setRepos] = useState(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) - const [showRepoPicker, setShowRepoPicker] = useState(false) - const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true) + const [drawerView, setDrawerView] = useState('form') + const drawerTransitionTimerRef = useRef | null>(null) + const createInFlightRef = useRef(false) + const setupTrustActionInFlightRef = useRef(false) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( null ) const [agentOverriddenState, setAgentOverridden] = useState(false) - const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [name, setName] = useState('') const [note, setNote] = useState('') + const [availableProviders, setAvailableProviders] = useState([]) + const [tasksSupported, setTasksSupported] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -200,12 +223,40 @@ function NewWorktreeModalContent({ const [error, setError] = useState('') const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + const selectedRepoWorktreeBranches = useMemo( + () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), + [existingWorktrees, selectedRepo] + ) - // Why: matches the desktop UI — the input shows a generic "Workspace name" - // placeholder, not the suggested creature. The creature name is only used - // as a server-bound fallback when the user submits with a blank field, so - // it's recomputed lazily inside handleCreate() to stay fresh against - // existingWorktreePaths at submission time. + useEffect(() => { + return () => { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + } + }, []) + + function transitionDrawer(nextView: Exclude): void { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + setDrawerView('transition') + drawerTransitionTimerRef.current = setTimeout(() => { + drawerTransitionTimerRef.current = null + setDrawerView(nextView) + }, NEW_WORKTREE_DRAWER_TRANSITION_MS) + } + + // The Smart source picker owns the workspace name AND the linked-source + // selection: typing names the workspace and drives source search, and picking + // a source resolves the base/branch/push metadata (matching desktop). The + // creature-name fallback is only computed lazily at submit for a blank name. + const composer = useMobileComposerSource({ + client, + selectedRepoId: selectedRepo?.id ?? null, + worktreeBranches: selectedRepoWorktreeBranches, + onError: setError + }) const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const sshGate = deriveWorkspaceSshGate({ @@ -242,6 +293,25 @@ function NewWorktreeModalContent({ } const selectedAgent = selectedAgentResolution.selectedAgent + const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true + const sourceAvailability: SmartModeAvailabilityInput = { + textOnly: selectedRepo != null && !selectedRepoIsGit, + tasksSupported, + hasRepo: selectedRepo != null, + githubAvailable: availableProviders.includes('github'), + gitlabAvailable: availableProviders.includes('gitlab'), + linearAvailable: availableProviders.includes('linear') + } + const pasteRepos = useMemo( + () => + repos.map((repo) => ({ + id: repo.id, + displayName: repo.displayName, + slug: deriveRepoSlug(repo) + })), + [repos] + ) + useEffect(() => { if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { return @@ -298,27 +368,68 @@ function NewWorktreeModalContent({ }) void (async () => { - try { - const [settingsResponse, uiResponse] = await Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('ui.get') - ]) - if (stale) { - return - } - if (settingsResponse.ok) { - const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } - setRuntimeSettings(result.settings) - } - if (uiResponse.ok) { - const result = (uiResponse as RpcSuccess).result as { - ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } - } - setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {}) - } - } catch { - // Non-critical; repo.list owns the visible loading state. + // Why: settle each RPC independently so a flaky availability probe (e.g. a + // linear.status timeout, which rejects rather than resolving {ok:false}) + // can't discard the already-resolved critical settings/ui results. + const probes = Promise.allSettled([ + client.sendRequest('status.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + const okResult = (entry: PromiseSettledResult): RpcSuccess | null => + entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null + // Why: hydrate settings/trust the moment their own RPCs settle — gating them + // on the probes (a first-open preflight.check can take seconds) widens the + // window where an already-trusted setup hook spuriously re-prompts on create. + const [settingsRes, uiRes] = await Promise.allSettled([ + client.sendRequest('settings.get'), + client.sendRequest('ui.get') + ]) + if (stale) { + return } + + const settingsResult = okResult(settingsRes) + const settingsValue = settingsResult + ? ( + settingsResult.result as { + settings: RuntimeSettings & { visibleTaskProviders?: unknown } + } + ).settings + : null + if (settingsValue) { + setRuntimeSettings(settingsValue) + } + const uiResult = okResult(uiRes) + if (uiResult) { + const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui + setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) + } + + const [statusRes, preflightRes, linearRes] = await probes + if (stale) { + return + } + // Tasks is an additive RPC surface, so older paired desktops without the + // capability fall back to branch + blank sources only. + const statusResult = okResult(statusRes) + const capabilities = + (statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? [] + setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY)) + const glabInstalled = + (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab + ?.installed === true + const linearConnected = + (okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true + const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) + setAvailableProviders( + // Drop filterAvailableTaskProviders' forced 'github' fallback when the user + // hid GitHub; the Branch tab always guarantees at least one tab remains. + filterAvailableTaskProviders(visibleProviders, { + gitlabInstalled: glabInstalled, + linearConnected + }).filter((provider) => visibleProviders.includes(provider)) + ) })() return () => { stale = true @@ -486,31 +597,11 @@ function NewWorktreeModalContent({ } } - async function persistSetupHookTrust( - repoId: string, - contentHash: string, - alwaysTrust: boolean - ): Promise { - if (!client) { - return - } - const next = trustedOrcaHooksWithSetupApproval({ - trust: trustedOrcaHooks, - repoId, - contentHash, - alwaysTrust - }) - const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) - if (!response.ok) { - throw new Error(response.error.message) - } - setTrustedOrcaHooks(next) - } - async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo) { + if (!client || !selectedRepo || createInFlightRef.current) { return } + createInFlightRef.current = true setCreating(true) setError('') @@ -555,24 +646,9 @@ function NewWorktreeModalContent({ // server invent one. The pre-flight basename dedupe is only a hint; // the authoritative collision is checked server-side against git // branches/remotes/PRs, so we also retry-with-suffix on conflict. - const trimmedName = name.trim() + const trimmedName = composer.name.trim() const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? []) - // Why: mirrors src/renderer/src/store/slices/worktrees.ts - // (createWorktree retry loop). Server-side checks (Branch X already - // exists locally / on a remote / already has PR #N) can fire even - // after the pre-flight basename dedupe — branches outlive worktrees - // in git, and remote branches/PRs aren't visible from worktree.ps. - // Retry up to 25 times by appending -2, -3, ... before surfacing - // the error. The desktop applies this to user-typed names too, so - // mobile follows suit for parity. - const retryablePatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /already has pr #\d+/i - ] - const candidateFor = (attempt: number): string => - attempt === 0 ? baseName : `${baseName}-${attempt + 1}` let setupDecision: SetupDecision = 'inherit' if (setupCommand) { if (options.setupOverride) { @@ -602,44 +678,46 @@ function NewWorktreeModalContent({ contentHash: setupTrust.contentHash, previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) }) + transitionDrawer('trust') return } - let lastError: string | null = null - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = candidateFor(attempt) - const params: Record = { - repo: `id:${selectedRepo.id}`, - startupCommand: command, - setupDecision, - name: candidateName - } - if (selectedAgent.id !== '__blank__') { - params.createdWithAgent = selectedAgent.id - } - if (note.trim()) { - params.comment = note.trim() - } - - const response = await client.sendRequest('worktree.create', params, { - timeoutMs: WORKTREE_CREATE_TIMEOUT_MS - }) - if (response.ok) { - const result = (response as RpcSuccess).result as { worktree: { id: string } } - onClose() - onCreated(result.worktree.id, candidateName) - return - } - - lastError = response.error.message - if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) { - break - } + const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined + const trimmedNote = note.trim() || undefined + const createSelection = composer.createSelection + const result = createSelection + ? await createWorkspaceFromComposerSource({ + client, + selection: createSelection, + targetRepoId: selectedRepo.id, + setupDecision, + agent: { + choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank', + startupCommand: command + }, + workspaceName: trimmedName || undefined, + note: trimmedNote, + nameIsAutoManaged: composer.isNameAutoManaged + }) + : await createBlankWorkspace({ + client, + repoId: selectedRepo.id, + baseName, + startupCommand: command, + createdWithAgentId, + comment: trimmedNote, + setupDecision + }) + if ('error' in result) { + setError(result.error) + return } - setError(lastError ?? 'Failed to create workspace') + onClose() + onCreated(result.worktreeId, result.name) } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create workspace') } finally { + createInFlightRef.current = false setCreating(false) } } @@ -670,15 +748,74 @@ function NewWorktreeModalContent({ ) function prepareSelectionPickerOpen(): void { - // Why: picker taps can beat the delayed name-field focus; suppressing it - // prevents the keyboard from reopening under the picker drawer. - setNameAutoFocusEnabled(false) + // Why: picker taps can beat an open soft keyboard; dismissing it prevents the + // keyboard from reopening under the picker drawer. Keyboard.dismiss() } + function handleRepoSelected(repo: Repo): void { + const repoChanged = repo.id !== selectedRepo?.id + setSelectedRepo(repo) + // Branch and provider-backed sources are repo-scoped; Linear/Jira are global + // work context and survive choosing a different implementation repo. + if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) { + composer.handleClearSmartNameSelection() + } + } + + async function approveSetupTrust(alwaysTrust: boolean): Promise { + if ( + !client || + !setupTrustPrompt || + setupTrustActionInFlightRef.current || + createInFlightRef.current + ) { + return + } + setupTrustActionInFlightRef.current = true + setCreating(true) + try { + const nextTrust = await persistSetupHookTrustApproval({ + client, + trust: trustedOrcaHooks, + repoId: setupTrustPrompt.repoId, + contentHash: setupTrustPrompt.contentHash, + alwaysTrust + }) + setTrustedOrcaHooks(nextTrust) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + transitionDrawer('form') + await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } finally { + setupTrustActionInFlightRef.current = false + if (!createInFlightRef.current) { + setCreating(false) + } + } + } + + function closeSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + setSetupTrustPrompt(null) + transitionDrawer('form') + } + + function skipSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + closeSetupTrust() + void handleCreate({ setupOverride: 'skip' }) + } + return ( <> - + Create Workspace @@ -702,7 +839,7 @@ function NewWorktreeModalContent({ style={styles.fieldButton} onPress={() => { prepareSelectionPickerOpen() - setShowRepoPicker(true) + transitionDrawer('repo') }} > {selectedRepo ? ( @@ -720,6 +857,18 @@ function NewWorktreeModalContent({ + setError('')} + onOpenDrawer={() => transitionDrawer('source')} + /> + + {composer.forkPushWarning ? ( + {composer.forkPushWarning} + ) : null} + {selectedRepoConnectionId ? ( SSH Connection @@ -763,28 +912,6 @@ function NewWorktreeModalContent({ ) : null} - - - Workspace Name [Optional] - - { - setName(t) - setError('') - }} - placeholderTextColor={colors.textMuted} - shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0} - returnKeyType="done" - onSubmitEditing={() => { - if (canCreate) { - void handleCreate() - } - }} - /> - - Agent { prepareSelectionPickerOpen() - setShowAgentPicker(true) + transitionDrawer('agent') }} > @@ -814,6 +941,11 @@ function NewWorktreeModalContent({ {showAdvanced && ( <> + + Note - {/* Sub-modals for pickers — rendered outside the main modal so they - layer on top and scroll without touch conflicts. */} + {/* Why: list drawers stay outside the form's ScrollView, and the transition + state prevents overlapping native modals from swallowing iOS taps. */} + { + const nextRepo = repos.find((repo) => repo.id === repoId) + if (nextRepo) { + setSelectedRepo(nextRepo) + } + }} + onClose={() => transitionDrawer('form')} + /> + setSelectedRepo(item.repo)} - onClose={() => setShowRepoPicker(false)} + onSelect={(item) => handleRepoSelected(item.repo)} + onClose={() => transitionDrawer('form')} renderIcon={(item) => { return }} /> setShowAgentPicker(false)} + onClose={() => transitionDrawer('form')} renderIcon={(agent) => } /> - setSetupTrustPrompt(null)} - > - {setupTrustPrompt ? ( - - - - {setupTrustPrompt.previouslyApproved - ? `${setupTrustPrompt.repoName}'s setup script changed` - : `Run setup from ${setupTrustPrompt.repoName}?`} - - - This repository's orca.yaml runs before the workspace starts. Only run it if you - trust this repository. - - - - - - {setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} - - {setupTrustPrompt.scriptContent} - - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - false - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Run hooks - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - true - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Always trust and run - - - { - setSetupTrustPrompt(null) - void handleCreate({ setupOverride: 'skip' }) - }} - > - Don't run - - - - ) : null} - + void approveSetupTrust(false)} + onAlwaysTrust={() => void approveSetupTrust(true)} + onDontRun={skipSetupTrust} + onClose={closeSetupTrust} + /> ) } @@ -1164,6 +1228,12 @@ const styles = StyleSheet.create({ fontSize: 13, marginBottom: spacing.md }, + sourceWarning: { + marginTop: -spacing.sm, + marginBottom: spacing.md, + fontSize: 12, + color: colors.statusAmber + }, advancedToggle: { flexDirection: 'row', alignItems: 'center', @@ -1247,52 +1317,6 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, color: colors.textPrimary }, - trustHeader: { - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - trustScriptBox: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - borderWidth: 1, - borderColor: colors.borderSubtle, - padding: spacing.md, - marginBottom: spacing.md - }, - trustScriptLabel: { - fontSize: 12, - fontWeight: '600', - color: colors.textSecondary, - marginBottom: spacing.sm - }, - trustScriptText: { - fontSize: 13, - fontFamily: typography.monoFamily, - color: colors.textPrimary - }, - trustActionGroup: { - backgroundColor: colors.bgPanel, - borderRadius: radii.input, - overflow: 'hidden' - }, - trustActionRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md - }, - trustActionText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.textPrimary, - fontWeight: '500' - }, - trustActionSeparator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index df422642951..9a062692d17 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -12,6 +12,7 @@ type Props = { client: RpcClient | null hostId?: string existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void onCreated: (worktreeId: string, name: string) => void @@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef diff --git a/mobile/src/components/SetupHookTrustDrawer.tsx b/mobile/src/components/SetupHookTrustDrawer.tsx new file mode 100644 index 00000000000..6095aaa174f --- /dev/null +++ b/mobile/src/components/SetupHookTrustDrawer.tsx @@ -0,0 +1,137 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type SetupTrustPrompt = { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean +} + +type Props = { + visible: boolean + prompt: SetupTrustPrompt | null + busy: boolean + onRunOnce: () => void + onAlwaysTrust: () => void + onDontRun: () => void + onClose: () => void +} + +// The repo-owned orca.yaml setup-hook trust prompt, shown before a workspace +// create that would run an untrusted setup script. Extracted from NewWorktreeModal +// to keep that file focused; the async persist/create logic stays with the caller. +export function SetupHookTrustDrawer({ + visible, + prompt, + busy, + onRunOnce, + onAlwaysTrust, + onDontRun, + onClose +}: Props) { + return ( + + {prompt ? ( + + + + {prompt.previouslyApproved + ? `${prompt.repoName}'s setup script changed` + : `Run setup from ${prompt.repoName}?`} + + + This repository's orca.yaml runs before the workspace starts. Only run it if you trust + this repository. + + + + + + {prompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + {prompt.scriptContent} + + + + + + Run hooks + + + + + Always trust and run + + + + Don't run + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + trustHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + trustScriptBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md + }, + trustScriptLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.sm + }, + trustScriptText: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + trustActionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + overflow: 'hidden' + }, + trustActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + }, + trustActionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '500' + }, + trustActionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/components/SmartSourceModeIcon.tsx b/mobile/src/components/SmartSourceModeIcon.tsx new file mode 100644 index 00000000000..3d066369ed6 --- /dev/null +++ b/mobile/src/components/SmartSourceModeIcon.tsx @@ -0,0 +1,18 @@ +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes' +import { TaskProviderLogo } from './TaskProviderLogo' + +// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes, +// lucide glyphs for the neutral modes. +export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) { + if (icon.type === 'provider') { + return + } + if (icon.name === 'sparkles') { + return + } + if (icon.name === 'git-branch') { + return + } + return +} diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx new file mode 100644 index 00000000000..4af0ad682b5 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -0,0 +1,102 @@ +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + composer: MobileComposerSource + selectedRepoIsGit: boolean +} + +// The Advanced-section source controls: the editable Name appears once a source +// pill is shown (the field itself is no longer the name input); the branch-name +// override and reuse toggle mirror the desktop composer's advanced branch fields. +export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) { + const selection = composer.smartNameSelection + const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch') + return ( + <> + {selection ? ( + + Name + + + ) : null} + + {showBranchOverride ? ( + + Branch name + + + ) : null} + + {composer.reuseEligibleBranch ? ( + + + + Reuse branch “{composer.reuseEligibleBranch}” + + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + reuseRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + reuseLabel: { + flex: 1, + fontSize: 13, + color: colors.textSecondary + }, + reuseSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx new file mode 100644 index 00000000000..c5f1a6c47db --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { + MR_STATE_FILTER_OPTIONS, + resolveAvailableSmartModes, + resolveDefaultSmartMode, + SMART_MODE_OPTIONS, + type SmartModeAvailabilityInput, + type SmartModeOption +} from '../tasks/mobile-smart-source-modes' +import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types' +import { + lookupGitHubItemByOwnerRepo, + type PasteRepoCandidate +} from '../tasks/smart-source-paste-intent' +import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' +import { SmartSourceModeIcon } from './SmartSourceModeIcon' +import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow' + +type Props = { + visible: boolean + client: RpcClient | null + composer: MobileComposerSource + availability: SmartModeAvailabilityInput + repoId: string | null + repos: readonly PasteRepoCandidate[] + linearWorkspaceId?: string | null + sshReady: boolean + onRepoChange: (repoId: string) => void + onClose: () => void +} + +export function SmartWorkspaceSourceDrawer({ + visible, + client, + composer, + availability, + repoId, + repos, + linearWorkspaceId, + sshReady, + onRepoChange, + onClose +}: Props) { + const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability]) + const [mode, setMode] = useState(() => resolveDefaultSmartMode(availability)) + const [mrStateFilter, setMrStateFilter] = useState('opened') + // Why: read latest availability inside the open effect without making it a + // reactive dep (the object is recreated each render), so re-seeding happens + // only on open, not on every availability recompute. + const availabilityRef = useRef(availability) + availabilityRef.current = availability + + // Reset to the default mode each time the drawer opens. + useEffect(() => { + if (visible) { + setMode(resolveDefaultSmartMode(availabilityRef.current)) + } + }, [visible]) + + // Snap the chosen mode back into the available set if availability changes. + const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text') + + // Linear searches without a repo; every other provider/branch search needs a + // connected repo-backed target. + const searchEnabled = visible && (effectiveMode === 'linear' || sshReady) + + const { + rows, + loading, + error, + needsGitHubRemote, + emptyHint, + crossRepoPrompt, + dismissCrossRepoPrompt + } = useSmartWorkspaceSource({ + client, + enabled: searchEnabled, + mode: effectiveMode, + query: composer.name, + repoId, + githubAvailable: availability.githubAvailable, + gitlabAvailable: availability.gitlabAvailable, + linearAvailable: availability.linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + }) + + function closeSoon(): void { + setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS) + } + + function handleSelectRow(row: SourceRow): void { + switch (row.kind) { + case 'use-name': + composer.setName(row.name) + break + case 'create-branch': + composer.handleSmartCreateBranch(row.name) + break + case 'github': + composer.handleSmartGitHubItemSelect(row.item) + break + case 'gitlab': + composer.handleSmartGitLabItemSelect(row.item) + break + case 'branch': + composer.handleSmartBranchSelect(row.refName, row.localBranchName) + break + case 'linear': + composer.handleSmartLinearIssueSelect(row.issue) + break + } + onClose() + } + + async function handleAcceptCrossRepo(): Promise { + if (!client || !crossRepoPrompt) { + return + } + const { link, matchingRepo } = crossRepoPrompt + try { + const item = await lookupGitHubItemByOwnerRepo( + client, + matchingRepo.id, + link.slug, + link.number, + link.type + ) + if (item) { + onRepoChange(matchingRepo.id) + composer.handleSmartGitHubItemSelect(item) + onClose() + } + } catch { + dismissCrossRepoPrompt() + } + } + + const showEmpty = + !loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0 + + return ( + + + Name or 'Create From' + + Done + + + + + + + {SMART_MODE_OPTIONS.filter((option: SmartModeOption) => + availableModes.includes(option.id) + ).map((option) => { + const selected = option.id === effectiveMode + const tint = selected ? colors.textPrimary : colors.textSecondary + return ( + setMode(option.id)} + > + + + {option.label} + + + ) + })} + + + {effectiveMode === 'gitlab' ? ( + + {MR_STATE_FILTER_OPTIONS.map((option) => { + const selected = option.id === mrStateFilter + return ( + setMrStateFilter(option.id)} + > + + {option.label} + + + ) + })} + + ) : null} + + {crossRepoPrompt ? ( + + + This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}. + + + + Cancel + + void handleAcceptCrossRepo()}> + + Switch to {crossRepoPrompt.matchingRepo.displayName} + + + + + ) : null} + + {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? ( + Connect the repository to search sources. + ) : needsGitHubRemote ? ( + + This SSH repo needs a GitHub remote to list issues and PRs. + + ) : error ? ( + {error} + ) : null} + + row.value} + style={styles.list} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled + ListFooterComponent={ + loading ? ( + + + + ) : showEmpty ? ( + {emptyHint || 'No results found.'} + ) : null + } + renderItem={({ item }) => ( + handleSelectRow(item)} /> + )} + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + done: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.accentBlue + }, + search: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.sm + }, + tabRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginBottom: spacing.sm + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + tabSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + tabText: { + fontSize: 13, + color: colors.textSecondary + }, + tabTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + chipRow: { + flexDirection: 'row', + gap: spacing.xs, + marginBottom: spacing.sm + }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + chipSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + chipText: { + fontSize: 12, + color: colors.textSecondary + }, + chipTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + crossRepo: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.sm + }, + crossRepoText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm + }, + crossRepoDismiss: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + crossRepoDismissText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoSwitch: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.textSecondary + }, + crossRepoSwitchText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + notice: { + fontSize: 12, + color: colors.textMuted, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + errorNotice: { + fontSize: 12, + color: colors.statusRed, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + list: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden', + maxHeight: 420, + flexGrow: 0 + }, + loading: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + empty: { + paddingVertical: spacing.lg, + textAlign: 'center', + color: colors.textMuted, + fontSize: 13 + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx new file mode 100644 index 00000000000..eb08ddca66a --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceField.tsx @@ -0,0 +1,145 @@ +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { + CircleDot, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + X +} from 'lucide-react-native' +import type { SmartNameSelection } from '../tasks/mobile-composer-source-types' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + composer: MobileComposerSource + label: string + disabled?: boolean + onBeforeOpen?: () => void + onOpenDrawer: () => void +} + +function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) { + if (kind === 'github-pr') { + return + } + if (kind === 'gitlab-mr') { + return + } + if (kind === 'github-issue' || kind === 'gitlab-issue') { + return + } + if (kind === 'branch') { + return + } + return +} + +export function SmartWorkspaceSourceField({ + composer, + label, + disabled, + onBeforeOpen, + onOpenDrawer +}: Props) { + const selection = composer.smartNameSelection + + function openDrawer(): void { + if (disabled) { + return + } + onBeforeOpen?.() + onOpenDrawer() + } + + return ( + + + {label} [Optional] + + {selection ? ( + + + + {selection.label} + + {selection.url ? ( + selection.url && void Linking.openURL(selection.url).catch(() => {})} + > + + + ) : null} + + + + + ) : ( + + + {composer.name || 'Type a name or search a source'} + + + )} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + disabled: { + opacity: 0.55 + }, + inputText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + inputPlaceholder: { + color: colors.textMuted + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + pillLabel: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceRow.tsx b/mobile/src/components/SmartWorkspaceSourceRow.tsx new file mode 100644 index 00000000000..ee601c2ebde --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceRow.tsx @@ -0,0 +1,134 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + row: SourceRow + onPress: () => void +} + +type RowContent = { + icon: React.ReactNode + title: string + subtitle?: string + status?: string +} + +function resolveRowContent(row: SourceRow): RowContent { + switch (row.kind) { + case 'use-name': + return { + icon: , + title: `Use "${row.name}"`, + subtitle: 'Name this workspace' + } + case 'create-branch': + return { + icon: , + title: `Create branch "${row.name}"`, + subtitle: 'New branch' + } + case 'github': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'gitlab': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'branch': + return { + icon: , + title: row.localBranchName || row.refName, + subtitle: row.refName + } + case 'linear': + return { + icon: , + title: row.issue.title, + subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`, + status: row.issue.state?.name + } + default: + return { icon: , title: '' } + } +} + +export function SmartWorkspaceSourceRow({ row, onPress }: Props) { + const content = resolveRowContent(row) + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + > + {content.icon} + + + {content.title} + + {content.subtitle ? ( + + {content.subtitle} + + ) : null} + + {content.status ? ( + + + {content.status} + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + icon: { + width: 18, + alignItems: 'center' + }, + copy: { + flex: 1, + minWidth: 0 + }, + title: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + subtitle: { + fontSize: 12, + color: colors.textMuted, + marginTop: 1 + }, + pill: { + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + pillText: { + fontSize: 11, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'capitalize' + } +}) diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts new file mode 100644 index 00000000000..1d35d29f758 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createBlankWorkspace } from './blank-workspace-create' + +type Call = { method: string; params: unknown } + +function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params }) + const result = script(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('createBlankWorkspace', () => { + it('assembles exactly the params the modal historically sent, omitting empty extras', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + method: 'worktree.create', + params: { + repo: 'id:repo-1', + startupCommand: undefined, + setupDecision: 'inherit', + name: 'octopus' + } + }) + const params = calls[0]?.params as Record + expect('createdWithAgent' in params).toBe(false) + expect('comment' in params).toBe(false) + }) + + it('includes createdWithAgent and comment only when provided', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + startupCommand: 'claude', + createdWithAgentId: 'claude', + comment: 'spike', + setupDecision: 'run' + }) + + expect(calls[0]?.params).toMatchObject({ + repo: 'id:repo-2', + name: 'manatee', + startupCommand: 'claude', + setupDecision: 'run', + createdWithAgent: 'claude', + comment: 'spike' + }) + }) + + it('retries with a numeric suffix on a branch-collision error', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists locally. Pick a different branch name.') + } + return { worktree: { id: 'wt-3' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + const retryParams = calls[1]?.params as Record + expect(retryParams.name).toBe('octopus-2') + }) + + it('retries on the bare older-runtime collision message', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists.') + } + return { worktree: { id: 'wt-4' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + }) + + it('surfaces a non-collision error without retrying', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('SSH connection is not available'), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'skip' + }) + + expect(result).toEqual({ error: 'SSH connection is not available' }) + expect(calls).toHaveLength(1) + }) +}) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts new file mode 100644 index 00000000000..014af558198 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -0,0 +1,37 @@ +import type { TuiAgent } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorkspaceCreateSetupDecision } from './workspace-create-params' + +// The blank/named create path, extracted from NewWorktreeModal so the modal keeps +// only the UI-coupled setup-trust flow. Assembles worktree.create params and +// applies the shared name-collision retry. +export async function createBlankWorkspace(args: { + client: RpcClient + repoId: string + baseName: string + startupCommand: string | undefined + createdWithAgentId: TuiAgent | undefined + comment: string | undefined + setupDecision: WorkspaceCreateSetupDecision +}): Promise { + return createWorktreeWithNameRetry({ + client: args.client, + baseName: args.baseName, + buildParams: (name) => { + const params: Record = { + repo: `id:${args.repoId}`, + startupCommand: args.startupCommand, + setupDecision: args.setupDecision, + name + } + if (args.createdWithAgentId) { + params.createdWithAgent = args.createdWithAgentId + } + if (args.comment) { + params.comment = args.comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts new file mode 100644 index 00000000000..d08d1388a0e --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' + +describe('linked work item builders', () => { + it('maps a GitHub PR into a linked work item', () => { + const linked = buildGitHubLinkedWorkItem({ + type: 'pr', + number: 42, + title: 'Fix bug', + url: 'https://github.com/o/r/pull/42', + repoId: 'repo-1' + }) + expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' }) + }) + + it('maps a GitLab MR into a linked work item', () => { + const linked = buildGitLabLinkedWorkItem({ + type: 'mr', + number: 7, + title: 'Add feature', + url: 'https://gitlab.com/g/p/-/merge_requests/7', + repoId: 'repo-2' + }) + expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' }) + }) + + it('maps a Linear issue with identifier, workspace, and org key', () => { + const linked = buildLinearLinkedWorkItem({ + identifier: 'ENG-9', + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + workspaceId: 'ws-1' + }) + expect(linked).toMatchObject({ + provider: 'linear', + type: 'issue', + number: 0, + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + }) + }) +}) + +describe('shouldApplyAutoName', () => { + it('applies when the name is empty or the previous auto-name', () => { + expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true) + }) + + it('applies when the name is a lookup query (URL / #N)', () => { + expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false) + }) + + it('keeps a deliberately typed name', () => { + expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe( + false + ) + }) +}) + +describe('resolveWorkItemAutoName', () => { + it('slugifies the title subject', () => { + expect( + resolveWorkItemAutoName({ + type: 'issue', + number: 3, + title: 'Fix the Login Bug', + provider: 'github' + }) + ).toBe('fix-the-login-bug') + }) +}) + +describe('buildSmartNameSelection', () => { + const base = (over: Record) => ({ + provider: 'github' as const, + type: 'pr' as const, + number: 12, + title: 'T', + url: 'u', + ...over + }) + + it('maps GitHub PR / issue kinds and numbers the label', () => { + expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({ + kind: 'github-pr', + label: '#12 T', + url: 'u' + }) + expect( + buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined }) + ).toMatchObject({ kind: 'github-issue' }) + }) + + it('maps GitLab MR / issue kinds', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-mr' }) + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-issue' }) + }) + + it('maps Linear with a bare title label', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }), + baseBranch: undefined + }) + ).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' }) + }) + + it('falls back to a branch pill', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({ + kind: 'branch', + label: 'main' + }) + }) + + it('returns null when nothing is selected', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull() + }) +}) + +describe('resolveComposerCreateSelection', () => { + const baseCreateArgs = { + branch: null, + reuseEligibleBranch: null, + reuseSelectedBranch: false, + branchCreateIntent: false, + name: '' + } + + it('prefers a linked work item and passes resolved base fields', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: { + provider: 'github', + type: 'pr', + number: 5, + title: 'T', + url: 'u', + repoId: 'repo-1' + }, + base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' } + }) + expect(selection).toMatchObject({ + kind: 'work-item', + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'pr-5' + }) + }) + + it('marks reuse when the eligible branch is toggled on', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: { baseBranch: 'feature', branchNameOverride: 'feature' }, + branch: { refName: 'feature', localBranchName: 'feature' }, + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true + }) + expect(selection).toEqual({ + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + }) + }) + + it('returns a new-branch selection when create-branch intent is set', () => { + expect( + resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: {}, + branchCreateIntent: true, + name: 'feature/login' + }) + ).toEqual({ kind: 'new-branch', branchName: 'feature/login' }) + }) + + it('returns null with no work item, no branch base, and no intent', () => { + expect( + resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} }) + ).toBeNull() + }) +}) + +describe('resolveComposerBranchPick', () => { + it('auto-names and enables reuse for an unused local branch', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: [] + }) + expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' }) + expect(pick).toMatchObject({ + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true, + name: 'feature' + }) + }) + + it('does not reuse a branch already checked out elsewhere', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: ['refs/heads/feature'] + }) + expect(pick.reuseEligibleBranch).toBeNull() + expect(pick.reuseSelectedBranch).toBe(false) + expect(pick.base.branchNameOverride).toBeUndefined() + }) +}) + +// Keep the exported type aliases referenced so the module surface stays covered. +export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue] diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts new file mode 100644 index 00000000000..ed2bc4dbf2d --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -0,0 +1,153 @@ +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name' +import { + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + shouldApplyWorkspaceSourceAutoName +} from '../../../src/shared/new-workspace/workspace-source' +import { resolveComposerBranchPick as resolveSharedComposerBranchPick } from '../../../src/shared/composer-branch-selection' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export function buildGitHubLinkedWorkItem(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitHubWorkspaceSource(item) +} + +export function buildGitLabLinkedWorkItem(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitLabWorkspaceSource(item) +} + +export function buildLinearLinkedWorkItem(issue: { + identifier: string + title: string + url: string + workspaceId?: string +}): MobileLinkedWorkItem { + return buildLinearWorkspaceSource(issue) +} + +// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name +// replaces the current field only when it's empty, still the last auto-name, or +// a lookup query — never a name the user deliberately typed. +export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { + return shouldApplyWorkspaceSourceAutoName(args) +} + +export function resolveWorkItemAutoName(item: { + type: 'issue' | 'pr' | 'mr' + number: number + title: string + provider: 'github' | 'gitlab' | 'linear' + linearIdentifier?: string +}): string { + return getWorkspaceSourceName({ ...item, url: '' }).seedName +} + +export function resolveLinearAutoName(issue: { identifier: string; title: string }): string { + return getLinearIssueWorkspaceName(issue) +} + +// Derives the pill descriptor from the linked item (or a plain branch base), +// mirroring desktop's smartNameSelection memo. +export function buildSmartNameSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + baseBranch: string | undefined +}): SmartNameSelection | null { + return buildWorkspaceSourceSelection(args) as SmartNameSelection | null +} + +// Derives the create-time selection from composer state: a linked work item wins +// (carrying its resolved base/push fields), else a picked branch, else null (a +// name-only/blank create). +export function resolveComposerCreateSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + base: { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + branch: { refName: string; localBranchName: string } | null + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + branchCreateIntent: boolean + name: string +}): MobileComposerCreateSelection | null { + const { linkedWorkItem, base, branch, reuseEligibleBranch, reuseSelectedBranch } = args + if (linkedWorkItem) { + return { + kind: 'work-item', + item: linkedWorkItem, + baseBranch: base.baseBranch, + compareBaseRef: base.compareBaseRef, + pushTarget: base.pushTarget, + branchNameOverride: base.branchNameOverride + } + } + if (branch && base.baseBranch) { + return { + kind: 'branch', + baseBranch: base.baseBranch, + refName: branch.refName, + localBranchName: branch.localBranchName, + reuse: reuseSelectedBranch && reuseEligibleBranch === branch.localBranchName, + branchNameOverride: base.branchNameOverride + } + } + if (args.branchCreateIntent && args.name.trim()) { + return { kind: 'new-branch', branchName: args.name.trim() } + } + return null +} + +export type ComposerBranchPick = { + base: { baseBranch: string; branchNameOverride?: string } + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + name?: string + lastAutoName?: string +} + +// Pure port of desktop handleSmartBranchSelect's derivation: base + reuse +// eligibility/default + the auto-name to apply, from the shared branch helpers. +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const selection = resolveSharedComposerBranchPick(args) + return { + base: { + baseBranch: selection.baseBranch, + branchNameOverride: selection.branchNameOverride + }, + reuseEligibleBranch: selection.reuseEligibleBranch, + reuseSelectedBranch: selection.defaultReuse, + ...(selection.name !== undefined && selection.lastAutoName !== undefined + ? { name: selection.name, lastAutoName: selection.lastAutoName } + : {}) + } +} + +export type { GitHubWorkItem, GitLabWorkItem, LinearIssue } diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts new file mode 100644 index 00000000000..73c7eaa1839 --- /dev/null +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -0,0 +1,76 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { GitHubPrStartPoint } from '../../../src/shared/types' + +// The resolved start point for a linked PR/MR: the base branch to create from +// plus the optional review-compare ref, push target, and exact branch name. +export type ComposerHostedBase = Pick< + GitHubPrStartPoint, + 'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify' +> + +type HostedBaseResult = ComposerHostedBase | { error: string } + +// Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's +// select-time resolution. The runtime returns a soft { error } payload rather +// than an RPC error for provider failures. +export async function resolveComposerPrBase(args: { + client: RpcClient + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolvePrBase', + { + repo: `id:${repoId}`, + prNumber, + ...(headRefName ? { headRefName } : {}), + ...(baseRefName ? { baseRefName } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + return result +} + +// Resolves a GitLab MR's base via worktree.resolveMrBase. +export async function resolveComposerMrBase(args: { + client: RpcClient + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolveMrBase', + { + repo: `id:${repoId}`, + mrIid, + ...(sourceBranch ? { sourceBranch } : {}), + ...(targetBranch ? { targetBranch } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as HostedBaseResult + if ('error' in result) { + throw new Error(result.error) + } + return result +} diff --git a/mobile/src/tasks/mobile-composer-source-types.ts b/mobile/src/tasks/mobile-composer-source-types.ts new file mode 100644 index 00000000000..d36215189af --- /dev/null +++ b/mobile/src/tasks/mobile-composer-source-types.ts @@ -0,0 +1,64 @@ +import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { + WorkspaceSourceLinkedItem, + WorkspaceSourceSelection +} from '../../../src/shared/new-workspace/workspace-source' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export type { SmartNameMode } + +export type ComposerBaseState = { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + +// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask +// superset): the one work item a Smart selection pins the workspace to. Linear +// items carry the workspace/org routing the runtime needs to relink the issue. +export type MobileLinkedWorkItem = Omit & { + provider: Exclude +} + +export type SmartNameSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + +// The pill descriptor the field renders once a source is selected. Same shape +// as desktop's `SmartWorkspaceNameSelection`. +export type SmartNameSelection = Omit & { + kind: SmartNameSelectionKind +} + +// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default +// is 'opened' (Open). +export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all' + +// The resolved selection the create flow consumes. Work-item selections carry +// the base/push fields the composer resolved at select time; branch selections +// carry the ref + reuse intent. +export type MobileComposerCreateSelection = + | { + kind: 'work-item' + item: MobileLinkedWorkItem + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + | { + kind: 'branch' + baseBranch: string + refName: string + localBranchName: string + reuse: boolean + branchNameOverride?: string + } + // A brand-new branch created by name (no ref picked); the branch is created off + // the repo's default base and the typed name is kept verbatim as the branch. + | { kind: 'new-branch'; branchName: string } diff --git a/mobile/src/tasks/mobile-smart-source-modes.test.ts b/mobile/src/tasks/mobile-smart-source-modes.test.ts new file mode 100644 index 00000000000..dc726569095 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MR_STATE_FILTER, + MR_STATE_FILTER_OPTIONS, + normalizeSmartMode, + resolveAvailableSmartModes, + resolveDefaultSmartMode +} from './mobile-smart-source-modes' + +const fullyAvailable = { + textOnly: false, + tasksSupported: true, + hasRepo: true, + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true +} + +describe('resolveAvailableSmartModes', () => { + it('lists every mode in desktop order when all are available', () => { + expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([ + 'smart', + 'github', + 'linear', + 'gitlab', + 'branches', + 'text' + ]) + }) + + it('collapses to Name for a non-git (text-only) repo', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text']) + }) + + it('drops provider + smart modes when the tasks RPC surface is missing', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([ + 'branches', + 'text' + ]) + }) + + it('gates provider tabs on their availability and a selected repo', () => { + expect( + resolveAvailableSmartModes({ + ...fullyAvailable, + githubAvailable: false, + gitlabAvailable: false + }) + ).toEqual(['smart', 'linear', 'branches', 'text']) + expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([ + 'smart', + 'linear', + 'text' + ]) + }) +}) + +describe('resolveDefaultSmartMode', () => { + it('defaults to smart for a git repo with search', () => { + expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart') + }) + + it('defaults to text for a non-git repo', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text') + }) + + it('defaults to branches for a git repo without the tasks surface', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches') + }) +}) + +describe('normalizeSmartMode', () => { + it('keeps a valid mode and snaps an unavailable one back to default', () => { + expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab') + expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe( + 'smart' + ) + }) +}) + +describe('MR state filters', () => { + it('exposes Open/Merged/Closed/All with an Open default', () => { + expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all']) + expect(DEFAULT_MR_STATE_FILTER).toBe('opened') + }) +}) diff --git a/mobile/src/tasks/mobile-smart-source-modes.ts b/mobile/src/tasks/mobile-smart-source-modes.ts new file mode 100644 index 00000000000..911219d28c4 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.ts @@ -0,0 +1,93 @@ +import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types' + +// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand +// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand +// icons. +export type SmartModeIcon = + | { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' } + | { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' } + +export type SmartModeOption = { + id: SmartNameMode + label: string + icon: SmartModeIcon +} + +// Order + labels + icons mirror desktop getSmartWorkspaceNameModes(): +// Smart · GitHub · Linear · GitLab · Branch · Name. +export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [ + { id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } }, + { id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } }, + { id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } }, + { id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } }, + { id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } }, + { id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } } +] + +export type SmartModeAvailabilityInput = { + textOnly: boolean + tasksSupported: boolean + hasRepo: boolean + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean +} + +// Faithful port of the desktop availableModes filter. Non-git repos collapse to +// the Name tab; provider tabs gate on availability + a selected repo + the tasks +// RPC surface; branches only need a git repo (new-branch-by-name works without +// the search capability). +export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] { + if (input.textOnly) { + return ['text'] + } + return SMART_MODE_OPTIONS.filter((option) => { + switch (option.id) { + case 'smart': + return input.tasksSupported + case 'github': + return input.tasksSupported && input.hasRepo && input.githubAvailable + case 'gitlab': + return input.tasksSupported && input.hasRepo && input.gitlabAvailable + case 'linear': + return input.tasksSupported && input.linearAvailable + case 'branches': + return input.hasRepo + case 'text': + return true + } + }).map((option) => option.id) +} + +// Default mode when the picker opens: 'smart' for a git repo when search is +// available, else the first available mode (branches for git without tasks, +// 'text' for non-git). +export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode { + const available = resolveAvailableSmartModes(input) + if (available.includes('smart')) { + return 'smart' + } + return available[0] ?? 'text' +} + +// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a +// non-git folder), mirroring desktop's snap-to-available effect. +export function normalizeSmartMode( + mode: SmartNameMode, + input: SmartModeAvailabilityInput +): SmartNameMode { + const available = resolveAvailableSmartModes(input) + return available.includes(mode) ? mode : resolveDefaultSmartMode(input) +} + +export type MrStateFilterOption = { id: MrStateFilter; label: string } + +// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'. +export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [ + { id: 'opened', label: 'Open' }, + { id: 'merged', label: 'Merged' }, + { id: 'closed', label: 'Closed' }, + { id: 'all', label: 'All' } +] + +export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened' diff --git a/mobile/src/tasks/mobile-tasks-capability.ts b/mobile/src/tasks/mobile-tasks-capability.ts new file mode 100644 index 00000000000..f8498bfd968 --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-capability.ts @@ -0,0 +1,5 @@ +// Runtime capability the desktop advertises when it supports the mobile Tasks RPC +// surface (github/gitlab/linear work items + repo.searchRefs). Older paired +// desktops omit it, so mobile must degrade to blank/new-branch sources only. +// Mirrors the 'mobile.tasks.v1' entry in src/shared/protocol-version.ts. +export const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' diff --git a/mobile/src/tasks/setup-hook-trust.test.ts b/mobile/src/tasks/setup-hook-trust.test.ts index b09bdd0d4b3..0e2d91bb7ac 100644 --- a/mobile/src/tasks/setup-hook-trust.test.ts +++ b/mobile/src/tasks/setup-hook-trust.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { isSetupHookTrusted, normalizeSetupHookTrust, + persistSetupHookTrustApproval, trustedOrcaHooksWithSetupApproval, wasSetupHookPreviouslyApproved } from './setup-hook-trust' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' describe('setup hook trust', () => { it('trusts a setup script only when the approved hash matches', () => { @@ -71,6 +73,27 @@ describe('setup hook trust', () => { }) }) + it('persists and returns the approved trust state', async () => { + let persisted: unknown + const client = { + sendRequest: async (_method: string, params: unknown) => { + persisted = params + return { ok: true, result: null } + } + } as unknown as RpcClient + + const next = await persistSetupHookTrustApproval({ + client, + trust: {}, + repoId: 'repo-1', + contentHash: 'setup-hash', + alwaysTrust: false + }) + + expect(persisted).toEqual({ trustedOrcaHooks: next }) + expect(isSetupHookTrusted(next, 'repo-1', 'setup-hash')).toBe(true) + }) + it('detects previous setup approval and ignores incomplete trust payloads', () => { expect( wasSetupHookPreviouslyApproved( diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 847420f1e86..04c52152f45 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -1,4 +1,5 @@ import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' export type SetupHookTrust = { contentHash: string @@ -36,6 +37,21 @@ export function trustedOrcaHooksWithSetupApproval(args: { return { ...args.trust, [args.repoId]: nextRepo } } +export async function persistSetupHookTrustApproval(args: { + client: RpcClient + trust: PersistedTrustedOrcaHooks + repoId: string + contentHash: string + alwaysTrust: boolean +}): Promise { + const next = trustedOrcaHooksWithSetupApproval(args) + const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!response.ok) { + throw new Error(response.error.message) + } + return next +} + export function normalizeSetupHookTrust( setupTrust: SetupHookTrust | null | undefined ): SetupHookTrust | null { diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts new file mode 100644 index 00000000000..cffdc287e22 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch } from './smart-source-fan-out' + +type Call = { method: string; params: Record } + +function fakeClient(byMethod: Record, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = byMethod[method] + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const smartArgs = { + mode: 'smart' as const, + query: 'bug', + repoId: 'repo-1', + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true, + mrStateFilter: 'opened' as const, + linearWorkspaceId: null +} + +describe('fanOutSmartSearch', () => { + it('fans out to every provider in smart mode and stamps repoId', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] }, + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] }, + 'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(calls.map((c) => c.method).sort()).toEqual([ + 'github.listWorkItems', + 'gitlab.listWorkItems', + 'linear.searchIssues', + 'repo.searchRefs' + ]) + expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' }) + expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' }) + expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' }) + expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }]) + expect(result.error).toBe('') + }) + + it('swallows a single provider failure in smart mode (best-effort)', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': new Error('gh down'), + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [] }, + 'repo.searchRefs': { refDetails: [] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(result.error).toBe('') + expect(result.gitlabItems).toHaveLength(1) + }) + + it('surfaces the error for a single-provider mode', async () => { + const calls: Call[] = [] + const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls) + const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client }) + expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems']) + expect(result.error).toBe('gl boom') + }) + + it('only searches branches in smart mode when the query is non-empty', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, query: '', client }) + expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs') + }) + + it('skips GitHub in smart mode when GitHub is unavailable', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client }) + expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems') + }) + + it('does not send oversized source queries to any provider', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client }) + expect(calls).toEqual([]) + expect(result).toMatchObject({ + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] + }) + }) +}) diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts new file mode 100644 index 00000000000..e2217597ab9 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -0,0 +1,141 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import { + isSmartWorkspaceSourceQueryWithinLimit, + type SmartNameMode +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + searchBranches, + searchGitHubItems, + searchGitLabItems, + searchLinearIssues +} from './smart-source-search-requests' + +export type SmartFanOutResult = { + githubItems: GitHubWorkItem[] + gitlabItems: GitLabWorkItem[] + linearIssues: LinearIssue[] + branches: BaseRefSearchResult[] + needsGitHubRemote: boolean + error: string +} + +const EMPTY: Omit = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] +} + +function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean { + return githubAvailable && (mode === 'smart' || mode === 'github') +} + +function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean { + return gitlabAvailable && (mode === 'smart' || mode === 'gitlab') +} + +function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean { + return linearAvailable && (mode === 'smart' || mode === 'linear') +} + +function shouldSearchBranches(mode: SmartNameMode, query: string): boolean { + return mode === 'branches' || (mode === 'smart' && query.trim().length > 0) +} + +type FanOutArgs = { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined +} + +// Runs every provider search the active mode needs, concurrently. Smart mode is +// best-effort (a single provider failure never blocks the others); single-provider +// modes surface the failure. No cross-provider ranking/dedup — the shared row +// builder concatenates in provider order. +export async function fanOutSmartSearch(args: FanOutArgs): Promise { + if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) { + // Why: the source limit is an outbound-request boundary, not only a render + // limit; pasted payloads must never fan out to provider CLIs or SSH hosts. + return { ...EMPTY, needsGitHubRemote: false, error: '' } + } + const { + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter + } = args + const isSmart = mode === 'smart' + const tasks = { + github: + shouldSearchGitHub(mode, githubAvailable) && repoId + ? searchGitHubItems(client, repoId, query) + : null, + gitlab: + shouldSearchGitLab(mode, gitlabAvailable) && repoId + ? searchGitLabItems(client, repoId, query, mrStateFilter) + : null, + linear: shouldSearchLinear(mode, linearAvailable) + ? searchLinearIssues(client, query, args.linearWorkspaceId) + : null, + branches: + shouldSearchBranches(mode, query) && repoId ? searchBranches(client, repoId, query) : null + } + const [github, gitlab, linear, branches] = await Promise.allSettled([ + tasks.github ?? Promise.resolve([]), + tasks.gitlab ?? Promise.resolve([]), + tasks.linear ?? Promise.resolve([]), + tasks.branches ?? Promise.resolve([]) + ]) + + let needsGitHubRemote = false + let error = '' + const fail = (reason: unknown) => { + if (!isSmart) { + error = reason instanceof Error ? reason.message : 'Search failed' + } + } + if (github.status === 'rejected') { + if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) { + needsGitHubRemote = true + } else { + fail(github.reason) + } + } + if (gitlab.status === 'rejected') { + fail(gitlab.reason) + } + if (linear.status === 'rejected') { + fail(linear.reason) + } + if (branches.status === 'rejected') { + fail(branches.reason) + } + + return { + ...EMPTY, + githubItems: github.status === 'fulfilled' ? github.value : [], + gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [], + linearIssues: linear.status === 'fulfilled' ? linear.value : [], + branches: branches.status === 'fulfilled' ? branches.value : [], + needsGitHubRemote, + error + } +} diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts new file mode 100644 index 00000000000..f7c724e7637 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + deriveRepoSlug, + findRepoMatchingSlug, + findRepoMatchingSlugForPaste, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' +import type { RpcClient } from '../transport/rpc-client' + +describe('resolvePasteIntent', () => { + it('classifies a GitHub issue/PR URL as a github-link', () => { + expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ + kind: 'github-link', + link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + }) + }) + + it('classifies a bare #number as a github-number', () => { + expect(resolvePasteIntent('#42')).toEqual({ kind: 'github-number', number: 42 }) + }) + + it('classifies a GitLab MR URL as a gitlab-link', () => { + const intent = resolvePasteIntent('https://gitlab.com/group/proj/-/merge_requests/8') + expect(intent?.kind).toBe('gitlab-link') + if (intent?.kind === 'gitlab-link') { + expect(intent.link).toMatchObject({ number: 8, type: 'mr' }) + } + }) + + it('returns null for plain search text', () => { + expect(resolvePasteIntent('login bug')).toBeNull() + }) + + it('rejects oversized paste intents before any exact lookup', () => { + expect( + resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`) + ).toBeNull() + }) +}) + +describe('deriveRepoSlug', () => { + it('prefers the upstream identity', () => { + expect(deriveRepoSlug({ upstream: { owner: 'up', repo: 'stream' } })).toEqual({ + owner: 'up', + repo: 'stream' + }) + }) + + it('parses an SSH remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('parses an HTTPS remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'https://github.com/acme/widgets' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('returns null when no slug can be derived', () => { + expect(deriveRepoSlug({})).toBeNull() + }) +}) + +describe('findRepoMatchingSlug', () => { + const repos: PasteRepoCandidate[] = [ + { id: 'a', displayName: 'A', slug: { owner: 'acme', repo: 'widgets' } }, + { id: 'b', displayName: 'B', slug: null } + ] + + it('matches case-insensitively', () => { + expect(findRepoMatchingSlug(repos, { owner: 'Acme', repo: 'Widgets' })?.id).toBe('a') + }) + + it('returns null when no repo matches', () => { + expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() + }) + + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { + const calls: string[] = [] + const client = { + sendRequest: async (_method: string, params: unknown) => { + const repo = (params as { repo: string }).repo + calls.push(repo) + return { + ok: true, + result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + } + } + } as unknown as RpcClient + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toMatchObject({ id: 'b' }) + expect(calls).toEqual(['id:a', 'id:b']) + }) + + it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => { + let calls = 0 + const client = { + sendRequest: async () => { + calls += 1 + return { + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' } + } + } + } as unknown as RpcClient + const cache = new Map() + + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'widgets' }, cache) + ).resolves.toBeNull() + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'other' }, cache) + ).resolves.toBeNull() + expect(calls).toBe(1) + }) + + it('keeps local matching usable when the optional repo slug lookup rejects', async () => { + const client = { + sendRequest: async () => { + throw new Error('connection closed') + } + } as unknown as RpcClient + + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toBeNull() + }) +}) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts new file mode 100644 index 00000000000..72c23a3737f --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -0,0 +1,178 @@ +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + normalizeGitHubLinkQuery, + parseGitHubIssueOrPRLink, + type GitHubIssueOrPRLink, + type RepoSlug +} from '../../../src/shared/new-workspace/github-links' +import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links' +import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' + +// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived +// best-effort from the repo's remote metadata. +export type PasteRepoCandidate = { + id: string + displayName: string + slug: RepoSlug | null +} + +export type GitHubPasteIntent = + | { kind: 'github-link'; link: GitHubIssueOrPRLink } + | { kind: 'github-number'; number: number } + +export type GitLabPasteIntent = { + kind: 'gitlab-link' + link: NonNullable> +} + +export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null + +// Pure: classify pasted text into a work-item lookup intent. A slug-bearing +// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number'; +// a GitLab issue/MR URL becomes 'gitlab-link'. +export function resolvePasteIntent(query: string): PasteIntent { + if (!isSmartWorkspaceSourceQueryWithinLimit(query)) { + return null + } + const trimmed = query.trim() + if (!trimmed) { + return null + } + const ghLink = parseGitHubIssueOrPRLink(trimmed) + if (ghLink) { + return { kind: 'github-link', link: ghLink } + } + const normalizedGh = normalizeGitHubLinkQuery(trimmed) + if (normalizedGh.directNumber !== null && !/^https?:\/\//i.test(trimmed)) { + return { kind: 'github-number', number: normalizedGh.directNumber } + } + const glLink = parseGitLabIssueOrMRLink(trimmed) + if (glLink) { + return { kind: 'gitlab-link', link: glLink } + } + return null +} + +// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted +// cross-repo URL can be matched to a locally known repo. +export function deriveRepoSlug(repo: { + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null +}): RepoSlug | null { + if (repo.upstream?.owner && repo.upstream.repo) { + return { owner: repo.upstream.owner, repo: repo.upstream.repo } + } + const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' + const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) + if (match) { + return { owner: match[1], repo: match[2] } + } + return null +} + +function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { + if (!a || !b) { + return false + } + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() + ) +} + +export function findRepoMatchingSlug( + repos: readonly PasteRepoCandidate[], + slug: RepoSlug +): PasteRepoCandidate | null { + return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null +} + +export async function findRepoMatchingSlugForPaste( + client: RpcClient, + repos: readonly PasteRepoCandidate[], + slug: RepoSlug, + cache: Map +): Promise { + const projected = findRepoMatchingSlug(repos, slug) + if (projected) { + return projected + } + // Why: projected remote metadata is incomplete for SSH and GitHub Enterprise; + // ask each repo's owning runtime instead of assuming github.com URL syntax. + for (const repo of repos) { + let resolved = cache.get(repo.id) + if (!cache.has(repo.id)) { + try { + const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` }) + if (!response.ok && response.error.code === 'method_not_found') { + // Why: RPC availability is host-wide; avoid repeating an unsupported + // probe for every repo or on the next paste attempt. + repos.forEach((candidate) => cache.set(candidate.id, null)) + return null + } + resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null + } catch { + resolved = null + } + cache.set(repo.id, resolved ?? null) + } + if (slugsEqual(resolved ?? null, slug)) { + return repo + } + } + return null +} + +export async function lookupGitHubItemByNumber( + client: RpcClient, + repoId: string, + number: number +): Promise { + const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitHubItemByOwnerRepo( + client: RpcClient, + repoId: string, + slug: RepoSlug, + number: number, + type: 'issue' | 'pr' +): Promise { + const response = await client.sendRequest('github.workItemByOwnerRepo', { + repo: `id:${repoId}`, + owner: slug.owner, + ownerRepo: slug.repo, + number, + type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitLabItemByPath( + client: RpcClient, + repoId: string, + link: NonNullable> +): Promise { + const response = await client.sendRequest('gitlab.workItemByPath', { + repo: `id:${repoId}`, + host: link.slug.host, + path: link.slug.path, + iid: link.number, + type: link.type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitLabWorkItem | null + return item ? { ...item, repoId } : null +} diff --git a/mobile/src/tasks/smart-source-search-requests.test.ts b/mobile/src/tasks/smart-source-search-requests.test.ts new file mode 100644 index 00000000000..ecf029feb70 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests' + +type Call = { method: string; params: Record } + +function fakeClient(result: unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('scopeGitHubQuery', () => { + it('passes the raw trimmed query so BOTH issues and PRs are returned', () => { + // Empty stays empty (runtime lists recent issues + PRs); no forced is:issue. + expect(scopeGitHubQuery('')).toBe('') + expect(scopeGitHubQuery(' login bug ')).toBe('login bug') + }) + + it('preserves an explicit is:pr / is:issue scope the user typed', () => { + expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth') + expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth') + }) +}) + +describe('searchLinearIssues', () => { + it('lists assigned issues for an empty query (desktop default)', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, '', null) + expect(calls[0]!.method).toBe('linear.listIssues') + expect(calls[0]!.params).toMatchObject({ filter: 'assigned' }) + }) + + it('searches when a query is present', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, 'bug', 'ws-1') + expect(calls[0]!.method).toBe('linear.searchIssues') + expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' }) + }) +}) diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts new file mode 100644 index 00000000000..5875cd4d642 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -0,0 +1,119 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { extractLinearIssueReadItems } from './linear-mobile-issue-read' +import { PER_REPO_FETCH_LIMIT } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' + +const GITLAB_PER_PAGE = 50 +const LINEAR_LIMIT = 50 +const BRANCH_LIMIT = 20 + +// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's +// parseTaskQuery defaults scope 'all', and an empty query lists recent items of +// both types. So pass the raw trimmed query straight through (an explicit +// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty +// so the runtime lists recent issues + PRs. +export function scopeGitHubQuery(query: string): string { + return query.trim() +} + +export async function searchGitHubItems( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest('github.listWorkItems', { + repo: `id:${repoId}`, + limit: PER_REPO_FETCH_LIMIT, + query: scopeGitHubQuery(query) + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] } + // Stamp repoId so the shared row builder + create flow can attribute each item + // to the searched repo (the runtime omits it, like the desktop fetcher). + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchGitLabItems( + client: RpcClient, + repoId: string, + query: string, + state: MrStateFilter +): Promise { + const response = await client.sendRequest('gitlab.listWorkItems', { + repo: `id:${repoId}`, + state, + page: 1, + perPage: GITLAB_PER_PAGE, + query: query.trim() || undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { + items: GitLabWorkItem[] + error?: { type?: string; message: string } + } + if (envelope.error?.type && envelope.error.type !== 'not_found') { + throw new Error(envelope.error.message) + } + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchLinearIssues( + client: RpcClient, + query: string, + linearWorkspaceId: string | null | undefined +): Promise { + const trimmed = query.trim() + const response = trimmed + ? await client.sendRequest('linear.searchIssues', { + query: trimmed, + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + : await client.sendRequest('linear.listIssues', { + // Empty query lists the viewer's assigned issues, matching desktop's + // Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')). + filter: 'assigned', + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + // extractLinearIssueReadItems yields the mobile issue-read shape; the fields the + // row builder/create flow read (id/identifier/title/url/state/team) are a subset. + return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[] +} + +export async function searchBranches( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest( + 'repo.searchRefs', + { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as { + refDetails?: BaseRefSearchResult[] + refs?: string[] + } + return ( + result.refDetails ?? + (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) + ) +} diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts new file mode 100644 index 00000000000..d23e0c66317 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createWorkspaceFromComposerSource } from './source-workspace-create' +import type { MobileComposerCreateSelection } from './mobile-composer-source-types' + +type Call = { method: string; params: Record } + +function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = handle(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const agent = { choice: 'blank' as const, startupCommand: undefined } + +const baseArgs = { + targetRepoId: 'repo-1', + setupDecision: 'inherit' as const, + agent, + workspaceName: undefined, + note: undefined +} + +describe('createWorkspaceFromComposerSource', () => { + it('creates a GitHub issue workspace linking the issue to its own repo', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-9' + } + } + // The composer supplies the title-derived name as workspaceName; with none, + // buildTaskWorkspaceCreateParams falls back to the "-" slug. + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.method).toBe('worktree.create') + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-9', + linkedIssue: 7, + displayName: 'Bug' + }) + }) + + it('passes composer-resolved PR base fields straight through (no re-resolve)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'pr', + number: 3, + title: 'Feat', + url: 'u', + repoId: 'repo-1' + }, + baseBranch: 'main', + compareBaseRef: 'origin/main', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' }, + branchNameOverride: 'feat-3' + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.create']) + expect(calls[0]!.params).toMatchObject({ + linkedPR: 3, + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feat-3', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' } + }) + }) + + it('resolves a PR base as a fallback when the selection carries none', async () => { + const calls: Call[] = [] + const client = fakeClient( + (method) => + method === 'worktree.resolvePrBase' + ? { baseBranch: 'develop' } + : { worktree: { id: 'wt-3' } }, + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { provider: 'github', type: 'pr', number: 4, title: 'X', url: 'u', repoId: 'repo-1' } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.resolvePrBase', 'worktree.create']) + expect(calls[1]!.params).toMatchObject({ baseBranch: 'develop', linkedPR: 4 }) + }) + + it('creates a Linear workspace with workspace + org routing', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-4' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + linkedLinearIssue: 'ENG-9', + linkedLinearIssueWorkspaceId: 'ws-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) + + it('reuses an existing branch with a single attempt (no suffix retry)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('Branch "feature" already exists.'), calls) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect('error' in result).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.params).toMatchObject({ + baseBranch: 'feature', + branchNameOverride: 'feature' + }) + }) + + it('creates a brand-new branch by name, keeping a slashy name as the branch', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-nb' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'new-branch', + branchName: 'feature/login' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature/login' }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + name: 'feature/login', + branchNameOverride: 'feature/login' + }) + }) + + it('suppresses displayName when the name is user-edited (not auto-managed)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-1' + } + } + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + workspaceName: 'my-name', + nameIsAutoManaged: false + }) + expect(calls[0]!.params.displayName).toBeUndefined() + expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 }) + }) + + it('creates a new branch off a ref, bumping the branch on collision', async () => { + const calls: Call[] = [] + const client = fakeClient( + (_m, n) => (n === 1 ? new Error('already exists locally') : { worktree: { id: 'wt-5' } }), + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'main', + localBranchName: 'topic', + reuse: false, + branchNameOverride: 'topic' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-5', name: 'topic-2' }) + expect(calls).toHaveLength(2) + expect(calls[1]!.params).toMatchObject({ + baseBranch: 'main', + branchNameOverride: 'topic-2', + name: 'topic-2' + }) + }) +}) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts new file mode 100644 index 00000000000..d16cc174142 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.ts @@ -0,0 +1,250 @@ +import type { RpcClient } from '../transport/rpc-client' +import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem +} from './mobile-composer-source-types' +import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' +import type { WorkspaceAgentChoice } from './workspace-agent-selection' +import { + buildTaskWorkspaceCreateParams, + type WorkspaceCreateSetupDecision, + type WorkspaceCreateTaskItem +} from './workspace-create-params' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' + +// The agent bundle the modal already resolved: the choice drives +// buildTaskWorkspaceCreateParams for work-item sources; the explicit launch +// command is used for branch sources (which have no work-item URL to seed the draft). +export type WorkspaceCreateAgentBundle = { + choice: WorkspaceAgentChoice + startupCommand: string | undefined +} + +export type CreateWorkspaceFromComposerArgs = { + client: RpcClient + selection: MobileComposerCreateSelection + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +} + +export async function createWorkspaceFromComposerSource( + args: CreateWorkspaceFromComposerArgs +): Promise { + if (args.selection.kind === 'branch') { + return createBranchWorkspace({ ...args, selection: args.selection }) + } + if (args.selection.kind === 'new-branch') { + return createNewBranchWorkspace({ ...args, selection: args.selection }) + } + return createWorkItemWorkspace({ ...args, selection: args.selection }) +} + +function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem { + if (item.provider === 'github') { + return { + provider: 'github', + source: { + type: item.type === 'pr' ? 'pr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + if (item.provider === 'gitlab') { + return { + provider: 'gitlab', + source: { + type: item.type === 'mr' ? 'mr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + return { + provider: 'linear', + source: { + identifier: item.linearIdentifier ?? '', + title: item.title, + url: item.url, + ...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}), + ...(item.linearOrganizationUrlKey + ? { organizationUrlKey: item.linearOrganizationUrlKey } + : {}) + } + } +} + +async function createWorkItemWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const item = selection.item + const taskItem = toTaskItem(item, targetRepoId) + + // The composer resolves PR/MR base at select time; only re-resolve as a + // fallback when a linked PR/MR reached create without one. + let baseBranch = selection.baseBranch + let compareBaseRef = selection.compareBaseRef + let pushTarget = selection.pushTarget + let branchNameOverride = selection.branchNameOverride + if (!baseBranch && item.provider !== 'linear' && (item.type === 'pr' || item.type === 'mr')) { + const repoId = item.repoId ?? targetRepoId + const resolved = + item.type === 'pr' + ? await resolveComposerPrBase({ client, repoId, prNumber: item.number }).catch(() => null) + : await resolveComposerMrBase({ client, repoId, mrIid: item.number }).catch(() => null) + if (resolved) { + baseBranch = resolved.baseBranch + compareBaseRef = resolved.compareBaseRef + pushTarget = resolved.pushTarget + branchNameOverride = resolved.branchNameOverride ?? branchNameOverride + } + } + + const params = buildTaskWorkspaceCreateParams({ + item: taskItem, + targetRepoId, + setupDecision, + agent: agent.choice, + workspaceName, + note, + baseBranch, + compareBaseRef, + branchNameOverride, + pushTarget, + nameIsAutoManaged: args.nameIsAutoManaged + }) + // buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base + // so collisions still append -2, -3, ... like the blank path does. + const baseName = String(params.name) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (name) => ({ ...params, name }) + }) +} + +async function createBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + const applyCommon = (params: Record): Record => { + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + + if (selection.reuse) { + // Reusing a fixed existing branch: branchNameOverride is pinned to the reused + // branch, so a branch collision can't be cleared by suffixing the display + // name — fail fast instead of burning the retry budget. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + maxAttempts: 1, + buildParams: (name) => + applyCommon({ + repo: `id:${targetRepoId}`, + name, + setupDecision, + baseBranch: selection.refName, + branchNameOverride: selection.localBranchName, + startupCommand: agent.startupCommand + }) + }) + } + + // New branch off the selected ref. The retry base is the branch name so a + // collision bumps the branch itself. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.branchNameOverride || selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + baseBranch: selection.baseBranch, + startupCommand: agent.startupCommand + } + if (selection.branchNameOverride) { + params.branchNameOverride = candidate + } + return applyCommon(params) + } + }) +} + +async function createNewBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + // A brand-new branch off the repo's default base. The typed name is kept as the + // git branch (via branchNameOverride) so a slash like `feature/login` survives; + // the runtime sanitizes the worktree folder from the same name. The retry base is + // the branch name so a collision bumps the branch (and folder) together. + return createWorktreeWithNameRetry({ + client, + baseName: selection.branchName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + branchNameOverride: candidate, + startupCommand: agent.startupCommand + } + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts new file mode 100644 index 00000000000..6785e2fe30b --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -0,0 +1,330 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection' +import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity' +import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning' +import type { RpcClient } from '../transport/rpc-client' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveLinearAutoName, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' +import { + resolveComposerMrBase, + resolveComposerPrBase, + type ComposerHostedBase +} from './composer-source-base-resolve' +import type { + ComposerBaseState, + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +const EMPTY_BASE: ComposerBaseState = {} + +export type UseMobileComposerSourceArgs = { + client: RpcClient | null + selectedRepoId: string | null + worktreeBranches?: readonly string[] + onError?: (message: string) => void +} + +export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { + const { client, selectedRepoId, worktreeBranches = [], onError } = args + const [name, setNameState] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState(null) + const [base, setBase] = useState(EMPTY_BASE) + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) + const [forkPushWarning, setForkPushWarning] = useState(null) + const [resolvingBase, setResolvingBase] = useState(false) + // Set when the "Create branch " row is picked, so the typed name (which + // may contain slashes) is kept verbatim as the git branch (folder is sanitized). + const [branchCreateIntent, setBranchCreateIntent] = useState(false) + + const lastAutoNameRef = useRef('') + const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null) + // Guards async base resolution: only the latest selection applies its result. + const resolveTokenRef = useRef(0) + + const setName = useCallback((value: string) => setNameState(value), []) + + const applyAutoName = useCallback((suggested: string, currentName: string) => { + if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + }, []) + + const clearBaseAndBranch = useCallback(() => { + branchSelectionRef.current = null + setBranchCreateIntent(false) + setBase(EMPTY_BASE) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) + setForkPushWarning(null) + // Why: a superseding selection bumps the resolve token, so an in-flight base + // resolve's token-gated finally can no longer clear this — reset it here so + // resolvingBase never sticks true after switching sources. + setResolvingBase(false) + }, []) + + // Applies an async PR/MR base resolution guarded by the current token so only + // the latest selection wins; failures clear the base and surface the error. + const runBaseResolve = useCallback( + (token: number, resolve: Promise) => { + setResolvingBase(true) + void resolve + .then((result) => { + if (resolveTokenRef.current !== token) { + return + } + setBase({ + baseBranch: result.baseBranch, + compareBaseRef: result.compareBaseRef, + pushTarget: result.pushTarget, + branchNameOverride: result.branchNameOverride + }) + setForkPushWarning(getForkPushWarning(result)) + }) + .catch((error: unknown) => { + if (resolveTokenRef.current !== token) { + return + } + setBase(EMPTY_BASE) + onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.') + }) + .finally(() => { + if (resolveTokenRef.current === token) { + setResolvingBase(false) + } + }) + }, + [onError] + ) + + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem) => { + const token = (resolveTokenRef.current += 1) + const identity = resolveGitHubWorkItemIdentity(item) + // Resolve the PR base against the item's OWN repo — a cross-repo accept + // switches repos then selects synchronously, so selectedRepoId is stale. + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitHubLinkedWorkItem({ + type: identity.type, + number: identity.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ ...identity, title: item.title, provider: 'github' }), + name + ) + clearBaseAndBranch() + if (identity.type !== 'pr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerPrBase({ + client, + repoId, + prNumber: identity.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartGitLabItemSelect = useCallback( + (item: GitLabWorkItem) => { + const token = (resolveTokenRef.current += 1) + // Resolve the MR base against the item's OWN repo (see the GitHub handler). + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitLabLinkedWorkItem({ + type: item.type, + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ + type: item.type, + number: item.number, + title: item.title, + provider: 'gitlab' + }), + name + ) + clearBaseAndBranch() + if (item.type !== 'mr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerMrBase({ + client, + repoId, + mrIid: item.number, + ...(item.branchName ? { sourceBranch: item.branchName } : {}), + ...(item.baseRefName ? { targetBranch: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(buildLinearLinkedWorkItem(issue)) + const suggested = resolveLinearAutoName(issue) + const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase() + if ( + suggested && + (identifierTyped || + shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current })) + ) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + clearBaseAndBranch() + }, + [clearBaseAndBranch, name] + ) + + const handleSmartBranchSelect = useCallback( + (refName: string, localBranchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + setForkPushWarning(null) + setBranchCreateIntent(false) + setResolvingBase(false) + const pick = resolveComposerBranchPick({ + refName, + localBranchName, + currentName: name, + lastAutoName: lastAutoNameRef.current, + worktreeBranches + }) + setReuseEligibleBranch(pick.reuseEligibleBranch) + setReuseSelectedBranch(pick.reuseSelectedBranch) + setBase(pick.base) + branchSelectionRef.current = { refName, localBranchName } + if (pick.name !== undefined) { + setNameState(pick.name) + lastAutoNameRef.current = pick.lastAutoName ?? '' + } + }, + [name, worktreeBranches] + ) + + // Picking "Create branch ": name the workspace and mark a new-branch + // intent so the typed (possibly slashy) name is kept verbatim as the git branch. + const handleSmartCreateBranch = useCallback( + (branchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setNameState(branchName) + lastAutoNameRef.current = branchName + setBranchCreateIntent(true) + }, + [clearBaseAndBranch] + ) + + const handleClearSmartNameSelection = useCallback(() => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setResolvingBase(false) + if (name === lastAutoNameRef.current) { + setNameState('') + lastAutoNameRef.current = '' + } + }, [clearBaseAndBranch, name]) + + const handleBranchNameOverrideChange = useCallback( + (value: string) => { + const next = resolveComposerManualBranchNameChange({ + value, + pushTarget: base.pushTarget, + forkPushWarning + }) + setBase({ + ...base, + branchNameOverride: next.branchNameOverride, + pushTarget: next.pushTarget + }) + setForkPushWarning(next.forkPushWarning) + }, + [base, forkPushWarning] + ) + + const smartNameSelection = useMemo( + () => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }), + [base.baseBranch, linkedWorkItem] + ) + + const createSelection = useMemo( + () => + resolveComposerCreateSelection({ + linkedWorkItem, + base, + branch: branchSelectionRef.current, + reuseEligibleBranch, + reuseSelectedBranch, + branchCreateIntent, + name + }), + [base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch] + ) + + // Auto-managed until the user edits the name away from the last derived value; + // desktop suppresses the workspace displayName once the name is user-edited. + const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current + + return { + name, + setName, + linkedWorkItem, + branchNameOverride: base.branchNameOverride, + handleBranchNameOverrideChange, + reuseEligibleBranch, + reuseSelectedBranch, + setReuseSelectedBranch, + forkPushWarning, + resolvingBase, + isNameAutoManaged, + smartNameSelection, + createSelection, + handleSmartGitHubItemSelect, + handleSmartGitLabItemSelect, + handleSmartLinearIssueSelect, + handleSmartBranchSelect, + handleSmartCreateBranch, + handleClearSmartNameSelection + } +} + +export type MobileComposerSource = ReturnType diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts new file mode 100644 index 00000000000..1d0c88dafd5 --- /dev/null +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + buildSmartWorkspaceSourceRows, + getSmartWorkspaceEmptyHint, + type SmartNameMode, + type SmartWorkspaceSourceRow +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + findRepoMatchingSlugForPaste, + lookupGitHubItemByNumber, + lookupGitHubItemByOwnerRepo, + lookupGitLabItemByPath, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' + +const DEBOUNCE_MS = 200 +const RESULT_LIMIT = 36 + +export type SmartCrossRepoPrompt = { + link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + matchingRepo: PasteRepoCandidate +} + +export type UseSmartWorkspaceSourceArgs = { + client: RpcClient | null + enabled: boolean + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId?: string | null + repos: readonly PasteRepoCandidate[] +} + +const EMPTY_FAN: SmartFanOutResult = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [], + needsGitHubRemote: false, + error: '' +} + +type PasteResolved = { github: GitHubWorkItem | null; gitlab: GitLabWorkItem | null } + +export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { + const { + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + } = args + const [fan, setFan] = useState(EMPTY_FAN) + const [paste, setPaste] = useState({ github: null, gitlab: null }) + const [loading, setLoading] = useState(false) + const [crossRepoPrompt, setCrossRepoPrompt] = useState(null) + // Why: preserve results across keystrokes (debounce) but drop them the moment + // the mode/repo changes so one provider's rows never render under another tab. + const scopeRef = useRef('') + const dismissedPasteRef = useRef('') + const repoSlugCacheRef = useRef>(new Map()) + + useEffect(() => { + if (!client || !enabled || mode === 'text') { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setLoading(false) + setCrossRepoPrompt(null) + return + } + const scope = `${mode}:${repoId ?? ''}` + const scopeChanged = scopeRef.current !== scope + scopeRef.current = scope + if (scopeChanged) { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setCrossRepoPrompt(null) + } + setLoading(true) + let stale = false + const timer = setTimeout(() => { + void runSmartSearch({ + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos, + dismissedPasteRef, + repoSlugCache: repoSlugCacheRef.current + }) + .then((result) => { + if (stale) { + return + } + setFan(result.fan) + setPaste(result.paste) + setCrossRepoPrompt(result.crossRepoPrompt) + setLoading(false) + }) + .catch(() => { + if (!stale) { + setLoading(false) + } + }) + }, DEBOUNCE_MS) + return () => { + stale = true + clearTimeout(timer) + } + }, [ + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + ]) + + const rows = useMemo( + () => + buildSmartWorkspaceSourceRows({ + branches: fan.branches, + githubItems: paste.github ? [paste.github] : fan.githubItems, + gitlabAvailable, + gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems, + linearAvailable, + linearIssues: fan.linearIssues, + mode, + resultLimit: RESULT_LIMIT, + value: query + }), + [fan, gitlabAvailable, linearAvailable, mode, paste, query] + ) + + const dismissCrossRepoPrompt = useCallback(() => { + dismissedPasteRef.current = query.trim() + setCrossRepoPrompt(null) + }, [query]) + + return { + rows, + loading, + error: fan.error, + needsGitHubRemote: fan.needsGitHubRemote, + emptyHint: getSmartWorkspaceEmptyHint(mode), + crossRepoPrompt, + dismissCrossRepoPrompt + } +} + +async function runSmartSearch(args: { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined + repos: readonly PasteRepoCandidate[] + dismissedPasteRef: { current: string } + repoSlugCache: Map +}): Promise<{ + fan: SmartFanOutResult + paste: PasteResolved + crossRepoPrompt: SmartCrossRepoPrompt | null +}> { + const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args + const fan = await fanOutSmartSearch(args) + const paste: PasteResolved = { github: null, gitlab: null } + let crossRepoPrompt: SmartCrossRepoPrompt | null = null + + const intent = + mode === 'branches' || dismissedPasteRef.current === query.trim() + ? null + : resolvePasteIntent(query) + if (intent && repoId) { + try { + if (intent.kind === 'github-number') { + paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) + } else if (intent.kind === 'github-link') { + const matchingRepo = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) + if (matchingRepo && matchingRepo.id !== repoId) { + crossRepoPrompt = { link: intent.link, matchingRepo } + } else { + paste.github = await lookupGitHubItemByOwnerRepo( + client, + repoId, + intent.link.slug, + intent.link.number, + intent.link.type + ) + } + } else if (intent.kind === 'gitlab-link') { + paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link) + } + } catch { + // Best-effort paste resolution; fall back to the fan-out results. + } + } + return { fan, paste, crossRepoPrompt } +} diff --git a/mobile/src/tasks/work-item-lookup-text.test.ts b/mobile/src/tasks/work-item-lookup-text.test.ts new file mode 100644 index 00000000000..bd37f46d606 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { isWorkItemLookupText } from './work-item-lookup-text' + +describe('isWorkItemLookupText', () => { + it('treats references as lookup text, not names', () => { + expect(isWorkItemLookupText('#42')).toBe(true) + expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true) + expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true) + expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true) + expect(isWorkItemLookupText('ENG-9')).toBe(false) + }) + + it('treats plain names as non-lookup text', () => { + expect(isWorkItemLookupText('')).toBe(false) + expect(isWorkItemLookupText('fix the login bug')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) + }) +}) diff --git a/mobile/src/tasks/work-item-lookup-text.ts b/mobile/src/tasks/work-item-lookup-text.ts new file mode 100644 index 00000000000..b130292e8d3 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.ts @@ -0,0 +1 @@ +export * from '../../../src/shared/new-workspace/work-item-lookup-text' diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index f15c7afce30..e53c7965116 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -144,7 +144,7 @@ describe('task workspace create params', () => { ).toMatchObject({ repo: 'id:repo-linear', name: 'eng-42', - displayName: 'Ship Linear parity', + displayName: 'ENG-42 Ship Linear parity', linkedLinearIssue: 'ENG-42', startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', createdWithAgent: 'grok' diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index ec945a11828..c6667470279 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -1,19 +1,16 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { + CreateSparseCheckoutRequest, + GitPushTarget, + SetupDecision, + TuiAgent +} from '../../../src/shared/types' +import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' -export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip' - -export type WorkspaceCreateSparseCheckout = { - directories: string[] - presetId?: string -} - -export type WorkspaceCreateGitPushTarget = { - remoteName: string - branchName: string - remoteUrl?: string -} +export type WorkspaceCreateSetupDecision = SetupDecision +export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest +export type WorkspaceCreateGitPushTarget = GitPushTarget export type WorkspaceCreateHostedStartPoint = { baseBranch: string @@ -48,6 +45,8 @@ type WorkspaceCreateLinearItem = { identifier: string title: string url: string + workspaceId?: string + organizationUrlKey?: string } } @@ -66,9 +65,12 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName?: string note?: string baseBranch?: string + compareBaseRef?: string branchNameOverride?: string + pushTarget?: WorkspaceCreateGitPushTarget sparseCheckout?: WorkspaceCreateSparseCheckout hostedStartPoint?: WorkspaceCreateHostedStartPoint + nameIsAutoManaged?: boolean }): WorkspaceCreateParams { const { item, @@ -78,22 +80,41 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName, note, baseBranch, + compareBaseRef, branchNameOverride, + pushTarget, sparseCheckout, - hostedStartPoint + hostedStartPoint, + nameIsAutoManaged = true } = args const shouldLaunchAgent = agent !== 'blank' const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined const comment = note?.trim() const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch + const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget + // Why: desktop only sends displayName while the name is still auto-derived; a + // user-edited name suppresses it so the runtime keeps the user's chosen name. + const sourceName = + item.provider === 'linear' + ? getWorkspaceSourceName({ + provider: 'linear', + type: 'issue', + number: 0, + title: item.source.title, + url: item.source.url, + linearIdentifier: item.source.identifier + }) + : getWorkspaceSourceName({ provider: item.provider, ...item.source }) + const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {} const common = { setupDecision, activate: true, ...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}), ...(createdWithAgent ? { createdWithAgent } : {}), ...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}), + ...(compareBaseRef ? { compareBaseRef } : {}), ...(branchNameOverride ? { branchNameOverride } : {}), - ...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}), + ...(selectedPushTarget ? { pushTarget: selectedPushTarget } : {}), ...(sparseCheckout ? { sparseCheckout } : {}), ...(comment ? { comment } : {}) } @@ -103,7 +124,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedIssue: item.source.number } @@ -116,7 +137,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedGitLabIssue: item.source.number } @@ -130,8 +151,12 @@ export function buildTaskWorkspaceCreateParams(args: { draft: workspaceName, fallback: item.source.identifier.toLowerCase() }), - displayName: item.source.title, + ...displayName, linkedLinearIssue: item.source.identifier, + ...(item.source.workspaceId ? { linkedLinearIssueWorkspaceId: item.source.workspaceId } : {}), + ...(item.source.organizationUrlKey + ? { linkedLinearIssueOrganizationUrlKey: item.source.organizationUrlKey } + : {}), ...common } } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts new file mode 100644 index 00000000000..4eb749f5c23 --- /dev/null +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -0,0 +1,46 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' + +// Why: server-side collision checks (branch already exists locally / on a remote +// / already has PR #N) can fire even after a pre-flight basename dedupe — +// branches outlive worktrees in git, and remote branches/PRs aren't visible from +// worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree +// loop in src/renderer/src/store/slices/worktrees.ts. +export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } + +// Creates a worktree, retrying with a numeric suffix on a name-collision error. +// buildParams receives the candidate name so callers can assemble source-specific +// params (linked issue/PR, base branch, etc.) around it. Callers that can't clear +// a collision by re-suffixing (e.g. reusing a fixed existing branch) pass +// maxAttempts: 1 to fail fast instead of burning the full retry budget. +export async function createWorktreeWithNameRetry(args: { + client: RpcClient + baseName: string + buildParams: (name: string) => Record + maxAttempts?: number +}): Promise { + const { client, baseName, buildParams } = args + const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS + let lastError: string | null = null + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(baseName, attempt) + const response = await client.sendRequest('worktree.create', buildParams(candidateName), { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + return { worktreeId: result.worktree.id, name: candidateName } + } + lastError = response.error.message + if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { + break + } + } + return { error: lastError ?? 'Failed to create workspace' } +} diff --git a/mobile/src/transport/protocol-compat.test.ts b/mobile/src/transport/protocol-compat.test.ts new file mode 100644 index 00000000000..d70ffd61bb1 --- /dev/null +++ b/mobile/src/transport/protocol-compat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { evaluateCompat } from './protocol-compat' + +describe('evaluateCompat', () => { + it('allows the current mobile app to connect before a protocol-2 desktop updates', () => { + expect( + evaluateCompat({ + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + ).toEqual({ kind: 'ok' }) + }) +}) diff --git a/package.json b/package.json index a062d03ca1f..1bf2bc74f30 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pr-evidence/1-before-both-collapsed.png b/pr-evidence/1-before-both-collapsed.png new file mode 100644 index 00000000000..e8b721a239b Binary files /dev/null and b/pr-evidence/1-before-both-collapsed.png differ diff --git a/pr-evidence/2-agents-expanded.png b/pr-evidence/2-agents-expanded.png new file mode 100644 index 00000000000..9b3c71f4791 Binary files /dev/null and b/pr-evidence/2-agents-expanded.png differ diff --git a/pr-evidence/3-after-children-toggle-agents-still-expanded.png b/pr-evidence/3-after-children-toggle-agents-still-expanded.png new file mode 100644 index 00000000000..413204d402f Binary files /dev/null and b/pr-evidence/3-after-children-toggle-agents-still-expanded.png differ diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 93623967e57..008ab2eb764 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -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 diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 6247ee9f45c..c1f46a602fb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -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:` 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) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 1619cf9a9ef..c6e7fb8d10e 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -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:` 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') } diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index f495f91b5e7..e9a88921943 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -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 { 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) diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index 8cc8f029b2e..c7050310834 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -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 { const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - const parsed = splitWorktreeId(worktreeId) + // Why: oldWorktreePath feeds an on-disk folder move. Resolve the synthetic + // `::workspace:` 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, diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 6324e75174e..547679df46f 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -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', () => { diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 136a2a682c9..3297b9be9c1 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -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 ` } diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 166db56079d..61b95d0e50d 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -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: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index ab7fba775f9..5c3f40cafe0 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -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 { - try { + const runPull = async (effectiveArgs: string[]): Promise => { 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/. 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')) } diff --git a/src/main/index.ts b/src/main/index.ts index b76964ae872..5196b7eac01 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -229,6 +229,10 @@ const recoveryReloadInFlight = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() let managedWslCliReconciliationReady: Promise = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = 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 { 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, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 35d386eb473..4838711803d 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 305ebabfc14..0dd30b24b1e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 ) } diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index 829aa8c3b82..168b32b48ad 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -13,35 +13,55 @@ export type WorktreeBaseCollectedChanges = { overflow: boolean structureRepoIds: string[] gitStatusRepoIds: string[] + headIdentityRepoIds: string[] +} + +type ChangeBuckets = { + structureRepoIds: Set + gitStatusRepoIds: Set + headIdentityRepoIds: Set +} + +function emptyBuckets(): ChangeBuckets { + return { + structureRepoIds: new Set(), + gitStatusRepoIds: new Set(), + headIdentityRepoIds: new Set() + } } function emptyChanges(): WorktreeBaseCollectedChanges { - return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] } + return { + overflow: false, + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + } } function addMatchingChange( target: WorktreeBaseWatchTarget, event: WorktreeBaseWatcherEvent, - structureRepoIds: Set, - gitStatusRepoIds: Set + 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, - gitStatusRepoIds: Set -): 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() - const gitStatusRepoIds = new Set() + 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() - const gitStatusRepoIds = new Set() + 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) } diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts index 2a87b9f0b25..330be8b925c 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.test.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -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', () => { diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 7f161e33823..551af5a6568 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -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( diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 7f580421aaa..c560b5e51aa 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -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) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index d62eb2384ba..23352d688f1 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -25,6 +25,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { notifyTimer: ReturnType | null pendingStructureRepoIds: Set pendingGitStatusRepoIds: Set + pendingHeadIdentityRepoIds: Set headIdentityRefresh: WorktreeHeadIdentityRefreshState disposed: boolean } @@ -35,13 +36,17 @@ let syncGeneration = 0 let scheduledSync: ReturnType | 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> + +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 { 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) }) diff --git a/src/main/ipc/worktree-folder-rename-target.ts b/src/main/ipc/worktree-folder-rename-target.ts index 9e52fc15fa7..5725b4efd75 100644 --- a/src/main/ipc/worktree-folder-rename-target.ts +++ b/src/main/ipc/worktree-folder-rename-target.ts @@ -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 ?? ''}` } } diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index 6f0fc39acb6..0d61f25f48e 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -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 diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 9f4df5bde87..8943c223dd2 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -91,7 +91,9 @@ export async function readNativeChatTranscriptCached( ): Promise { 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) diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index c6c2223c81f..cd87f2bdd59 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -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) + } }) }) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 1dbbd7dd82a..e2ea727c809 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -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 { 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) } } } diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 55009521a99..4ff621d05bc 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -24,6 +24,14 @@ async function tempFile(initial: string): Promise { 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 { + 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) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 4bbd934f0eb..1f55c33124a 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -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 { - 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 { + 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 | 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 { + 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 { + 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) +} diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 90ccae11ea6..3d9962fcb48 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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:` 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 } diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 550295460ad..fe0d94ab9cb 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -24,6 +24,7 @@ describe('addOrcaWslInteropEnv', () => { const env: Record = { 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') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 161621e8c0f..3f136e0a284 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -24,6 +24,7 @@ export function addOrcaWslInteropEnv(env: Record): 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', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9aa5c00a853..96ba22d5cea 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 293075176e9..6f1ca862fbe 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 } diff --git a/src/main/runtime/orchestration/cli-command.test.ts b/src/main/runtime/orchestration/cli-command.test.ts new file mode 100644 index 00000000000..1d07d335c8a --- /dev/null +++ b/src/main/runtime/orchestration/cli-command.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts new file mode 100644 index 00000000000..c0f4883f1e4 --- /dev/null +++ b/src/main/runtime/orchestration/cli-command.ts @@ -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' +} diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 8563c3a1e87..cb532b71e36 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -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 () => { diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 73307eddd35..04f0788577c 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -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. diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 198437e4cd7..ef133551034 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -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', diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 9aaa488318f..a8012c56854 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -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' diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index fc7536c2e4f..95339cb3190 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -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' }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 9caaf1ac57a..bd2721d1b99 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -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 } } diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 0c21af7de72..1b8afbd2f46 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -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. diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 625959f6d64..d81faa169cd 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -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 }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d27d771f321..55be4d0b21e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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 diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 9712f1c23d5..010ef2bea11 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => { }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) + + it('accepts worktree.create payloads sent by the previous mobile protocol', () => { + const create = methodParams(WORKTREE_METHODS, 'worktree.create') + + expectParses(create, { + repo: 'id:repo-github', + name: 'fix-mobile-tasks', + displayName: 'Fix mobile tasks', + setupDecision: 'run', + activate: true, + startupDraft: 'https://github.com/acme/app/pull/123', + createdWithAgent: 'codex', + linkedPR: 123, + baseBranch: 'origin/main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feature/mobile-tasks', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + }) + expectParses(create, { + repo: 'id:repo-gitlab', + name: 'mr-7', + linkedGitLabMR: 7, + sparseCheckout: { directories: ['mobile'], presetId: 'mobile' }, + comment: 'keep mobile parity' + }) + expectParses(create, { + repo: 'id:repo-linear', + name: 'eng-42', + linkedLinearIssue: 'ENG-42', + linkedLinearIssueWorkspaceId: 'workspace-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index ace969138c4..67261b221b3 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -232,11 +232,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.updatePRState', 'github.repoSlug', 'github.workItem', + // Cross-repo GitHub work-item lookup: lets the mobile create-workspace Smart + // picker resolve a pasted github.com URL that points at a different repo. + 'github.workItemByOwnerRepo', 'github.workItemDetails', 'gitlab.createIssue', 'gitlab.addIssueComment', 'gitlab.addMRComment', 'gitlab.listWorkItems', + // Mobile create-workspace Smart picker: resolve a pasted GitLab URL to an exact + // issue/MR. (MR listing reuses gitlab.listWorkItems, which returns issues + MRs.) + 'gitlab.workItemByPath', 'gitlab.mergeMR', 'gitlab.resolveMRDiscussion', 'gitlab.todos', diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index d99177285af..b9adfc6b8f2 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -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)') diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts index e59201e6a02..b2a56a415e8 100644 --- a/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts @@ -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((resolve) => { - resolveReconciliation = resolve + const reconciliation = new Promise((_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((resolve) => { - resolveWork = resolve + const reconciliation = new Promise((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((_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() } diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts index 5f70ad7eeb0..056f225b8de 100644 --- a/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts @@ -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, @@ -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((resolve) => { diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 718711b629d..c2a730f9026 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -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() } }, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index cc03c40a904..0dd54c2c598 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5784d5f77c7..9fd010a8695 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 83466e7b962..9e5b7e419e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 diff --git a/src/relay/git-handler-pull-reconciliation.test.ts b/src/relay/git-handler-pull-reconciliation.test.ts index 1820faa0fec..5db00d16202 100644 --- a/src/relay/git-handler-pull-reconciliation.test.ts +++ b/src/relay/git-handler-pull-reconciliation.test.ts @@ -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) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index c6709cd3f2a..510dd60f84b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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, pullArgs: string[]) { this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string + const runPull = async (effectiveArgs: string[]): Promise => { + 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/. 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/. 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. diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 5f1e46eecdd..7c505ae652d 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -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 Promise>> } +type InstalledSupervisorWatch = InstalledWatch & { dir: string } + +function stubRelayWatcherSupervisors() { + const installed = new Map() + 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' diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts index af6cb8285e2..e0093b9a166 100644 --- a/src/relay/relay-watcher-process-pool.ts +++ b/src/relay/relay-watcher-process-pool.ts @@ -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, diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 8b1ee8fd01d..9b6fcef5b29 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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(() => { @@ -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') } } diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts new file mode 100644 index 00000000000..78889b1d2db --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts @@ -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 }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts new file mode 100644 index 00000000000..507eee5543d --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts @@ -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 + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + settings: AppState['settings'] +} + +export function buildWorktreeChecksReviewIndex({ + worktrees, + repoByHostIdentity, + prCache, + hostedReviewCache, + settings +}: WorktreeChecksReviewIndexArgs): Map { + const reviews = new Map() + 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 +} diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts new file mode 100644 index 00000000000..2e9254e86bf --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + EMPTY_WORKTREE_PALETTE_CACHE_INPUTS, + selectWorktreePaletteCacheInputs +} from './worktree-palette-cache-inputs' + +describe('selectWorktreePaletteCacheInputs', () => { + it('stays referentially stable across review cache writes while the palette is closed', () => { + const emptyState: Pick = { + prCache: {}, + issueCache: {}, + hostedReviewCache: {} + } + const filledState: Pick = { + prCache: { pr: { data: null, fetchedAt: 1 } }, + issueCache: { issue: { data: null, fetchedAt: 1 } }, + hostedReviewCache: { review: { data: null, fetchedAt: 1 } } + } + + const before = selectWorktreePaletteCacheInputs(emptyState, false) + const after = selectWorktreePaletteCacheInputs(filledState, false) + + expect(before).toBe(EMPTY_WORKTREE_PALETTE_CACHE_INPUTS) + expect(after).toBe(before) + }) + + it('returns live cache identities while the palette is visible', () => { + const state = { + prCache: {}, + issueCache: {}, + hostedReviewCache: {} + } as Pick + + const selected = selectWorktreePaletteCacheInputs(state, true) + + expect(selected).toEqual(state) + expect(selected.prCache).toBe(state.prCache) + expect(selected.issueCache).toBe(state.issueCache) + expect(selected.hostedReviewCache).toBe(state.hostedReviewCache) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts new file mode 100644 index 00000000000..1a1c234f12d --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts @@ -0,0 +1,29 @@ +import type { AppState } from '@/store/types' + +export type WorktreePaletteCacheInputs = { + prCache: AppState['prCache'] | null + issueCache: AppState['issueCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null +} + +export const EMPTY_WORKTREE_PALETTE_CACHE_INPUTS: WorktreePaletteCacheInputs = Object.freeze({ + prCache: null, + issueCache: null, + hostedReviewCache: null +}) + +export function selectWorktreePaletteCacheInputs( + state: Pick, + active: boolean +): WorktreePaletteCacheInputs { + // Why: the palette stays mounted while closed; cache replacement from Checks + // must not rerender it when no search results are visible. + if (!active) { + return EMPTY_WORKTREE_PALETTE_CACHE_INPUTS + } + return { + prCache: state.prCache, + issueCache: state.issueCache, + hostedReviewCache: state.hostedReviewCache + } +} diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 21b116ea246..43a9276b15e 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -346,8 +346,9 @@ function EditorPanelInner({ activeMarkdownContent && extractFrontMatter(activeMarkdownContent) ) + // Why: front-matter shows by default; the map only carries per-file hide overrides. const isMarkdownFrontmatterVisible = - markdownFrontmatterVisible[markdownDocumentStateFileId] ?? false + markdownFrontmatterVisible[markdownDocumentStateFileId] ?? true const isMarkdownTableOfContentsVisible = markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 38d146c1312..f47b3610f3c 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -619,12 +619,11 @@ export default function MarkdownPreview({ .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') .trim() }, [frontMatter]) - // Why: front matter is hidden by default (#4468) and controlled from the - // markdown preview actions menu, keeping metadata out of the reading surface - // unless the user explicitly asks for it. + // Why: front matter shows by default and is toggled off from the markdown + // preview actions menu; the store map only carries per-file hide overrides. const toggleableSourceFileId: string | null = sourceFileId ?? null const frontmatterVisible = toggleableSourceFileId - ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? false) + ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? true) : true const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState(null) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx new file mode 100644 index 00000000000..c8be699f8f6 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cancelPendingSends: vi.fn(), + fieldProps: null as { onSend?: () => void; onStop?: () => void } | null, + sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, + sendNativeChatMessage: vi.fn(), + trackPendingSend: vi.fn(), + setDraft: vi.fn() +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ dictationState: 'idle', settings: { voice: { enabled: false } } }) +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: () => ({}) +})) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatMessageWithImageAttachments: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] })) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('./use-native-chat-draft', () => ({ + useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft }) +})) +vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' })) +vi.mock('./NativeChatComposerField', () => ({ + NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { + mocks.fieldProps = props + return null + } +})) +vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => [] })) +vi.mock('./use-native-chat-composer-attachments', () => ({ + useNativeChatComposerAttachments: () => ({ + imageAttachments: [], + attachResolvedPaths: vi.fn(), + clearImageAttachments: vi.fn(), + removeImageAttachment: vi.fn() + }) +})) +vi.mock('./use-native-chat-composer-paste', () => ({ + useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() }) +})) +vi.mock('./use-native-chat-external-attachments', () => ({ + useNativeChatExternalAttachments: () => ({ + attachExternalPaths: vi.fn(), + resolveAttachmentOwner: vi.fn() + }) +})) +vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) +vi.mock('./use-native-chat-composer-keydown', () => ({ + useNativeChatComposerKeyDown: () => vi.fn() +})) +vi.mock('./use-native-chat-send-lifecycle', () => ({ + useNativeChatSendLifecycle: () => ({ + cancelPendingSends: mocks.cancelPendingSends, + trackPendingSend: mocks.trackPendingSend + }) +})) + +import { NativeChatComposer } from './NativeChatComposer' + +describe('NativeChatComposer', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.fieldProps = null + mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + Object.defineProperty(window, 'api', { + configurable: true, + value: { ui: { onFileDrop: () => vi.fn() } } + }) + }) + + afterEach(() => cleanup()) + + it('cancels delayed composer writes before the Stop button interrupts the agent', () => { + const onStop = vi.fn() + render( + + ) + + act(() => mocks.fieldProps?.onStop?.()) + + expect(mocks.cancelPendingSends).toHaveBeenCalledOnce() + expect(onStop).toHaveBeenCalledOnce() + expect(mocks.cancelPendingSends.mock.invocationCallOrder[0]).toBeLessThan( + onStop.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('associates a delayed submit with its optimistic cache entry', () => { + const onOptimisticSend = vi.fn(() => 'pending-1') + render( + + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) + expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d85a3a59bbe..41b254639dc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -17,6 +17,7 @@ import { sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt } from './native-chat-runtime-send' +import type { NativeChatSendHandle } from './native-chat-runtime-send' import { getAgentSlashCommands } from './native-chat-agent-commands' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { @@ -44,6 +45,7 @@ import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' +import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' // Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a // PTY (matching how xterm forwards Escape). The richer interrupt-intent @@ -70,7 +72,9 @@ export type NativeChatComposerProps = { onStop?: () => void /** Optional optimistic-send hook: called with the sent text so the view can * render a "queued" echo until the real transcript turn lands (mobile parity). */ - onOptimisticSend?: (text: string, imagePaths?: string[]) => void + onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + /** Remove an optimistic echo when its delayed submit is canceled. */ + onOptimisticSendCanceled?: (pendingId: string) => void /** Called with a dispatched slash command (e.g. `/clear`) so the view can show * a small "Ran /clear" system line — slash commands aren't chat turns and * otherwise leave no visible trace that anything happened. */ @@ -111,6 +115,7 @@ export const NativeChatComposer = forwardRef(null) + const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( + terminalTabId, + targetPtyId, + onOptimisticSendCanceled + ) const dictationState = useAppStore((store) => store.dictationState) const voiceSettings = useAppStore((store) => store.settings?.voice) const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' @@ -293,21 +303,33 @@ export const NativeChatComposer = forwardRef 0) { - sendNativeChatMessageWithImageAttachments(target.settings, target.ptyId, text, imagePaths) + pendingHandle = sendNativeChatMessageWithImageAttachments( + target.settings, + target.ptyId, + text, + imagePaths + ) } else if (text.trim().length > 0) { - sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) } else { submitNativeChatPrompt(target.settings, target.ptyId) } // Slash commands don't echo a user bubble, but DO surface a small // "Ran /clear" system line so the command leaves a visible trace. if (isSlashCommand) { + if (pendingHandle) { + trackPendingSend(pendingHandle) + } onSlashCommand?.(text.trim()) } else { - onOptimisticSend?.(text, imagePaths) + const pendingId = onOptimisticSend?.(text, imagePaths) + if (pendingHandle) { + trackPendingSend(pendingHandle, pendingId) + } } // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. @@ -329,10 +351,12 @@ export const NativeChatComposer = forwardRef { + cancelPendingSends() if (isWorking && onStop) { onStop() return @@ -342,7 +366,7 @@ export const NativeChatComposer = forwardRef { @@ -362,7 +386,7 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index a9b3bca01ce..1f8af9bcef7 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -25,6 +25,7 @@ import { appendCommandMarkerCache, launchPromptAsMessage, pendingSendsAsMessages, + nextNativeChatPendingSendId, prunePendingSends, readCommandMarkerCache, readPendingSendCache, @@ -211,7 +212,6 @@ function NativeChatResolvedView({ const [pending, setPending] = useState(() => readPendingSendCache(pendingScope) ) - const pendingCounter = useRef(0) // Slash commands aren't chat turns, so they get a small local "Ran /clear" // system line instead of a user bubble. Capped + cached per conversation. const [commandMarkers, setCommandMarkers] = useState(() => @@ -246,14 +246,27 @@ function NativeChatResolvedView({ const onOptimisticSend = useCallback( (text: string, imagePaths?: string[]) => { setWorkingInterrupted(false) - pendingCounter.current += 1 + const sentAt = Date.now() + const boundary = session.messages.at(-1) const entry: NativeChatPendingSend = { - id: `${pendingCounter.current}`, + id: nextNativeChatPendingSendId(sentAt), text, - sentAt: Date.now(), + sentAt, + afterMessageId: boundary?.id ?? null, + afterMessageTimestamp: boundary?.timestamp ?? null, ...(imagePaths ? { imagePaths } : {}) } setPending(appendPendingSendCache(pendingScope, entry)) + return entry.id + }, + [pendingScope, session.messages] + ) + const onOptimisticSendCanceled = useCallback( + (pendingId: string) => { + // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo + // must not come back from the pane cache as a prompt that was delivered. + const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) + setPending(writePendingSendCache(pendingScope, next)) }, [pendingScope] ) @@ -293,15 +306,20 @@ function NativeChatResolvedView({ // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. - const streamingText = useMemo( - () => - deriveNativeChatStreamingText({ - messages: sessionAfterCommandBoundaries.messages, - previewText: hookPreview, - working: hookWorking - }), - [sessionAfterCommandBoundaries.messages, hookPreview, hookWorking] + const pendingMessages = useMemo( + () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), + [pending, sessionAfterCommandBoundaries.messages] ) + const streamingText = useMemo(() => { + return deriveNativeChatStreamingText({ + messages: + pendingMessages.length > 0 + ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] + : sessionAfterCommandBoundaries.messages, + previewText: hookPreview, + working: hookWorking + }) + }, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, hookWorking]) const sessionWithPending = useMemo(() => { if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { return sessionAfterCommandBoundaries @@ -312,10 +330,10 @@ function NativeChatResolvedView({ ...sessionAfterCommandBoundaries.messages, ...commandMarkersAsMessages(commandMarkers), ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), - ...pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages) + ...pendingMessages ] } - }, [sessionAfterCommandBoundaries, pending, commandMarkers, streamingText]) + }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) // Derive the view state from the pending-augmented session so a send into an // otherwise-empty conversation flips to the list (showing the queued bubble) // instead of staying on the empty state. @@ -436,6 +454,7 @@ function NativeChatResolvedView({ isWorking={isWorking} onStop={stopAgent} onOptimisticSend={onOptimisticSend} + onOptimisticSendCanceled={onOptimisticSendCanceled} onSlashCommand={onSlashCommand} /> {contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts new file mode 100644 index 00000000000..1d284d64b3e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { + nativeChatLaunchAgentForLeaf, + resolveNativeChatLeafRoute +} from './native-chat-leaf-routing' + +describe('nativeChatLaunchAgentForLeaf', () => { + it('uses the tab launch hint only for its sole leaf', () => { + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-a', + leafIds: ['leaf-a'] + }) + ).toBe('claude') + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-b', + leafIds: ['leaf-a'] + }) + ).toBeNull() + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-a', + leafIds: [] + }) + ).toBeNull() + }) + + it('does not lend the original launch agent to either leaf of a mixed split', () => { + const leafIds = ['agent-leaf', 'shell-leaf'] + + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'agent-leaf', + leafId: 'agent-leaf', + leafIds + }) + ).toBeNull() + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'agent-leaf', + leafId: 'shell-leaf', + leafIds + }) + ).toBeNull() + }) + + it('does not transfer the launch hint when the original leaf closes', () => { + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'closed-agent-leaf', + leafId: 'remaining-shell-leaf', + leafIds: ['remaining-shell-leaf'] + }) + ).toBeNull() + }) +}) + +describe('resolveNativeChatLeafRoute', () => { + it('keeps chat attached to its eligible leaf when focus moves to a shell sibling', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'agent-leaf', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: true, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: 'agent-leaf', exitChat: false }) + }) + + it('moves chat to an eligible active sibling after its leaf closes', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'closed-leaf', + activeLeafId: 'agent-sibling', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + }) + + it('moves chat to an eligible active sibling when its mounted leaf becomes ineligible', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'stopped-agent', + activeLeafId: 'agent-sibling', + chatLeafStillMounted: true, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + }) + + it('exits chat rather than inheriting an active shell after close', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'closed-agent', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: null, exitChat: true }) + }) + + it('exits chat when its leaf becomes ineligible and the active leaf is a shell', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'stopped-agent', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: null, exitChat: true }) + }) + + it('attaches a tab-level chat request to the eligible active leaf', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: null, + activeLeafId: 'active-agent', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'active-agent', exitChat: false }) + }) + + it('waits through manager hydration when there is no concrete active leaf', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'restored-agent', + activeLeafId: null, + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: 'restored-agent', exitChat: false }) + }) + + it('clears leaf ownership after returning to terminal view', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: false, + chatLeafId: 'agent-leaf', + activeLeafId: 'agent-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: true, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: null, exitChat: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts new file mode 100644 index 00000000000..f381f589014 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -0,0 +1,94 @@ +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TuiAgent +} from '../../../../shared/types' + +function layoutNodeContainsLeaf(node: TerminalPaneLayoutNode | null, leafId: string): boolean { + if (!node) { + return false + } + if (node.type === 'leaf') { + return node.leafId === leafId + } + return layoutNodeContainsLeaf(node.first, leafId) || layoutNodeContainsLeaf(node.second, leafId) +} + +export function resolveNativeChatActiveLayoutLeafId( + layout: TerminalLayoutSnapshot | null | undefined +): string | null { + if (!layout) { + return null + } + if (layout.activeLeafId) { + // Why: close/hydration races can leave activeLeafId one snapshot behind + // the topology; stale pane evidence must not route chat to a removed leaf. + return !layout.root || layoutNodeContainsLeaf(layout.root, layout.activeLeafId) + ? layout.activeLeafId + : null + } + return layout.root?.type === 'leaf' ? layout.root.leafId : null +} + +export function isNativeChatTabWideFallbackSafe( + layout: TerminalLayoutSnapshot | null | undefined +): boolean { + if (!layout?.root) { + return true + } + if (layout.root.type === 'split') { + return false + } + // Why: a stale active id means the single-leaf collapse is not yet settled; + // tab-wide launch/title evidence could still describe the removed sibling. + return !layout.activeLeafId || layout.activeLeafId === layout.root.leafId +} + +export function nativeChatLaunchAgentForLeaf(args: { + launchAgent?: TuiAgent | null + launchAgentLeafId: string | null + leafId: string | null + leafIds: readonly string[] +}): TuiAgent | null { + const { launchAgent, launchAgentLeafId, leafId, leafIds } = args + if (!launchAgent || !launchAgentLeafId || !leafId) { + return null + } + // Why: launchAgent belongs to the tab's original pane. Once a split exists, + // it is not evidence that an agent is running in any particular sibling. + return leafIds.length === 1 && leafIds[0] === leafId && launchAgentLeafId === leafId + ? launchAgent + : null +} + +export type NativeChatLeafRoute = { + chatLeafId: string | null + exitChat: boolean +} + +export function resolveNativeChatLeafRoute(args: { + isChatViewMode: boolean + chatLeafId: string | null + activeLeafId: string | null + chatLeafStillMounted: boolean + chatLeafIsEligible: boolean + activeLeafIsEligible: boolean +}): NativeChatLeafRoute { + if (!args.isChatViewMode) { + return { chatLeafId: null, exitChat: false } + } + if (args.chatLeafId && args.chatLeafStillMounted && args.chatLeafIsEligible) { + return { chatLeafId: args.chatLeafId, exitChat: false } + } + // Manager hydration can briefly have no active pane; preserve the requested + // mode until a concrete leaf exists instead of toggling it off during mount. + if (!args.activeLeafId) { + return { chatLeafId: args.chatLeafId, exitChat: false } + } + if (args.activeLeafIsEligible) { + return { chatLeafId: args.activeLeafId, exitChat: false } + } + // Why: closing or invalidating the chat-owning leaf must not move its composer + // onto a plain-shell sibling. Return the tab to terminal mode instead. + return { chatLeafId: null, exitChat: true } +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts new file mode 100644 index 00000000000..06d7111095d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + appendPendingSendCache, + clearPendingSendCacheForTests, + pendingSendsAsMessages, + prunePendingSends, + type NativeChatPendingSendScope +} from './native-chat-pending' + +const scope: NativeChatPendingSendScope = { paneKey: 'tab:leaf', agent: 'codex' } + +function message( + id: string, + role: 'user' | 'assistant', + text: string, + timestamp: number +): NativeChatMessage { + return { + id, + role, + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +describe('pending send occurrence reconciliation', () => { + beforeEach(() => clearPendingSendCacheForTests()) + + it('keeps the next identical echo after pruning an earlier occurrence', () => { + const first = appendPendingSendCache(scope, { + id: 'p1', + text: 'repeat', + sentAt: 100, + afterMessageId: 'paged-out-boundary' + }) + const repeated = appendPendingSendCache(scope, { + id: 'p2', + text: 'repeat', + sentAt: 200, + afterMessageId: 'paged-out-boundary' + }) + expect(first[0]?.matchingOccurrence).toBeUndefined() + expect(repeated[1]).toMatchObject({ matchingOccurrence: 2, matchingAfterTimestamp: 100 }) + + const firstCompletedTurn = [ + message('u1', 'user', 'repeat', 150), + message('a1', 'assistant', 'done', 160) + ] + const afterFirstPrune = prunePendingSends(repeated, firstCompletedTurn) + + expect(afterFirstPrune.map((entry) => entry.id)).toEqual(['p2']) + expect( + pendingSendsAsMessages(afterFirstPrune, firstCompletedTurn).map((entry) => entry.id) + ).toEqual(['pending:p2']) + + const secondCompletedTurn = [ + ...firstCompletedTurn, + message('u2', 'user', 'repeat', 250), + message('a2', 'assistant', 'done again', 260) + ] + expect(pendingSendsAsMessages(afterFirstPrune, secondCompletedTurn)).toEqual([]) + expect(prunePendingSends(afterFirstPrune, secondCompletedTurn)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts new file mode 100644 index 00000000000..3c94e5bfadd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -0,0 +1,119 @@ +import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import { + isImageRefBlock, + isTextBlock, + type NativeChatMessage +} from '../../../../shared/native-chat-types' + +export type NativeChatPendingOccurrence = { + text: string + imagePaths?: readonly string[] + sentAt: number + afterMessageId?: string | null + afterMessageTimestamp?: number | null + matchingOccurrence?: number + matchingAfterTimestamp?: number +} + +export function normalizeNativeChatPendingText(text: string): string { + return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') +} + +export function nativeChatPendingContentKey( + pending: Pick +): string { + const text = normalizeNativeChatPendingText(pending.text) + if (text) { + return `text:${text}` + } + const imagePaths = pending.imagePaths?.filter(Boolean) ?? [] + return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty' +} + +function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join(' ') + const imagePaths = message.blocks + .filter(isImageRefBlock) + .map((block) => block.path) + .filter((path): path is string => Boolean(path)) + const key = nativeChatPendingContentKey({ text, imagePaths }) + return key === 'empty' ? null : key +} + +export function matchingNativeChatUserContentCounts( + messages: readonly NativeChatMessage[] +): Map { + const counts = new Map() + for (const message of messages) { + const key = nativeChatUserMessageContentKey(message) + if (key) { + counts.set(key, (counts.get(key) ?? 0) + 1) + } + } + return counts +} + +export function advancedNativeChatUserContentCounts( + messages: readonly NativeChatMessage[] +): Map { + const advanced = new Map() + const waiting = new Map() + for (const message of messages) { + if (message.role === 'user') { + const key = nativeChatUserMessageContentKey(message) + if (key) { + waiting.set(key, (waiting.get(key) ?? 0) + 1) + } + continue + } + for (const [key, count] of waiting) { + advanced.set(key, (advanced.get(key) ?? 0) + count) + } + waiting.clear() + } + return advanced +} + +export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string { + return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}` +} + +export function assignNativeChatPendingOccurrence( + existing: readonly T[], + entry: T +): T { + const key = nativeChatPendingMatchKey(entry) + const matching = existing.filter((candidate) => nativeChatPendingMatchKey(candidate) === key) + if (matching.length === 0) { + return entry + } + const previousOccurrence = Math.max( + ...matching.map((candidate, index) => candidate.matchingOccurrence ?? index + 1) + ) + const first = matching[0] + // Why: pruning an earlier echo must not let a later identical send reuse the + // same transcript occurrence, even after the read pages out its boundary. + return { + ...entry, + matchingOccurrence: previousOccurrence + 1, + matchingAfterTimestamp: + first?.matchingAfterTimestamp ?? first?.afterMessageTimestamp ?? first?.sentAt + } +} + +export function nativeChatPendingMatchingAfter(pending: NativeChatPendingOccurrence): number { + return pending.matchingAfterTimestamp ?? pending.afterMessageTimestamp ?? pending.sentAt +} + +export function nativeChatPendingOccurrence( + pending: NativeChatPendingOccurrence, + alreadyConsumed: number +): number { + return pending.matchingOccurrence ?? alreadyConsumed + 1 +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 58393460467..b63dba307b2 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -11,6 +11,7 @@ import { isLaunchPromptMessageId, isPendingMessageId, launchPromptAsMessage, + nextNativeChatPendingSendId, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, @@ -41,6 +42,16 @@ function assistantMessage(id: string, text: string): NativeChatMessage { } } +function imageMessage(id: string, ...paths: string[]): NativeChatMessage { + return { + id, + role: 'user', + blocks: paths.map((path) => ({ type: 'image-ref' as const, path })), + timestamp: 1, + source: 'transcript' + } +} + const pendingOf = (id: string, text: string): NativeChatPendingSend => ({ id, text, sentAt: 100 }) describe('prunePendingSends', () => { @@ -84,6 +95,16 @@ describe('prunePendingSends', () => { expect(next).toEqual([]) }) + it('drops an attachment-only pending send once its image turn advances', () => { + const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/first.png', '/tmp/second.png'] }] + const transcript = [ + imageMessage('m1', '/tmp/first.png', '/tmp/second.png'), + assistantMessage('m2', 'two images') + ] + + expect(prunePendingSends(pending, transcript)).toEqual([]) + }) + it('keeps a pending send that has not landed yet', () => { const pending = [pendingOf('p1', 'not yet')] const next = prunePendingSends(pending, [assistantMessage('m1', 'working on it')]) @@ -104,6 +125,21 @@ describe('prunePendingSends', () => { ]) expect(next).toEqual([pendingOf('p2', 'second')]) }) + + it('does not prune a repeated prompt against a turn before its send boundary', () => { + const oldUser = userMessage('old-user', 'run tests') + const oldAnswer = assistantMessage('old-answer', 'passed') + const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: oldAnswer.id }] + + expect(prunePendingSends(pending, [oldUser, oldAnswer])).toEqual(pending) + }) + + it('prunes only one of two identical pending sends for one completed turn', () => { + const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] + expect( + prunePendingSends(pending, [userMessage('u1', 'repeat'), assistantMessage('a1', 'done')]) + ).toEqual([pendingOf('p2', 'repeat')]) + }) }) describe('pendingSendsAsMessages', () => { @@ -130,12 +166,65 @@ describe('pendingSendsAsMessages', () => { ]) }) + it('hides an attachment-only pending send while its real image turn is visible', () => { + const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/shot.png'] }] + + expect(pendingSendsAsMessages(pending, [imageMessage('u1', '/tmp/shot.png')])).toEqual([]) + }) + it('hides a pending send while its real user turn is visible', () => { const pending = [pendingOf('p1', 'first prompt')] expect(pendingSendsAsMessages(pending, [userMessage('u1', 'first prompt')])).toEqual([]) expect(pendingSendsAsMessages(pending, [])).toHaveLength(1) }) + + it('keeps a repeated prompt visible when its only match predates the send boundary', () => { + const history = [userMessage('old-user', 'run tests'), assistantMessage('old-answer', 'passed')] + const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: 'old-answer' }] + + expect(pendingSendsAsMessages(pending, history).map((message) => message.id)).toEqual([ + 'pending:new-send' + ]) + }) + + it('keeps a loading-time send visible when older matching history arrives later', () => { + const history = [ + { ...userMessage('old-user', 'run tests'), timestamp: 10 }, + { ...assistantMessage('old-answer', 'passed'), timestamp: 20 } + ] + const pending = [{ ...pendingOf('new-send', 'run tests'), sentAt: 100, afterMessageId: null }] + + expect(pendingSendsAsMessages(pending, history).map((message) => message.id)).toEqual([ + 'pending:new-send' + ]) + expect(prunePendingSends(pending, history)).toEqual(pending) + }) + + it('uses the transcript boundary clock after pagination, not the renderer send clock', () => { + const pending = [ + { + ...pendingOf('new-send', 'run tests'), + sentAt: 100_000, + afterMessageId: 'paged-out-answer', + afterMessageTimestamp: 20 + } + ] + const remoteTranscript = [ + { ...userMessage('new-user', 'run tests'), timestamp: 30 }, + { ...assistantMessage('new-answer', 'passed'), timestamp: 40 } + ] + + expect(pendingSendsAsMessages(pending, remoteTranscript)).toEqual([]) + expect(prunePendingSends(pending, remoteTranscript)).toEqual([]) + }) + + it('hides only one of two identical pending sends for one real user turn', () => { + const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] + expect(pendingSendsAsMessages(pending, [userMessage('u1', 'repeat')]).map((m) => m.id)).toEqual( + ['pending:p2'] + ) + }) }) describe('launchPromptAsMessage', () => { @@ -165,7 +254,7 @@ describe('launchPromptAsMessage', () => { text: 'Fix failing checks', createdAt: 42 }, - [userMessage('u1', 'Fix failing checks')] + [{ ...userMessage('u1', 'Fix failing checks'), timestamp: 43 }] ) ).toBeNull() }) @@ -180,11 +269,14 @@ describe('launchPromptAsMessage', () => { ' fix spacing' ].join('\n') const transcript = [ - userMessage( - 'u1', - 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing' - ), - assistantMessage('a1', 'I will fix it') + { + ...userMessage( + 'u1', + 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing' + ), + timestamp: 43 + }, + { ...assistantMessage('a1', 'I will fix it'), timestamp: 44 } ] expect( @@ -208,14 +300,34 @@ describe('launchPromptAsMessage', () => { createdAt: 42 } - expect(shouldPruneLaunchPrompt(prompt, [userMessage('u1', 'Fix failing checks')])).toBe(false) expect( shouldPruneLaunchPrompt(prompt, [ - userMessage('u1', 'Fix failing checks'), - assistantMessage('a1', 'working') + { ...userMessage('u1', 'Fix failing checks'), timestamp: 43 } + ]) + ).toBe(false) + expect( + shouldPruneLaunchPrompt(prompt, [ + { ...userMessage('u1', 'Fix failing checks'), timestamp: 43 }, + { ...assistantMessage('a1', 'working'), timestamp: 44 } ]) ).toBe(true) }) + + it('does not bind a launch prompt to an older identical completed turn', () => { + const entry = { + tabId: 'tab-1', + agent: 'claude' as const, + text: 'run tests', + createdAt: 100 + } + const oldHistory = [ + { ...userMessage('old-user', 'run tests'), timestamp: 10 }, + { ...assistantMessage('old-answer', 'passed'), timestamp: 20 } + ] + + expect(launchPromptAsMessage(entry, oldHistory)).not.toBeNull() + expect(shouldPruneLaunchPrompt(entry, oldHistory)).toBe(false) + }) }) describe('pending send cache', () => { @@ -230,6 +342,13 @@ describe('pending send cache', () => { expect(readPendingSendCache({ ...scope, agent: 'claude' })).toEqual([]) }) + it('mints unique ids across chat-view remounts while the cache survives', () => { + clearPendingSendCacheForTests() + const first = nextNativeChatPendingSendId(100) + const second = nextNativeChatPendingSendId(100) + expect(second).not.toBe(first) + }) + it('clears cached pending sends when pruning removes all entries', () => { clearPendingSendCacheForTests() const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index c03f4c0b96a..92dbaf17ef7 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -1,12 +1,20 @@ // Pure logic for desktop optimistic "queued" composer sends (mobile parity). // A sent prompt is echoed immediately as a queued entry and pruned once its real // user turn lands in the transcript. Kept separate from the view so the prune -// rule (match on normalized user-message text) is unit-testable without React. +// rule (match on normalized user-message content) is unit-testable without React. -import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import { + advancedNativeChatUserContentCounts, + assignNativeChatPendingOccurrence, + matchingNativeChatUserContentCounts, + nativeChatPendingContentKey, + nativeChatPendingMatchKey, + nativeChatPendingMatchingAfter, + nativeChatPendingOccurrence +} from './native-chat-pending-occurrence' /** An optimistic, not-yet-confirmed composer send. */ export type NativeChatPendingSend = { @@ -18,6 +26,15 @@ export type NativeChatPendingSend = { imagePaths?: string[] /** Epoch ms when the send was issued, so the queued bubble sorts to the end. */ sentAt: number + /** Last authoritative transcript message visible when this send was issued. + * Matching starts after it so repeated prompts cannot bind to an old turn. */ + afterMessageId?: string | null + /** Timestamp of that boundary in the transcript host's clock domain. */ + afterMessageTimestamp?: number | null + /** 1-based occurrence among identical sends sharing the same boundary. */ + matchingOccurrence?: number + /** Shared time boundary when that message boundary is unavailable. */ + matchingAfterTimestamp?: number } export type NativeChatPendingSendScope = { @@ -27,6 +44,7 @@ export type NativeChatPendingSendScope = { const PENDING_SEND_LIMIT = 8 const pendingSendCache = new Map() +let pendingSendCounter = 0 function pendingSendScopeKey(scope: NativeChatPendingSendScope): string { return `${scope.paneKey}\0${scope.agent}` @@ -57,56 +75,48 @@ export function appendPendingSendCache( scope: NativeChatPendingSendScope, entry: NativeChatPendingSend ): NativeChatPendingSend[] { - return writePendingSendCache(scope, [...readPendingSendCache(scope), entry]) + const existing = readPendingSendCache(scope) + const next = assignNativeChatPendingOccurrence(existing, entry) + return writePendingSendCache(scope, [...existing, next]) } export function clearPendingSendCacheForTests(): void { pendingSendCache.clear() + pendingSendCounter = 0 } -function normalize(text: string): string { - return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') -} - -/** The prose of a user message, normalized for matching against a pending send. */ -function userMessageText(message: NativeChatMessage): string | null { - if (message.role !== 'user') { - return null +function messagesAfterPendingBoundary( + messages: readonly NativeChatMessage[], + pending: NativeChatPendingSend +): readonly NativeChatMessage[] { + if (pending.afterMessageId === undefined) { + return messages } - const text = message.blocks - .filter(isTextBlock) - .map((block) => block.text) - .join(' ') - return normalize(text) + if (pending.afterMessageId === null) { + return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending)) + } + const boundaryIndex = messages.findIndex((message) => message.id === pending.afterMessageId) + if (boundaryIndex >= 0) { + return messages.slice(boundaryIndex + 1) + } + // A bounded authoritative read can page the boundary out. Fall back to the + // send time instead of matching an arbitrary older identical prompt. + return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending)) } -function matchingUserMessageTexts(messages: NativeChatMessage[]): Set { - const texts = new Set() - for (const message of messages) { - const text = userMessageText(message) - if (text) { - texts.add(text) - } +function messageIsAfterPendingTimestamp( + message: NativeChatMessage, + pending: NativeChatPendingSend +): boolean { + if (message.timestamp === null) { + return false } - return texts -} - -function advancedPastUserMessageTexts(messages: NativeChatMessage[]): Set { - const advanced = new Set() - const waiting = new Set() - for (const message of messages) { - if (message.role === 'user') { - const text = userMessageText(message) - if (text) { - waiting.add(text) - } - continue - } - for (const text of waiting) { - advanced.add(text) - } - } - return advanced + const boundary = nativeChatPendingMatchingAfter(pending) + // A transcript-clock boundary describes an existing message, so exclude ties. + // Local send time has no existing record and remains inclusive. + return pending.afterMessageTimestamp == null + ? message.timestamp >= boundary + : message.timestamp > boundary } /** @@ -122,8 +132,22 @@ export function prunePendingSends( if (pending.length === 0) { return pending } - const advanced = advancedPastUserMessageTexts(messages) - const next = pending.filter((entry) => !advanced.has(normalize(entry.text))) + const consumed = new Map() + const next = pending.filter((entry) => { + const contentKey = nativeChatPendingContentKey(entry) + const key = nativeChatPendingMatchKey(entry) + const available = + advancedNativeChatUserContentCounts(messagesAfterPendingBoundary(messages, entry)).get( + contentKey + ) ?? 0 + const used = consumed.get(key) ?? 0 + const occurrence = nativeChatPendingOccurrence(entry, used) + consumed.set(key, Math.max(used, occurrence)) + if (occurrence > available) { + return true + } + return false + }) return next.length === pending.length ? pending : next } @@ -137,9 +161,23 @@ export function pendingSendsAsMessages( pending: NativeChatPendingSend[], existingMessages: NativeChatMessage[] = [] ): NativeChatMessage[] { - const represented = matchingUserMessageTexts(existingMessages) + const consumed = new Map() return pending - .filter((entry) => !represented.has(normalize(entry.text))) + .filter((entry) => { + const contentKey = nativeChatPendingContentKey(entry) + const key = nativeChatPendingMatchKey(entry) + const represented = + matchingNativeChatUserContentCounts( + messagesAfterPendingBoundary(existingMessages, entry) + ).get(contentKey) ?? 0 + const used = consumed.get(key) ?? 0 + const occurrence = nativeChatPendingOccurrence(entry, used) + consumed.set(key, Math.max(used, occurrence)) + if (occurrence > represented) { + return true + } + return false + }) .map((entry) => ({ id: `pending:${entry.id}`, role: 'user' as const, @@ -167,8 +205,12 @@ export function launchPromptAsMessage( if (!entry) { return null } - const represented = matchingUserMessageTexts(existingMessages) - if (represented.has(normalize(entry.text))) { + const represented = matchingNativeChatUserContentCounts( + existingMessages.filter( + (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + ) + ) + if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) { return null } return { @@ -187,7 +229,17 @@ export function shouldPruneLaunchPrompt( entry: NativeChatLaunchPrompt, messages: NativeChatMessage[] ): boolean { - return advancedPastUserMessageTexts(messages).has(normalize(entry.text)) + const relevant = messages.filter( + (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + ) + return ( + (advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0 + ) +} + +export function nextNativeChatPendingSendId(now = Date.now()): string { + pendingSendCounter += 1 + return `${now}-${pendingSendCounter}` } export function isLaunchPromptMessageId(id: string): boolean { diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 20badc4d9e4..25efca9af09 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -37,7 +37,7 @@ describe('sendNativeChatMessage', () => { }) it('writes the framed body immediately, before the Enter', () => { - sendNativeChatMessage(SETTINGS, PTY, 'hello world') + const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world') // Body lands synchronously; Enter is still pending on the timer. expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) expect(sendRuntimePtyInput).toHaveBeenCalledWith( @@ -45,6 +45,7 @@ describe('sendNativeChatMessage', () => { PTY, buildNativeChatPasteBytes('hello world') ) + expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS) }) it('does not fire Enter before the proven 500ms gap (busy-agent safety)', () => { @@ -62,6 +63,14 @@ describe('sendNativeChatMessage', () => { expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) + it('cancels the delayed Enter when its owning composer is detached', () => { + const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi') + handle.cancel() + vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + }) + it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => { expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) }) @@ -77,10 +86,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => { - sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [ + const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [ '/tmp/orca-paste-image.png' ]) + expect(handle.settleAfterMs).toBe( + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + ) + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, @@ -102,7 +115,11 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('waits the normal submit gap for an attachment-only send', () => { - sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', ['/tmp/orca-paste-image.png']) + const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', [ + '/tmp/orca-paste-image.png' + ]) + + expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) @@ -111,6 +128,16 @@ describe('sendNativeChatMessageWithImageAttachments', () => { expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) + + it('cancels deferred prompt and Enter writes after the attachment path', () => { + const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'describe', [ + '/tmp/orca-paste-image.png' + ]) + handle.cancel() + vi.runAllTimers() + + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + }) }) describe('empty prompt submit', () => { @@ -160,7 +187,9 @@ describe('sendNativeChatAnswer', () => { it('multi-line: 3 bodies + 3 Enters in order, each Enter 500ms after its body, next body only after prior Enter+buffer', () => { const lines = ['answer one', 'answer two', 'answer three'] - sendNativeChatAnswer(SETTINGS, PTY, lines) + const handle = sendNativeChatAnswer(SETTINGS, PTY, lines) + + expect(handle.settleAfterMs).toBe(nativeChatQuestionOffsets(lines.length - 1).enterAt) // Nothing fires synchronously: even question 0's body is scheduled (setTimeout 0). expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 4a2b9e54381..1f5721e07df 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -51,7 +51,11 @@ export function nativeChatQuestionOffsets(index: number): { /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ -export type NativeChatSendHandle = { cancel: () => void } +export type NativeChatSendHandle = { + cancel: () => void + /** Time after which every scheduled write has fired and the handle can drop. */ + settleAfterMs: number +} /** * Send a native-chat message through the verified runtime pty path: framed body @@ -68,7 +72,7 @@ export function sendNativeChatMessage( const timer = setTimeout(() => { sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) }, NATIVE_CHAT_SUBMIT_DELAY_MS) - return { cancel: () => clearTimeout(timer) } + return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS } } export function sendNativeChatMessageWithImageAttachments( @@ -107,7 +111,11 @@ export function sendNativeChatMessageWithImageAttachments( for (const timer of timers) { clearTimeout(timer) } - } + }, + settleAfterMs: + trimmedText.length > 0 + ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS } } @@ -157,6 +165,7 @@ export function sendNativeChatAnswer( for (const timer of timers) { clearTimeout(timer) } - } + }, + settleAfterMs: nativeChatQuestionOffsets(lines.length - 1).enterAt } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx new file mode 100644 index 00000000000..09075d99903 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cancel: vi.fn(), + sendRuntimePtyInput: vi.fn(), + sendNativeChatAnswer: vi.fn(), + sendNativeChatMessage: vi.fn() +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args) +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: (terminalTabId: string) => ({ terminalTabId }) +})) + +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatAnswer: (...args: unknown[]) => mocks.sendNativeChatAnswer(...args), + sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args) +})) + +import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' + +describe('useNativeChatInteractiveSend', () => { + beforeEach(() => { + vi.clearAllMocks() + const handle = { cancel: mocks.cancel, settleAfterMs: 500 } + mocks.sendNativeChatAnswer.mockReturnValue(handle) + mocks.sendNativeChatMessage.mockReturnValue(handle) + }) + + it('cancels delayed answer writes when the PTY target changes', () => { + const { result, rerender } = renderHook( + ({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', targetPtyId, 'codex'), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.sendAnswer('continue')) + rerender({ targetPtyId: 'pty-2' }) + + expect(mocks.cancel).toHaveBeenCalledOnce() + }) + + it('cancels delayed answer writes before interrupting the active PTY', () => { + const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude')) + + act(() => result.current.sendAnswer('one\ntwo')) + act(() => result.current.cancel()) + + expect(mocks.cancel).toHaveBeenCalledOnce() + expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith( + { terminalTabId: 'tab-1' }, + 'pty-1', + '\x1b' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 7065fc19cea..36d7d8cb1ea 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef } from 'react' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AgentType } from '../../../../shared/native-chat-types' @@ -42,7 +42,9 @@ export function useNativeChatInteractiveSend( inFlightRef.current?.cancel() inFlightRef.current = null }, []) - useEffect(() => cancelInFlight, [cancelInFlight]) + // Why: a split can be rebound without unmounting this view. Cancel during + // commit so no delayed answer write can race the replacement PTY. + useLayoutEffect(() => cancelInFlight, [cancelInFlight, targetPtyId, terminalTabId]) const sendRaw = useCallback( (raw: string) => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index c2b6e5d7e1b..f60ce34d3c8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -293,3 +293,129 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.messages.map((m) => m.id)).not.toContain('stale') }) }) + +// Regression for #8401: a just-created Claude Code session's transcript can +// take up to minutes to exist on disk, so the first readSession commonly +// misses. Before this fix, the hook settled into a permanent 'error' phase +// on that first miss and never recovered. +describe('useNativeChatLiveSession — notFound retry (#8401)', () => { + const AGENT = 'claude' as const + const SESSION = 'sess-notfound' + const PANE = 'pane-notfound' + const roots: Root[] = [] + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatLiveSession(props) + return null + } + + async function render(props: UseNativeChatLiveSessionArgs): Promise { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(Probe, props)) + await Promise.resolve() + await Promise.resolve() + }) + return root + } + + beforeEach(() => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + latest = null + vi.clearAllMocks() + resetMockTransports() + vi.useRealTimers() + }) + + it('retries a notFound miss with backoff and settles into ready without ever exposing an error', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession + .mockResolvedValueOnce({ error: 'No transcript found', notFound: true }) + .mockResolvedValueOnce({ messages: [assistant('a-1', 'hello')] }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + // First backoff step (1s) fires the second readSession, which resolves. + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + + expect(latest?.status).not.toBe('error') + expect(transport.readSession).toHaveBeenCalledTimes(2) + expect(latest?.messages.map((m) => m.id)).toContain('a-1') + }) + + it('surfaces an error once the ~60s retry window is exhausted', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + await act(async () => { + await vi.advanceTimersByTimeAsync(70_000) + }) + + expect(latest?.status).toBe('error') + expect(latest?.error).toBe('No transcript found') + }) + + it('renders live-appended content instead of loading while the read is still retrying', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + let onAppended: ((messages: NativeChatMessage[]) => void) | null = null + transport.subscribe.mockImplementationOnce( + (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { + onAppended = cb + return transport.unsubscribe + } + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + // The watcher's first drain lands mid-retry — content must win over the spinner. + await act(async () => { + onAppended?.([assistant('a-early', 'landed during retry')]) + }) + + expect(latest?.status).not.toBe('loading') + expect(latest?.messages.map((m) => m.id)).toContain('a-early') + }) + + it('renders live-appended content even when the initial read settled into a permanent error', async () => { + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) + let onAppended: ((messages: NativeChatMessage[]) => void) | null = null + transport.subscribe.mockImplementationOnce( + (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { + onAppended = cb + return transport.unsubscribe + } + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('error') + + await act(async () => { + onAppended?.([assistant('a-late', 'landed late')]) + }) + + expect(latest?.status).not.toBe('error') + expect(latest?.error).toBeUndefined() + expect(latest?.messages.map((m) => m.id)).toContain('a-late') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 8832b6e1966..384ae724769 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -76,6 +76,17 @@ function nextSubscriptionId(): string { return `native-chat-${subscriptionCounter}-${Date.now()}` } +// Why: a brand-new session's transcript can take seconds to minutes to appear +// on disk (#8401), so a `notFound` miss retries — 1s/2s/4s/8s then every 10s — +// until the window below elapses. +const NOTFOUND_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] +const NOTFOUND_RETRY_FIXED_DELAY_MS = 10_000 +const NOTFOUND_RETRY_WINDOW_MS = 60_000 + +function notFoundRetryDelayMs(attempt: number): number { + return NOTFOUND_RETRY_DELAYS_MS[attempt] ?? NOTFOUND_RETRY_FIXED_DELAY_MS +} + type ReadState = | { phase: 'loading' } | { phase: 'ready'; messages: NativeChatMessage[] } @@ -161,31 +172,50 @@ export function useNativeChatLiveSession( } let cancelled = false + let retryTimer: ReturnType | null = null + const retryStartedAt = Date.now() + // Re-bound as a plain const: TS doesn't retain the `!sessionId` narrowing + // above inside a nested function declaration (it's hoisted, so the + // narrowing can't be proven to hold at every call site). + const activeSessionId = sessionId limitRef.current = NATIVE_CHAT_INITIAL_LIMIT setRead({ phase: 'loading' }) replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) - void transport - .readSession(agent, sessionId, limitRef.current, transcriptPath ?? undefined) - .then((result) => { - if (cancelled) { - return - } - if (result && 'error' in result) { - setRead({ phase: 'error', error: result.error }) - return - } - const messages = result?.messages ?? [] - setRead({ phase: 'ready', messages }) - setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) - }) - .catch((err: unknown) => { - if (!cancelled) { - setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) - } - }) + function loadSession(attempt: number): void { + void transport + .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined) + .then((result) => { + if (cancelled) { + return + } + if (result && 'error' in result) { + // A not-yet-flushed transcript: stay in 'loading' and retry with + // backoff instead of settling into a permanent error (#8401). + if (result.notFound && Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) { + retryTimer = setTimeout(() => { + retryTimer = null + loadSession(attempt + 1) + }, notFoundRetryDelayMs(attempt)) + return + } + setRead({ phase: 'error', error: result.error }) + return + } + const messages = result?.messages ?? [] + setRead({ phase: 'ready', messages }) + setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) + }) + .catch((err: unknown) => { + if (!cancelled) { + setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) + } + }) + } + + loadSession(0) const subscriptionId = nextSubscriptionId() const unsubscribe = transport.subscribe( @@ -208,6 +238,10 @@ export function useNativeChatLiveSession( return () => { cancelled = true + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } // Desktop returns a sync unsubscribe fn; the web RPC bridge returns a // Promise instead (and can't deliver streaming callbacks). Calling a // Promise as a function crashed the whole chat view, so resolve it first @@ -304,9 +338,23 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, - loading: read.phase === 'loading', - ...(read.phase === 'error' ? { error: read.error } : {}) + // Why: a watcher append (fix for #8401) can land content while the read is + // still retrying ('loading') or after it settled into 'error' — in both + // cases showing the live content beats a spinner or a stale error, so each + // override only applies while there is nothing appended to render. + loading: read.phase === 'loading' && appended.length === 0, + ...(read.phase === 'error' && appended.length === 0 ? { error: read.error } : {}) }) return { ...session, hasMore, loadingEarlier, loadEarlier } - }, [assembledMessages, read, sessionId, agent, hookState, hasMore, loadingEarlier, loadEarlier]) + }, [ + assembledMessages, + read, + sessionId, + agent, + hookState, + hasMore, + loadingEarlier, + loadEarlier, + appended + ]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx new file mode 100644 index 00000000000..63525c1e3e7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' + +function handle(settleAfterMs = 500) { + return { cancel: vi.fn<() => void>(), settleAfterMs } +} + +describe('useNativeChatSendLifecycle', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('cancels owned writes when the PTY target changes and when the composer unmounts', () => { + vi.useFakeTimers() + const first = handle() + const second = handle() + const onPendingSendCanceled = vi.fn() + const { result, rerender, unmount } = renderHook( + ({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.trackPendingSend(first, 'pending-1')) + rerender({ targetPtyId: 'pty-2' }) + expect(first.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1') + + act(() => result.current.trackPendingSend(second, 'pending-2')) + unmount() + expect(second.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-2') + }) + + it('cancels pending writes immediately on interrupt without double-cancelling', () => { + vi.useFakeTimers() + const pending = handle() + const onPendingSendCanceled = vi.fn() + const { result, unmount } = renderHook(() => + useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled) + ) + + act(() => result.current.trackPendingSend(pending, 'pending-1')) + act(() => result.current.cancelPendingSends()) + expect(pending.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1') + + unmount() + expect(pending.cancel).toHaveBeenCalledOnce() + }) + + it('drops settled handles so a later interrupt does not revisit completed sends', () => { + vi.useFakeTimers() + const settled = handle(800) + const onPendingSendCanceled = vi.fn() + const { result } = renderHook(() => + useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled) + ) + + act(() => result.current.trackPendingSend(settled, 'pending-1')) + act(() => vi.advanceTimersByTime(settled.settleAfterMs)) + act(() => result.current.cancelPendingSends()) + + expect(settled.cancel).not.toHaveBeenCalled() + expect(onPendingSendCanceled).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts new file mode 100644 index 00000000000..41e46148bfe --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -0,0 +1,46 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type { NativeChatSendHandle } from './native-chat-runtime-send' + +export type NativeChatSendLifecycle = { + cancelPendingSends: () => void + trackPendingSend: (handle: NativeChatSendHandle, pendingId?: string) => void +} + +export function useNativeChatSendLifecycle( + terminalTabId: string, + targetPtyId: string | null, + onPendingSendCanceled?: (pendingId: string) => void +): NativeChatSendLifecycle { + const pendingSendHandlesRef = useRef( + new Map< + NativeChatSendHandle, + { cleanupTimer: ReturnType; pendingId?: string } + >() + ) + const cancelPendingSends = useCallback(() => { + for (const [handle, entry] of pendingSendHandlesRef.current) { + const { cleanupTimer, pendingId } = entry + clearTimeout(cleanupTimer) + handle.cancel() + if (pendingId) { + onPendingSendCanceled?.(pendingId) + } + } + pendingSendHandlesRef.current.clear() + }, [onPendingSendCanceled]) + const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => { + const cleanupTimer = setTimeout(() => { + pendingSendHandlesRef.current.delete(handle) + }, handle.settleAfterMs) + pendingSendHandlesRef.current.set(handle, { + cleanupTimer, + ...(pendingId ? { pendingId } : {}) + }) + }, []) + + // Why: delayed Enter/image writes belong to the exact PTY target. A pane + // swap or unmount must cancel them before that PTY can close or be reused. + useLayoutEffect(() => cancelPendingSends, [cancelPendingSends, targetPtyId, terminalTabId]) + + return { cancelPendingSends, trackPendingSend } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts index bc89a09539f..d2a037da269 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts @@ -1,15 +1,24 @@ import { describe, expect, it } from 'vitest' -import { - isNativeChatShortcutTitleFallbackSafe, - resolveNativeChatToggleShortcutDetectedAgent -} from './use-native-chat-toggle-shortcut' +import { isNativeChatTabWideFallbackSafe } from './native-chat-leaf-routing' +import { resolveNativeChatToggleShortcutDetectedAgent } from './use-native-chat-toggle-shortcut' + +const splitLayout = { + root: { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, leafId: 'leaf-1' }, + second: { type: 'leaf' as const, leafId: 'leaf-2' } + }, + activeLeafId: 'leaf-2', + expandedLeafId: null +} describe('resolveNativeChatToggleShortcutDetectedAgent', () => { it('uses the active split leaf instead of the first tab agent entry', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: 'leaf-2', + terminalLayout: splitLayout, agentStatusByPaneKey: { 'tab-1:leaf-1': { agentType: 'gemini' }, 'tab-1:leaf-2': { agentType: 'codex' } @@ -22,7 +31,7 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: 'leaf-2', + terminalLayout: splitLayout, agentStatusByPaneKey: { 'tab-1:leaf-1': { agentType: 'claude' }, 'tab-1:leaf-2': { agentType: 'grok' } @@ -35,7 +44,6 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: null, agentStatusByPaneKey: { 'tab-2:leaf-1': { agentType: 'codex' }, 'tab-1:leaf-1': { agentType: 'claude' } @@ -43,24 +51,80 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { }) ).toBe('claude') }) + + it('does not inherit a split sibling before the active leaf is known', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { ...splitLayout, activeLeafId: null }, + agentStatusByPaneKey: { + 'tab-1:agent-leaf': { agentType: 'claude' }, + 'tab-1:shell-leaf': {} + } + }) + ).toBeNull() + }) + + it('uses the sole layout leaf when activeLeafId has not hydrated yet', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: null, + expandedLeafId: null + }, + agentStatusByPaneKey: { + 'tab-1:closed-leaf': { agentType: 'claude' }, + 'tab-1:leaf-2': { agentType: 'codex' } + } + }) + ).toBe('codex') + }) + + it('rejects a stale active leaf instead of reading its retained status', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + }, + agentStatusByPaneKey: { + 'tab-1:closed-leaf': { agentType: 'claude' }, + 'tab-1:leaf-2': { agentType: 'codex' } + } + }) + ).toBeNull() + }) }) -describe('isNativeChatShortcutTitleFallbackSafe', () => { +describe('isNativeChatTabWideFallbackSafe', () => { it('allows title fallback before a layout snapshot exists', () => { - expect(isNativeChatShortcutTitleFallbackSafe(null)).toBe(true) + expect(isNativeChatTabWideFallbackSafe(null)).toBe(true) }) it('allows title fallback for a single leaf layout', () => { - expect(isNativeChatShortcutTitleFallbackSafe({ type: 'leaf', leafId: 'leaf-1' })).toBe(true) + expect( + isNativeChatTabWideFallbackSafe({ + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + }) + ).toBe(true) }) it('rejects title fallback for split layouts', () => { + expect(isNativeChatTabWideFallbackSafe(splitLayout)).toBe(false) + }) + + it('rejects title fallback while a collapsed layout still has a stale active id', () => { expect( - isNativeChatShortcutTitleFallbackSafe({ - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf-1' }, - second: { type: 'leaf', leafId: 'leaf-2' } + isNativeChatTabWideFallbackSafe({ + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null }) ).toBe(false) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index c53b5263de0..3096077e820 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -1,31 +1,33 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' import { getConnectionIdFromState } from '@/lib/connection-context' - -export function isNativeChatShortcutTitleFallbackSafe( - root: TerminalPaneLayoutNode | null | undefined -): boolean { - return !root || root.type === 'leaf' -} +import { + isNativeChatTabWideFallbackSafe, + resolveNativeChatActiveLayoutLeafId +} from './native-chat-leaf-routing' export function resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId, - activeLeafId, + terminalLayout, agentStatusByPaneKey }: { terminalTabId: string - activeLeafId: string | null + terminalLayout?: TerminalLayoutSnapshot | null agentStatusByPaneKey: Record }): AgentType | null { + const activeLeafId = resolveNativeChatActiveLayoutLeafId(terminalLayout) if (activeLeafId) { return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null } + if (!isNativeChatTabWideFallbackSafe(terminalLayout)) { + return null + } return ( Object.entries(agentStatusByPaneKey).find(([paneKey]) => paneKey.startsWith(`${terminalTabId}:`) @@ -67,13 +69,13 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive // Pane keys are `${entityId}:${leafId}` — the backing terminal tab id, not // the unified tab id. const terminalLayout = state.terminalLayoutsByTabId[tab.entityId] - const activeLeafId = terminalLayout?.activeLeafId ?? null + const tabWideFallbackSafe = isNativeChatTabWideFallbackSafe(terminalLayout) const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: tab.entityId, - activeLeafId, + terminalLayout, agentStatusByPaneKey: state.agentStatusByPaneKey }) - const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) + const titleFallbackAgent = tabWideFallbackSafe ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) : null @@ -81,7 +83,7 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive !canToggleNativeChat({ experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true, contentType: 'terminal', - launchAgent: detectedAgent ? null : terminalTab?.launchAgent, + launchAgent: detectedAgent || !tabWideFallbackSafe ? null : terminalTab?.launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : titleFallbackAgent, nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( diff --git a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts index 58d0e4289d3..273c92b8dbe 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts @@ -1,54 +1,2 @@ -export type SmartWorkspaceCommandRowKind = - | 'use-name' - | 'create-branch' - | 'github' - | 'gitlab' - | 'branch' - | 'linear' - -export type SmartWorkspaceCommandRow = { - kind: SmartWorkspaceCommandRowKind - value: string -} - -export type SmartWorkspaceSourceIntent = 'github' | 'gitlab' | 'linear' | null - -export function resolveSmartWorkspaceCommandValue({ - currentValue, - rows, - isQueryStale, - sourceIntent -}: { - currentValue: string - rows: readonly SmartWorkspaceCommandRow[] - isQueryStale: boolean - sourceIntent: SmartWorkspaceSourceIntent -}): string { - if (rows.length === 0) { - return currentValue - } - - if (isQueryStale) { - const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch') - return typedTextRow?.value ?? '' - } - - if (sourceIntent === 'github') { - const githubRow = rows.find((row) => row.kind === 'github') - if (githubRow) { - return githubRow.value - } - } else if (sourceIntent === 'gitlab') { - const gitlabRow = rows.find((row) => row.kind === 'gitlab') - if (gitlabRow) { - return gitlabRow.value - } - } else if (sourceIntent === 'linear') { - const linearRow = rows.find((row) => row.kind === 'linear') - if (linearRow) { - return linearRow.value - } - } - - return rows.some((row) => row.value === currentValue) ? currentValue : rows[0].value -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../../shared/new-workspace/smart-workspace-command-value' diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts index abbbfb04062..afa2d10651f 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts @@ -1,190 +1,2 @@ -import type { - BaseRefSearchResult, - GitHubWorkItem, - GitLabWorkItem, - LinearCollectionResult, - LinearIssue -} from '../../../../shared/types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' - -export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text' - -export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048 - -export type SmartWorkspaceSourceRow = - | { kind: 'use-name'; value: string; name: string } - | { kind: 'create-branch'; value: string; name: string } - | { kind: 'github'; value: string; item: GitHubWorkItem } - | { kind: 'gitlab'; value: string; item: GitLabWorkItem } - | { kind: 'branch'; value: string; refName: string; localBranchName: string } - | { kind: 'linear'; value: string; issue: LinearIssue } - -type LinearIssueSourceInput = LinearIssue[] | LinearCollectionResult | null | undefined - -const EMPTY_HINT_BY_MODE: Record = { - smart: 'Start typing to create a name or find a source.', - github: 'Start typing to search GitHub PRs and issues.', - gitlab: 'Start typing to search GitLab MRs and issues.', - branches: 'No matching branches.', - linear: 'Start typing to search Linear issues.', - text: '' -} - -export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string { - return EMPTY_HINT_BY_MODE[mode] -} - -export function isSmartWorkspaceSourceQueryWithinLimit( - query: string, - maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES -): boolean { - return !isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function getBranchSearchRequest({ - branchesEnabled, - disabled, - textOnly, - mode, - selectedRepoId, - query, - limit -}: { - branchesEnabled?: boolean - disabled: boolean - textOnly: boolean - mode: SmartNameMode - selectedRepoId: string | null - query: string - limit: number -}): { repoId: string; query: string; limit: number } | null { - if ( - branchesEnabled === false || - disabled || - textOnly || - !isSmartWorkspaceSourceQueryWithinLimit(query) || - !selectedRepoId - ) { - return null - } - const trimmedQuery = query.trim() - const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0) - if (!shouldSearchBranches) { - return null - } - return { repoId: selectedRepoId, query: trimmedQuery, limit } -} - -export function getVisibleBranchResults({ - branches, - mode, - resultRepoId, - resultQuery, - selectedRepoId, - value -}: { - branches: BaseRefSearchResult[] - mode: SmartNameMode - resultRepoId: string | null - resultQuery: string | null - selectedRepoId: string | null - value: string -}): BaseRefSearchResult[] { - if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { - return [] - } - const currentQuery = value.trim() - if (mode !== 'branches' && mode !== 'smart') { - return [] - } - if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) { - return [] - } - return branches -} - -export function buildSmartWorkspaceSourceRows({ - branches, - githubItems, - gitlabAvailable, - gitlabItems, - linearAvailable, - linearIssues, - mode, - resultLimit, - value -}: { - branches: BaseRefSearchResult[] - githubItems: GitHubWorkItem[] - gitlabAvailable: boolean - gitlabItems: GitLabWorkItem[] - linearAvailable: boolean - linearIssues: LinearIssueSourceInput - mode: SmartNameMode - resultLimit: number - value: string -}): SmartWorkspaceSourceRow[] { - if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { - return [] - } - const trimmed = value.trim() - const nextRows: SmartWorkspaceSourceRow[] = [] - if (trimmed && mode === 'smart') { - nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }) - } - if (mode === 'text') { - return nextRows - } - if (mode === 'smart' || mode === 'github') { - nextRows.push( - ...githubItems.map((item) => ({ - kind: 'github' as const, - value: `github-${item.repoId}-${item.type}-${item.number}`, - item - })) - ) - } - if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { - nextRows.push( - ...gitlabItems.map((item) => ({ - kind: 'gitlab' as const, - value: `gitlab-${item.repoId}-${item.type}-${item.number}`, - item - })) - ) - } - const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0) - if (shouldShowBranches) { - const branchExactMatch = branches.some( - (branch) => branch.refName === trimmed || branch.localBranchName === trimmed - ) - if (trimmed && mode === 'branches' && !branchExactMatch) { - nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }) - } - nextRows.push( - ...branches.map((branch) => ({ - kind: 'branch' as const, - value: `branch-${branch.refName}`, - refName: branch.refName, - localBranchName: branch.localBranchName - })) - ) - } - if (linearAvailable && (mode === 'smart' || mode === 'linear')) { - // Why: mixed-version runtime responses may briefly carry the paginated - // collection shape into this render path; rendering must stay recoverable. - const resolvedLinearIssues = Array.isArray(linearIssues) - ? linearIssues - : Array.isArray(linearIssues?.items) - ? linearIssues.items - : [] - nextRows.push( - ...resolvedLinearIssues.map((issue) => ({ - kind: 'linear' as const, - value: `linear-${issue.id}`, - issue - })) - ) - } - return nextRows.slice(0, resultLimit + 1) -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../../shared/new-workspace/smart-workspace-source-results' diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx new file mode 100644 index 00000000000..cc3a83d0a70 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx @@ -0,0 +1,223 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearWorktreeAgentExpansionStateForTests, + getWorktreeAgentExpansionCountForTests, + MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS, + seedWorktreeAgentExpansionStateForTests, + useWorktreeAgentExpansionState +} from './worktree-card-agents-expansion-state' + +let mockAgents: unknown[] = [] + +function mockAgent(paneKey: string, prompt: string): unknown { + return { + paneKey, + tab: { id: paneKey.split(':')[0] }, + agentType: 'codex', + rowSource: undefined, + state: 'done', + startedAt: 1000, + entry: { + prompt, + lastAssistantMessage: undefined, + state: 'done', + stateStartedAt: 1000, + stateHistory: [], + orchestration: undefined + }, + lineage: undefined + } +} + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + agentActivityDisplayMode: 'compact', + acknowledgedAgentsByPaneKey: {}, + cacheTimerByKey: {}, + dropAgentStatus: vi.fn(), + dismissRetainedAgent: vi.fn(), + agentSendPopoverTargetMode: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + sendPromptToSidebarAgentTarget: vi.fn(), + settings: { promptCacheTimerEnabled: false, promptCacheTtlMs: 60_000 } + }) +})) + +vi.mock('./useWorktreeAgentRows', () => ({ + useWorktreeAgentRows: vi.fn(() => mockAgents) +})) + +vi.mock('@/components/dashboard/useNow', () => ({ + useNow: vi.fn(() => 2000) +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +const mountedRoots: { root: Root; host: HTMLElement }[] = [] + +async function mountAgents(worktreeId: string): Promise { + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + mountedRoots.push({ root, host }) + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + await act(async () => { + root.render() + }) + return host +} + +function summaryButton(host: HTMLElement): HTMLButtonElement { + // The compact multi-agent summary is the only control carrying aria-expanded + // when the agents are flat (no per-agent child disclosure). + const button = host.querySelector('button[aria-expanded]') + if (!button) { + throw new Error('compact agent summary button not found') + } + return button +} + +describe('WorktreeCardAgents inline-list expansion durability', () => { + beforeEach(() => { + clearWorktreeAgentExpansionStateForTests() + mockAgents = [mockAgent('tab-1:1', 'One'), mockAgent('tab-2:2', 'Two')] + }) + + afterEach(async () => { + await act(async () => { + for (const { root, host } of mountedRoots.splice(0)) { + root.unmount() + host.remove() + } + }) + document.body.innerHTML = '' + clearWorktreeAgentExpansionStateForTests() + }) + + it('keeps the compact agent summary expanded across a card remount', async () => { + const host = await mountAgents('wt-remount') + expect(summaryButton(host).getAttribute('aria-expanded')).toBe('false') + + await act(async () => { + summaryButton(host).dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(summaryButton(host).getAttribute('aria-expanded')).toBe('true') + + // Simulate the WorktreeCard remount that a virtualizer recycle or a sibling + // child-worktrees toggle triggers: fully unmount, then mount a fresh tree + // for the same worktree. Before the fix this reset the summary to collapsed. + await act(async () => { + const first = mountedRoots.shift()! + first.root.unmount() + first.host.remove() + }) + const remounted = await mountAgents('wt-remount') + + expect(summaryButton(remounted).getAttribute('aria-expanded')).toBe('true') + }) + + it('does not leak expansion between different worktrees', async () => { + const first = await mountAgents('wt-a') + await act(async () => { + summaryButton(first).dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(summaryButton(first).getAttribute('aria-expanded')).toBe('true') + + const second = await mountAgents('wt-b') + expect(summaryButton(second).getAttribute('aria-expanded')).toBe('false') + }) +}) + +describe('worktree-card-agents-expansion-state module cache', () => { + beforeEach(() => { + clearWorktreeAgentExpansionStateForTests() + }) + + afterEach(() => { + clearWorktreeAgentExpansionStateForTests() + }) + + it('persists a collapsed lineage parent across a hook remount and toggles independently', async () => { + function Probe({ worktreeId }: { worktreeId: string }) { + const { collapsedLineageParents, toggleLineageParent } = + useWorktreeAgentExpansionState(worktreeId) + return ( + + ) + } + + const host = document.createElement('div') + document.body.append(host) + let root = createRoot(host) + await act(async () => { + root.render() + }) + const read = () => host.querySelector('button')!.getAttribute('data-collapsed') + expect(read()).toBe('false') + + await act(async () => { + host.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(read()).toBe('true') + + // Remount the hook consumer: the collapsed parent must survive. + await act(async () => root.unmount()) + root = createRoot(host) + await act(async () => { + root.render() + }) + expect(read()).toBe('true') + + await act(async () => root.unmount()) + host.remove() + }) + + it('drops default (empty) state and bounds the cache with LRU eviction', () => { + seedWorktreeAgentExpansionStateForTests('wt-default', { + collapsedLineageParents: new Set(), + compactRootListExpanded: false + }) + expect(getWorktreeAgentExpansionCountForTests()).toBe(0) + + for (let i = 0; i < MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS + 25; i++) { + seedWorktreeAgentExpansionStateForTests(`wt-${i}`, { + collapsedLineageParents: new Set(), + compactRootListExpanded: true + }) + } + expect(getWorktreeAgentExpansionCountForTests()).toBe(MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 2a924211874..9e8faa70acd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -24,6 +24,7 @@ import { import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model' import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants' import { revealElementInScrollContainer } from './worktree-sidebar-reveal' +import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = @@ -231,13 +232,24 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ [agents] ) const hasLineage = childrenByParentPaneKey.size > 0 - const [collapsedLineageParents, setCollapsedLineageParents] = useState>( - () => new Set() - ) - const [compactRootListExpanded, setCompactRootListExpanded] = useState(false) + // Why: keep disclosure state out of raw local useState so the WorktreeCard + // remount that fires on virtualizer recycle or a sibling child-worktrees + // toggle no longer resets it (which read as one section expanding the other). + const { + collapsedLineageParents, + compactRootListExpanded, + toggleLineageParent: toggleLineageParentState, + toggleCompactRootList + } = useWorktreeAgentExpansionState(worktreeId) + // Why: reveal only on a genuine user collapse→expand within this mount. + // Seeding an already-expanded panel from the durable cache on a remount must + // not re-trigger the reveal scroll, or recycled cards would fight the scroll. + const previousCompactExpandedRef = useRef(compactRootListExpanded) useLayoutEffect(() => { - if (compactRootListExpanded && agentActivityDisplayMode === 'compact') { + const wasExpanded = previousCompactExpandedRef.current + previousCompactExpandedRef.current = compactRootListExpanded + if (!wasExpanded && compactRootListExpanded && agentActivityDisplayMode === 'compact') { dispatchSuppressScrollAdjustment() // Why: defer the reveal scroll out of the expand commit. Running it inline // forces a synchronous sidebar layout that blocks the animation's opening @@ -250,18 +262,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ } return undefined }, [agentActivityDisplayMode, compactRootListExpanded]) - const toggleLineageParent = useCallback((paneKey: string) => { - dispatchSuppressScrollAdjustment() - setCollapsedLineageParents((current) => { - const next = new Set(current) - if (next.has(paneKey)) { - next.delete(paneKey) - } else { - next.add(paneKey) - } - return next - }) - }, []) + const toggleLineageParent = useCallback( + (paneKey: string) => { + dispatchSuppressScrollAdjustment() + toggleLineageParentState(paneKey) + }, + [toggleLineageParentState] + ) const stopBubble = useCallback((e: React.MouseEvent) => { e.stopPropagation() @@ -445,7 +452,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ expanded={compactRootListExpanded} onToggle={() => { dispatchSuppressScrollAdjustment() - setCompactRootListExpanded((expanded) => !expanded) + toggleCompactRootList() }} /> diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx new file mode 100644 index 00000000000..6190a6acd5a --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -0,0 +1,581 @@ +// @vitest-environment happy-dom + +// Regression test for the child-worktrees <-> agent-list expansion coupling: +// in a worktree card that shows BOTH inline agent rows (with orchestration +// lineage) AND a "N children" child-worktrees chip, toggling the child-worktrees +// chip used to reset the agent list's expansion state (it remounts the card). +// It renders the REAL WorktreeCardAgents (not a mock) inside the REAL +// WorktreeList so the remount and the durable-expansion fix are exercised +// end-to-end. The two toggles must stay independent in both directions. +// +// NOTE: unlike the sibling lineage test, this file's useVirtualizer mock HONORS +// the real `getItemKey` (which returns getRenderRowKey(row)). Without that, the +// parent card's virtual-row key would be a static `row-` and the +// item<->lineage-group remount would NOT reproduce (false negative). + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext +} from '../../../../shared/agent-status-types' +import type { + Repo, + TerminalTab, + Worktree, + WorktreeCardProperty, + WorktreeLineage +} from '../../../../shared/types' +import { clearWorktreeAgentExpansionStateForTests } from './worktree-card-agents-expansion-state' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const mockStore = vi.hoisted(() => ({ + state: {} as Record, + activateWorktreeFromSidebar: vi.fn(), + openModal: vi.fn() +})) + +type WorktreeListComponent = React.ComponentType<{ + scrollOffsetRef: React.RefObject + scrollAnchorRef: React.RefObject +}> + +let WorktreeList: WorktreeListComponent + +vi.mock('@/store', () => { + const useAppStore = ((selector: (state: Record) => unknown) => + selector(mockStore.state)) as (( + selector: (state: Record) => unknown + ) => unknown) & { + getState: () => Record + } + useAppStore.getState = () => mockStore.state + return { useAppStore } +}) + +// Why: honor the real getItemKey so each virtual row's React key equals +// getRenderRowKey(row). This is what makes the parent card remount when it +// moves between a standalone 'item' row (key wt:...) and a 'lineage-group' row +// (key lineage-group:...) as the child-worktrees group collapses/expands. +vi.mock('@tanstack/react-virtual', () => ({ + defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => + Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index), + measureElement: () => 32, + useVirtualizer: ({ + count, + getItemKey + }: { + count: number + getItemKey?: (index: number) => string | number + }) => ({ + elementsCache: new Map(), + getTotalSize: () => count * 96, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: getItemKey ? getItemKey(index) : `row-${index}`, + start: index * 96 + })), + measureElement: vi.fn(), + scrollToIndex: vi.fn() + }) +})) + +vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({ + VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor', + useVirtualizedScrollAnchor: vi.fn() +})) + +vi.mock('./project-header-drag', () => ({ + useRepoHeaderDrag: () => ({ + state: { draggingRepoId: null, dropIndicatorY: null }, + onHandlePointerDown: vi.fn() + }), + isRepoHeaderActionTarget: () => false +})) + +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn() +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null, + usePromptCacheCountdownForPane: () => null +})) + +// NOTE: intentionally NOT mocking ./WorktreeCardAgents — we render the real one. + +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' +})) + +const TAB_ID = 'tabP' +const PANE_ROOT = `${TAB_ID}:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` +const PANE_CHILD = `${TAB_ID}:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb` +const PANE_ROOT_2 = `${TAB_ID}:cccccccc-cccc-4ccc-8ccc-cccccccccccc` + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/tmp/lineage-agent-coupling', + displayName: 'lineage-agent-coupling', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(args: { + id: string + displayName: string + branch: string + sortOrder: number + instanceId: string +}): Worktree { + return { + id: args.id, + instanceId: args.instanceId, + repoId: 'repo-1', + path: `/tmp/lineage-agent-coupling/${args.id}`, + displayName: args.displayName, + branch: args.branch, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: args.sortOrder, + lastActivityAt: args.sortOrder + } +} + +function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId!, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId!, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + createdAt: 1 + } +} + +function makeAgentEntry( + paneKey: string, + prompt: string, + orchestration?: AgentStatusOrchestrationContext +): AgentStatusEntry { + const now = Date.now() + return { + state: 'working', + prompt, + updatedAt: now, + stateStartedAt: now, + agentType: 'claude', + paneKey, + worktreeId: 'parent', + stateHistory: [], + ...(orchestration ? { orchestration } : {}) + } +} + +function makeParentTab(): TerminalTab { + return { + id: TAB_ID, + ptyId: null, + worktreeId: 'parent', + title: 'Parent Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function setAgentLineageState(options: { + agentActivityDisplayMode: 'compact' | 'full' + secondRootAgent?: boolean + collapsedGroups?: Set +}): void { + const repo = makeRepo() + const parent = makeWorktree({ + id: 'parent', + instanceId: 'parent-instance', + displayName: 'lineage parent', + branch: 'parent-branch', + sortOrder: 20 + }) + const child = makeWorktree({ + id: 'child', + instanceId: 'child-instance', + displayName: 'lineage child', + branch: 'child-branch', + sortOrder: 10 + }) + const agentStatusByPaneKey: Record = { + [PANE_ROOT]: makeAgentEntry(PANE_ROOT, 'PARENT_AGENT_PROMPT'), + [PANE_CHILD]: makeAgentEntry(PANE_CHILD, 'CHILD_AGENT_PROMPT', { + taskId: 't1', + dispatchId: 'd1', + parentPaneKey: PANE_ROOT + }) + } + if (options.secondRootAgent) { + agentStatusByPaneKey[PANE_ROOT_2] = makeAgentEntry(PANE_ROOT_2, 'SECOND_ROOT_PROMPT') + } + + mockStore.state = { + // Reactive-ish toggle: the chip's onClick eventually calls + // toggleCollapsedGroup; mutate the live Set so the next manual re-render + // (store isn't reactive in this harness) reflects the user's toggle. + collapsedGroups: options.collapsedGroups ?? new Set(), + toggleCollapsedGroup: vi.fn((key: string) => { + // Why: mirror the real store's IMMUTABLE update — a fresh Set reference is + // what makes WorktreeList's `rows` useMemo (keyed on the Set identity) + // recompute buildRenderableRows and flip the parent's render-row. + const set = new Set(mockStore.state.collapsedGroups as Set) + if (set.has(key)) { + set.delete(key) + } else { + set.add(key) + } + mockStore.state.collapsedGroups = set + }), + + // ── worktree list plumbing ── + activeModal: '', + activeView: 'terminal', + activeWorktreeId: null, + activeWorkspaceKey: null, + activeTabId: null, + activeTabType: null, + agentStatusEpoch: 0, + browserTabsByWorktree: {}, + clearPendingRevealWorktreeId: vi.fn(), + deleteStateByWorktreeId: {}, + detectedWorktreesByRepo: {}, + fetchFolderWorkspacePathStatus: vi.fn(), + fetchHostedReviewForBranch: vi.fn(), + fetchIssue: vi.fn(), + fetchLinearIssue: vi.fn(), + filterRepoIds: [], + folderWorkspaces: [], + folderWorkspacePathStatuses: {}, + getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), + getFreshFolderWorkspacePathStatus: () => null, + gitConflictOperationByWorktree: {}, + groupBy: 'none', + hideDefaultBranchWorkspace: false, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + linearStatus: null, + migrationUnsupportedByPtyId: {}, + openModal: mockStore.openModal, + openSettingsPage: vi.fn(), + openSettingsTarget: null, + openTaskPage: vi.fn(), + pendingRevealWorktree: null, + prCache: {}, + projectGroups: [], + ptyIdsByTabId: {}, + recordFeatureInteraction: vi.fn(), + remoteBranchConflictByWorktreeId: {}, + reorderRepos: vi.fn(), + reportVisibleGitHubPRRefreshCandidates: vi.fn(), + repos: [repo], + retainedAgentsByPaneKey: {}, + revealWorktreeInSidebar: vi.fn(), + runtimePaneTitlesByTabId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + setFilterRepoIds: vi.fn(), + setHideDefaultBranchWorkspace: vi.fn(), + setRenamingWorktreeId: vi.fn(), + setShowSleepingWorkspaces: vi.fn(), + setSortBy: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + settings: null, + showSleepingWorkspaces: true, + sortBy: 'manual', + sortEpoch: 0, + sshConnectedGeneration: 0, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + terminalLayoutsByTabId: {}, + updateRepo: vi.fn(), + updateWorktreeMeta: vi.fn(), + updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', + workspacePortScan: null, + workspaceStatuses: [], + worktreeCardProperties: [ + 'status', + 'pr', + 'comment', + 'inline-agents' + ] satisfies WorktreeCardProperty[], + worktreeLineageById: { [child.id]: makeLineage(child, parent) }, + worktreesByRepo: { [repo.id]: [parent, child] }, + + // ── agent-list specific state (real WorktreeCardAgents deps) ── + agentActivityDisplayMode: options.agentActivityDisplayMode, + agentSendPopoverTargetMode: null, + acknowledgedAgentsByPaneKey: {}, + dropAgentStatus: vi.fn(), + dismissRetainedAgent: vi.fn(), + sendPromptToSidebarAgentTarget: vi.fn(), + tabsByWorktree: { parent: [makeParentTab()] }, + agentStatusByPaneKey + } +} + +const mountedRoots: Root[] = [] + +async function renderWorktreeList(): Promise<{ container: HTMLDivElement; root: Root }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render( + + ) + }) + return { container, root } +} + +// Why: the mocked store isn't reactive. Re-render with FRESH ref props so +// React.memo(WorktreeList) doesn't bail out, letting a collapsedGroups mutation +// flow into buildRenderableRows (and thus flip the parent's virtual-row key). +async function rerender(root: Root): Promise { + await act(async () => { + root.render( + + ) + }) +} + +function findButtonByAriaLabel(container: HTMLElement, pattern: RegExp): HTMLButtonElement | null { + return ( + [...container.querySelectorAll('button[aria-expanded]')].find((button) => + pattern.test(button.getAttribute('aria-label') ?? '') + ) ?? null + ) +} + +function childWorktreeChip(container: HTMLElement): HTMLButtonElement | null { + return findButtonByAriaLabel(container, /child workspace/i) +} + +function agentChildDisclosure(container: HTMLElement): HTMLButtonElement | null { + return findButtonByAriaLabel(container, /child agent/i) +} + +function compactAgentSummary(container: HTMLElement): HTMLButtonElement | null { + return ( + [...container.querySelectorAll('button.compact-agent-summary-button')][0] ?? + null + ) +} + +function childWorktreeCardPresent(container: HTMLElement): boolean { + return container.querySelector('[id="worktree-list-option-all%3Achild"]') !== null +} + +function parentVirtualRowKey(container: HTMLElement): string | null { + return ( + container + .querySelector('[data-worktree-id="parent"]') + ?.closest('[data-worktree-virtual-row]') + ?.getAttribute('data-worktree-virtual-row-key') ?? null + ) +} + +async function click(el: Element): Promise { + await act(async () => { + el.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('WorktreeCard agent-list <-> child-worktrees expansion coupling', () => { + beforeAll(async () => { + WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + // Expansion now persists in a module-level cache that survives remounts, so + // it must be reset between cases or one test's collapse leaks into the next. + clearWorktreeAgentExpansionStateForTests() + }) + + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + clearWorktreeAgentExpansionStateForTests() + }) + + it('[full mode] both toggles render independently at mount', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container } = await renderWorktreeList() + + // Sanity: real agent rows rendered (parent + its lineage child agent). + expect(container.textContent).toContain('PARENT_AGENT_PROMPT') + expect(container.textContent).toContain('CHILD_AGENT_PROMPT') + + // Both controls exist and are independent DOM elements. + expect(childWorktreeChip(container)).not.toBeNull() + expect(agentChildDisclosure(container)).not.toBeNull() + + // Defaults: child worktrees expanded (chip aria-expanded true) AND child + // agents expanded (disclosure aria-expanded true). + expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true') + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('true') + expect(childWorktreeCardPresent(container)).toBe(true) + }) + + it('[full mode] toggling AGENTS does NOT change the child-worktrees chip (agents -> children uncoupled)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container } = await renderWorktreeList() + + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('true') + expect(childWorktreeCardPresent(container)).toBe(true) + + // Collapse the child AGENTS via the disclosure chevron. + await click(agentChildDisclosure(container)!) + + // Agent children now collapsed (local state)... + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('.worktree-agent-lineage-children')).toBeNull() + // ...but the child WORKTREES are untouched: chip still expanded, child card present. + expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true') + expect(childWorktreeCardPresent(container)).toBe(true) + // And collapsedGroups was never mutated by an agent toggle. + expect((mockStore.state.collapsedGroups as Set).size).toBe(0) + }) + + it('[full mode] toggling CHILD WORKTREES still remounts the card but PRESERVES agent expansion (regression)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container, root } = await renderWorktreeList() + + // Parent starts inside a lineage-group render row (children expanded). + expect(parentVirtualRowKey(container)).toBe('lineage-group:all:lineage:parent') + + // User collapses the child AGENTS. + await click(agentChildDisclosure(container)!) + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + + // User now clicks the CHILD-WORKTREES chip to collapse child worktrees. + await click(childWorktreeChip(container)!) + expect(mockStore.state.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + // Store isn't reactive; flush the collapsedGroups change into a re-render. + await rerender(root) + + // The remount still happens: the parent moved to a standalone 'item' render + // row with a DIFFERENT React key, and the child card is gone. + expect(parentVirtualRowKey(container)).toBe('wt:all:parent') + expect(childWorktreeCardPresent(container)).toBe(false) + + // FIXED: the card remounted, but the durable expansion cache means the + // child AGENTS the user collapsed stay collapsed — the child-worktrees + // toggle no longer bleeds into the agent list. + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('.worktree-agent-lineage-children')).toBeNull() + }) + + it('[full mode] CONTROL: a re-render that does NOT change collapsedGroups preserves agent state (isolates the remount)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container, root } = await renderWorktreeList() + + await click(agentChildDisclosure(container)!) + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + + // Re-render WITHOUT touching collapsedGroups: the parent's virtual-row key + // stays 'lineage-group:all:lineage:parent', so there is no remount. + await rerender(root) + + expect(parentVirtualRowKey(container)).toBe('lineage-group:all:lineage:parent') + // Agent collapse survives => proves it is the KEY change (remount), not the + // re-render itself, that resets the agent expansion. + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + }) + + it('[compact mode] toggling CHILD WORKTREES preserves the compact agent summary expansion (regression)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'compact', secondRootAgent: true }) + const { container, root } = await renderWorktreeList() + + // Two root agents => compact summary pill is shown, collapsed by default. + const summary = compactAgentSummary(container) + expect(summary).not.toBeNull() + expect(summary!.getAttribute('aria-expanded')).toBe('false') + + // User expands the compact agent summary. + await click(summary!) + expect(compactAgentSummary(container)!.getAttribute('aria-expanded')).toBe('true') + + // User toggles child worktrees. + await click(childWorktreeChip(container)!) + expect(mockStore.state.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + await rerender(root) + + // FIXED: the card remounts (child card gone), but the expanded "N agents" + // summary is restored from the durable cache instead of collapsing. + expect(childWorktreeCardPresent(container)).toBe(false) + expect(compactAgentSummary(container)!.getAttribute('aria-expanded')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index b9a527d174c..c6b920bd9d8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -1,9 +1,12 @@ -import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { - getLinkedWorkItemProvider, - getLinkedWorkItemWorkspaceName, - type LinkedWorkItemSummary -} from '@/lib/new-workspace' + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + getWorkspaceSourceProvider +} from '../../../../shared/new-workspace/workspace-source' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoExecutionHostId, @@ -63,7 +66,7 @@ export function toFolderWorkspaceLinkedTask( if (!item) { return null } - const provider = getLinkedWorkItemProvider(item) + const provider = getWorkspaceSourceProvider(item) return { provider, type: item.type, @@ -79,60 +82,23 @@ export function toFolderWorkspaceLinkedTask( export function getSmartNameSelection( linkedWorkItem: LinkedWorkItemSummary | null ): SmartWorkspaceNameSelection | null { - if (!linkedWorkItem) { - return null - } - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const kind: SmartWorkspaceNameSelection['kind'] = - provider === 'linear' - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } + return buildWorkspaceSourceSelection({ linkedWorkItem }) as SmartWorkspaceNameSelection | null } export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null { - return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null) + return getWorkspaceSourceName(item).displayName || null } export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'github', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitHubWorkspaceSource(item) } export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'gitlab', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitLabWorkspaceSource(item) } export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - return buildLinearIssueLinkedWorkItem(issue) + return buildLinearWorkspaceSource(issue) } export function getFolderWorkspacePrimaryActionLabel(): string { diff --git a/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts b/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts new file mode 100644 index 00000000000..b357cb21956 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts @@ -0,0 +1,130 @@ +import { useCallback, useState } from 'react' + +/** + * Expand/collapse state for a WorktreeCard's inline agent list: + * - `collapsedLineageParents`: agent-lineage parent paneKeys the user folded. + * - `compactRootListExpanded`: whether the "N agents" compact summary is open. + */ +export type WorktreeAgentExpansionState = { + collapsedLineageParents: ReadonlySet + compactRootListExpanded: boolean +} + +const EMPTY_COLLAPSED_PARENTS: ReadonlySet = new Set() + +const DEFAULT_EXPANSION_STATE: WorktreeAgentExpansionState = { + collapsedLineageParents: EMPTY_COLLAPSED_PARENTS, + compactRootListExpanded: false +} + +// Why: the inline agent list's expand/collapse must outlive the WorktreeCard +// remount that fires (a) when the sidebar virtualizer recycles the row on +// scroll and (b) when the sibling child-worktrees section toggles — that toggle +// flips the card between an `item` and a `lineage-group` render row, changing +// its virtual-row key and forcing a fresh mount. Holding this in plain local +// useState reset it on every such remount, so folding one section visibly +// re-expanded the other. Renderer-only and LRU-bounded: it resets on reload, +// matching the ephemeral live-agent lineage it tracks, and never grows without +// bound in a long-lived renderer. +export const MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS = 512 +const expansionByWorktreeId = new Map() + +function trimPersistedExpansions(): void { + while (expansionByWorktreeId.size > MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS) { + const oldest = expansionByWorktreeId.keys().next().value + if (oldest === undefined) { + break + } + expansionByWorktreeId.delete(oldest) + } +} + +function readExpansionState(worktreeId: string): WorktreeAgentExpansionState { + return expansionByWorktreeId.get(worktreeId) ?? DEFAULT_EXPANSION_STATE +} + +function persistExpansionState(worktreeId: string, state: WorktreeAgentExpansionState): void { + // Re-insert to refresh LRU order; drop entries that carry no non-default + // state so idle worktrees never occupy a slot. + expansionByWorktreeId.delete(worktreeId) + if (state.compactRootListExpanded || state.collapsedLineageParents.size > 0) { + expansionByWorktreeId.set(worktreeId, state) + trimPersistedExpansions() + } +} + +export type WorktreeAgentExpansionControls = { + collapsedLineageParents: ReadonlySet + compactRootListExpanded: boolean + /** Fold/unfold a single agent-lineage parent by its paneKey. */ + toggleLineageParent: (paneKey: string) => void + /** Open/close the compact multi-agent summary panel. */ + toggleCompactRootList: () => void +} + +/** + * Remount-durable expand/collapse state for one worktree card's inline agent + * list. Reads seed from a module-level, session-scoped cache so a virtualizer + * recycle or a sibling child-worktrees toggle no longer wipes the user's + * disclosure choices. Independent per worktree id. + */ +export function useWorktreeAgentExpansionState(worktreeId: string): WorktreeAgentExpansionControls { + const [rendered, setRendered] = useState<{ + worktreeId: string + state: WorktreeAgentExpansionState + }>(() => ({ worktreeId, state: readExpansionState(worktreeId) })) + + // Why: a memoized body can be handed a new worktreeId without remounting; + // fall back to the cache so we never show a stale card's disclosure. + const current = + rendered.worktreeId === worktreeId ? rendered.state : readExpansionState(worktreeId) + + const commit = useCallback( + (next: WorktreeAgentExpansionState) => { + persistExpansionState(worktreeId, next) + setRendered({ worktreeId, state: next }) + }, + [worktreeId] + ) + + const toggleLineageParent = useCallback( + (paneKey: string) => { + const base = readExpansionState(worktreeId) + const nextParents = new Set(base.collapsedLineageParents) + if (nextParents.has(paneKey)) { + nextParents.delete(paneKey) + } else { + nextParents.add(paneKey) + } + commit({ ...base, collapsedLineageParents: nextParents }) + }, + [commit, worktreeId] + ) + + const toggleCompactRootList = useCallback(() => { + const base = readExpansionState(worktreeId) + commit({ ...base, compactRootListExpanded: !base.compactRootListExpanded }) + }, [commit, worktreeId]) + + return { + collapsedLineageParents: current.collapsedLineageParents, + compactRootListExpanded: current.compactRootListExpanded, + toggleLineageParent, + toggleCompactRootList + } +} + +export function clearWorktreeAgentExpansionStateForTests(): void { + expansionByWorktreeId.clear() +} + +export function getWorktreeAgentExpansionCountForTests(): number { + return expansionByWorktreeId.size +} + +export function seedWorktreeAgentExpansionStateForTests( + worktreeId: string, + state: WorktreeAgentExpansionState +): void { + persistExpansionState(worktreeId, state) +} diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 292952390bf..7a7cc8f0c21 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -81,7 +81,10 @@ import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' +import { + selectNativeChatTabWideFallbackUnsafeTabsById, + selectTabAgentTypesByTabId +} from './tab-agent-types-by-tab-id' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' const isWindows = navigator.userAgent.includes('Windows') @@ -449,16 +452,20 @@ function TabBarInner({ [unifiedTabs] ) - // Why: gate the tab long-press view-mode toggle to agent terminals. A tab is - // eligible when it launched an agent or has a live agent-status entry on any of - // its panes (paneKey = `${unifiedTabId}:…`), mirroring the toggle button's gate. + // Why: gate the tab long-press view-mode toggle to the agent in its active leaf. + // Tab-wide launch/title hints are safe only before the terminal is split. const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) // Why: the strip only needs each tab's stable agent identity, but the whole // agentStatusByPaneKey map churns on every working↔idle transition app-wide. // Select a shallow-stable { tabId: agentType } projection so the strip // re-renders only when a tab gains/loses/changes its agent, not on status flips. const tabAgentTypesByTabId = useAppStore( - useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {})) + useShallow((s) => + selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {}, s.terminalLayoutsByTabId) + ) + ) + const nativeChatTabWideFallbackUnsafeTabsById = useAppStore( + useShallow((s) => selectNativeChatTabWideFallbackUnsafeTabsById(s.terminalLayoutsByTabId)) ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) const nativeChatTranscriptIsLocalReadable = useAppStore((s) => @@ -1117,14 +1124,16 @@ function TabBarInner({ // agent-status pane keys are `${terminalTab.id}:${leafId}`, and // the unified tab id can differ from it. const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null + const tabWideFallbackSafe = + nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true const canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', - launchAgent: terminalTab.launchAgent, + launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, detectedAgent, - resolvedAgent, + resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, nativeChatTranscriptIsLocalReadable, isChatViewMode: unifiedTabForItem.viewMode === 'chat' }) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index 75ac3995670..727624abbef 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -1,13 +1,30 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' -import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' +import { + selectNativeChatTabWideFallbackUnsafeTabsById, + selectTabAgentTypesByTabId +} from './tab-agent-types-by-tab-id' function entry(partial: Partial): AgentStatusEntry { return { state: 'working', updatedAt: 0, ...partial } as AgentStatusEntry } +function splitLayout(activeLeafId: string | null): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + activeLeafId, + expandedLeafId: null + } +} + describe('selectTabAgentTypesByTabId', () => { it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => { const map: Record = { @@ -36,6 +53,131 @@ describe('selectTabAgentTypesByTabId', () => { ) }) + it('uses the active split leaf regardless of pane-map insertion order', () => { + const layouts = { 'tab-1': splitLayout('leaf-b') } + const agentFirst = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }) + } + const activeFirst = { + 'tab-1:leaf-b': entry({ agentType: 'codex' }), + 'tab-1:leaf-a': entry({ agentType: 'claude' }) + } + + expect(selectTabAgentTypesByTabId(agentFirst, layouts)['tab-1']).toBe('codex') + expect(selectTabAgentTypesByTabId(activeFirst, layouts)['tab-1']).toBe('codex') + expect(selectNativeChatTabWideFallbackUnsafeTabsById(layouts)).toEqual({ 'tab-1': true }) + }) + + it('does not inherit a supported sibling when the active split leaf is a shell', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: undefined }) + }, + { 'tab-1': splitLayout('leaf-b') } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + }) + + it('uses the reassigned active sibling after the prior agent leaf closes', () => { + const statuses = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }) + } + + expect( + selectTabAgentTypesByTabId(statuses, { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-b' }, + activeLeafId: 'leaf-b', + expandedLeafId: null + } + })['tab-1'] + ).toBe('codex') + }) + + it('does not fall back to insertion order while a split has no active leaf', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: undefined }) + }, + { 'tab-1': splitLayout(null) } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + }) + + it('ignores a stale active leaf id that is no longer in the layout', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:closed-leaf': entry({ agentType: 'claude' }), + 'tab-1:leaf-a': entry({ agentType: undefined }) + }, + { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + } + } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + expect( + selectNativeChatTabWideFallbackUnsafeTabsById({ + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + } + }) + ).toEqual({ 'tab-1': true }) + }) + + it('uses the pane entry while a rootless layout is still hydrating', () => { + expect( + selectTabAgentTypesByTabId( + { 'tab-1:leaf-a': entry({ agentType: 'claude' }) }, + { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } } + ) + ).toEqual({ 'tab-1': 'claude' }) + }) + + it('treats a missing layout map as no unsafe split evidence during hydration', () => { + expect(selectNativeChatTabWideFallbackUnsafeTabsById()).toEqual({}) + }) + + it('resolves the active leaf through nested splits and ignores expanded siblings', () => { + const layout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-b' }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + }, + activeLeafId: 'leaf-c', + expandedLeafId: 'leaf-a' + } + + expect( + selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-c': entry({ agentType: 'codex' }) + }, + { 'tab-1': layout } + ) + ).toEqual({ 'tab-1': 'codex' }) + }) + it('stays shallow-equal across a working<->idle status flip (no re-render)', () => { const working: Record = { 'tab-1:leaf-a': entry({ agentType: 'claude', state: 'working' }) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index 5fc8335a93c..68d78a43ab3 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -1,4 +1,9 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { + isNativeChatTabWideFallbackSafe, + resolveNativeChatActiveLayoutLeafId +} from '../native-chat/native-chat-leaf-routing' /** * Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }` @@ -13,16 +18,34 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-statu * those transitions, so the strip re-renders only when a tab actually gains, loses, * or changes its agent. * - * First matching pane per tab wins, mirroring `findTabAgentEntry` exactly (tab ids - * are colon-free by construction, so the substring before the first `:` is the - * tab id). A pane whose entry has no `agentType` still claims the tab and yields - * `null`, identical to `findTabAgentEntry(...)?.agentType ?? null`. + * The active layout leaf wins when available because that is where a tab-level + * chat action opens. Before layout hydration, the first matching pane preserves + * the legacy lookup behavior (tab ids are colon-free by construction). */ export function selectTabAgentTypesByTabId( - agentStatusByPaneKey: Record + agentStatusByPaneKey: Record, + terminalLayoutsByTabId: Record = {} ): Record { const byTabId: Record = {} const claimed = new Set() + // Why: the tab action opens chat on the active split leaf, so that leaf's + // identity must outrank object insertion order from unrelated siblings. + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + // A rootless snapshot with no active leaf is hydration absence, not a + // topology decision; preserve the legacy tab lookup until a leaf exists. + if (!layout.root && !layout.activeLeafId) { + continue + } + claimed.add(tabId) + const activeLeafId = resolveNativeChatActiveLayoutLeafId(layout) + if (!activeLeafId) { + continue + } + const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`] + if (entry?.agentType != null) { + byTabId[tabId] = entry.agentType + } + } for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { const colon = paneKey.indexOf(':') if (colon <= 0) { @@ -39,3 +62,17 @@ export function selectTabAgentTypesByTabId( } return byTabId } + +export function selectNativeChatTabWideFallbackUnsafeTabsById( + terminalLayoutsByTabId: Record = {} +): Record { + // Why: legacy and hydrating store shapes may not expose layout state yet; + // absence carries no unsafe split evidence and must not crash tab rendering. + const unsafeTabs: Record = {} + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + if (!isNativeChatTabWideFallbackSafe(layout)) { + unsafeTabs[tabId] = true + } + } + return unsafeTabs +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4f6e1ecbe9e..f9a4e1fc40f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -101,6 +101,10 @@ import { } from '@/lib/pane-manager/mobile-driver-state' import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send-eligibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { + nativeChatLaunchAgentForLeaf, + resolveNativeChatLeafRoute +} from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -376,10 +380,8 @@ export default function TerminalPane({ // list via managerRef.current?.getPanes()) re-runs when a pane is split or // closed. managerRef is imperative and doesn't trigger React's dependency // tracking. The lifecycle hook updates this via setPaneCount on - // onPaneCreated / onPaneClosed / onLayoutChanged. The value is never - // read — the portal map at line ~914 calls `managerRef.current?.getPanes()` - // imperatively, so `setPaneCount` is used only as a render-trigger side - // effect to force that map to re-run when a pane is split or closed. + // onPaneCreated / onPaneClosed / onLayoutChanged. The portal map reads the + // manager imperatively; the count also wakes leaf-ownership initialization. const [paneCount, setPaneCount] = useState(0) // Why: pane reorders can move panes without changing count or size, so // overlay rects need an explicit layout-change render trigger. @@ -394,6 +396,9 @@ export default function TerminalPane({ } | null>(null) const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false) const [chatLeafId, setChatLeafId] = useState(null) + const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState( + undefined + ) // Why: the terminal menu can be the first quick-command entry point, so each // Add action starts with a fresh draft instead of reusing cancelled text. const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft) @@ -685,53 +690,106 @@ export default function TerminalPane({ selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) ) const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) + const setTabViewMode = useAppStore((store) => store.setTabViewMode) const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) const terminalTab = useAppStore((store) => getCachedTerminalTabForWorktree(store.tabsByWorktree, worktreeId, tabId) ) + const restoredLayout = useMemo( + () => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout), + [savedLayout, terminalTab] + ) + const expectedLayoutLeafIds = useMemo( + () => collectLeafIdsInOrder(restoredLayout.root), + [restoredLayout.root] + ) + const getNativeChatLeafIds = useCallback((): string[] => { + const mountedLeafIds = managerRef.current?.getPanes().map((pane) => pane.leafId) ?? [] + // Why: a partially hydrated manager can expose one pane from a restored + // split. Union both sources so tab-wide evidence stays disabled meanwhile. + return [...new Set([...expectedLayoutLeafIds, ...mountedLeafIds])] + }, [expectedLayoutLeafIds]) + const getTabWideAgentHintLeafId = useCallback((): string | null => { + if (tabWideAgentHintLeafId !== undefined) { + return tabWideAgentHintLeafId + } + const leafIds = getNativeChatLeafIds() + return leafIds.length === 1 ? leafIds[0] : null + }, [getNativeChatLeafIds, tabWideAgentHintLeafId]) + useEffect(() => { + if (tabWideAgentHintLeafId !== undefined) { + return + } + const leafIds = getNativeChatLeafIds() + if (leafIds.length === 0) { + return + } + // Why: tab-wide launch/title metadata predates leaf ownership. Bind it only + // when the first concrete topology proves which sole leaf it can describe. + setTabWideAgentHintLeafId(leafIds.length === 1 ? leafIds[0] : null) + }, [getNativeChatLeafIds, paneCount, tabWideAgentHintLeafId]) const resolveTitleAgentForLeaf = useCallback( - (leafId: string | null) => - resolveNativeChatLeafTitleAgent({ + (leafId: string | null) => { + const hasSingleKnownLeaf = + getNativeChatLeafIds().length === 1 && getTabWideAgentHintLeafId() === leafId + return resolveNativeChatLeafTitleAgent({ leafId, panes: managerRef.current?.getPanes() ?? [], runtimePaneTitlesByPaneId, - tabLabel: unifiedTabLabel, - terminalTitle: terminalTab?.title - }), - [runtimePaneTitlesByPaneId, terminalTab?.title, unifiedTabLabel] + tabLabel: hasSingleKnownLeaf ? unifiedTabLabel : null, + terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null + }) + }, + [ + getNativeChatLeafIds, + getTabWideAgentHintLeafId, + runtimePaneTitlesByPaneId, + terminalTab?.title, + unifiedTabLabel + ] ) // Per-leaf eligibility: a split can mix a supported agent in one leaf with an // unsupported one in another, so the toggle is gated by the specific leaf. // A leaf's own live agent is authoritative; the tab-wide launch/title hints // only fill in before hooks arrive (or for the single-pane case) so they // can't enable the toggle on a sibling actually running an unsupported agent. - const canToggleChatForLeaf = useCallback( + const isChatEligibleForLeaf = useCallback( (leafId: string | null): boolean => { const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null - // Scope the "always allow toggling back" rule to the leaf actually showing - // chat — passing the tab-wide flag would re-enable the toggle on an - // unsupported sibling whenever any leaf in the split is in chat view. - const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId + const launchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminalTab?.launchAgent, + launchAgentLeafId: getTabWideAgentHintLeafId(), + leafId, + leafIds: getNativeChatLeafIds() + }) return canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', - launchAgent: detectedAgent ? null : terminalTab?.launchAgent, + launchAgent: detectedAgent ? null : launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), - nativeChatTranscriptIsLocalReadable, - isChatViewMode: isChatViewForLeaf + nativeChatTranscriptIsLocalReadable }) }, [ tabAgentTypeByLeaf, - effectiveChatViewMode, - chatLeafId, nativeChatEnabled, nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, + getNativeChatLeafIds, + getTabWideAgentHintLeafId, resolveTitleAgentForLeaf ] ) + const canToggleChatForLeaf = useCallback( + (leafId: string | null): boolean => { + // Scope the "always allow toggling back" rule to the leaf actually showing + // chat; it must not make an unsupported sibling look eligible. + const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId + return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId) + }, + [chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf, nativeChatEnabled] + ) const toggleNativeChatForLeaf = useCallback( (leafId: string) => { if (!unifiedTabId) { @@ -757,14 +815,6 @@ export default function TerminalPane({ toggleNativeChatForLeaf(activeLeafId) }, [toggleNativeChatForLeaf]) const setTabLayout = useAppStore((store) => store.setTabLayout) - const restoredLayout = useMemo( - () => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout), - [savedLayout, terminalTab] - ) - const expectedLayoutLeafIds = useMemo( - () => collectLeafIdsInOrder(restoredLayout.root), - [restoredLayout.root] - ) const expectedLayoutLeafIdsAttr = expectedLayoutLeafIds.length > 0 ? expectedLayoutLeafIds.join(' ') : undefined const initialLayoutRef = useRef(restoredLayout) @@ -2920,23 +2970,31 @@ export default function TerminalPane({ ? managedPanes.some((pane) => pane.leafId === chatLeafId) : false useEffect(() => { - if (!isChatViewMode) { - if (chatLeafId !== null) { - setChatLeafId(null) - } - return - } const activeLeafId = activePane?.leafId ?? null - if (!chatLeafId) { - if (activeLeafId) { - setChatLeafId(activeLeafId) - } - return + const route = resolveNativeChatLeafRoute({ + isChatViewMode, + chatLeafId, + activeLeafId, + chatLeafStillMounted, + chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId), + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + }) + if (route.chatLeafId !== chatLeafId) { + setChatLeafId(route.chatLeafId) } - if (!chatLeafStillMounted) { - setChatLeafId(activeLeafId) + if (route.exitChat && unifiedTabId) { + // Why: effect replay must not flip terminal mode back to chat. + setTabViewMode(unifiedTabId, 'terminal') } - }, [isChatViewMode, chatLeafId, activePane?.leafId, chatLeafStillMounted]) + }, [ + isChatViewMode, + chatLeafId, + activePane?.leafId, + chatLeafStillMounted, + isChatEligibleForLeaf, + unifiedTabId, + setTabViewMode + ]) const chatPane = isChatViewMode && chatLeafId ? (managedPanes.find((pane) => pane.leafId === chatLeafId) ?? null) @@ -2945,6 +3003,12 @@ export default function TerminalPane({ ? (paneTransportsRef.current.get(chatPane.id)?.getPtyId() ?? null) : null const chatPaneResolvedAgent = chatPane ? resolveTitleAgentForLeaf(chatPane.leafId) : null + const chatPaneLaunchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminalTab?.launchAgent, + launchAgentLeafId: getTabWideAgentHintLeafId(), + leafId: chatPane?.leafId ?? null, + leafIds: getNativeChatLeafIds() + }) const activePaneIsChatLeaf = Boolean( isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId ) @@ -3045,7 +3109,7 @@ export default function TerminalPane({ terminalTabId={tabId} paneKey={makePaneKey(tabId, chatPane.leafId)} targetPtyId={chatPanePtyId} - launchAgent={terminalTab?.launchAgent} + launchAgent={chatPaneLaunchAgent} resolvedAgent={chatPaneResolvedAgent} onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)} contextMenuActions={{ diff --git a/src/renderer/src/components/terminal-pane/pty-applied-size-read-e2e-delay.ts b/src/renderer/src/components/terminal-pane/pty-applied-size-read-e2e-delay.ts new file mode 100644 index 00000000000..a56e093a309 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-applied-size-read-e2e-delay.ts @@ -0,0 +1,15 @@ +import { e2eConfig } from '@/lib/e2e-config' + +// Why: e2e seam for the stale-reveal-resize regression spec. The visibility +// resume readback (pty-size-reassertion) is only safe when the applied-size +// read is processed before the reveal fit's PTY resize; a busy daemon or +// SSH/relay round-trip breaks that ordering in the field. The spec sets this +// window global to reproduce the losing ordering deterministically. Gated on +// exposeStore so packaged builds ignore it. +export function getAppliedSizeReadE2eDelayMs(): number { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return 0 + } + const delayMs = window.__e2ePtyAppliedSizeReadDelayMs + return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ae752d77d97..4c56bc6458f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -47,6 +47,7 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' +import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' import { createPtySizeReassertion } from './pty-size-reassertion' import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' import { @@ -3499,7 +3500,15 @@ export function connectPanePty( shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), fit: () => safeFit(pane), getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: pane.terminal.rows }), - getAppliedSize: (ptyId) => window.api.pty.getSize(ptyId), + getAppliedSize: async (ptyId) => { + // Why: e2e seam — delays the read past the reveal fit to reproduce the + // busy-daemon/SSH ordering; returns 0 outside e2e builds. + const delayMs = getAppliedSizeReadE2eDelayMs() + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + return window.api.pty.getSize(ptyId) + }, forwardResize: forwardPtyResize }) let pendingForegroundGridDriftCheckRaf: number | null = null @@ -6055,7 +6064,9 @@ export function connectPanePty( } // Why: snapshot replay clears and rebuilds xterm state; re-apply the // user's scroll intent once so hidden catch-up cannot repin the viewport. - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) + // Restore by bottom offset — the rebuilt buffer renumbers every row, so + // the pre-replay absolute viewport line points at arbitrary content. + enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent, { restoreBy: 'bottomOffset' }) } function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts index 5ad5accfe5f..3b22b2d9acf 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts @@ -70,7 +70,8 @@ describe('createPtySizeReassertion', () => { reassertion.request() await flushAsyncTicks() - expect(calls).toEqual(['fit', 'measure', 'read-applied']) + // The trailing measure is the resolve-time staleness check. + expect(calls).toEqual(['fit', 'measure', 'read-applied', 'measure']) }) it('does not duplicate the resize when fit already triggered xterm onResize', async () => { @@ -217,6 +218,150 @@ describe('createPtySizeReassertion', () => { expect(forwardResize).not.toHaveBeenCalledWith(100, 40) }) + it('suppresses a stale forward when xterm was refit while the read was in flight', async () => { + // Regression: a reveal-time fit (or snapshot-restore xterm resize) changes + // the grid after the target was captured, with no second request() to + // guard it. The resolved callback must not resize the PTY back to the + // pre-reveal grid. + let dims = { cols: 80, rows: 24 } + let resolveRead: (value: { cols: number; rows: number }) => void = () => {} + const getAppliedSize = vi + .fn<() => Promise<{ cols: number; rows: number } | null>>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + .mockResolvedValue({ cols: 145, rows: 78 }) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + dims = { cols: 145, rows: 78 } + resolveRead({ cols: 145, rows: 78 }) + await flushAsyncTicks() + + expect(forwardResize).not.toHaveBeenCalledWith(80, 24) + expect(forwardResize).not.toHaveBeenCalled() + }) + + it('re-runs against the fresh grid when the PTY kept the old size across a mid-flight refit', async () => { + let dims = { cols: 80, rows: 24 } + let resolveRead: (value: { cols: number; rows: number }) => void = () => {} + const getAppliedSize = vi + .fn<() => Promise<{ cols: number; rows: number } | null>>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + .mockResolvedValue({ cols: 80, rows: 24 }) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + dims = { cols: 145, rows: 78 } + resolveRead({ cols: 80, rows: 24 }) + await flushAsyncTicks() + + expect(getAppliedSize).toHaveBeenCalledTimes(2) + expect(forwardResize).toHaveBeenCalledTimes(1) + expect(forwardResize).toHaveBeenCalledWith(145, 78) + }) + + it('keeps a single read in flight and converges when the grid oscillates across resolves', async () => { + // Why: a flapping layout (competing fitters) must not amplify into more + // than one concurrent readback, and the re-run chain must stop as soon as + // the grid holds still for one read. + const grids = [ + { cols: 80, rows: 24 }, + { cols: 145, rows: 78 }, + { cols: 80, rows: 24 }, + { cols: 100, rows: 40 } + ] + let readCount = 0 + let dims = grids[0] + const getAppliedSize = vi.fn(async () => { + readCount += 1 + // The grid moves during each of the first three flights, then settles. + dims = grids[Math.min(readCount, grids.length - 1)] + return { cols: 100, rows: 40 } + }) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + await flushAsyncTicks(20) + + // One re-run per moved-grid flight, then convergence: applied 100x40 + // matches the settled grid, so nothing is forwarded. + expect(getAppliedSize).toHaveBeenCalledTimes(4) + expect(forwardResize).not.toHaveBeenCalled() + }) + + it('does not forward a stale target when the readback fails after a mid-flight refit', async () => { + let dims = { cols: 80, rows: 24 } + let rejectRead: (reason: Error) => void = () => {} + const getAppliedSize = vi + .fn<() => Promise<{ cols: number; rows: number } | null>>() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRead = reject + }) + ) + .mockRejectedValue(new Error('unavailable')) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + dims = { cols: 145, rows: 78 } + rejectRead(new Error('unavailable')) + await flushAsyncTicks() + + expect(forwardResize).not.toHaveBeenCalledWith(80, 24) + // The unguarded fallback resize still happens, but at the fresh grid. + expect(forwardResize).toHaveBeenCalledWith(145, 78) + }) + it('forwards once when applied-size readback fails', async () => { const forwardResize = vi.fn() const reassertion = createPtySizeReassertion({ diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts index 78a2e8cf5aa..7f127b67083 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts @@ -63,6 +63,14 @@ export function createPtySizeReassertion(options: PtySizeReassertionOptions): Pt if (pending) { return } + // Why: a reveal fit or snapshot-restore resize can change xterm while the + // applied-size read is in flight without queuing a request; forwarding the + // captured target would resize the PTY back to the pre-reveal grid, so + // re-run against the fresh grid instead. + if (!dimensionsMatch(options.getTerminalDimensions(), target)) { + pending = true + return + } if (dimensionsMatch(actual, target)) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts new file mode 100644 index 00000000000..1fb24a000dd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' + +describe('shouldClearLaunchAgentForClosedPane', () => { + it('clears launch identity only when the launch-owning PTY closes', () => { + const tab = { launchAgent: 'codex' as const, ptyId: 'pty-agent' } + + expect(shouldClearLaunchAgentForClosedPane(tab, 'pty-agent')).toBe(true) + expect(shouldClearLaunchAgentForClosedPane(tab, 'pty-shell')).toBe(false) + }) + + it('does not mutate identity-free or not-yet-bound tabs', () => { + expect(shouldClearLaunchAgentForClosedPane({ ptyId: 'pty-1' }, 'pty-1')).toBe(false) + expect( + shouldClearLaunchAgentForClosedPane({ launchAgent: 'claude', ptyId: null }, 'pty-1') + ).toBe(false) + }) +}) + +describe('resolveTabTitleAfterPaneClose', () => { + it('uses the promoted sibling title when one is known', () => { + expect(resolveTabTitleAfterPaneClose({ 2: 'codex' }, 2)).toBe('codex') + }) + + it('resets to the tab fallback when the promoted shell has no title', () => { + expect(resolveTabTitleAfterPaneClose({ 1: 'closed agent' }, 2)).toBe('') + expect(resolveTabTitleAfterPaneClose({}, null)).toBe('') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts new file mode 100644 index 00000000000..2325241098f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts @@ -0,0 +1,19 @@ +import type { TerminalTab } from '../../../../shared/types' + +export function shouldClearLaunchAgentForClosedPane( + tab: Pick | null | undefined, + closedPtyId: string | null | undefined +): boolean { + // Why: launchAgent describes the tab's original PTY only. Closing that PTY + // must not transfer its bootstrap identity to a surviving shell sibling. + return Boolean(tab?.launchAgent && closedPtyId && tab.ptyId === closedPtyId) +} + +export function resolveTabTitleAfterPaneClose( + runtimePaneTitlesByPaneId: Readonly>, + activePaneId: number | null | undefined +): string { + // Why: an empty update resets the tab to its stable fallback instead of + // leaving the closed pane's agent title attached to an untitled survivor. + return activePaneId == null ? '' : (runtimePaneTitlesByPaneId[activePaneId] ?? '') +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index bfd6b988291..27d2c383f5d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -45,6 +45,10 @@ vi.mock('./pty-dispatcher', () => ({ })) type MockStoreState = { + tabsByWorktree: Record< + string, + { id: string; launchAgent?: 'claude' | 'codex'; ptyId: string | null }[] + > terminalLayoutsByTabId: Record< string, { @@ -55,8 +59,10 @@ type MockStoreState = { } > runtimePaneTitlesByTabId: Record> + clearTabLaunchAgent: ReturnType clearRuntimePaneTitle: ReturnType setTabLayout: ReturnType + updateTabTitle: ReturnType } let mockStoreState: MockStoreState @@ -102,10 +108,13 @@ function syncParked(args?: { describe('terminal-parked-tab-watchers', () => { beforeEach(() => { mockStoreState = { + tabsByWorktree: {}, terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {}, + clearTabLaunchAgent: vi.fn(), clearRuntimePaneTitle: vi.fn(), - setTabLayout: vi.fn() + setTabLayout: vi.fn(), + updateTabTitle: vi.fn() } ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } }) @@ -370,6 +379,64 @@ describe('terminal-parked-tab-watchers', () => { }) }) + it('retires launch/title hints when the launch-owning parked leaf exits', () => { + mockStoreState.tabsByWorktree = { + [WORKTREE_ID]: [{ id: TAB_ID, launchAgent: 'codex', ptyId: PTY_ID }] + } + mockStoreState.runtimePaneTitlesByTabId = { + [TAB_ID]: { 1: 'Codex', 2: 'PowerShell' } + } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(mockStoreState.clearTabLaunchAgent).toHaveBeenCalledWith(TAB_ID) + expect(mockStoreState.updateTabTitle).toHaveBeenCalledWith(TAB_ID, 'PowerShell') + }) + + it('keeps launch ownership when only a parked shell sibling exits', () => { + mockStoreState.tabsByWorktree = { + [WORKTREE_ID]: [{ id: TAB_ID, launchAgent: 'claude', ptyId: PTY_ID }] + } + mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: 'Claude Code' } } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + + expect(mockStoreState.clearTabLaunchAgent).not.toHaveBeenCalled() + expect(mockStoreState.updateTabTitle).toHaveBeenCalledWith(TAB_ID, 'Claude Code') + }) + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 8871bdf4905..ba86ba3e512 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -18,6 +18,10 @@ import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { subscribeToPtyExit } from './pty-dispatcher' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' import { capturedPanesByTabId, disposeParkedTabWatchers, @@ -232,7 +236,24 @@ function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { } const detached = detachTerminalLayoutLeaf(layout, leafId) if (detached) { + const terminalTab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if (shouldClearLaunchAgentForClosedPane(terminalTab, ptyId)) { + state.clearTabLaunchAgent(tabId) + } state.setTabLayout(tabId, detached.sourceLayout) + const activeLeafId = detached.sourceLayout.activeLeafId + const activePtyId = activeLeafId + ? detached.sourceLayout.ptyIdsByLeafId?.[activeLeafId] + : undefined + const activePaneId = activePtyId + ? (parkedWatchersByTabId.get(tabId)?.paneIdByPtyId.get(activePtyId) ?? null) + : null + state.updateTabTitle( + tabId, + resolveTabTitleAfterPaneClose(state.runtimePaneTitlesByTabId[tabId] ?? {}, activePaneId) + ) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 5a64694000e..feeedbc6cd0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -127,6 +127,10 @@ import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' export function recordRuntimeCreatedTerminalPaneSplit( createdPane: unknown, @@ -1278,6 +1282,13 @@ export function useTerminalPaneLifecycle({ mouseHideDisposablesRef.current.delete(paneId) } const transport = paneTransportsRef.current.get(paneId) + const closedPtyId = transport?.getPtyId() ?? null + const terminalTab = useAppStore + .getState() + .tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + if (!isDetachedToTab && shouldClearLaunchAgentForClosedPane(terminalTab, closedPtyId)) { + useAppStore.getState().clearTabLaunchAgent(tabId) + } const panePtyBinding = panePtyBindings.get(paneId) if (panePtyBinding) { panePtyBinding.dispose() @@ -1350,10 +1361,7 @@ export function useTerminalPaneLifecycle({ if (newActivePane) { reportActiveRendererPtyForPane(paneTransportsRef.current, newActivePane.id) const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {} - const activeTitle = paneTitles[newActivePane.id] - if (activeTitle) { - updateTabTitle(tabId, activeTitle) - } + updateTabTitle(tabId, resolveTabTitleAfterPaneClose(paneTitles, newActivePane.id)) } scheduleRuntimeGraphSync() }, diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 676fa253529..e07b5d6a32b 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -73,6 +73,7 @@ declare global { parkedTabIds: () => string[] } __monacoEditorE2E?: MonacoE2EProbe + __e2ePtyAppliedSizeReadDelayMs?: number } } diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index cf4e8fb7c61..e19aad2a013 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + getComposerRepoWorktreeBranches, isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, @@ -209,6 +210,20 @@ describe('isBranchCheckedOutInWorktrees', () => { }) }) +describe('getComposerRepoWorktreeBranches', () => { + it('supplies only the selected repo branches to reuse eligibility', () => { + expect( + getComposerRepoWorktreeBranches( + [ + { repoId: 'repo-a', branch: 'feature/a' }, + { repoId: 'repo-b', branch: 'feature/b' } + ], + 'repo-a' + ) + ).toEqual(['feature/a']) + }) +}) + describe('resolveComposerReuseOverride', () => { it('keeps the selection override for a reusable (non-busy) local branch', () => { expect( diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index 02f2dfce79f..935c1689e84 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -1,9 +1,12 @@ export { isBranchCheckedOutInWorktrees, + getComposerRepoWorktreeBranches, + resolveComposerBranchPick, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, resolveComposerBranchSelection, resolveComposerManualBranchNameChange, resolveComposerReuseOverride, + type ComposerBranchPick, type ComposerBranchSelection } from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/fork-push-warning.ts b/src/renderer/src/hooks/fork-push-warning.ts index 27ab7af8073..a528541421a 100644 --- a/src/renderer/src/hooks/fork-push-warning.ts +++ b/src/renderer/src/hooks/fork-push-warning.ts @@ -1,21 +1 @@ -import type { GitHubPrStartPoint } from '../../../shared/types' - -export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING = - 'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.' - -// Why: only warn for fork PRs where the push target points away from origin and -// whose author left "Allow edits from maintainers" off. That's the one case -// where our push to the contributor's fork can be rejected by GitHub. Returns -// the warning text to show, or null when no warning applies. -export function getForkPushWarning( - result: Pick -): string | null { - if ( - result.maintainerCanModify === false && - result.pushTarget !== undefined && - result.pushTarget.remoteName !== 'origin' - ) { - return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING - } - return null -} +export * from '../../../shared/new-workspace/fork-push-warning' diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 922f1262830..eecc7a6c974 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -147,6 +147,10 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import { getForkPushWarning } from './fork-push-warning' +import { + buildWorkspaceSourceSelection, + shouldApplyWorkspaceSourceAutoName +} from '../../../shared/new-workspace/workspace-source' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' @@ -164,12 +168,10 @@ import { } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { - isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchReuse, - resolveComposerBranchSelection, + resolveComposerBranchPick, resolveComposerManualBranchNameChange, - resolveComposerReuseOverride + getComposerRepoWorktreeBranches } from './composer-branch-selection' import { isCurrentComposerDropOwner } from './composer-drop-owner' import { @@ -2072,7 +2074,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // name or it silently becomes a slugified-URL workspace name. if ( suggestedName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(suggestedName) lastAutoNameRef.current = suggestedName @@ -2307,7 +2312,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = titleName?.seedName ?? suggestedName if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -2909,7 +2917,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3013,7 +3024,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3110,11 +3124,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleSmartBranchSelect = useCallback( (refName: string, localBranchName: string): void => { smartGitHubPrStartPointSelectionRef.current = null - const selection = resolveComposerBranchSelection({ + const selection = resolveComposerBranchPick({ refName, localBranchName, currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + worktreeBranches: getComposerRepoWorktreeBranches(worktreesByRepo[repoId] ?? [], repoId) }) setBaseBranch(selection.baseBranch) setCompareBaseRef(undefined) @@ -3130,34 +3145,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Note: worktreesByRepo only covers visible worktrees; a branch busy only // in a hidden external worktree falls through to the backend conflict // check, which rejects it with a clear "already exists locally" error. - const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( - localBranchName, - (worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch) - ) - const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = - resolveComposerBranchReuse({ - refName, - localBranchName, - selectionProducedOverride: selection.branchNameOverride !== undefined, - branchCheckedOutElsewhere - }) + const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = selection setReuseEligibleBranch(nextReuseEligibleBranch) setReuseSelectedBranch(defaultReuse) setBranchNameOverridePreservesNameEdits(defaultReuse) - const effectiveOverride = resolveComposerReuseOverride({ - refName, - localBranchName, - branchNameOverride: selection.branchNameOverride, - branchCheckedOutElsewhere - }) if (selection.name !== undefined && selection.lastAutoName !== undefined) { setName(selection.name) lastAutoNameRef.current = selection.lastAutoName - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' - setBranchNameOverride(effectiveOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) } else { - setBranchNameOverride(effectiveOverride) - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' } }, [name, worktreesByRepo, repoId] @@ -3194,9 +3193,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const suggestedName = getLinkedItemDisplayName(linkedItem) ?? getLinearIssueWorkspaceName(issue) if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3213,9 +3213,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: same lookup-text rule as applyLinkedWorkItem, plus the typed // Linear identifier ("STA-123") that matched this issue. if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3262,33 +3263,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (isProjectGroupTarget) { return getFolderSmartNameSelection(linkedWorkItem) } - if (linkedWorkItem) { - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const isLinear = provider === 'linear' - const kind: SmartWorkspaceNameSelection['kind'] = isLinear - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - isLinear || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } - } - if (baseBranch) { - return { kind: 'branch', label: baseBranch } - } - return null + return buildWorkspaceSourceSelection({ + linkedWorkItem, + baseBranch + }) as SmartWorkspaceNameSelection | null }, [baseBranch, isProjectGroupTarget, linkedWorkItem]) const handleOpenAgentSettings = useCallback((): void => { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index dee27778244..ec7764ec287 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1732,6 +1732,7 @@ describe('useIpcEvents updater integration', () => { command?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -1760,6 +1761,7 @@ describe('useIpcEvents updater integration', () => { cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string activate?: boolean presentation?: 'background' | 'focused' @@ -1877,6 +1879,7 @@ describe('useIpcEvents updater integration', () => { command?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -1906,6 +1909,7 @@ describe('useIpcEvents updater integration', () => { cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string activate?: boolean presentation?: 'background' | 'focused' @@ -2223,11 +2227,16 @@ describe('useIpcEvents updater integration', () => { targetGroupId: 'group-left', title: 'Runtime Terminal', command: 'codex', + launchAgent: 'codex', + viewMode: 'terminal', activate: true, source: 'runtime-session' }) - expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, undefined) + expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { + launchAgent: 'codex', + viewMode: 'terminal' + }) expect(replyTerminalCreate).toHaveBeenCalledWith({ requestId: 'req-runtime-session', tabId: 'tab-new', @@ -2364,6 +2373,36 @@ describe('useIpcEvents updater integration', () => { } ) + createTab.mockClear() + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-explicit-terminal', + launchAgent: 'codex', + viewMode: 'terminal' + }) + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-explicit-terminal', + activate: false, + launchAgent: 'codex', + viewMode: 'terminal' + }) + + createTab.mockClear() + storeState.settings.openAgentTabsInChatByDefault = false + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-explicit-chat', + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-explicit-chat', + activate: false, + launchAgent: 'codex', + viewMode: 'chat' + }) + storeState.settings.openAgentTabsInChatByDefault = true + createTab.mockClear() setActiveView.mockClear() setActiveWorktree.mockClear() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b93f3d1610b..e341ff8a185 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1434,6 +1434,7 @@ export function useIpcEvents(): void { launchConfig, launchToken, launchAgent, + viewMode, title, ptyId, activate, @@ -1502,13 +1503,17 @@ export function useIpcEvents(): void { ...(launchAgent ? { launchAgent, - ...initialAgentTabViewModeProps(store.settings, { - agent: launchAgent, - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }) + // Why: a paired client resolved explicit mode before + // PTY materialization; only omitted mode uses host defaults. + ...(viewMode + ? { viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: launchAgent, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })) } : {}), ...(cwd ? { startupCwd: cwd } : {}), @@ -1684,16 +1689,20 @@ export function useIpcEvents(): void { if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) } + // Why: the paired launch client already resolved the initial mode, so + // its explicit choice must win over this host renderer's local default. const tabOptions = data.launchAgent ? { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, - ...initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }), + ...(data.viewMode + ? { viewMode: data.viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: data.launchAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })), ...(data.cwd ? { startupCwd: data.cwd } : {}) } : shouldActivate diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 40492be7b71..d6d8725c2d8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "Comment", "issue": "Issue", + "mr": "MR", "port": "Port", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f909b7687b9..a2bbc411665 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "Comentario", "issue": "Issue", + "mr": "MR", "port": "Puerto", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 33bc07fbfd1..8ce317e4ae4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "コメント", "issue": "Issue", + "mr": "MR", "port": "ポート", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 67187a4b21c..37326de084a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "댓글", "issue": "이슈", + "mr": "MR", "port": "포트", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dbf29920404..060903dce0e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "评论", "issue": "议题", + "mr": "MR", "port": "端口", "pr": "PR" } @@ -2818,7 +2819,7 @@ "e8e1a52edb": "删除“{{value0}}”?", "37e1bb90ce": "运行:{{value0}}", "77ac113df0": "开始 {{value0}}: {{value1}}", - "7b1c9d6ae1": "跑步", + "7b1c9d6ae1": "执行", "c781f992e4": "destructive", "be8f0ff166": "删除", "f3a8c2d1e7": "搜索快捷命令...", @@ -5967,7 +5968,7 @@ "b91a0f297d": "在归档工作树之前运行的本地和共享脚本。", "9a100323ff": "存档脚本", "21fb607a87": "创建新工作树时的默认行为。", - "793dcee97d": "何时跑步", + "793dcee97d": "何时执行", "63e1783173": "当安装脚本可用时选择默认行为。", "fb6bebcf7e": "何时运行安装程序", "30d555acd2": "创建新工作树后运行的本地和共享脚本。", @@ -8028,7 +8029,7 @@ "b86c727100": "新项目管理", "b949a7c0a0": "PNPM", "0b78c4a165": "启动", - "2d8aff42be": "跑步", + "2d8aff42be": "执行", "1c5bdcd0f2": "存储库", "89d2a9ad9f": "仓库", "f58b92a48f": "项目", @@ -10915,7 +10916,7 @@ "ca36f7b27c": "通过", "25f6838e43": "Lint", "2ef0b97954": "类型检查", - "8ed213397c": "跑步", + "8ed213397c": "执行中", "d340c052fb": "核实", "9a097cae12": "1 待定", "2f37142229": "压缩合并", @@ -12343,14 +12344,14 @@ "13988187b3": "token", "86a248187e": "花费", "149c0b49c7": "工作区", - "8faaa00726": "跑步", + "8faaa00726": "执行", "53fc5f07ab": "运行历史", "a00e38d1a3": "不适用", "fdb3caa8fb": "已知的" }, "AutomationRunPageFrame": { "40a511bed4": "运行上下文", - "33741dd973": "返回跑步" + "33741dd973": "返回执行" }, "AutomationSchedulePicker": { "22359b186a": "上午或下午", @@ -12374,7 +12375,7 @@ "2695883141": "删除", "c3a28c9793": "选择一个自动化来查看运行。", "295698292f": "重新运行", - "0e110a3469": "跑步", + "0e110a3469": "执行", "bb1b2cd31e": "概述", "97ff587ee3": "连接此源以检查远程配置文件中的 Hermes 自动化。", "aaa007846f": "来源不可用", @@ -12437,7 +12438,7 @@ "dd0bc7a1ba": "每次运行新的", "7b2e285552": "SSH 连接在此客户端中不可用。", "d441032f7e": "暂停", - "5918020edc": "跑步", + "5918020edc": "执行", "a21f6c33ad": "自动化来源已刷新。", "53f06f0ad5": "重试来源", "pendingAutomationMissing": "自动化已不可用。", @@ -12468,7 +12469,7 @@ "766abf833c": "Hermes", "bf5f67b590": "爱马仕", "e66091daf4": "运行", - "8e9165af08": "跑步", + "8e9165af08": "执行", "2b0adbce21": "已暂停", "b3feba84c7": "当前", "92405f1431": "不可用", @@ -12485,11 +12486,11 @@ "be551397ca": "状态", "a813df9808": "预览", "d4b34feb66": "运行时间", - "2d4388a908": "跑步", + "2d4388a908": "执行", "9c080765ff": "尚未找到 Hermes 运行信息。", "8ea934cacf": "正在加载运行...", "d5527d8fe7": "运行", - "872d032d05": "跑步" + "872d032d05": "执行" }, "HermesCronOutputView": { "e27c716b43": "提示词", diff --git a/src/renderer/src/lib/github-work-item-identity.ts b/src/renderer/src/lib/github-work-item-identity.ts index c9b24e316ff..3f964159cd2 100644 --- a/src/renderer/src/lib/github-work-item-identity.ts +++ b/src/renderer/src/lib/github-work-item-identity.ts @@ -1,20 +1,2 @@ -import { parseGitHubIssueOrPRLink } from '@/lib/github-links' - -export type GitHubWorkItemIdentity = { - type: 'issue' | 'pr' - number: number -} - -export function resolveGitHubWorkItemIdentity(item: { - type: 'issue' | 'pr' - number: number - url?: string | null -}): GitHubWorkItemIdentity { - const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null - if (link) { - // Why: stale cached work-item payloads can disagree with a pasted URL. The - // URL path is the user-visible intent, so it decides issue-vs-PR launches. - return { type: link.type, number: link.number } - } - return { type: item.type, number: item.number } -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/github-work-item-identity' diff --git a/src/renderer/src/lib/gitlab-links.ts b/src/renderer/src/lib/gitlab-links.ts index 1bd36c13712..d2e537d7a85 100644 --- a/src/renderer/src/lib/gitlab-links.ts +++ b/src/renderer/src/lib/gitlab-links.ts @@ -1,138 +1,2 @@ -import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' - -// Why: GitLab project paths can include nested groups, and the host may -// be self-hosted (gitlab.example.com), so the URL pattern uses the -// project-internal `/-/` separator as the GitLab-specific signal rather -// than locking to gitlab.com. Anything matching `//-/(issues| -// work_items|merge_requests)/` is treated as a GitLab item URL -// regardless of host. Modern GitLab emits issue URLs as -// `/-/work_items/`; treat that as an issue work item, same as the -// legacy `/-/issues/` form. -const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i -const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i - -export type ProjectSlug = { - /** GitLab hostname, preserving self-hosted instances from pasted URLs. */ - host: string - /** Full GitLab project path including any nested groups. */ - path: string -} - -export type GitLabLinkQuery = { - query: string - directNumber: number | null - tooLarge?: boolean -} - -/** - * Parse a GitLab issue or MR reference from plain input. Accepts: - * - bare numbers ("42") - * - hash-prefixed numbers ("#42") - * - exclamation-prefixed numbers ("!42") — GitLab convention for MRs - * - full GitLab URLs (any host) for issues or merge_requests - */ -export function parseGitLabIssueOrMRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - // Why: GitLab references issues with `#` and MRs with `!` in markdown - // and copy-paste contexts. Accept both prefixes so users can drop in - // either form. - const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return Number.parseInt(numeric, 10) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - const match = GL_ITEM_PATH_RE.exec(url.pathname) - if (!match) { - return null - } - // Why: the basic pattern matches plain GitHub URLs too (e.g. - // /owner/repo/issues/123). Require the `/-/` separator that's - // unique to GitLab to avoid mis-classifying a GitHub URL. - if (!url.pathname.includes('/-/')) { - return null - } - return Number.parseInt(match[1], 10) -} - -/** - * Parse a GitLab URL into project path + iid + type. Returns null for - * anything that isn't a recognizable GitLab issue or merge-request URL. - */ -export function parseGitLabIssueOrMRLink(input: string): { - slug: ProjectSlug - number: number - type: 'issue' | 'mr' -} | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname) - if (!match) { - return null - } - - const path = match[1] - // Why: a project path needs at least one slash (group/project). A - // single-segment path is the user/group root, not a project. - if (!path.includes('/')) { - return null - } - - return { - slug: { host: url.host, path }, - type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue', - number: Number.parseInt(match[3], 10) - } -} - -/** - * Normalize link-picker input so both raw issue/MR numbers and full - * GitLab URLs resolve to a usable query + direct-number lookup. - */ -export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery { - if (isWorkItemLinkQueryTooLarge(raw)) { - return { query: '', directNumber: null, tooLarge: true } - } - const trimmed = raw.trim() - if (!trimmed) { - return { query: '', directNumber: null } - } - - const direct = parseGitLabIssueOrMRNumber(trimmed) - if (direct !== null && !trimmed.startsWith('http')) { - return { query: trimmed, directNumber: direct } - } - - const link = parseGitLabIssueOrMRLink(trimmed) - if (!link) { - return { query: trimmed, directNumber: null } - } - - // Why: any GitLab issue/MR URL is accepted by number regardless of - // project slug, mirroring the GitHub-side behavior — fork checkouts - // can legitimately target an upstream's issue numbers. - return { - query: trimmed, - directNumber: link.number - } -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/gitlab-links' diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fa7283725c4..467ca7d07fe 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -300,7 +300,8 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', targetGroupId: 'group-1', activate: true, - agent: 'claude' + agent: 'claude', + viewMode: 'terminal' }) expect(mockCreateTab).not.toHaveBeenCalled() expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() @@ -345,12 +346,61 @@ describe('launchAgentInNewTab', () => { agentArgs: '--model gpt-5 --reasoning-effort high', agentEnv: { CODEX_PROFILE: 'captured' } }, - launchAgent: 'codex' + launchAgent: 'codex', + viewMode: 'terminal' }) expect(mockCreateTab).not.toHaveBeenCalled() expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() }) + it('propagates the default chat mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agent: 'codex', + viewMode: 'chat' + }) + ) + }) + + it('propagates the resolved terminal mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agent: 'codex', + viewMode: 'terminal' + }) + ) + }) + it('surfaces a toast when host agent launch fails in paired web clients', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) mockCreateWebRuntimeSessionTerminal.mockResolvedValue(false) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 52255ac16cf..292cc9e4626 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -201,6 +201,18 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI return null } + // Why: host-owned paired tabs must receive the same initial-view decision as + // local tabs; the remote host cannot infer this client's draft/default choice. + const viewModePromptDelivery = + hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery + const initialViewModeProps = initialAgentTabViewModeProps(store.settings, { + agent, + promptDelivery: viewModePromptDelivery, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + }) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId) && pasteDraftAfterLaunch === null) { launchAgentInWebHostTab({ @@ -210,6 +222,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI groupId, hasPrompt, startupPlan, + // Why: omission means terminal locally, but would let a paired host apply + // its own default; send the client's resolved terminal choice explicitly. + viewMode: initialViewModeProps.viewMode ?? 'terminal', onPromptDelivered }) return { tabId: null, startupPlan, pasteDraftAfterLaunch: false } @@ -224,18 +239,10 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // stays false), so gate the initial chat view like a `draft` launch — // otherwise a default `auto-submit` followup would open native chat with no // submitted turn to render. - const viewModePromptDelivery = - hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialAgentTabViewModeProps(store.settings, { - agent, - promptDelivery: viewModePromptDelivery, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }) + ...initialViewModeProps }) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index b95ccd8aba1..c685dea9968 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -5,7 +5,7 @@ import { createWebRuntimeSessionTerminal, isWebTerminalSurfaceTabId } from '@/runtime/web-runtime-session' -import type { TuiAgent } from '../../../shared/types' +import type { Tab, TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { @@ -32,16 +32,26 @@ export function launchAgentInWebHostTab(args: { groupId?: string hasPrompt: boolean startupPlan: AgentStartupPlan + viewMode?: Tab['viewMode'] onPromptDelivered?: () => void }): void { - const { agent, worktreeId, environmentId, groupId, hasPrompt, startupPlan, onPromptDelivered } = - args + const { + agent, + worktreeId, + environmentId, + groupId, + hasPrompt, + startupPlan, + viewMode, + onPromptDelivered + } = args removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) void createWebRuntimeSessionTerminal({ worktreeId, environmentId, targetGroupId: groupId, activate: true, + ...(viewMode ? { viewMode } : {}), ...(hasPrompt ? { command: startupPlan.launchCommand, diff --git a/src/renderer/src/lib/linear-linked-work-item.ts b/src/renderer/src/lib/linear-linked-work-item.ts index 530a3c01028..5bb212f2b48 100644 --- a/src/renderer/src/lib/linear-linked-work-item.ts +++ b/src/renderer/src/lib/linear-linked-work-item.ts @@ -1,6 +1,6 @@ import type { LinearIssue } from '../../../shared/types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear-links' +import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source' export function isLinearLinkedWorkItem( item: Pick | null | undefined @@ -9,21 +9,5 @@ export function isLinearLinkedWorkItem( } export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) - return { - type: 'issue', - provider: 'linear', - // Why: Linear issue prose must not enter prompt metadata; keep only the - // string identifier/link and leave numeric issue metadata empty. - number: 0, - title: issue.title, - url: issue.url, - linearIdentifier: issue.identifier, - ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), - ...(organizationUrlKey - ? { - linearOrganizationUrlKey: organizationUrlKey - } - : {}) - } + return buildLinearWorkspaceSource(issue) } diff --git a/src/renderer/src/lib/linked-work-item-provider.ts b/src/renderer/src/lib/linked-work-item-provider.ts index 7ce731622ff..62918eac598 100644 --- a/src/renderer/src/lib/linked-work-item-provider.ts +++ b/src/renderer/src/lib/linked-work-item-provider.ts @@ -1,50 +1,4 @@ -import type { LinkedWorkItemSummary } from './new-workspace' - -// Why: self-hosted GitLab issue URLs may not contain "gitlab", and modern -// GitLab emits issue URLs as `/-/work_items/` as well as the legacy -// `/-/issues/`. Recognize both forms. -const GL_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i - -export function isGitLabIssueUrl(url: string): boolean { - try { - return GL_ISSUE_PATH_RE.test(new URL(url).pathname) - } catch { - return GL_ISSUE_PATH_RE.test(url) - } -} - -function isJiraIssueUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ( - /\.atlassian\.net$/i.test(parsed.hostname) || - /\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname) - ) - } catch { - return false - } -} - -export function getLinkedWorkItemProvider( - item: LinkedWorkItemSummary -): NonNullable { - if (item.provider) { - return item.provider - } - if (item.linearIdentifier) { - return 'linear' - } - if (item.jiraIdentifier || isJiraIssueUrl(item.url)) { - return 'jira' - } - if (item.type === 'mr') { - return 'gitlab' - } - if (isGitLabIssueUrl(item.url)) { - return 'gitlab' - } - if (item.number === 0 && !item.url.includes('github.com')) { - return 'linear' - } - return 'github' -} +export { + getWorkspaceSourceProvider as getLinkedWorkItemProvider, + isGitLabIssueUrl +} from '../../../shared/new-workspace/workspace-source' diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index abe054b80c1..16a4e0092b6 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -199,6 +199,58 @@ describe('safeFit', () => { expect(activeBuffer.viewportY).toBe(42) }) + it('restores pinned content via marker when fit reflow renumbers buffer lines', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + // Reflow at narrower cols rewraps lines; the tracked content now lives + // at a different absolute line than the pre-fit viewport number. + activeBuffer.baseY = 130 + activeBuffer.viewportY = 0 + marker.line = 57 + }) + + safeFit(pane) + + expect(pane.terminal.scrollToLine).toHaveBeenCalledWith(57) + expect(activeBuffer.viewportY).toBe(57) + expect(marker.dispose).toHaveBeenCalled() + }) + + it('keeps a follow-output pane at the bottom through fit', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + activeBuffer.viewportY = 100 + activeBuffer.baseY = 100 + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 130 + activeBuffer.viewportY = 0 + }) + + safeFit(pane) + + expect(pane.terminal.scrollToBottom).toHaveBeenCalled() + }) + it('does not throw when xterm rejects scroll restoration during layout', () => { const pane = createPane({ proposedCols: 100, diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 3b2af6fed87..d069244cf4f 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -10,8 +10,11 @@ import { getFitOverrideForPty } from './mobile-fit-overrides' import { disposeWebgl, attachWebgl } from './pane-webgl-renderer' import { captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent + enforceTerminalWriteScrollIntent, + syncTerminalScrollIntentFromViewport } from './terminal-scroll-intent' +import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll' +import type { ScrollState } from './pane-manager-types' export { captureScrollState, restoreScrollState } from './pane-scroll' @@ -73,7 +76,16 @@ export function safeFit(pane: ManagedPane): void { return } let scrollIntent = null as ReturnType + let pinnedScrollState: ScrollState | null = null let shouldRestoreScroll = false + const captureScrollForFit = (): void => { + scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) + // Why: fit can reflow and renumber every buffer row; a marker tracks the + // pinned content itself, while a numeric line would point elsewhere after. + pinnedScrollState = + scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null + shouldRestoreScroll = true + } try { // Why: when a mobile client has resized this PTY to phone dimensions, // the desktop must keep xterm at those dimensions instead of fitting to @@ -85,8 +97,7 @@ export function safeFit(pane: ManagedPane): void { if (override) { if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { if (canPreserveScrollIntentForFit(pane)) { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - shouldRestoreScroll = true + captureScrollForFit() } pane.terminal.resize(override.cols, override.rows) } @@ -101,8 +112,7 @@ export function safeFit(pane: ManagedPane): void { return } if (canPreserveScrollIntentForFit(pane)) { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - shouldRestoreScroll = true + captureScrollForFit() } pane.fitAddon.fit() } catch { @@ -110,10 +120,19 @@ export function safeFit(pane: ManagedPane): void { } finally { if (shouldRestoreScroll) { try { - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) + if (pinnedScrollState) { + restoreScrollState(pane.terminal, pinnedScrollState) + syncTerminalScrollIntentFromViewport(pane.terminal) + } else { + enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) + } } catch { // Why: xterm can temporarily expose a terminal whose renderer has not // initialized dimensions yet during SSH reattach/layout. Fit is best-effort. + } finally { + if (pinnedScrollState) { + releaseScrollStateMarker(pinnedScrollState) + } } } } diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index bc728ce7dd2..205bd8ecaa9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -367,4 +367,119 @@ describe('terminal scroll intent', () => { expect(terminal.scrollToLine).toHaveBeenCalledWith(40) }) + + it('reverts a wheel pin to followOutput when the viewport never leaves the bottom', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + // A sub-row trackpad delta or a wheel consumed by a mouse-reporting TUI: + // the wheel event fires but xterm's viewport never moves. + const wheelUp = new Event('wheel') as WheelEvent + Object.defineProperty(wheelUp, 'deltaY', { value: -2 }) + host.dispatchEvent(wheelUp) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + + await Promise.resolve() + while (frameCallbacks.length) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('keeps a wheel pin when the viewport leaves the bottom before settle', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + const wheelUp = new Event('wheel') as WheelEvent + Object.defineProperty(wheelUp, 'deltaY', { value: -10 }) + host.dispatchEvent(wheelUp) + + terminal.buffer.active.viewportY = 60 + await Promise.resolve() + while (frameCallbacks.length) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(60) + disposable.dispose() + }) + + it('does not freeze the viewport when a pinned intent is latched at the bottom', () => { + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + // Phantom pin: a wheel/PageUp the viewport never followed latched + // pinnedViewport while the terminal was still at the bottom. + markTerminalPinnedViewport(terminal) + + for (let batch = 1; batch <= 2; batch += 1) { + const snapshot = captureTerminalWriteScrollIntent(terminal) + // xterm follows output during the write because the viewport was at bottom. + terminal.buffer.active.baseY += 25 + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + enforceTerminalWriteScrollIntent(terminal, snapshot) + expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY) + } + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + + it('resumes following on visibility enforce when the stored pin was recorded at the bottom', () => { + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + markTerminalPinnedViewport(terminal) + + terminal.buffer.active.baseY = 400 + terminal.buffer.active.viewportY = 100 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToBottom).toHaveBeenCalled() + expect(terminal.buffer.active.viewportY).toBe(400) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + + it('restores a pinned viewport by bottom offset after a rebuild shrinks the scrollback', () => { + const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + markTerminalPinnedViewport(terminal) + + // Snapshot replay rebuilds a shorter, renumbered buffer. + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(150) + expect(terminal.buffer.active.viewportY).toBe(150) + }) + + it('supports bottom-offset restore for buffer-rebuild write paths', () => { + const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalWriteScrollIntent(terminal) + + terminal.buffer.active.baseY = 80 + terminal.buffer.active.viewportY = 80 + enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) + expect(terminal.buffer.active.viewportY).toBe(30) + }) }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index c0ab35f654c..88e70096a59 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -29,6 +29,14 @@ type TerminalScrollIntentWriteSnapshot = { kind: TerminalScrollIntentKind bufferType: BufferType viewportY: number + baseY: number +} + +type TerminalScrollIntentEnforceOptions = { + // 'viewportLine' restores the absolute buffer line (correct while content + // only grows). 'bottomOffset' restores the distance from the bottom — + // required after a buffer rebuild (snapshot replay, reflow) renumbers rows. + restoreBy?: 'viewportLine' | 'bottomOffset' } const terminalScrollIntentByTerminal = new WeakMap< @@ -180,7 +188,11 @@ export function syncTerminalScrollIntentSoon( queueMicrotask(sync) requestAnimationFrame(sync) requestAnimationFrame(() => requestAnimationFrame(sync)) - setTimeout(sync, 80) + // Why: preservePinnedAtBottom only bridges xterm's async scroll application. + // The settle tick must reclassify from the real viewport, otherwise a wheel + // the viewport never followed (sub-row delta, TUI-consumed mouse report, + // plain PageUp/Home sent to the app) latches a phantom pin at the bottom. + setTimeout(() => syncTerminalScrollIntentFromViewport(terminal), 80) } export function getTerminalScrollIntentKind( @@ -205,19 +217,27 @@ export function captureTerminalWriteScrollIntent( return null } const existing = readStoredIntent(terminal) - const kind = + let kind = existing?.kind ?? (isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport') + // Why: a pinned intent whose live viewport still sits at the bottom is a + // phantom pin (the user's scroll never detached the viewport). Enforcing it + // would freeze the terminal at the current line on every write batch. + if (kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + kind = 'followOutput' + } return { kind, bufferType: snapshot.bufferType, - viewportY: snapshot.viewportY + viewportY: snapshot.viewportY, + baseY: snapshot.baseY } } export function enforceTerminalWriteScrollIntent( terminal: TerminalScrollIntentTarget, - snapshot: TerminalScrollIntentWriteSnapshot | null + snapshot: TerminalScrollIntentWriteSnapshot | null, + options: TerminalScrollIntentEnforceOptions = {} ): void { if (!snapshot) { return @@ -232,7 +252,11 @@ export function enforceTerminalWriteScrollIntent( } return } - const targetY = clampViewportY(snapshot.viewportY, current.baseY) + const requestedY = + options.restoreBy === 'bottomOffset' + ? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY) + : snapshot.viewportY + const targetY = clampViewportY(requestedY, current.baseY) if (current.viewportY !== targetY) { safeScrollCall(() => terminal.scrollToLine?.(targetY)) } @@ -241,14 +265,29 @@ export function enforceTerminalWriteScrollIntent( export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void { const existing = readStoredIntent(terminal) - const snapshot = existing - ? { - kind: existing.kind, - bufferType: existing.bufferType, - viewportY: existing.viewportY - } - : captureTerminalWriteScrollIntent(terminal) - enforceTerminalWriteScrollIntent(terminal, snapshot) + if (!existing) { + enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal)) + return + } + const snapshot = { + kind: existing.kind, + bufferType: existing.bufferType, + viewportY: existing.viewportY, + baseY: existing.baseY + } + if (snapshot.kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + // Why: a pin recorded at the bottom means the viewport never detached; + // resuming must follow live output, not freeze at that stale line. + snapshot.kind = 'followOutput' + } + const current = readBufferSnapshot(terminal) + // Why: a shorter live buffer than the stored intent means the buffer was + // rebuilt (snapshot replay/remount); absolute lines are renumbered there. + const restoreBy = + snapshot.kind === 'pinnedViewport' && current && current.baseY < snapshot.baseY + ? 'bottomOffset' + : 'viewportLine' + enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy }) } export function attachTerminalScrollIntentTracking( diff --git a/src/renderer/src/lib/work-item-link-query-bounds.ts b/src/renderer/src/lib/work-item-link-query-bounds.ts index 6cbb407da84..831c942ef0c 100644 --- a/src/renderer/src/lib/work-item-link-query-bounds.ts +++ b/src/renderer/src/lib/work-item-link-query-bounds.ts @@ -1,10 +1,2 @@ -import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' - -export const WORK_ITEM_LINK_QUERY_MAX_BYTES = 2 * 1024 - -export function isWorkItemLinkQueryTooLarge( - query: string, - maxBytes = WORK_ITEM_LINK_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/work-item-link-query-bounds' diff --git a/src/renderer/src/lib/work-item-lookup-text.test.ts b/src/renderer/src/lib/work-item-lookup-text.test.ts index 3ced0ea2372..fbdf4779eab 100644 --- a/src/renderer/src/lib/work-item-lookup-text.test.ts +++ b/src/renderer/src/lib/work-item-lookup-text.test.ts @@ -37,5 +37,6 @@ describe('isWorkItemLookupText', () => { expect(isWorkItemLookupText('fix-2')).toBe(false) expect(isWorkItemLookupText('terminal scrollbar polish')).toBe(false) expect(isWorkItemLookupText('https://example.com/some/page')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) }) }) diff --git a/src/renderer/src/lib/work-item-lookup-text.ts b/src/renderer/src/lib/work-item-lookup-text.ts index e8c412d7eac..8ef5ae53099 100644 --- a/src/renderer/src/lib/work-item-lookup-text.ts +++ b/src/renderer/src/lib/work-item-lookup-text.ts @@ -1,24 +1 @@ -import { getSmartGitHubSubmitIntent } from './smart-github-submit' -import { parseGitLabIssueOrMRLink } from './gitlab-links' - -const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i - -/** - * Why: text typed into the smart name field to *find* a work item — a GitHub - * or GitLab URL, "#123", or a linear.app link — is a lookup query, never a - * deliberate workspace name. Selection handlers use this to decide that the - * resolved item's title-derived name may replace the field content; otherwise - * the pasted URL silently survives behind the selection pill and the - * workspace gets a slugified-URL name. - */ -export function isWorkItemLookupText(value: string): boolean { - const trimmed = value.trim() - if (!trimmed) { - return false - } - return ( - getSmartGitHubSubmitIntent(trimmed) !== null || - parseGitLabIssueOrMRLink(trimmed) !== null || - LINEAR_ISSUE_URL_RE.test(trimmed) - ) -} +export * from '../../../shared/new-workspace/work-item-lookup-text' diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index 2e24a297132..ee2884ac400 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -176,18 +176,18 @@ describe('buildWorkspaceSessionPayload', () => { expect(payload.browserTabsByWorktree?.['wt-1'][0].loading).toBe(false) }) - it('persists front-matter visibility only for restored editor files', () => { + it('persists front-matter hide overrides only for restored editor files', () => { const payload = buildWorkspaceSessionPayload( createSnapshot({ markdownFrontmatterVisible: { - '/tmp/demo.ts': true, - '/tmp/demo.diff': true, - '/tmp/closed.md': true + '/tmp/demo.ts': false, + '/tmp/demo.diff': false, + '/tmp/closed.md': false } }) ) - expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': true }) + expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': false }) }) it('does not persist empty split groups from transient simulator tab creation', () => { diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 21a11eb0eec..f0acc22e620 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -188,10 +188,12 @@ export function buildEditorSessionData( WorkspaceVisibleTabType > const allEditFileIds = new Set(Object.values(editFileIdsByWorktree).flatMap((ids) => [...ids])) + // Why: preserve the actual value so per-file hide overrides survive restart; + // the map only ever carries `false` entries (visible is the default). const persistedMarkdownFrontmatterVisible = Object.fromEntries( - Object.keys(markdownFrontmatterVisible ?? {}) - .filter((fileId) => allEditFileIds.has(fileId)) - .map((fileId) => [fileId, true]) + Object.entries(markdownFrontmatterVisible ?? {}).filter(([fileId]) => + allEditFileIds.has(fileId) + ) ) return { diff --git a/src/renderer/src/lib/worktree-palette-review-match.ts b/src/renderer/src/lib/worktree-palette-review-match.ts new file mode 100644 index 00000000000..9945722a366 --- /dev/null +++ b/src/renderer/src/lib/worktree-palette-review-match.ts @@ -0,0 +1,47 @@ +import type { HostedReviewInfo } from '../../../shared/hosted-review' + +type SearchableReview = Pick +type WorktreePaletteReviewMatch = { + labelKind: 'pr' | 'mr' + text: string + matchRange: { start: number; end: number } +} + +export function matchWorktreePaletteReview( + review: SearchableReview, + query: string, + numericQuery: string +): WorktreePaletteReviewMatch | null { + const isMergeRequest = review.provider === 'gitlab' + const numberPrefix = isMergeRequest ? 'MR !' : 'PR #' + const hasPullRequestSigil = query.startsWith('#') + const hasMergeRequestSigil = query.startsWith('!') + const sigilMatchesProvider = + (!hasPullRequestSigil && !hasMergeRequestSigil) || + (hasPullRequestSigil && !isMergeRequest) || + (hasMergeRequestSigil && isMergeRequest) + const reviewNumericQuery = hasMergeRequestSigil ? query.slice(1) : numericQuery + const reviewNumberIndex = sigilMatchesProvider + ? String(review.number).indexOf(reviewNumericQuery) + : -1 + if (reviewNumericQuery && reviewNumberIndex !== -1) { + return { + labelKind: isMergeRequest ? 'mr' : 'pr', + text: `${numberPrefix}${review.number}`, + matchRange: { + start: numberPrefix.length + reviewNumberIndex, + end: numberPrefix.length + reviewNumberIndex + reviewNumericQuery.length + } + } + } + + const titleIndex = review.title.toLowerCase().indexOf(query) + if (titleIndex === -1) { + return null + } + return { + labelKind: isMergeRequest ? 'mr' : 'pr', + text: review.title, + matchRange: { start: titleIndex, end: titleIndex + query.length } + } +} diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts index ff63dd75ba5..7059ee4ad51 100644 --- a/src/renderer/src/lib/worktree-palette-search.test.ts +++ b/src/renderer/src/lib/worktree-palette-search.test.ts @@ -5,6 +5,7 @@ import { isWorktreePaletteQueryTooLarge } from './worktree-palette-query-bounds' import type { Repo, Worktree } from '../../../shared/types' +import type { HostedReviewInfo } from '../../../shared/hosted-review' function makeWorktree(overrides: Partial = {}): Worktree { return { @@ -172,6 +173,214 @@ describe('worktree-palette-search', () => { }) }) + it('matches the GitLab review title and number already selected by Checks', () => { + const review: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'Reuse checks tab review metadata', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const worktree = makeWorktree() + const reviews = new Map([[worktree, review]]) + + const titleResults = searchWorktrees( + [worktree], + 'checks tab', + repoMap, + null, + null, + undefined, + reviews + ) + const numberResults = searchWorktrees( + [worktree], + '!17', + repoMap, + null, + null, + undefined, + reviews + ) + + expect(titleResults[0].supportingText).toEqual({ + labelKind: 'mr', + text: review.title, + matchRange: { start: 6, end: 16 } + }) + expect(numberResults[0].supportingText).toEqual({ + labelKind: 'mr', + text: 'MR !17', + matchRange: { start: 4, end: 6 } + }) + }) + + it('does not search stale GitHub cache metadata when Checks selected another review', () => { + const review: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'Current merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const staleWorktree = makeWorktree({ + branch: 'refs/heads/feature/palette-refresh', + linkedPR: 99 + }) + const reviews = new Map([[staleWorktree, review]]) + + expect( + searchWorktrees( + [staleWorktree], + 'stale github title', + repoMap, + { + '/repo/orca::feature/palette-refresh': { + data: { number: 99, title: 'Stale GitHub title' } + } + }, + null, + undefined, + reviews + ) + ).toEqual([]) + + expect( + searchWorktrees([staleWorktree], '#99', repoMap, null, null, undefined, reviews) + ).toEqual([]) + }) + + it('does not search stale GitHub metadata while a linked non-GitHub review is loading', () => { + const stalePRCache = { + '/repo/orca::feature/palette-refresh': { + data: { number: 99, title: 'Stale GitHub title' } + } + } + const staleWorktree = makeWorktree({ + branch: 'refs/heads/feature/palette-refresh', + linkedPR: 99, + linkedGitLabMR: 17 + }) + const authoritativeEmptyReviews = new Map([ + [staleWorktree, null] + ]) + + expect( + searchWorktrees( + [staleWorktree], + 'stale github title', + repoMap, + stalePRCache, + null, + undefined, + authoritativeEmptyReviews + ) + ).toEqual([]) + expect( + searchWorktrees( + [staleWorktree], + '#99', + repoMap, + stalePRCache, + null, + undefined, + authoritativeEmptyReviews + ) + ).toEqual([]) + }) + + it('keeps review matches isolated between same-id worktrees on different hosts', () => { + const localWorktree = makeWorktree({ hostId: 'local' }) + const sshWorktree = makeWorktree({ hostId: 'ssh:staging' }) + const review: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'Remote-only merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + + const results = searchWorktrees( + [localWorktree, sshWorktree], + 'remote-only', + repoMap, + null, + null, + undefined, + new Map([[sshWorktree, review]]) + ) + + expect(results).toHaveLength(1) + expect(results[0].supportingText?.text).toBe(review.title) + }) + + it('scopes PR and MR number sigils to their providers', () => { + const gitHubReview: HostedReviewInfo = { + provider: 'github', + number: 42, + title: 'GitHub pull request', + state: 'open', + url: 'https://github.com/acme/orca/pull/42', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const gitLabReview: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'GitLab merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const gitHubWorktree = makeWorktree() + const gitLabWorktree = makeWorktree() + + expect( + searchWorktrees( + [gitHubWorktree], + '!42', + repoMap, + null, + null, + undefined, + new Map([[gitHubWorktree, gitHubReview]]) + ) + ).toEqual([]) + expect( + searchWorktrees( + [gitLabWorktree], + '#17', + repoMap, + null, + null, + undefined, + new Map([[gitLabWorktree, gitLabReview]]) + ) + ).toEqual([]) + expect( + searchWorktrees( + [makeWorktree({ linkedIssue: 42 })], + '!42', + repoMap, + null, + null, + new Map([['wt-1', [{ port: 42 }]]]) + ) + ).toEqual([]) + }) + it('preserves input order when query matches a repo name', () => { const worktrees = [ makeWorktree({ diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts index e7a5933050b..f8dce99582b 100644 --- a/src/renderer/src/lib/worktree-palette-search.ts +++ b/src/renderer/src/lib/worktree-palette-search.ts @@ -1,8 +1,10 @@ import { branchName } from '@/lib/git-utils' import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import type { HostedReviewInfo } from '../../../shared/hosted-review' import type { Repo, Worktree } from '../../../shared/types' import { extractWorktreePaletteCommentSnippet } from './worktree-palette-comment-snippet' import { isWorktreePaletteQueryTooLarge } from './worktree-palette-query-bounds' +import { matchWorktreePaletteReview } from './worktree-palette-review-match' export type MatchRange = { start: number; end: number } @@ -16,7 +18,7 @@ export type PaletteMatchedField = | 'port' export type PaletteSupportingText = { - labelKind: 'comment' | 'pr' | 'issue' | 'port' + labelKind: 'comment' | 'pr' | 'mr' | 'issue' | 'port' text: string matchRange: MatchRange | null } @@ -69,7 +71,8 @@ export function searchWorktrees( repoMap: Map, prCache: Record | null, issueCache: Record | null, - workspacePortsByWorktreeId?: Map + workspacePortsByWorktreeId?: Map, + checksReviewByWorktree?: ReadonlyMap ): PaletteSearchResult[] { if (isWorktreePaletteQueryTooLarge(query)) { return [] @@ -199,42 +202,30 @@ export function searchWorktrees( } const repo = repoMap.get(worktree.repoId) + const checksReview = checksReviewByWorktree?.get(worktree) + const hasChecksReviewEntry = checksReview !== undefined + if (checksReview) { + const supportingText = matchWorktreePaletteReview(checksReview, q, numericQuery) + if (supportingText) { + results.push(makeResult(worktree.id, 'pr', { supportingText })) + continue + } + } + const prKey = repo ? `${repo.path}::${branch}` : '' - const pr = prKey && prCache ? prCache[prKey]?.data : undefined + const pr = !hasChecksReviewEntry && prKey && prCache ? prCache[prKey]?.data : undefined if (pr) { - const prText = `PR #${pr.number}` - const prNumberIndex = String(pr.number).indexOf(numericQuery) - if (prNumberIndex !== -1) { - results.push( - makeResult(worktree.id, 'pr', { - supportingText: { - labelKind: 'pr', - text: prText, - matchRange: { - start: 'PR #'.length + prNumberIndex, - end: 'PR #'.length + prNumberIndex + numericQuery.length - } - } - }) - ) + const supportingText = matchWorktreePaletteReview( + { ...pr, provider: 'github' }, + q, + numericQuery + ) + if (supportingText) { + results.push(makeResult(worktree.id, 'pr', { supportingText })) continue } - - const prTitleIndex = pr.title.toLowerCase().indexOf(q) - if (prTitleIndex !== -1) { - results.push( - makeResult(worktree.id, 'pr', { - supportingText: { - labelKind: 'pr', - text: pr.title, - matchRange: { start: prTitleIndex, end: prTitleIndex + q.length } - } - }) - ) - continue - } - } else if (worktree.linkedPR != null) { + } else if (!hasChecksReviewEntry && worktree.linkedPR != null) { const prText = `PR #${worktree.linkedPR}` const prNumberIndex = String(worktree.linkedPR).indexOf(numericQuery) if (prNumberIndex !== -1) { diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 72b020df4ec..64c12946a4b 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -104,6 +104,44 @@ describe('runtime git client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('uses the backing folder path for local folder-workspace status', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const workspaceId = '123e4567-e89b-12d3-a456-426614174000' + + await getRuntimeGitStatus({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: `folder-repo::/home/user::workspace:${workspaceId}`, + worktreePath: `/home/user::workspace:${workspaceId}` + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/home/user', + connectionId: undefined + }) + }) + + it('uses the backing folder path for other local folder-workspace git ops', async () => { + // Why: status is not the only command run as a subprocess cwd. Every local + // op (diff, submodule status, upstream, stage, …) must strip the synthetic + // `::workspace:` suffix or Git spawns against a nonexistent directory. + gitDiff.mockResolvedValue({ hunks: [] }) + gitSubmoduleStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const workspaceId = '123e4567-e89b-12d3-a456-426614174000' + const context = { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: `folder-repo::/home/user::workspace:${workspaceId}`, + worktreePath: `/home/user::workspace:${workspaceId}` + } + + await getRuntimeGitDiff(context, { filePath: 'a.ts', staged: false }) + await getRuntimeGitSubmoduleStatus(context, 'sub') + + expect(gitDiff).toHaveBeenCalledWith(expect.objectContaining({ worktreePath: '/home/user' })) + expect(gitSubmoduleStatus).toHaveBeenCalledWith( + expect.objectContaining({ worktreePath: '/home/user' }) + ) + }) + it('forwards includeIgnored to local git status only when enabled', async () => { gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index a1768bef5ca..972e2b7fdcb 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -22,7 +22,7 @@ import type { HostedReviewProvider } from '../../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../../shared/worktree-id' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' @@ -72,6 +72,17 @@ export type RuntimeGitContext = { connectionId?: string } +// Why: folder-workspace instance IDs carry a synthetic `::workspace:` +// suffix for identity, and store placeholders surface that suffix on +// `worktree.path`. Every local Git op runs `worktreePath` as a subprocess cwd, +// so resolve the real filesystem path here or Git spawns against a nonexistent +// directory (ENOENT). Ordinary worktrees keep their parsed path unchanged. +function resolveLocalWorktreePath(context: RuntimeGitContext): string { + return context.worktreeId + ? (splitWorktreeIdForFilesystem(context.worktreeId)?.worktreePath ?? context.worktreePath) + : context.worktreePath +} + export type RuntimeGenerateCommitMessageOverrides = { sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams sourceControlAi?: GlobalSettings['sourceControlAi'] @@ -131,7 +142,7 @@ export async function getRuntimeGitStatus( : {} if (target.kind === 'local' || !context.worktreeId) { return window.api.git.status({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...includeIgnoredArgs, ...upstreamCacheBypassArgs @@ -157,7 +168,7 @@ export async function getRuntimeGitSubmoduleStatus( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.submoduleStatus({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), submodulePath, connectionId: context.connectionId, area @@ -185,7 +196,7 @@ export async function getRuntimeGitIgnoredPaths( } if (target.kind === 'local' || !context.worktreeId) { return window.api.git.checkIgnored({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, paths }) @@ -205,7 +216,7 @@ export async function getRuntimeGitHistory( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.history({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...options }) @@ -224,7 +235,7 @@ export async function getRuntimeGitConflictOperation( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.conflictOperation({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) } @@ -240,7 +251,7 @@ export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise< const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.abortMerge({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -257,7 +268,7 @@ export async function abortRuntimeGitRebase(context: RuntimeGitContext): Promise const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.abortRebase({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -277,7 +288,7 @@ export async function getRuntimeGitDiff( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.diff({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath: args.filePath, staged: args.staged, compareAgainstHead: args.compareAgainstHead, @@ -299,7 +310,7 @@ export async function getRuntimeGitBranchCompare( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchCompare({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), baseRef, connectionId: context.connectionId }) @@ -319,7 +330,7 @@ export async function getRuntimeGitCommitCompare( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commitCompare({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), commitId, connectionId: context.connectionId }) @@ -339,7 +350,7 @@ export async function getRuntimeGitUpstreamStatus( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.upstreamStatus({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -362,7 +373,7 @@ export async function fetchRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.fetch({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -386,7 +397,7 @@ export async function syncRuntimeGitForkDefaultBranch( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.syncFork({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, expectedUpstream }) @@ -409,7 +420,7 @@ export async function pullRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.pull({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -433,7 +444,7 @@ export async function fastForwardRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.fastForward({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -457,7 +468,7 @@ export async function rebaseRuntimeGitFromBase( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.rebaseFromBase({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), baseRef, connectionId: context.connectionId }) @@ -478,7 +489,7 @@ export async function pushRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.push({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(args.publish !== undefined ? { publish: args.publish } : {}), ...(args.pushTarget !== undefined ? { pushTarget: args.pushTarget } : {}), @@ -510,7 +521,7 @@ export async function getRuntimeGitBranchDiff( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchDiff({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), compare: args.compare, filePath: args.filePath, oldPath: args.oldPath, @@ -537,7 +548,7 @@ export async function getRuntimeGitCommitDiff( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commitDiff({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), commitOid: args.commitOid, parentOid: args.parentOid, filePath: args.filePath, @@ -560,7 +571,7 @@ export async function commitRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commit({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), message, connectionId: context.connectionId }) @@ -580,7 +591,7 @@ export async function generateRuntimeCommitMessage( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generateCommitMessage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), repoId: context.worktreeId ? getRepoIdFromWorktreeId(context.worktreeId) : undefined, connectionId: context.connectionId, ...(overrides?.sourceControlAiResolvedParams @@ -614,7 +625,7 @@ export async function discoverRuntimeCommitMessageModels( if (target.kind === 'local' || !context.worktreeId) { return window.api.git.discoverCommitMessageModels({ agentId, - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) as Promise } @@ -638,7 +649,7 @@ export async function cancelRuntimeGenerateCommitMessage( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.cancelGenerateCommitMessage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -659,7 +670,7 @@ export async function generateRuntimePullRequestFields( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generatePullRequestFields({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), repoId: context.worktreeId ? getRepoIdFromWorktreeId(context.worktreeId) : undefined, connectionId: context.connectionId, ...input, @@ -693,7 +704,7 @@ export async function cancelRuntimeGeneratePullRequestFields( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.cancelGeneratePullRequestFields({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -713,7 +724,7 @@ export async function stageRuntimeGitPath( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.stage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath, connectionId: context.connectionId }) @@ -734,7 +745,7 @@ export async function bulkStageRuntimeGitPaths( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkStage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePaths, connectionId: context.connectionId }) @@ -755,7 +766,7 @@ export async function unstageRuntimeGitPath( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.unstage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath, connectionId: context.connectionId }) @@ -776,7 +787,7 @@ export async function bulkUnstageRuntimeGitPaths( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkUnstage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePaths, connectionId: context.connectionId }) @@ -797,7 +808,7 @@ export async function bulkDiscardRuntimeGitPaths( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkDiscard({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePaths, connectionId: context.connectionId }) @@ -818,7 +829,7 @@ export async function discardRuntimeGitPath( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.discard({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath, connectionId: context.connectionId }) @@ -839,7 +850,7 @@ export async function getRuntimeGitRemoteFileUrl( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.remoteFileUrl({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), relativePath: args.relativePath, line: args.line, connectionId: context.connectionId @@ -864,7 +875,7 @@ export async function getRuntimeGitRemoteCommitUrl( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.remoteCommitUrl({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), sha: args.sha, connectionId: context.connectionId }) diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 85025022fea..af47e5a0350 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -490,6 +490,7 @@ describe('createWebRuntimeSessionTerminal', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) ).resolves.toBe(true) @@ -510,6 +511,7 @@ describe('createWebRuntimeSessionTerminal', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }, timeoutMs: 15_000 diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 338e92e8656..8cfc23eb19c 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -56,6 +56,7 @@ export async function createWebRuntimeSessionTerminal(args: { launchConfig?: SleepingAgentLaunchConfig agent?: TuiAgent launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean selectWorktree?: boolean }): Promise { @@ -85,6 +86,7 @@ export async function createWebRuntimeSessionTerminal(args: { ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), agent: args.agent, ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), activate: args.activate !== false }, timeoutMs: 15_000 diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 0859e675615..ac54883ebe3 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -1482,7 +1482,7 @@ describe('createEditorSlice markdown view state', () => { }, { preview: true } ) - store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', false) store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { @@ -1512,7 +1512,7 @@ describe('createEditorSlice markdown view state', () => { worktreeId: 'wt-1', language: 'markdown' }) - store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', false) store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { @@ -1520,7 +1520,7 @@ describe('createEditorSlice markdown view state', () => { }) expect(store.getState().markdownFrontmatterVisible).toEqual({ - '/repo/docs/README.md': true + '/repo/docs/README.md': false }) expect(store.getState().markdownTableOfContentsVisible).toEqual({ '/repo/docs/README.md': true @@ -1573,28 +1573,28 @@ describe('createEditorSlice editor view mode', () => { }) describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { - it('stores visible=true as an explicit entry keyed by fileId', () => { + it('stores hidden=false as an explicit entry keyed by fileId', () => { const store = createEditorStore() - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) - - expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) - }) - - it('deletes the entry when visibility resets to hidden', () => { - const store = createEditorStore() - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false }) + }) + + it('deletes the entry when visibility resets to visible', () => { + const store = createEditorStore() + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + expect(store.getState().markdownFrontmatterVisible).toEqual({}) }) - it('is a no-op when hiding a file that was never shown', () => { + it('is a no-op when showing a file that was never hidden', () => { const store = createEditorStore() const before = store.getState().markdownFrontmatterVisible - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) expect(store.getState().markdownFrontmatterVisible).toBe(before) }) @@ -1608,7 +1608,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { language: 'markdown', mode: 'edit' }) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().closeFile('/repo/notes.md') @@ -1630,11 +1630,11 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { worktreeId: 'wt-1', language: 'markdown' }) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().closeFile('/repo/notes.md') - expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false }) store.getState().closeFile('markdown-preview::/repo/notes.md') @@ -1662,7 +1662,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { }, { sourceFileId: '/repo/notes.md' } ) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().openFile( { @@ -1675,7 +1675,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { { preview: true } ) - expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false }) }) it('drops the visibility flag when all files are closed', () => { @@ -1687,7 +1687,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { language: 'markdown', mode: 'edit' }) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().closeAllFiles() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 8e9e86c0d93..7e825ec6f77 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1405,11 +1405,11 @@ export const createEditorSlice: StateCreator = (s markdownFrontmatterVisible: {}, setMarkdownFrontmatterVisible: (fileId, visible) => set((s) => { - // Why: default is hidden. Writing `false` explicitly when no entry exists - // would grow the record unnecessarily; delete instead so the shape stays - // minimal and hydration round-trips cleanly — same trade-off as - // setEditorViewMode above. - if (!visible) { + // Why: default is visible. Writing `true` explicitly when no entry exists + // would grow the record unnecessarily; delete instead so the map only + // carries hide overrides and hydration round-trips cleanly — same + // trade-off as setEditorViewMode above. + if (visible) { if (!(fileId in s.markdownFrontmatterVisible)) { return s } @@ -1417,7 +1417,7 @@ export const createEditorSlice: StateCreator = (s delete next[fileId] return { markdownFrontmatterVisible: next } } - return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } } + return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: false } } }), // Markdown table of contents visibility @@ -4496,24 +4496,26 @@ export const createEditorSlice: StateCreator = (s const nextActiveTabType = nextActiveFileId || activeTabType !== 'editor' ? activeTabType : 'terminal' const openFileIds = new Set(openFiles.map((file) => file.id)) - const visibleFrontmatterEntries = new Map() + // Why: visible is the default, so only restore per-file hide overrides + // (`false`); legacy `true` entries collapse back to the default. + const hiddenFrontmatterEntries = new Map() for (const [persistedFileId, visible] of Object.entries( persistedMarkdownFrontmatterVisible )) { - if (!visible) { + if (visible) { continue } if (openFileIds.has(persistedFileId)) { - visibleFrontmatterEntries.set(persistedFileId, true) + hiddenFrontmatterEntries.set(persistedFileId, false) } for (const migrations of Object.values(editorFileIdMigrationsByWorktree)) { const migratedFileId = migrations.get(persistedFileId) if (migratedFileId && openFileIds.has(migratedFileId)) { - visibleFrontmatterEntries.set(migratedFileId, true) + hiddenFrontmatterEntries.set(migratedFileId, false) } } } - const markdownFrontmatterVisible = Object.fromEntries(visibleFrontmatterEntries) + const markdownFrontmatterVisible = Object.fromEntries(hiddenFrontmatterEntries) return { openFiles, diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index dd113fa668a..ad4c4983b58 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -242,7 +242,7 @@ describe('createSettingsSlice runtime switching', () => { editorDrafts: { '/env-1/repo/stale.md': 'stale' }, markdownViewMode: { '/env-1/repo/stale.md': 'rich' }, editorViewMode: { '/env-1/repo/stale.md': 'changes' }, - markdownFrontmatterVisible: { '/env-1/repo/stale.md': true }, + markdownFrontmatterVisible: { '/env-1/repo/stale.md': false }, editorCursorLine: { '/env-1/repo/stale.md': 4 }, showDotfilesByWorktree: { 'repo-env-1::/env-1/repo': false }, gitIgnoredPathsByWorktree: { 'repo-env-1::/env-1/repo': ['dist/'] }, @@ -300,7 +300,7 @@ describe('createSettingsSlice runtime switching', () => { expect(store.getState().markdownViewMode).toEqual({ '/env-1/repo/stale.md': 'rich' }) expect(store.getState().editorViewMode).toEqual({ '/env-1/repo/stale.md': 'changes' }) expect(store.getState().markdownFrontmatterVisible).toEqual({ - '/env-1/repo/stale.md': true + '/env-1/repo/stale.md': false }) expect(store.getState().editorCursorLine).toEqual({ '/env-1/repo/stale.md': 4 }) expect(store.getState().showDotfilesByWorktree).toEqual({ 'repo-env-1::/env-1/repo': false }) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index f938dfecdb0..2a9a3de8e94 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -2079,7 +2079,7 @@ describe('hydrateEditorSession', () => { }, activeFileIdByWorktree: { [wt]: '/path/wt1/src/index.ts' }, activeTabTypeByWorktree: { [wt]: 'editor' }, - markdownFrontmatterVisible: { '/path/wt1/README.md': true } + markdownFrontmatterVisible: { '/path/wt1/README.md': false } }) const s = store.getState() @@ -2088,7 +2088,7 @@ describe('hydrateEditorSession', () => { expect(s.openFiles[0].mode).toBe('edit') expect(s.openFiles[0].isDirty).toBe(false) expect(s.openFiles[1].isPreview).toBe(true) - expect(s.markdownFrontmatterVisible).toEqual({ '/path/wt1/README.md': true }) + expect(s.markdownFrontmatterVisible).toEqual({ '/path/wt1/README.md': false }) expect(s.activeFileId).toBe('/path/wt1/src/index.ts') expect(s.activeTabType).toBe('editor') }) @@ -2167,10 +2167,46 @@ describe('hydrateEditorSession', () => { [FLOATING_TERMINAL_WORKTREE_ID]: filePath }, activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' }, + markdownFrontmatterVisible: { [filePath]: false } + }) + + expect(store.getState().markdownFrontmatterVisible).toEqual({ [fileId]: false }) + }) + + it('drops legacy visible=true front-matter entries so upgraded sessions fall back to the visible default', () => { + const store = createTestStore() + const filePath = '/orca/userData/floating-workspace/note.md' + const fileId = ownedEditorFileId(filePath, FLOATING_TERMINAL_WORKTREE_ID, null) + + store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + + store.getState().hydrateEditorSession({ + activeRepoId: null, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + filePath, + relativePath: 'note.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + runtimeEnvironmentId: null + } + ] + }, + activeFileIdByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: filePath + }, + activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' }, + // Pre-flip sessions stored `true` for the (then non-default) visible state. markdownFrontmatterVisible: { [filePath]: true } }) - expect(store.getState().markdownFrontmatterVisible).toEqual({ [fileId]: true }) + expect(store.getState().markdownFrontmatterVisible).toEqual({}) + expect(fileId in store.getState().markdownFrontmatterVisible).toBe(false) }) it('falls back to the floating workspace file id when duplicate paths are owner-qualified', () => { diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 60502611a66..62f4c35a3d1 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -197,6 +197,38 @@ describe('hydrateWorkspaceSession', () => { ]) }) + it('strips the synthetic workspace suffix from folder-workspace instance placeholders', () => { + const store = createTestStore() + const workspaceUuid = '123e4567-e89b-12d3-a456-426614174000' + const worktreeId = `folder-repo::/home/user::workspace:${workspaceUuid}` + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'folder-repo', + activeWorktreeId: worktreeId, + activeTabId: 'folder-tab', + activeWorktreeIdsOnShutdown: [worktreeId], + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'folder-tab', worktreeId, ptyId: 'folder-session' })] + } + } + + store.getState().hydrateWorkspaceSession(session, { + runtimeHostIdByWorkspaceSessionKey: { + [worktreeWorkspaceKey(worktreeId)]: 'runtime:env-1' + } + }) + + // Why: `id` keeps the `::workspace:` identity suffix, but `path` and + // `displayName` must resolve to the real folder so Git and other filesystem + // callers never spawn against a nonexistent cwd. + expect(store.getState().worktreesByRepo['folder-repo']).toEqual([ + expect.objectContaining({ id: worktreeId, path: '/home/user', displayName: 'user' }) + ]) + expect(store.getState().repos).toEqual([ + expect.objectContaining({ id: 'folder-repo', path: '/home/user' }) + ]) + }) + it('avoids duplicate repo placeholders when a same-id local repo is already loaded', () => { const store = createTestStore() const worktreeId = 'same-repo::/srv/remote-wt' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 817597d4132..be20477bc78 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -34,7 +34,10 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id' +import { + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../../../shared/worktree-id' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' @@ -168,7 +171,11 @@ function buildRuntimeSessionPlaceholders({ } const worktreeId = workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey - const parsed = splitWorktreeId(worktreeId) + // Why: folder-workspace instance IDs carry a synthetic `::workspace:` + // suffix. The placeholder's `id` keeps it for identity, but `path`/display + // must be the real folder path so Git and other filesystem callers do not + // spawn against a nonexistent cwd — matching the authoritative worktree. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!parsed) { continue } @@ -3263,7 +3270,9 @@ export const createTerminalSlice: StateCreator if (existing) { continue } - const path = splitWorktreeId(worktreeId)?.worktreePath ?? '' + // Why: strip the synthetic `::workspace:` folder-workspace suffix + // so the placeholder path is a real cwd; `id` above keeps it for identity. + const path = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? '' // Why: SSH worktree paths may use backslash separators on Windows remotes. const displayName = path.split(/[/\\]/).pop() || path const placeholder: Worktree = { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index dfd32303e25..55611abee4a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,11 @@ import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../../shared/new-workspace/worktree-create-retry-policy' import { classifyWorktreeForceDeleteReason, getLockedWorktreeRemovalReason, @@ -2954,22 +2959,13 @@ export const createWorktreeSlice: StateCreator options ) => { const automationProvenanceRequest = options?.automationProvenanceRequest - const retryableConflictPatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /^Branch ".+" already exists\./i, - /already has pr #\d+/i - ] - const nextCandidateName = (current: string, attempt: number): string => - attempt === 0 ? current : `${current}-${attempt + 1}` - try { - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = nextCandidateName(name, attempt) + for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(name, attempt) // Why: older runtimes may still reject exact PR branch overrides on // collision, so the renderer retries both branch and worktree names. const candidateBranchNameOverride = branchNameOverride - ? nextCandidateName(branchNameOverride, attempt) + ? getClientWorktreeCreateCandidate(branchNameOverride, attempt) : undefined try { // Why: Manual sort is user-authored order. Stamp new workspaces @@ -3130,8 +3126,8 @@ export const createWorktreeSlice: StateCreator return result } catch (error) { const message = error instanceof Error ? error.message : String(error) - const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message)) - if (!shouldRetry || attempt === 24) { + const shouldRetry = isRetryableWorktreeCreateConflict(message) + if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) { throw error } } diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a593d3bcae9..efafcaa5b06 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -26,6 +26,7 @@ import { clearGrokSessionPathLookupCacheForTests, findGrokChatHistoryBySessionId } from './grok-session-paths' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -2309,9 +2310,9 @@ describe('shared agent-hook-listener', () => { const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'r1' }) expect(stopped?.payload.state).toBe('done') - expect(stopped?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'r1', state: 'idle' }) - ]) + // Why: a finished one-shot leaves the sidebar instead of squatting as a + // permanent idle row for the rest of the session. + expect(stopped?.payload.subagents).toBeUndefined() }) it('keeps gating on tracked children when background_tasks is absent (older Claude)', () => { @@ -2408,6 +2409,35 @@ describe('shared agent-hook-listener', () => { ]) }) + it('keeps a teammate whose name differs from its configured agent type', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn reviewer' }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'areviewer-6d3cb5b52120b7bf', + agent_type: 'security-reviewer' + }) + + // Why: teammate name and agent type are separate Agent-tool inputs; the + // lifecycle id embeds the former while the hook reports the latter. + claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'areviewer-6d3cb5b52120b7bf', + agent_type: 'security-reviewer' + }) + const idled = claudeEvent({ + hook_event_name: 'TeammateIdle', + teammate_name: 'reviewer', + team_name: 'session-x' + }) + expect(idled?.payload.subagents).toEqual([ + expect.objectContaining({ + id: 'areviewer-6d3cb5b52120b7bf', + agentType: 'security-reviewer', + state: 'idle' + }) + ]) + }) + it('scopes subagent rosters per pane', () => { claudeEvent( { hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' }, @@ -2555,7 +2585,7 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.state).toBe('done') }) - it('demotes a snapshot-seeded child missing from a present background_tasks list', () => { + it('removes a snapshot-seeded child missing from a present background_tasks list', () => { seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ { id: 'a77', state: 'working', startedAt: 1000, agentType: 'general-purpose' } ]) @@ -2569,9 +2599,7 @@ describe('shared agent-hook-listener', () => { ] }) expect(stop?.payload.state).toBe('done') - expect(stop?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'a77', state: 'idle' }) - ]) + expect(stop?.payload.subagents).toBeUndefined() }) it('keeps a snapshot-seeded child working while background_tasks still lists it', () => { @@ -2589,6 +2617,29 @@ describe('shared agent-hook-listener', () => { ]) }) + it('keeps a live child omitted by the background task snapshot cap', () => { + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'alive-after-cap', + agent_type: 'general-purpose' + }) + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, + type: 'subagent', + status: 'running' + })) + }) + + // Why: the inventory was capped before this id, so omission cannot + // prove the lifecycle-tracked child finished or was killed. + expect(stop?.payload.subagents).toContainEqual( + expect.objectContaining({ id: 'alive-after-cap', state: 'working' }) + ) + expect(stop?.payload.state).toBe('working') + }) + it('does not adopt a known child turn-boundary event as the lead state', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claudeEvent({ @@ -2654,7 +2705,12 @@ describe('shared agent-hook-listener', () => { it('seeds the roster from persisted snapshots so a teammate-bearing Stop keeps child rows', () => { seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ - { id: 'aprobe2-abc', state: 'idle', startedAt: 1000, agentType: 'probe2' } + { + id: 'aprobe2-6d3cb5b52120b7bf', + state: 'idle', + startedAt: 1000, + agentType: 'security-reviewer' + } ]) claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'after restart' }) const stop = claudeEvent({ @@ -2665,7 +2721,11 @@ describe('shared agent-hook-listener', () => { }) expect(stop?.payload.state).toBe('done') expect(stop?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'aprobe2-abc', state: 'idle' }) + expect.objectContaining({ + id: 'aprobe2-6d3cb5b52120b7bf', + agentType: 'security-reviewer', + state: 'idle' + }) ]) }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ce3e2f720a4..1e3728eaef7 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -38,8 +38,9 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, + finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - markClaudeSubagentIdle, + isClaudeTeammateLifecycleId, markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, upsertWorkingClaudeSubagent, @@ -2411,7 +2412,14 @@ function normalizeClaudeSubagentLifecycleEvent( Date.now() ) } else { - markClaudeSubagentIdle(roster, agentId) + // Why: SubagentStop carries the session's task inventory; a stopping + // agent listed id-exact as a subagent task is a workflow/named one-shot + // (teammate lifecycle ids never appear there), not a resumable teammate. + const stopTasks = readClaudeBackgroundAgentTasks(hookPayload) + finishClaudeSubagent(roster, agentId, { + listedAsSubagentTask: + stopTasks.present && stopTasks.tasks.some((task) => !task.teammate && task.id === agentId) + }) // Why: a blocked child that dies (killed, errored) without another tool // event would otherwise pin its permission/question wait on the pane // forever — nothing else references that agent again. @@ -2449,9 +2457,13 @@ export function seedClaudeSubagentRosterFromSnapshots( startedAt: snapshot.startedAt, agentType: snapshot.agentType, description: snapshot.description, + // Why: teammate name and agent type can differ, but the provider id + // shape survives persistence and keeps the row across restart folds. + ...(isClaudeTeammateLifecycleId(snapshot.id) ? { teammate: true as const } : {}), // Why: the seed can be a phantom (child finished while Orca was down, // its SubagentStop lost). Let a PRESENT background_tasks list that - // omits the id demote it instead of gating the pane 'working' forever. + // omits the id remove it (or demote a teammate) instead of gating the + // pane 'working' forever. backgroundTasksAuthoritative: true }) } @@ -2609,7 +2621,8 @@ function normalizeClaudeEvent( foldClaudeBackgroundTasksIntoRoster( getOrCreateClaudeSubagentRoster(state, paneKey), backgroundTasks.tasks, - Date.now() + Date.now(), + { inventoryComplete: !backgroundTasks.truncated } ) } } diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index e8a4cb6cb1e..99cd9bbb8dc 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -4,8 +4,8 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, + finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - markClaudeSubagentIdle, markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, upsertWorkingClaudeSubagent, @@ -13,55 +13,136 @@ import { } from './claude-subagent-roster' describe('claude-subagent-roster', () => { - it('tracks spawn and stop as working → idle', () => { + it('removes a finished one-shot subagent on stop', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) expect(claudeRosterHasWorkingSubagent(roster)).toBe(true) - markClaudeSubagentIdle(roster, 'a1') - expect(claudeRosterHasWorkingSubagent(roster)).toBe(false) - expect(claudeRosterToSnapshots(roster)).toEqual([ - { - id: 'a1', - state: 'idle', - startedAt: 100, - agentType: 'general-purpose', - description: undefined - } - ]) + // Why: retaining finished one-shots as idle rows piled up dozens of dead + // "Idle - general-purpose" sidebar rows over a long workflow session. + finishClaudeSubagent(roster, 'a1') + expect(roster.size).toBe(0) + expect(claudeRosterToSnapshots(roster)).toBeUndefined() }) - it('re-marks an idle subagent working without resetting startedAt', () => { + it('idles a teammate on stop instead of removing it', () => { const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) - markClaudeSubagentIdle(roster, 'a1') - upsertWorkingClaudeSubagent(roster, 'a1', { description: 'round two' }, 200) - expect(roster.get('a1')).toMatchObject({ + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + }) + + it('removes a finished workflow lane despite its teammate-shaped id when task-listed', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: workflow lanes get name-embedding ids (afinder-C-) like + // teammates, but their SubagentStop payload lists them id-exact as a + // subagent task — real teammate lifecycle ids never appear there. + upsertWorkingClaudeSubagent( + roster, + 'afinder-C-5d713c0781b7f8d2', + { agentType: 'finder-C' }, + 100 + ) + finishClaudeSubagent(roster, 'afinder-C-5d713c0781b7f8d2', { listedAsSubagentTask: true }) + expect(roster.size).toBe(0) + }) + + it('reclassifies a task-listed teammate-shaped entry as a one-shot', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent( + roster, + 'av1-streaming-0b1c2d3e', + { agentType: 'v1-streaming' }, + 100 + ) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'av1-streaming-0b1c2d3e', + agentType: 'v1-streaming', + description: undefined, + running: true, + teammate: false + }, + { + id: 'tteam1', + agentType: undefined, + description: undefined, + running: true, + teammate: true + } + ], + 200 + ) + // A later stop without its own inventory still removes it: the fold + // already proved the id is a task id, not a teammate. + finishClaudeSubagent(roster, 'av1-streaming-0b1c2d3e') + expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false) + }) + + it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100) + finishClaudeSubagent(roster, 'acr-triage-1-c5a0588e') + expect(roster.get('acr-triage-1-c5a0588e')).toMatchObject({ state: 'idle' }) + upsertWorkingClaudeSubagent(roster, 'afix-main-11223344', { agentType: 'fix-main' }, 150) + + // Why: a teams session lists its teammates (even idle) as teammate-typed + // tasks; an inventory with none proves these name-shaped rows are dead + // workflow lanes, not resumable teammates. + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'aunrelated0000001', + agentType: 'general-purpose', + description: undefined, + running: true, + teammate: false + } + ], + 200 + ) + expect(roster.has('acr-triage-1-c5a0588e')).toBe(false) + expect(roster.has('afix-main-11223344')).toBe(false) + expect(roster.has('aunrelated0000001')).toBe(true) + }) + + it('re-marks an idle teammate working without resetting startedAt', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { description: 'round two' }, 200) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working', startedAt: 100, description: 'round two' }) }) - it('ignores unknown ids on markClaudeSubagentIdle', () => { + it('ignores unknown ids on finishClaudeSubagent', () => { const roster: ClaudeSubagentRoster = new Map() - markClaudeSubagentIdle(roster, 'ghost') + finishClaudeSubagent(roster, 'ghost') expect(roster.size).toBe(0) }) it('caps roster size, evicting the oldest idle entry first', () => { const roster: ClaudeSubagentRoster = new Map() - for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 0) + for (let i = 1; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) } // Why: all working — a new spawn cannot evict live children and is dropped. upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999) expect(roster.has('overflow')).toBe(false) - markClaudeSubagentIdle(roster, 'a3') + // Why: only teammates hold idle entries now; an idle teammate is the + // eviction pool when a burst of new spawns hits the cap. + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000) expect(roster.has('replacement')).toBe(true) - expect(roster.has('a3')).toBe(false) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) }) @@ -105,11 +186,21 @@ describe('claude-subagent-roster', () => { expect(readClaudeBackgroundAgentTasks({ background_tasks: 'nope' }).present).toBe(false) }) + it('marks a background task inventory truncated after the snapshot cap', () => { + const tasks = Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: `a${index}`, + type: 'subagent', + status: 'running' + })) + const result = readClaudeBackgroundAgentTasks({ background_tasks: tasks }) + expect(result.tasks).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + expect(result.truncated).toBe(true) + }) + it('folds background_tasks in without trusting ambiguous entries', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) - markClaudeSubagentIdle(roster, 'a1') - upsertWorkingClaudeSubagent(roster, 'ateam-xyz', { agentType: 'reviewer' }, 150) + upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -137,7 +228,56 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(2) // Why: id-exact matches are one-shot subagents whose run state IS reliable. expect(roster.get('a1')).toMatchObject({ state: 'working', description: 'review loop' }) - expect(roster.get('ateam-xyz')).toMatchObject({ state: 'working', agentType: 'reviewer' }) + // Why: the working lifecycle-tracked teammate is not listed by id, but + // omission proves nothing for teammates — it must survive the fold. + expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ + state: 'working', + agentType: 'security-reviewer' + }) + }) + + it('removes an id-matched task reported not running', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) + foldClaudeBackgroundTasksIntoRoster( + roster, + [{ id: 'a1', agentType: undefined, description: undefined, running: false, teammate: false }], + 200 + ) + expect(roster.size).toBe(0) + }) + + it('removes a killed one-shot missing from a present list', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: a running one-shot is always listed id-exact at a lead Stop, so a + // working non-teammate missing from the list is dead (SubagentStop lost); + // keeping it pinned the pane 'working' forever. + upsertWorkingClaudeSubagent(roster, 'akilled0000000001', { agentType: 'general-purpose' }, 100) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } + ], + 200 + ) + expect(roster.size).toBe(0) + }) + + it('retains an unlisted live child when the background task inventory was truncated', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'alive-after-cap', {}, 100) + const parsed = readClaudeBackgroundAgentTasks({ + background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, + type: 'subagent', + status: 'running' + })) + }) + + foldClaudeBackgroundTasksIntoRoster(roster, parsed.tasks, 200, { + inventoryComplete: !parsed.truncated + }) + expect(roster.has('alive-after-cap')).toBe(true) }) it('recreates unmatched running one-shot subagents after a listener restart', () => { @@ -174,17 +314,14 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(0) }) - it('demotes task-id-authoritative entries missing from a present list', () => { + it('removes non-teammate authoritative entries and keeps live teammates on omission', () => { const roster: ClaudeSubagentRoster = new Map() - // Why: seeded/bt-sourced ids ARE task ids; absence from a present list - // proves the task finished. Lifecycle-tracked ids (teammates) prove - // nothing by absence and must keep their state. roster.set('a-phantom', { state: 'working', startedAt: 100, backgroundTasksAuthoritative: true }) - upsertWorkingClaudeSubagent(roster, 'ateam-xyz', { agentType: 'reviewer' }, 150) + upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -193,11 +330,33 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a-phantom')).toMatchObject({ state: 'idle' }) - expect(roster.get('ateam-xyz')).toMatchObject({ state: 'working' }) + expect(roster.has('a-phantom')).toBe(false) + expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ state: 'working' }) }) - it('marks fold-recreated entries as task-id-authoritative for later folds', () => { + it('demotes a seeded teammate phantom missing from a present list to idle', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: a teammate seeded from a pre-restart snapshot may be dead, but its + // task id never appears in background_tasks — demote instead of delete so + // a live idle teammate keeps its row while a phantom stops gating the pane. + roster.set('aprobe1-6d3cb5b5', { + state: 'working', + startedAt: 100, + agentType: 'probe1', + teammate: true, + backgroundTasksAuthoritative: true + }) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } + ], + 200 + ) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + }) + + it('removes fold-recreated entries missing from a later present list', () => { const roster: ClaudeSubagentRoster = new Map() foldClaudeBackgroundTasksIntoRoster( roster, @@ -211,13 +370,21 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a9')).toMatchObject({ state: 'idle' }) + expect(roster.has('a9')).toBe(false) }) - it('stops demoting an entry once live activity re-tracks it', () => { + it('keeps a re-tracked working teammate missing from a present list', () => { const roster: ClaudeSubagentRoster = new Map() - roster.set('a-seeded', { state: 'working', startedAt: 100, backgroundTasksAuthoritative: true }) - upsertWorkingClaudeSubagent(roster, 'a-seeded', {}, 150) + roster.set('aprobe1-6d3cb5b5', { + state: 'working', + startedAt: 100, + agentType: 'probe1', + teammate: true, + backgroundTasksAuthoritative: true + }) + // Why: live activity clears the authoritative flag; a busy teammate must + // not be demoted by a Stop that (as always) omits its lifecycle id. + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -226,7 +393,7 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a-seeded')).toMatchObject({ state: 'working' }) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working' }) }) it('matches teammate ids by name only up to the hyphen-free suffix', () => { diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index b4b4f7ef946..b0b945a76e2 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -14,12 +14,18 @@ export type TrackedClaudeSubagent = { description?: string state: 'working' | 'idle' startedAt: number - /** The id came from background_tasks or a persisted snapshot, not live - * lifecycle events, so a PRESENT list omitting it proves the task is gone - * (a phantom seeded before restart would otherwise gate the pane 'working' - * forever — teams sessions never send an empty list). Cleared once live - * activity re-tracks the id, so a seeded-but-alive teammate is demoted at - * most until its next tool event. */ + /** Known agent-teams teammate: its id embeds its name (`a-`) + * while one-shot ids are hyphen-free (`a`). Teammates are long-lived — + * SubagentStop means "finished a task", not "gone" — and their lifecycle + * ids never appear in background_tasks, so omission proves nothing. */ + teammate?: true + /** The id came from a persisted snapshot or background_tasks, not live + * lifecycle events. Only matters for teammate-shaped seeds now: a PRESENT + * list omitting a seeded-working teammate demotes it to idle so a phantom + * seeded before restart can't gate the pane 'working' forever (teams + * sessions never send an empty list). Cleared once live activity re-tracks + * the id. Non-teammate entries omitted from a present list are removed + * outright regardless of this flag. */ backgroundTasksAuthoritative?: boolean } @@ -36,6 +42,14 @@ export type ClaudeBackgroundAgentTask = { teammate: boolean } +/** Agent-team lifecycle ids are `a-`. The teammate name + * and agent type are independent spawn fields, so the id shape is the only + * reliable discriminator available on SubagentStart/SubagentStop hooks. */ +export function isClaudeTeammateLifecycleId(id: string): boolean { + const separator = id.lastIndexOf('-') + return separator > 1 && id.startsWith('a') && /^[0-9a-f]+$/i.test(id.slice(separator + 1)) +} + export function upsertWorkingClaudeSubagent( roster: ClaudeSubagentRoster, id: string, @@ -45,11 +59,15 @@ export function upsertWorkingClaudeSubagent( if (id.length === 0 || id.length > CLAUDE_SUBAGENT_ID_MAX_LENGTH) { return } + const teammate = isClaudeTeammateLifecycleId(id) const existing = roster.get(id) if (existing) { existing.state = 'working' existing.agentType = fields.agentType ?? existing.agentType existing.description = fields.description ?? existing.description + if (teammate) { + existing.teammate = true + } // Why: live activity proves the lifecycle stream owns this id again; // background_tasks absence must stop demoting it (teammate ids never // appear there). The fold re-tags its own recreations after this call. @@ -63,7 +81,8 @@ export function upsertWorkingClaudeSubagent( state: 'working', startedAt: now, agentType: fields.agentType, - description: fields.description + description: fields.description, + ...(teammate ? { teammate: true as const } : {}) }) } @@ -83,11 +102,31 @@ function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean { return true } -export function markClaudeSubagentIdle(roster: ClaudeSubagentRoster, id: string): void { +/** SubagentStop: a finished one-shot subagent leaves the sidebar immediately — + * retaining it as an idle row made long workflow/ultracode sessions pile up + * dozens of dead rows. Teammates only idle (alive + resumable): SubagentStop + * fires each time a teammate finishes a task, not just at shutdown. + * + * Workflow/named one-shots share the teammate id shape (`a