Finish host context merge repair

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 18:24:21 -07:00
co-authored by Orca
parent 694fb2947e
commit 293f84546c
164 changed files with 10835 additions and 5088 deletions
+4
View File
@@ -93,6 +93,10 @@ docs/reference/react-performance-audit.md
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Playwright
test-results/
playwright-report/
@@ -0,0 +1,222 @@
# Host Context Implementation Checklist
This checklist tracks the remaining work needed to make Hosts first-class across projects, tasks, automations, SSH, remote servers, and future cloud VMs.
The target model is:
- **Project** is the durable logical project.
- **Project host setup** means that project exists on a specific host at a specific path.
- **Task source context** means which host/account/provider is used to fetch or mutate external work items.
- **Run host** means where a workspace, agent, automation, terminal, clone, or command executes.
- Source context and run host are explicit, may differ, and should not be inferred from ambient client state.
## Checklist Status
- [ ] Not started.
- [~] In progress or partially covered.
- [x] Complete and verified.
## 1. Shared Model And Naming
- [x] Add a shared TaskSourceContext type that captures provider, host/source target, logical project id, optional projectHostSetupId, and provider-specific identity such as owner/repo, GitLab project id, Linear workspace/team, or Jira site/project.
- [x] Add a shared run-target type or selector for RunHostContext / WorkspaceRunContext so workspace creation, agent launch, automations, and remote execution use the same vocabulary.
- [~] Audit user-facing copy so the product says **Host** for Local Mac, SSH hosts, remote servers, and future cloud VMs, while preserving technical labels only where useful.
- [x] Document the invariant that task source context and run host are separate choices.
- [~] Add compatibility adapters for existing repo-id/path-based callers so old data can be read while new code moves to explicit contexts.
- [~] Add schema migration notes for old automations, cached task selections, pending worktree requests, and any persisted task drawer state.
Done means: a new agent can read the shared types and understand exactly where work item data comes from versus where execution happens.
## 2. Provider Routing And Task Data
- [~] Update GitHub issue/PR listing to accept an explicit TaskSourceContext instead of deriving from selected repo, path, or active runtime state.
- [~] Update GitHub drawer mutations such as comment, state change, assignment, label, branch creation, and review actions to preserve the same source context as the list item.
- [~] Update GitHub cache keys to include provider identity and source host/runtime identity so two hosts with different GitHub accounts cannot collide.
- [x] Update GitLab IPC/preload/main handlers to accept explicit project/source context instead of path-only selectors.
- [~] Guard or retire GitLab path-only fallbacks where they can mutate or fetch from the wrong host.
- [~] Update Linear routing to use an explicit source account/workspace context instead of settings.activeRuntimeEnvironmentId.
- [~] Update Jira routing to use an explicit source account/site/project context instead of settings.activeRuntimeEnvironmentId.
- [~] Add provider diagnostics that show which host/account/site a task source is using.
- [~] Add error states for unavailable source hosts, missing provider auth, unsupported provider on host, and version-skewed remote servers.
Done means: if Machine A and Machine B have different provider accounts, every list and mutation goes through the user-selected source context.
## 3. Tasks UI
- [~] Add an explicit source-context picker or display for Tasks that can represent host, provider account, and project/source.
- [~] Decide and implement the default Tasks view when a logical project exists on multiple hosts.
- [~] Support switching the task source without changing the current run host.
- [~] Show enough account/host metadata to make mismatched GitHub/GitLab/Linear/Jira accounts understandable.
- [~] Persist task source context in task drawer URLs/state so refresh, deep links, and reopen preserve the same source.
- [~] Ensure task drawer actions reuse the drawer item source context, not a global provider default.
- [~] Add disabled/auth-needed/disconnected states for source hosts.
- [x] Add empty states explaining when a project has no configured task source on the selected host.
Done means: users can tell where Tasks are being fetched from, and changing execution host does not silently change provider data.
## 4. New Workspace Modal, Start Work, And Workspace Creation
- [x] Split “start from task/issue/PR” source context from workspace run target in New Workspace modal state.
- [~] When opening the New Workspace modal from Tasks, preselect the logical project and a reasonable run host without treating the source repo as the only execution target.
- [x] Ensure pending worktree/workspace creation persists both the source task context and selected run host.
- [x] Make “create workspace on host” only show hosts where the project has a ProjectHostSetup.
- [x] Keep “add project to host” as the path for making a project available on another host; do not present workspace creation as if Orca is responsible for bootstrapping repos/dependencies.
- [x] Update clone/browse/create project flows so selected host is not repeated as a fake editable SSH field.
- [x] Ensure clone destination defaults are human and host-appropriate, and allow changing the clone location where the host supports it.
- [x] Improve clone error messages for existing destination paths with the actual path and next action.
- [~] Ensure PR/MR base resolution uses source context for provider data and run host context for local/remote git commands.
Done means: starting work from an external item carries the item’s source identity separately from where the agent will run.
## 5. Automations
- [~] Migrate Automation.projectId away from repo-id semantics toward logical project id plus explicit run host/project host setup.
- [x] Store automation source context separately from automation run context.
- [x] Snapshot source context and run context on each automation run for auditability and retry safety.
- [x] Update automation dispatch/precheck code to validate selected run host availability and project host setup.
- [x] Verify automation live lookups do not use ambient settings: current Orca automations do not fetch issue/PR/task lists during dispatch, and branch/base-ref lookup routes through the selected repo host.
- [x] Update session reuse logic so an automation never resumes on the wrong host.
- [~] Add disabled states for disconnected run host, missing project host setup, missing source auth, unavailable source tool, and unsupported provider.
- [x] Add migration behavior for old automations that only know a repo id/path.
- [~] Update CLI/RPC automation APIs with v2 selectors for logical project, source context, and run host.
Done means: scheduled or manual automations behave predictably when a project exists on multiple hosts or providers differ by host.
## 6. Settings, Accounts, And Host Ownership
- [~] Clarify which settings are client-owned versus host-owned.
- [~] Add host/account selectors to provider settings where credentials may differ by host.
- [~] Make provider rate limits, diagnostics, and “current account” views show their host/source context.
- [x] Audit source-control AI, agent defaults, command defaults, and model selections for whether they should be client-global or host-scoped.
- [x] Preserve existing Windows/WSL-style setting patterns where they already solve host-specific settings.
- [x] Ensure remote server version/capability information is visible enough to explain unavailable controls.
Done means: settings pages no longer imply one global provider/account when the action may be host-owned.
## 7. Cache, Request Ownership, And Runtime Compatibility
- [~] Audit caches keyed by repo id, path, owner/repo, or active runtime and add host/source identity where needed.
- [~] Ensure request ownership is explicit for provider fetches, provider mutations, file-system calls, git calls, terminal calls, and automation runs.
- [~] Add capability negotiation for remote servers so new clients can degrade gracefully with older servers.
- [~] Add version-skew UX for unsupported source-context or host-context APIs.
- [~] Ensure SSH hosts and remote servers expose comparable availability/health states to the UI.
- [x] Keep future cloud VM support as a host capability model, not a separate user-facing mental model.
Done means: no background request can accidentally use the wrong host because it fell back to global state.
## 8. Sidebar And Project Host UX
- [x] Verify sidebar only shows host entries for projects that actually exist on those hosts.
- [~] Verify disconnected hosts are visually disabled and not selectable when selection would fail.
- [x] Keep local-only users in the low-jarring path: no unnecessary host chrome when only one local host exists.
- [~] Preserve workspace drag/reorder behavior and host-section drag behavior after project-first host changes.
- [x] Verify collapsed/expanded host behavior is stable while dragging.
- [x] Ensure host labels, counts, and status indicators are visually calm and aligned with docs/STYLEGUIDE.md.
Done means: the sidebar reflects project availability, not every configured SSH target, and disconnected states are understandable.
## 9. CLI, Remote Server, SSH, And Cloud-Ready Host APIs
- [~] Add CLI selectors for logical project, project host setup, source host/context, and run host.
- [x] Keep SSH and remote server behavior represented as host capabilities rather than separate product concepts.
- [ ] Ensure remote server mode can still support users who want the desktop client to behave like “the server” when explicitly configured.
- [x] Ensure SSH remains the lightweight “execute on this machine over SSH” path.
- [~] Ensure remote server remains the durable runtime path for web/mobile handoff, background work, and richer server-owned state.
- [x] Note cloud VM provisioning as future host onboarding/provisioning work, not a requirement for this PR.
Done means: Local, SSH, remote server, and future cloud VMs share one Host model while keeping their different capabilities clear.
## 10. Verification Matrix
- [~] Unit-test source-context serialization, cache keys, and provider routing.
- [x] Unit-test automation migration from repo-id/path-based records.
- [x] Unit-test workspace creation selectors for logical project plus host setup.
- [ ] Integration-test local project with local provider credentials.
- [x] Integration-test SSH project setup, clone/browse/create flows, and workspace creation.
- [x] Integration-test project available on local and SSH hosts, then create workspaces on each host.
- [x] Integration-test task source on Host A with workspace run on Host B.
- [ ] Integration-test two hosts with different GitHub accounts and verify issue/PR lists and mutations use the selected source.
- [ ] Integration-test GitLab where path-only selectors previously existed.
- [ ] Integration-test Linear/Jira with explicit source account selection.
- [~] Integration-test automations on local host, SSH host, and remote server where supported.
- [~] Integration-test disconnected host UX for Tasks, New Workspace modal, sidebar, and automations.
- [ ] Integration-test new client with older remote server and older client with newer server where practical.
- [~] Electron-test Add Project flows: browse folder, clone URL, and create project from scratch on local and SSH hosts.
- [~] Electron-test workspace options and sidebar host filtering.
- [~] Electron-test Tasks source picker/display and task drawer mutations.
- [~] Capture screenshots for all important UX states in a dark-background HTML report.
Done means: the test report demonstrates the complete user journey, including local, SSH, multi-host project, task source, automation, disconnected, and version-skew states.
## 11. Documentation And PR Evidence
- [x] Update the main design doc with source context versus run host boundaries.
- [x] Update PR description with exact completed scope and explicit out-of-scope items.
- [x] Link this checklist from the PR or implementation notes.
- [x] Keep evidence screenshots out of git and attach/report them through approved PR channels.
- [ ] Add plain-English release note copy for the Host model only after UX is verified.
Done means: reviewers and future agents can understand the vision, implementation state, verification evidence, and remaining risk without reconstructing this conversation.
## Implementation Log
- [x] 2026-06-12: Added shared TaskSourceContext and WorkspaceRunContext types, normalization helpers, cache-scope helpers, and unit coverage.
- [x] 2026-06-12: Added automation source/run context fields and snapshots on AutomationRun creation while preserving legacy repo-id projectId behavior.
- [x] 2026-06-12: Let Linear and Jira runtime clients accept TaskSourceContext while keeping legacy activeRuntimeEnvironmentId callers working.
- [x] 2026-06-12: Let GitHub work-item fetch accept an explicit GitHub TaskSourceContext for runtime routing and host-scoped cache ownership.
- [x] 2026-06-12: Persisted taskSourceContext in New Workspace draft state for linked GitHub work items.
- [x] 2026-06-12: Added automation CLI project/host/project-host-setup target resolution and runtime RPC context pass-through.
- [x] 2026-06-12: Added GitLab repoId/sourceContext support to preload and main IPC handlers with source-host validation.
- [x] 2026-06-12: Verified with focused Vitest coverage for shared context, persistence automations, GitHub work-item routing, CLI automation routing, runtime automation RPC, plus full pnpm run typecheck.
- [x] 2026-06-12: Wired TaskPage GitLab lists/todos and GitLabItemDialog detail/mutation calls through repoId/sourceContext, then verified web/node/full typechecks plus focused shared/GitLab/GitHub/automation/persistence/CLI tests.
- [x] 2026-06-12: Wired GitHubItemDialog repo-scoped details/comments/review comments/viewed state/reviewer requests/PR actions/check refreshes/issue metadata edits through sourceContext, added a GitHub IPC host-mismatch regression test, and verified GitHub-focused tests plus full typecheck.
- [x] 2026-06-12: Updated GitHub work-item cache keys to preserve full task source scope, including provider identity, and added regression coverage for two GitHub identities on the same host.
- [x] 2026-06-12: Added taskSourceContext to the New Workspace modal handoff and seeded it from GitHub/GitLab Tasks so start-work source identity survives independent of run-host selection.
- [x] 2026-06-12: Updated TaskPage cached work-item reads/selectors to use the same GitHub source cache scope as fetches, preventing source-scoped fetches from disappearing from the cached UI path.
- [x] 2026-06-12: Added automation run-target validation before dispatch/precheck so missing, stale, or non-ready project host setups are skipped with clear errors instead of launching on the wrong host; verified with automation service tests, focused persistence tests, and full typecheck.
- [x] 2026-06-12: Updated renderer automation dispatch to create new workspaces from the stored run-context repo and reject existing-workspace dispatches whose workspace repo does not match the run host target; verified with web and full typecheck.
- [x] 2026-06-12: Updated Automations list/detail/edit read paths to prefer the saved run-context repo, so multi-host automations reopen and display against their selected host instead of the legacy projectId repo; verified with web typecheck.
- [x] 2026-06-12: Added a Tasks source-context summary chip showing provider plus host/account/project context for GitHub, GitLab, Linear, and Jira; verified with focused summary tests and web typecheck.
- [x] 2026-06-12: Verified New Workspace host selection only renders ready project-host setup options and ignores non-ready selections; covered by project-host setup option and combobox tests.
- [x] 2026-06-12: Verified Add Project clone/browse/create flows keep the selected host as context instead of a redundant SSH selector, preserve editable host-appropriate clone destinations, and now convert existing-destination clone failures into path-aware next-action copy; verified with Add Project UI and clone failure tests.
- [x] 2026-06-12: Verified workspace creation selectors resolve logical project plus focused host/project-host setup targets, including explicit setup IDs and unavailable setups; covered by project-host workspace target and project option tests.
- [x] 2026-06-12: Scoped GitHub Project view cache and in-flight request keys by local/runtime source scope, and scoped Project view wrapper cache lookups/view-list state accordingly; verified with a runtime-vs-local cache regression test and web typecheck.
- [x] 2026-06-12: Added SSH source-host availability to the Tasks source summary so disconnected, reconnecting, and auth-needed source hosts are visible without changing the run-host selection; verified with focused summary tests and web typecheck.
- [x] 2026-06-12: Preserved GitHub task detail source context in Tasks open payloads, back/forward history entries, and replay state so a reopened detail does not fall back to ambient host selection; added a same-item/different-source-host history regression and verified with UI/history slice tests plus web typecheck.
- [x] 2026-06-12: Updated GitHub Tasks warm prefetch to accept and forward TaskSourceContext so direct task-detail opens warm the same source-scoped cache that the mounted page reads; verified with UI slice and GitHub source/cache-focused tests plus web typecheck.
- [x] 2026-06-12: Added Automations run-target availability checks for manual runs, disabling/explaining Run Now when the project/workspace is missing, the saved run context no longer matches the host setup, the host is an unsupported runtime target, or the SSH host is disconnected/auth-needed/connecting; verified with focused availability tests and web typecheck.
- [x] 2026-06-12: Threaded repoId through hosted-review creation eligibility and creation so PR creation validates the exact project host setup instead of falling back to path-only repo lookup; added a main-process repoId/path mismatch regression and verified hosted-review IPC/store tests plus full typecheck.
- [x] 2026-06-12: Added Linear/Jira Settings account-scope diagnostics showing whether provider credentials and account checks are owned by the local desktop client or the selected remote server; verified with provider account-scope tests and web typecheck.
- [x] 2026-06-12: Added repo-backed Tasks empty-state copy that distinguishes missing project sources from no matching GitHub/GitLab work, then verified with focused empty-state tests and web typecheck.
- [x] 2026-06-12: Fed Tasks source-context diagnostics from the shared execution-host registry so the source chip now reports SSH availability and blocked/incompatible remote-server hosts; verified with summary tests and web typecheck.
- [x] 2026-06-12: Extended Linear/Jira Tasks source diagnostics to include the account-owning host plus blocked remote-server availability, keeping account source separate from workspace run host; verified with summary tests and web typecheck.
- [x] 2026-06-12: Started the user-facing Host copy audit by replacing stale “remote project” and “Remote SSH” UI labels with project-on-host / SSH-host language; verified with web typecheck.
- [x] 2026-06-12: Re-ran the broader focused host-context suite covering source summaries, empty states, execution-host registry, project-host setup options/combobox, settings setup options, and automation target availability; 7 files / 34 tests passed plus full pnpm run typecheck.
- [x] 2026-06-12: Added a safer automatic Tasks repo default that selects one source per logical GitHub project, preferring local when the same project exists on multiple hosts while preserving explicit/manual multi-host selection; verified with default-selection tests and web typecheck.
- [x] 2026-06-12: Fixed logical project projection for legacy repos whose GitHub `upstream` was missing by using repo-icon GitHub slug metadata as a provider-identity fallback; verified with `project-host-setup-projection.test.ts`.
- [x] 2026-06-12: Repaired worktree discovery/backfill so existing workspace metadata upgrades from legacy repo-scoped project IDs to provider-backed logical project IDs when the project-host setup is the same, including SSH metadata fallback rows while preserving cached display/activity/sparse metadata; verified with `worktrees.test.ts` and full `pnpm run typecheck`.
- [x] 2026-06-12: Re-verified the live Electron/SSH scenario after relaunch: one logical GitHub project has local and SSH project-host setups, stale SSH main-worktree metadata repairs to `github:stablyai/orca`, a workspace can be created on SSH, and another workspace can be created on Local Mac from the same project; screenshots saved under `notes/artifacts/`.
- [x] 2026-06-12: Verified disconnected SSH metadata fallback in Electron: SSH worktrees remain visible from persisted metadata with repaired logical project ownership, the status area reports `SSH Disconnected`, the user sees a reconnect dialog, and project create controls are disabled while disconnected; screenshot saved under `notes/artifacts/host-context-disconnected-ssh-metadata-fallback.png`.
- [x] 2026-06-12: Verified Add Project on SSH through the real Electron UI: selected an SSH host once, confirmed browse/clone/create screens do not show a redundant SSH-target field, cloned a throwaway Git repo into `/home/orca/add-project-clones`, and created a new Git project in `/home/orca/add-project-created`; verified remote filesystem and Orca project-host setup state.
- [x] 2026-06-12: Cleaned Add Project copy from stale “server folder/filesystem/path” wording to host-language where the UI applies to SSH, remote server, and future cloud hosts; verified with focused AddRepo tests, typecheck, and live screenshot `notes/artifacts/host-context-add-project-ssh-create-screen-host-copy.png`.
- [x] 2026-06-12: Refreshed `notes/multihost-remote-verification-report.html` with the current dark-background evidence set for logical project repair, New Workspace host selection, disconnected SSH fallback, SSH clone, SSH create project, and sidebar state after Add Project flows.
- [x] 2026-06-12: Fixed Tasks React maximum-depth crashes by making repo-owner runtime settings selectors shallow-stable and preventing same-key GitHub slug metadata hooks from writing no-op cached/loading state; verified with a focused hook regression, `repo-runtime-owner.test.ts`, full typecheck, and live Electron Tasks rendering.
- [x] 2026-06-12: Fixed New Workspace `Run on` switching so changing project-host setup inside the same logical project preserves the selected GitHub/GitLab task source instead of degrading to plain text; verified live by starting from GitHub issue `#5200`, switching run host from Local Mac to SSH, and confirming the issue card remained selected.
- [x] 2026-06-12: Created a task-started SSH workspace from the live Tasks page at `/home/orca/task-ssh-source-local-163115`; verified the remote worktree exists and the sidebar shows `Linked issue #5200`, with screenshots added to `notes/multihost-remote-verification-report.html`.
- [x] 2026-06-12: Verified Automations with SSH connected but Hermes unavailable on the remote PATH; changed the UI from misleading `Connect SSH` copy to `Source unavailable` plus `Retry source`, and captured the corrected state in the dark-background report.
- [x] 2026-06-12: Verified repo-backed Tasks empty-state copy for no selected project sources with `task-page-empty-state.test.ts`, covering the user-facing fallback when no host/account source is available for GitHub/GitLab work.
- [x] 2026-06-12: Updated `notes/project-first-host-model.md` with the explicit `TaskSourceContext` versus `WorkspaceRunContext` boundary, current task/automation implementation status, and remaining verification gaps; this checklist remains the detailed implementation tracker linked from the design-doc evidence set.
- [x] 2026-06-12: Continued the Host copy audit by replacing the remaining English Add Project/sidebar “remote project” and “Remote SSH” labels with project-on-SSH-host / SSH-host wording, then verified focused Add Project tests and full `pnpm run typecheck`; non-English locale placeholders still need the normal localization bootstrap pass.
- [x] 2026-06-12: Re-verified the New Workspace run-host path only exposes ready project-host setups, so hosts where the project does not exist are not selectable from workspace creation; covered by `ProjectHostSetupCombobox`, `project-host-setup-options`, and `project-host-workspace-target` tests.
- [x] 2026-06-12: Re-ran focused sidebar host tests: host headers are suppressed when only one host has visible workspaces, registered-but-empty/disconnected hosts do not create sidebar sections, SSH connection status flows to host headers, and drag mode can temporarily collapse host sections without mutating persisted collapse state; 8 sidebar files / 146 tests passed.
- [x] 2026-06-12: Tightened Add Project host selection so disconnected/error/blocked hosts stay visible but disabled instead of being selectable; verified with focused Add Project host selector/start tests and full `pnpm run typecheck`.
- [x] 2026-06-12: Added `CreateFromPicker` host-routing regression coverage for Automations branch/base-ref lookup, proving runtime-owned repos query their owner runtime and explicit local repos stay local even when another runtime is focused; verified alongside persistence and automation service tests.
- [x] 2026-06-12: Tightened the project-first design doc so host-aware Add Project is the current path for browse/import, clone, and create, while bulk setup and cloud VM provisioning are future host-onboarding work. Re-verified shared host-model tests covering local/SSH/runtime host kinds, runtime capability gating, SSH reconnect/disconnect actions, host settings overrides, and task source host scopes.
- [x] 2026-06-12: Live-verified Add Project disconnected-host UX in Electron by simulating the SSH target as disconnected in renderer state: the Host picker defaulted to Local Mac and showed the SSH host as a disabled muted row; restored SSH to connected and added screenshot evidence to `notes/multihost-remote-verification-report.html`.
- [x] 2026-06-12: Audited Settings ownership for Source Control AI defaults, repository Source Control AI overrides, agent launch defaults, quick commands, workspace directories, and provider accounts; added a tested settings-ownership helper and surfaced the ownership language in Settings while preserving the existing Windows/WSL account and agent-location pattern. Verified with focused settings tests and web typecheck.
- [x] 2026-06-12: Improved remote-server Settings version-skew diagnostics by showing the exact compatibility explanation plus named runtime capabilities instead of only a capability count; verified with `RuntimeEnvironmentsPane` tests and web typecheck.
- [x] 2026-06-12: Polished sidebar host headers from card-like rows into quieter sidebar grouping rows while keeping explicit SSH/status/count text; verified with focused sidebar tests, web typecheck, and live Electron screenshot `notes/artifacts/host-context-sidebar-calm-host-headers.png`.
- [x] 2026-06-12: Added a narrow `.gitignore` rule for `notes/artifacts/` so verification screenshots stay local while the HTML report can reference them; verified with `git check-ignore`.
- [x] 2026-06-12: Added `taskSourceContext` and `workspaceRunContext` to retryable pending worktree creation requests, seeded them from the Composer's selected task source and project-host setup, and verified the pending store preserves both identities with focused worktree tests plus web typecheck.
- [x] 2026-06-12: Updated PR #5071 with the current verified scope, evidence docs, validation commands, explicit out-of-scope cloud/onboarding items, and remaining provider/version-skew risk.
- [x] 2026-06-12: Added load-time backfill for legacy automations and automation runs that predate `runContext`/`sourceContext`, deriving explicit project host and provider source contexts from the saved repo id/path; verified with focused persistence migration coverage, automation service/precheck tests, daemon/settings merge-repair tests, and full `pnpm run typecheck`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,278 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Host Context / SSH Verification</title>
<style>
:root {
color-scheme: dark;
background: #0b0d10;
color: #e6e8eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
padding: 32px;
line-height: 1.5;
}
main {
max-width: 1180px;
margin: auto;
}
h1,
h2 {
line-height: 1.15;
}
section {
border: 1px solid #29313a;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
background: #12161b;
}
code {
background: #20262e;
padding: 2px 5px;
border-radius: 4px;
}
ul {
padding-left: 22px;
}
.pass {
color: #8ee6a1;
}
.fix {
color: #ffd38a;
}
.warn {
color: #ffaaa5;
}
figure {
margin: 18px 0;
border: 1px solid #29313a;
border-radius: 8px;
overflow: hidden;
background: #07090b;
}
img {
display: block;
width: 100%;
height: auto;
}
figcaption {
padding: 10px 12px;
color: #b8c0cc;
font-size: 13px;
border-top: 1px solid #29313a;
}
</style>
</head>
<body>
<main>
<h1>Host Context / SSH Verification</h1>
<p>
Run on 2026-06-12 against the isolated dev app at <code>CDP 9336</code>,
renderer <code>127.0.0.1:5176</code>, profile
<code>/tmp/orca-dev-host-context-1781302330</code>, and Docker SSH target
<code>orca-ssh-test</code> on <code>127.0.0.1:2222</code>.
</p>
<section>
<h2>Fixes From This Pass</h2>
<ul>
<li class="fix">
Legacy repos whose GitHub <code>upstream</code> is missing now use GitHub repo-icon
slug metadata as a provider identity fallback, so local and SSH copies of the same
GitHub repo collapse into one logical project.
</li>
<li class="fix">
Worktree discovery now repairs stale repo-scoped project IDs to provider-backed logical
project IDs when the project-host setup is the same, including SSH metadata fallback rows.
</li>
<li class="fix">
Add Project copy now uses host-language for SSH/remote-host create and browse flows:
<code>host folder</code>, <code>host path</code>, and
<code>Browse host filesystem</code>.
</li>
<li class="fix">
Tasks no longer crashes from a same-key GitHub metadata render loop; repo-owner
runtime settings selectors are shallow-stable and cached slug metadata avoids no-op
state writes.
</li>
<li class="fix">
Switching <code>Run on</code> inside the New Workspace modal now preserves the selected
GitHub/GitLab task source when the user is only moving execution to another host setup
for the same logical project.
</li>
<li class="fix">
Automations SSH-source unavailable states no longer ask the user to
<code>Connect SSH</code> when the SSH host is already connected; the UI now says
<code>Source unavailable</code> and offers <code>Retry source</code>.
</li>
<li class="fix">
Add Project host selection now keeps disconnected SSH hosts visible but disabled,
and falls back to Local Mac instead of selecting a host that cannot be used.
</li>
</ul>
</section>
<section>
<h2>Flows Verified</h2>
<ul>
<li class="pass">
Linked local <code>/tmp/orca-host-context-local-project</code> and SSH
<code>/home/orca/orca-host-context-remote-project</code> copies into one logical
<code>github:stablyai/orca</code> project.
</li>
<li class="pass">
Created an SSH workspace from the multi-host project:
<code>/home/orca/host-context-ssh-workspace</code>.
</li>
<li class="pass">
Created a local workspace from the same multi-host project:
<code>/Users/jinwoohong/orca/workspaces/orca-host-context-local-project/host-context-local-workspace</code>.
</li>
<li class="pass">
Disconnected SSH and verified metadata fallback keeps SSH workspaces visible with repaired
logical project ownership, while the UI shows <code>SSH Disconnected</code> and disables
project create controls.
</li>
<li class="pass">
Cloned a throwaway Git repo through Add Project on SSH into
<code>/home/orca/add-project-clones/host-context-clone-source</code>; verified remote
filesystem and Orca project-host setup state.
</li>
<li class="pass">
Created a new Git project through Add Project on SSH at
<code>/home/orca/add-project-created/host-context-created-project</code>; verified
<code>.git</code> exists and Orca added it as an SSH project-host setup.
</li>
<li class="pass">
Opened Tasks in the multi-host project profile without a recoverable React error, with
the task source list rendering local and SSH-backed project contexts.
</li>
<li class="pass">
Started work from GitHub issue <code>#5200</code>, changed <code>Run on</code> from
Local Mac to SSH, confirmed the issue source card stayed selected, then created
<code>/home/orca/task-ssh-source-local-163115</code> on the SSH host with sidebar
metadata showing <code>Linked issue #5200</code>.
</li>
<li class="pass">
Opened Automations with the SSH host connected and Hermes unavailable on the remote
PATH; verified the UI reports <code>Source unavailable</code> with a
<code>Retry source</code> action rather than a misleading SSH connection prompt.
</li>
<li class="pass">
Simulated the SSH target as disconnected in the live renderer store, opened Add Project,
and verified the Host picker shows the SSH host as a disabled row while Local Mac remains
selected. Restored the SSH status to connected afterward so the app is ready for manual
inspection.
</li>
</ul>
</section>
<section>
<h2>Validation</h2>
<ul>
<li class="pass">
<code>pnpm vitest run --config config/vitest.config.ts src/main/ipc/worktrees.test.ts</code>
passed: 126 tests.
</li>
<li class="pass">
Focused Add Project/sidebar tests passed: 4 files, 35 tests.
</li>
<li class="pass"><code>pnpm run typecheck</code> passed.</li>
<li class="pass">
<code>pnpm vitest run --config config/vitest.config.ts src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx src/renderer/src/lib/repo-runtime-owner.test.ts</code>
passed: 9 tests.
</li>
<li class="pass">
Continued verification added focused Add Project host-selector tests, Automations
branch-routing tests, shared host-model tests, and another full
<code>pnpm run typecheck</code> pass.
</li>
<li class="warn">
Docker/SSH verification intentionally used throwaway paths under <code>/home/orca</code>
and an isolated Orca profile; evidence images are uncommitted.
</li>
</ul>
</section>
<section>
<h2>Screenshots</h2>
<figure>
<img src="artifacts/host-context-sidebar-after-link.png" alt="Linked local and SSH project" />
<figcaption>One logical project after local + SSH setup linking.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-live-after-worktree-meta-repair.png" alt="Live metadata repair" />
<figcaption>Live app after stale SSH worktree metadata repaired to the logical project ID.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-new-workspace-run-on-options-repaired.png" alt="Run on host options" />
<figcaption>New Workspace: Run on shows exactly Local Mac and the SSH project setup.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-new-workspace-local-selected.png" alt="Local run host selected" />
<figcaption>Selecting Local Mac re-enables local-only sparse checkout controls.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-local-workspace-created.png" alt="Local workspace created" />
<figcaption>Local workspace created from the same multi-host project.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-disconnected-ssh-metadata-fallback.png" alt="Disconnected SSH fallback" />
<figcaption>Disconnected SSH state: visible fallback rows, disabled create control, reconnect dialog.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-add-project-ssh-selected.png" alt="Add Project SSH selected" />
<figcaption>Add Project uses one Host selector for SSH.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-add-project-disconnected-host-disabled.png" alt="Disconnected SSH host disabled in Add Project host picker" />
<figcaption>Disconnected SSH hosts stay visible in the Add Project Host picker, but are disabled and muted.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-add-project-ssh-clone-screen.png" alt="SSH clone screen" />
<figcaption>SSH clone screen has editable parent folder and no redundant SSH target field.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-add-project-ssh-clone-complete.png" alt="SSH clone complete" />
<figcaption>SSH clone flow completed and activated the cloned project.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-add-project-ssh-create-screen-host-copy.png" alt="SSH create screen host copy" />
<figcaption>Create Project copy now says host folder and Browse host filesystem.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-add-project-ssh-create-complete.png" alt="SSH create complete" />
<figcaption>SSH create-project flow completed and activated the new project.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-sidebar-after-add-project-flows.png" alt="Sidebar after Add Project flows" />
<figcaption>Sidebar after clone/create SSH projects plus the multi-host project.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-sidebar-calm-host-headers.png" alt="Calmer host section headers" />
<figcaption>Host grouping view after the host-header visual polish: quiet grouping rows, explicit SSH label, aligned counts.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-tasks-source-display.png" alt="Tasks source display" />
<figcaption>Tasks renders in the multi-host profile after the metadata/render-loop fix.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-task-start-worktree-modal.png" alt="Task start workspace modal" />
<figcaption>Start from task opens New Workspace with source item separate from Run on.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-task-start-run-on-ssh.png" alt="Task start workspace on SSH" />
<figcaption>Changing Run on to SSH preserves the GitHub issue source before creation.</figcaption>
</figure>
<figure>
<img src="artifacts/host-context-automations-ssh-source-unavailable.png" alt="Automations SSH source unavailable" />
<figcaption>Automations distinguishes a connected SSH host from an unavailable remote automation source.</figcaption>
</figure>
</section>
</main>
</body>
</html>
+91 -24
View File
@@ -3,7 +3,8 @@
Companion summary: see
[`project-first-host-model-change-inventory.md`](./project-first-host-model-change-inventory.md)
for the current discussion recap, change count, and branch implementation
status.
status. The live implementation tracker is
[`host-context-implementation-checklist.md`](./host-context-implementation-checklist.md).
## Context
@@ -249,6 +250,44 @@ ownership rather than declared on the workspace/setup model.
The new model should preserve the current runtime routing discipline while
making host ownership explicit in the data model.
### Task Source Context Is Separate From Run Host
Tasks, provider metadata, issue/PR mutations, and automations add one more
identity axis: where provider data comes from. This is not always the same as
where a workspace or agent runs.
The durable rule is:
```text
TaskSourceContext = provider/account/project source
WorkspaceRunContext = project host setup used for execution
```
Examples:
- A GitHub issue can be fetched with the local Mac GitHub credentials, while
the workspace for that issue runs on an SSH host.
- A project can be available on Local Mac and a remote server, but Linear/Jira
account checks may still be owned by one selected account host.
- An automation stores the issue/source context it was created from and the
run context it should execute on, so retries and audits do not depend on the
user's currently focused host.
Implementation rules:
- Provider list/fetch/mutation calls should carry explicit `TaskSourceContext`
when the UI knows it.
- Workspace creation and automation dispatch should carry explicit
`WorkspaceRunContext` / project-host setup ownership.
- Changing `Run on` in New Workspace must not erase the selected issue/PR/MR
source when the user is only changing execution host for the same logical
project.
- Caches for provider data must include provider/source identity, not only
repo id or owner/repo slug, because two hosts may have different credentials
or reachable provider state.
- UI diagnostics should show enough host/account/source metadata that the user
can explain why a list is empty, disconnected, or unavailable.
## Data Model Shape
### Project
@@ -355,10 +394,10 @@ Branch: feature/remote-hosts
Run on: GPU VM 1
This project is not set up on GPU VM 1.
[Clone repo to host] [Import existing folder] [Cancel]
[Add project on this host] [Cancel]
```
### Set Up Project On A Host
### Make A Project Available On A Host
A project settings page should expose which hosts the project is available on:
@@ -368,14 +407,19 @@ Project: Orca
Available on:
Local Mac Ready /Users/me/orca
GPU VM 1 Ready /home/me/orca
Work Linux Not set up [Set up]
```
Setup methods:
Today, making a project available on another host is the same host-aware Add
Project flow Orca already exposes:
- clone from repo URL into a selected parent directory
- import an existing folder on the host
- future: provision cloud VM and clone automatically
- browse/import an existing folder on the selected host
- clone from a repo URL into a selected parent directory on the host
- create a new project folder/repo on the host
Orca should not imply that workspace creation will install dependencies,
bootstrap the repo, or otherwise own the user's environment. Users can run their
own setup scripts or configure dependencies manually after the project exists on
that host.
### Add New Host / VM
@@ -392,7 +436,8 @@ Make projects available here:
[ ] ML Runner Clone to /mnt/work/ml-runner
```
This is also the natural place for future cloud VM monetization:
Future Orca cloud VM monetization belongs in host onboarding, not this PR's
project-host implementation:
1. provision host
2. choose projects to materialize there
@@ -512,17 +557,16 @@ Concrete changes:
Adding a project must distinguish:
- create a new project identity
- set up that project on this host
- set up an existing project on another host
- add/import/clone that project on this host
- add/import/clone an existing project on another host
This likely replaces a single "add repo" flow with a project-first setup flow.
Concrete changes:
- add "Set up on this host" flows for local paths, SSH paths, runtime hosts,
and future cloud VMs
- support clone and import-existing-folder
- support bulk setup when a new host is added
- keep Add Project host-aware for local paths, SSH paths, and runtime hosts
- support browse/import, clone, and create-project entry points
- leave bulk setup and cloud VM provisioning for future host onboarding work
- keep current add-repo flows as compatibility entry points during migration
### 6. Project Settings
@@ -627,11 +671,17 @@ host/setup key because two machines can have different refs, branches, worktree
paths, installed agents, filesystem state, and server capabilities for the same
project.
Provider caches also need explicit source keys. GitHub, GitLab, Linear, and
Jira data may be account- or host-owned; a cache keyed only by repo id,
owner/repo, or the currently focused runtime can show stale or wrong-provider
data after the user switches hosts.
Concrete changes:
- decide which caches are project-global versus host/setup-local
- include host/setup ids in branch, worktree, status, filesystem, terminal, and
remote capability cache keys
- include task source identity in provider work-item/detail/project cache keys
- keep request cancellation scoped to the host that owns the operation
### 12. Tests And Validation
@@ -815,6 +865,19 @@ Landed so far:
selected runtime server, or preselected SSH target, and lets Clone from URL
clone on local, runtime, or SSH hosts. The old separate "Remote project" start
row is hidden in the host-aware flow.
- Added shared `TaskSourceContext` and workspace run-context types so provider
source ownership and workspace execution host can be stored independently.
- Threaded GitHub/GitLab task source context through task listing, detail
drawers, mutations, new-workspace handoff, and source-scoped caches.
- Added Tasks source diagnostics and empty-state copy so users can see which
provider/host/account/project source is being used, and why a source is empty
or unavailable.
- Added automation source/run context storage, run snapshots, dispatch
validation, run-target availability messages, session-reuse guards, and CLI/RPC
selectors so automations do not resume or run on the wrong host setup.
- Verified the key source/run split in Electron: a GitHub issue sourced from
one project context can create a workspace on the SSH project-host setup while
preserving the linked issue metadata.
- Added tests for local repos, SSH repos, same-provider multi-host grouping,
no-identity same-name non-grouping, selector cache behavior, persistence
backfill, repo mutation synchronization, renderer hydration, runtime RPC
@@ -848,11 +911,17 @@ Important limitation:
first-class setup create/update/delete now exist for independent metadata and
are exposed through the renderer store/settings, but actual provisioning
execution and full UI flows are still future work.
- Add Project is now host-aware for single-host Browse and Clone flows, but
multi-host setup rows are not yet implemented in the Add Project dialog. SSH
"Create new project" is visibly unavailable because the app still lacks a
first-class SSH create-project IPC/API equivalent; runtime create is already
handled through the existing runtime RPC path.
- Add Project is now host-aware for Browse, Clone, and Create flows that target
Local Mac, SSH hosts, and runtime hosts through their existing repo
compatibility paths. Bulk setup and cloud VM provisioning are intentionally
future host-onboarding work, not requirements for this PR.
- Provider source context has been threaded through the main GitHub/GitLab task
paths, but true two-account host verification is still outstanding. Linear
and Jira have source diagnostics and runtime-client context plumbing, but
their full account-selector UX still needs end-to-end validation.
- Remote-server capability gating exists for project-host setup APIs, but the
user-facing version-skew UX is still not broad enough to explain every
unsupported source-context or host-context control.
Remaining end-to-end work:
@@ -860,8 +929,6 @@ Remaining end-to-end work:
toward bulk setup and newly added host onboarding
- finish provisioning, bulk setup-on-host flows, and Add Project multi-host
setup rows with per-host paths
- add a first-class SSH create-project path so Create can target SSH hosts, not
just local and runtime hosts
- add actual provisioning execution APIs instead of only recording independent
setup/provisioning metadata and intent
- split settings into explicit client, host, project, and project-host setup
@@ -875,8 +942,8 @@ Remaining end-to-end work:
- add project-first CLI/API commands with compatibility aliases for existing
repo/worktree commands
- broaden version-skew UI/CLI coverage beyond the current runtime capability
guard, and validate migration, creation, settings, sidebar, SSH, and
version-skew flows end to end
guard, and validate migration, creation, settings, sidebar, SSH, provider
multi-account, automations, and version-skew flows end to end
## Recommendation
@@ -0,0 +1,105 @@
# Automations Host Model Audit
## Summary
Automations are still mostly repo/worktree anchored. The UI labels the target as "Project", but the persisted and dispatching model treats `automation.projectId` as a repo id, derives the run host from that repo, and creates/reuses worktrees through repo-scoped APIs. This is the main mismatch with the desired project-first model: an automation cannot explicitly say "logical Project X, run on ProjectHostSetup Y / host Z", nor can a run record snapshot enough host/provider context to remain correct after project setup migration, SSH reconnection state changes, or remote runtime capability changes.
## Findings
1. Must fix: `Automation.projectId` is used as a repo id, not a durable logical Project id.
- Shared type only stores `projectId`, `executionTargetType`, `executionTargetId`, `workspaceMode`, `workspaceId`, and `baseBranch`; it has no `hostId`, `projectHostSetupId`, or provider/source context fields. See `src/shared/automations-types.ts:77-101` and create/update inputs at `src/shared/automations-types.ts:126-160`.
- Store creation resolves `input.projectId` against `state.repos`, then persists it unchanged. See `src/main/persistence.ts:3630-3643`.
- Store update repeats the same repo lookup using `repoId = updates.projectId ?? current.projectId`. See `src/main/persistence.ts:3664-3688`.
- Renderer dispatch finds `state.repos.find((entry) => entry.id === automation.projectId)`. See `src/renderer/src/hooks/useAutomationDispatchEvents.ts:60-81`.
2. Must fix: run host selection is implicit and stale-prone.
- `executionTargetType`, `executionTargetId`, and `schedulerOwner` are derived once from the repo'\''s `connectionId` at create/update time. See `src/main/persistence.ts:3631-3643` and `src/main/persistence.ts:3670-3688`.
- Dispatch ignores these persisted execution fields for SSH setup and instead re-derives from the current repo record. See `src/renderer/src/hooks/useAutomationDispatchEvents.ts:60-118`.
- This means if a logical project later has multiple host setups, or a repo-backed compatibility record changes, the automation has no explicit run host contract to preserve intent.
3. Must fix: `workspaceMode` remains repo/workspace anchored.
- The only modes are `existing` and `new_per_run`. See `src/shared/automations-types.ts:3`.
- Existing mode stores only `workspaceId`; new-per-run stores only `baseBranch`. See `src/shared/automations-types.ts:87-90`.
- New-per-run dispatch calls `createWorktree(automation.projectId, ...)`, so it creates from a repo id rather than a `ProjectHostSetup` or host-scoped project setup. See `src/renderer/src/hooks/useAutomationDispatchEvents.ts:152-171`.
- Precheck cwd uses existing `workspaceId` or `store.getRepo(automation.projectId)?.path`, again binding new-per-run prechecks to the repo compatibility path. See `src/main/automations/service.ts:233-241`.
4. Must fix: provider/source context is absent for task/provider-driven automations.
- Automation types carry `agentId` but no source provider identity, task provider, issue source preference, GitHub/GitLab project reference, owner/repo snapshot, remote name, or provider source context. See `src/shared/automations-types.ts:77-101`.
- The logical `Project` type has `providerIdentity`, but currently only supports GitHub and is not referenced by automations. See `src/shared/types.ts:91-107`.
- CLI/API fields are still named `repo` and `workspace`, not project/setup/host. See `src/main/runtime/rpc/methods/automations.ts:54-69`, `src/main/runtime/rpc/methods/automations.ts:71-87`, and `src/cli/specs/automations.ts:4-12`.
5. Must fix: run records do not snapshot host/setup/source identity.
- `AutomationRun` stores `automationId`, `workspaceId`, display name, terminal session id, output, precheck, usage, and timestamps, but no `projectId`, `hostId`, `projectHostSetupId`, execution target, provider source, runtime id, or capability/version snapshot. See `src/shared/automations-types.ts:103-124`.
- Runs are created with `workspaceId: automation.workspaceId` and no host metadata. See `src/main/persistence.ts:3726-3760`.
- Dispatch result can update only workspace/session/output/precheck/usage/error, not host/setup/source fields. See `src/shared/automations-types.ts:168-178` and `src/main/persistence.ts:3769-3808`.
6. Must fix: disconnected/auth-needed SSH is handled only as a repo.connectionId dispatch concern.
- Dispatch checks passphrase and connection state using `repo.connectionId`. See `src/renderer/src/hooks/useAutomationDispatchEvents.ts:83-118`.
- Detail copy warns about SSH skip behavior, but it is keyed only from `automation.executionTargetType === '\''ssh'\''`. See `src/renderer/src/components/automations/AutomationDetail.tsx:178-185`.
- Precheck SSH execution requires an already connected SSH manager connection and reports "SSH target is not connected." See `src/main/automations/precheck-runner.ts:238-247`.
- There is no stored distinction between "host setup exists but disconnected", "interactive auth required", "remote runtime too old", or "host setup unsupported"; statuses are coarse `skipped_unavailable` / `skipped_needs_interactive_auth`. See statuses at `src/shared/automations-types.ts:7-17`.
7. Should fix: runtime version/capability gating is missing from automations.
- Project host setup flows already have runtime capability checks and user-facing unsupported-server copy elsewhere, but automation create/update/run paths do not reference these capabilities. Evidence of capability handling exists in repo slice search results around `src/renderer/src/store/slices/repos.ts:239-268`.
- Automation RPC schemas expose `repo`, `workspace`, and `workspaceMode` only. See `src/main/runtime/rpc/methods/automations.ts:54-87`.
- Session reuse can subscribe to remote runtime terminals, but it chooses runtime target from the PTY environment or active settings, not from an automation run host field. See `src/renderer/src/lib/automation-session-observer.ts:32-57`.
8. Should fix: session reuse is tied to workspace id and terminal tab id, not host/setup/session identity.
- Reuse candidates require same `automationId`, same `workspaceId`, completed status, and a live terminal tab id. See `src/renderer/src/lib/automation-session-reuse.ts:13-57`.
- It does not verify host id, project host setup id, execution target, runtime environment, or provider conversation/session identity. See `src/renderer/src/lib/automation-session-reuse.ts:29-55`.
9. Should fix: CLI automations are still repo-first and cannot target a host setup.
- CLI accepts `--repo`, `--workspace`, `--workspace-mode`, and `--base-branch`. See `src/cli/specs/automations.ts:4-12` and usage at `src/cli/specs/automations.ts:32-57`.
- Handler default target resolution falls back to current worktree and omits a target for remote clients; there is no `--project`, `--host`, or `--project-host-setup`. See `src/cli/handlers/automations.ts:292-313`.
- Runtime target resolver returns only `projectId`, `workspaceMode`, and `workspaceId`, with error messages referring to repo. See `src/main/runtime/orca-runtime.ts:1941-1990`.
10. Migration gap: old automations have no explicit compatibility projection to the project-first model.
- Existing normalization only normalizes precheck and session reuse; it does not backfill host/setup fields. See `src/main/persistence.ts:607-612`.
- Existing persisted automations are listed directly after this normalization. See `src/main/persistence.ts:3614-3618`.
- ProjectHostSetup compatibility records exist for repos, but automations are not currently projected through them during load/list/create/update based on inspected code.
## Concrete Recommendations
1. Change the automation target model to store durable logical target plus run host:
- `projectId`: logical Project id.
- `projectHostSetupId`: selected setup for execution.
- `hostId`: denormalized host id snapshot for filtering/display/recovery.
- Keep a migration-only `legacyRepoId` or compatibility resolver rather than overloading `projectId`.
2. Replace `workspaceMode` semantics with explicit host-scoped run target data:
- Existing workspace: `{ mode: '\''existing_workspace'\'', workspaceId, projectHostSetupId, hostId }`.
- Fresh run: `{ mode: '\''new_workspace_per_run'\'', projectHostSetupId, hostId, baseRef }`.
- Validate that `workspaceId` belongs to the same `projectId/projectHostSetupId/hostId` before save and before dispatch.
3. Snapshot run context when creating an `AutomationRun`:
- `projectId`, `projectHostSetupId`, `hostId`, `executionTargetType`, `executionTargetId`, `runtimeId/version/capabilities` where relevant, `sourceProviderContext`, and `baseRef`.
- This makes run history intelligible after workspace deletion, project setup migration, or provider-source changes.
4. Add a provider/source context object for task/provider-driven automations:
- Include task provider, hosted review provider, issue source preference/effective source, provider project/repo identity, and provider-specific source selector.
- Keep GitHub/GitLab-specific details behind provider discriminants.
5. Move dispatch/precheck off `repo.connectionId` and `store.getRepo(projectId)`.
- Resolve `projectHostSetupId` to a run host and path.
- Use setup state to classify skipped runs as disconnected, auth-needed, unsupported runtime, missing setup, or unavailable host.
6. Add capability/version checks before saving and before running.
- For remote/runtime-backed setups, require the automation target schema, project host setup APIs, terminal launch, precheck, and session reuse capabilities to be present.
- Persist a clear `skipped_unsupported_runtime` or equivalent status when a scheduled run cannot execute due to old host/runtime code.
7. Update CLI/RPC surface to be project-first while preserving compatibility.
- Add `--project`, `--host`, and/or `--project-host-setup`.
- Keep `--repo` as a deprecated compatibility selector that resolves to a project host setup.
- Rename runtime input fields away from `repo` in new methods, or add v2 methods to avoid breaking old CLIs.
8. Implement migration/backfill.
- For each old automation where `projectId` is a repo id, use the repo-backed `ProjectHostSetup` compatibility projection to fill `projectId`, `projectHostSetupId`, `hostId`, execution target, and any available provider identity.
- Leave old run history readable by adding best-effort host/setup snapshots on load or by preserving legacy fields.
## Unknowns
- I stopped before auditing all project-host setup helper APIs, so I did not verify the exact best resolver to map legacy repo ids to `ProjectHostSetup` records.
- I did not inspect the complete runtime capability graph or remote automation support, so the specific capability names/version gates are unknown.
- I did not audit external Hermes/OpenClaw automation creation deeply beyond the renderer save path; external automation host modeling may need a separate pass.
- I did not run tests or inspect persisted data migrations outside the lines cited above.
@@ -0,0 +1,57 @@
# Composer PR Launch Hosts Audit
## Summary
The Composer flow is partially adapted to the project-first host model: project/host setup selection resolves to a concrete `repoId`, and many repo operations route through repo-owner settings instead of the focused runtime. The biggest remaining gap is that several UX surfaces still treat the selected run repo as both the workspace creation target and the task/review source. In the desired model, a linked work item may come from one host/account while the workspace is created on another host for the same logical Project; today the metadata and launch APIs mostly carry only `repoId`, `repoPath`, `connectionId`, or active runtime.
## Findings
1. **Linked work items lose source-host identity once they enter Composer.**
- `src/renderer/src/hooks/useComposerState.ts:1848-1859` resolves PR base using `item.repoId` when present, then falls back to the currently selected repo. This works only when the task source repo is the same repo/setup as the run target.
- `src/renderer/src/hooks/useComposerState.ts:2200-2259` and `src/renderer/src/hooks/useComposerState.ts:2550-2588` create worktrees with linked issue/PR/MR numbers, but not the source host/account/repo context that produced those numbers.
- `src/renderer/src/lib/pending-worktree-creation.ts:26-57` persists a retryable creation request with `repoId` and linked numbers only. A failed/retried background create cannot preserve “source repo/host differs from run repo/host.”
2. **Task page “Use” opens Composer against the work item repo, not the logical Project/run host.**
- `src/renderer/src/components/TaskPage.tsx:5325-5338` opens the Composer with `initialRepoId: item.repoId` for GitHub.
- `src/renderer/src/components/TaskPage.tsx:5391-5404` does the same for GitLab.
In a project-first model, this should probably preselect the logical Project and carry the task source context separately, then let the user choose/run on any project host setup.
3. **Smart linked-work-item picker is still repo/path scoped and sometimes local-only.**
- GitHub lookups in `src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx:415-428`, `466-473`, and `868-880` pass `repoPath`/`repoId` only. Cross-repo URL acceptance calls `onRepoChange(targetRepo.id)`, which changes the run repo instead of attaching a source item to the current Project/run host.
- GitLab picker paths in `src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx:635-692` and `699-745` skip SSH repos with `selectedRepo.connectionId` and use local `window.api.gl` calls. That blocks or misroutes GitLab task selection for remote/project-host setups.
- Main GitLab IPC identifies repos by path only: `src/main/ipc/gitlab.ts:56-62` and `src/main/ipc/gitlab.ts:235-254`. This is risky when the same logical project/path shape exists across hosts.
4. **Direct launch/fix-check style creation is still run-repo centric.**
- `src/renderer/src/lib/launch-work-item-direct-types.ts:5-16` allows `LaunchableWorkItem.repoId?`, but `LaunchWorkItemDirectArgs` has only one required `repoId` at `18-29`.
- `src/renderer/src/lib/launch-work-item-direct.ts:75-123` derives repo owner settings, setup, hooks, PR base, workspace name, and create target from that one `repoId`.
- `src/renderer/src/lib/launch-work-item-direct-preflight.ts:11-42` resolves PR start point for the same `repoId` used by the launch. This cannot represent “resolve PR on source host, create workspace on run host.”
5. **Hosted review lookup/creation mostly routes by repo owner, but repo lookup remains path-biased in important places.**
- `src/renderer/src/store/slices/hosted-review.ts:194-216` finds the repo for eligibility by `candidate.path === args.repoPath`, then uses that owner settings. With duplicate project folders across hosts, path equality is not enough; callers need to pass `repoId`/projectHostSetup/run host explicitly.
- `src/main/ipc/hosted-review.ts:19-33` supports `repoId` for `forBranch`, but `getCreationEligibility` and `create` call `assertRegisteredRepo(args.repoPath, store)` without repoId at `98-126`.
- Cache identity is stronger than older code: `src/renderer/src/store/slices/hosted-review-cache-identity.ts:17-27` includes host scope and repoId when supplied. The problem is ensuring all callers supply the correct run-host repo identity.
6. **Issue command/hooks are tied to the run repo, not the linked task source.**
- `src/renderer/src/hooks/useComposerState.ts:681-694` and `1020-1052` load hooks and issue command templates for the selected repo/run host.
- `src/renderer/src/hooks/useComposerState.ts:2155-2175` runs issue automation when there is a linked issue number, but trust/command source is still the selected repo. If task source differs from run project, the UX needs an explicit rule: run project hooks only, task-source hooks only, or both. Today it is implicit.
## Concrete Recommendations
1. Introduce a first-class linked task source context and thread it separately from the workspace creation target. It should include provider, source repo/project identity, source host/account/connection/runtime, number/type/url, and any provider-specific base/head metadata.
2. Update Composer open/prefill APIs so TaskPage and smart-picker selections set `selectedProjectId` / run `projectHostSetupId` independently from linked task source. Avoid changing the run repo just because a pasted PR URL belongs to another setup of the same logical Project.
3. Split PR/MR start-point resolution into source resolution and run-host creation planning. For cross-host project launches, resolve the source PR/MR on its source host, then map the resulting branch/ref/push target onto the selected projectHostSetup or explicitly fall back with a clear UX message.
4. Extend `WorktreeCreationRequest`, `createWorktree`, and worktree metadata to persist source context, not just linked numbers. This is required for retries, review lookup, terminal-link reconciliation, and future task-source account correctness.
5. Make hosted review creation/eligibility require repo identity for ambiguous paths. Renderer and IPC should pass `repoId`/projectHostSetup/run host through eligibility and create, mirroring the stronger cache-key design.
6. Define the product rule for issueCommand/hooks in cross-host task launches. My recommendation: run hooks from the selected run projectHostSetup, but render task context from the source item, and label that distinction in trust UI.
## Unknowns
- I did not finish tracing all fix-checks launch call sites; I only confirmed the direct launch helper shape is single-`repoId`.
- I did not finish reviewing PR/MR base resolution in every main-process provider helper.
- I did not verify whether projectHostSetup metadata is already persisted on worktrees by main/runtime create; renderer call sites still do not pass source context.
- I did not run tests or execute UI flows because this was a read-only audit and investigation was stopped early.
@@ -0,0 +1,139 @@
# Settings/cache/CLI host-scope audit
## Summary
The branch has several solid host-scope foundations already: execution hosts are explicit (\`local\`, \`ssh:*\`, \`runtime:*\`), workspace session state is partitioned by host, GitHub repo cache keys include host scope, and Source Control AI model choices have host-keyed selection/discovery maps. The remaining risk is concentrated in older provider/account surfaces that still use client-global settings or repo-path-only selectors, plus a few user-facing settings labels that do not yet communicate whether the value applies globally, to a host, to a project, or to a project-host setup.
Must-fix gaps below focus on places where user-visible behavior can mix accounts/hosts or where a setting’s ownership model is unclear.
## Findings
### 1. GitLab IPC selectors are repo-path-only, unlike GitHub's newer repo-id-aware guard
- \`src/main/ipc/gitlab.ts:56-63\` validates requests by resolving \`repoPath\` and finding the first registered repo with the same path.
- All preload GitLab calls use \`{ repoPath }\` selectors without \`repoId\` or \`executionHostId\`; examples:
- \`src/preload/gitlab.ts:13-20\`
- \`src/preload/gitlab.ts:25-37\`
- \`src/preload/gitlab.ts:67-80\`
- \`src/preload/gitlab.ts:101-144\`
- GitHub already accepts \`repoId\` on many IPC selectors and rejects mismatched path/id pairs:
- \`src/main/ipc/github.ts:131-149\`
- \`src/preload/api-types.ts:1240-1301\`
Impact: in a project-first model, a local setup, SSH setup, and runtime setup can legitimately share display name or path-like identity. GitLab operations can still be routed by path alone, which is weaker than project-host identity and can mix user intent if duplicate paths or migrated project-host setups exist. This is especially risky for mutating calls such as update/comment/merge/retry at \`src/main/ipc/gitlab.ts:193-217\`, \`src/main/ipc/gitlab.ts:274-312\`, and \`src/main/ipc/gitlab.ts:338-418\`.
### 2. GitHub/GitLab user-scoped account diagnostics and rate limits are still client-global or repo-ambient
- GitHub rate limit and auth diagnosis take no host or repo selector:
- \`src/preload/index.ts:1182-1188\`
- \`src/preload/api-types.ts:1317-1325\`
- GitLab has a host string for rate limit, but viewer/auth diagnosis remain global:
- \`src/preload/gitlab.ts:8-11\`
- \`src/main/ipc/gitlab.ts:70-79\`
- GitLab todos are explicitly user-scoped and use \`repoPath\` only as a registered-repo guard:
- \`src/main/ipc/gitlab.ts:421-427\`
- \`src/preload/api-types.ts:1426-1427\`
Impact: Settings/Integrations can show “connected”, “viewer”, “rate limit”, or “todos” for whichever client/global CLI account is visible from the Electron process, not necessarily the account on the selected Local/SSH/runtime host. That becomes misleading when the user’s local \`gh\`/\`glab\` auth differs from a remote host’s CLI auth.
### 3. Source Control AI has host-scoped model choices, but command/agent defaults remain client-global and are sent to remote/runtime generation
- Host-keyed model selection/discovery exists:
- \`src/shared/source-control-ai-types.ts:11-15\`
- \`src/shared/source-control-ai-types.ts:24-37\`
- \`src/shared/source-control-ai.ts:749-786\`
- \`src/shared/source-control-ai.ts:874-918\`
- Resolution defaults to local if no discovery host key is provided:
- \`src/shared/source-control-ai.ts:1240-1249\`
- Runtime generation forwards global \`sourceControlAi\`, \`agentCmdOverrides\`, and discovery host key to runtime RPC:
- \`src/renderer/src/runtime/runtime-git-client.ts:70-99\`
- \`src/renderer/src/runtime/runtime-git-client.ts:523-534\`
- \`src/renderer/src/runtime/runtime-git-client.ts:603-614\`
- Global agent command/default args/env are client-global settings:
- \`src/shared/types.ts:2470-2475\`
- Resume also uses global launch overrides/default args/env:
- \`src/renderer/src/lib/resume-sleeping-agent-session.ts:45-51\`
Impact: model choice is host-aware, but the command used to invoke the provider and extra launch args/env are not. A remote Linux host, runtime server, and local macOS client may need different binary names, paths, auth env, or wrapper commands. Today a client-global override can silently apply to the wrong host.
### 4. \`hostSettingOverrides\` is intentionally narrow, but adjacent host-varying settings are still global
- Current host overrides only cover \`displayLabel\` and \`defaultWorktreeLocation\`:
- \`src/shared/types.ts:2190-2200\`
- \`src/shared/types.ts:2202-2206\`
- \`src/shared/host-setting-overrides.ts:17-34\`
- Many settings that can vary by host remain global, including workspace directory fallback and agent launch command/args/env:
- \`src/shared/types.ts:2202-2208\`
- \`src/shared/types.ts:2470-2475\`
Impact: the code documents the intended narrowness well, but the desired model says provider/account settings need explicit scope. There is no adjacent typed home yet for host-scoped agent launch configuration or host-scoped provider/account diagnostics, so Settings copy can imply a value is universal when it is actually host-sensitive.
### 5. Sleeping agent resume records do not persist full execution host identity
- Resume records store \`connectionId?: string | null\` but not \`executionHostId\` or runtime environment id:
- \`src/shared/agent-session-resume.ts:24-37\`
- The record creator does not include \`connectionId\` despite the record type allowing it:
- \`src/renderer/src/store/slices/agent-status.ts:186-217\`
- Resume launch platform infers remote only from the worktree’s current repo/path state:
- \`src/renderer/src/lib/resume-sleeping-agent-session.ts:15-23\`
Impact: provider session IDs belong to the host where the CLI ran. If a worktree is moved, rehydrated under a project-host setup, or associated with a runtime host, resume can lack durable proof of which host owns the provider session. This is likely fine for current local/SSH happy paths, but it is fragile for runtime/remote server and future cloud VM hosts.
### 6. Some renderer caches are host-scoped, but not consistently across adjacent selectors
Good:
- GitHub PR/repo cache keys include runtime/SSH host scope:
- \`src/renderer/src/store/slices/github-cache-key.ts:10-25\`
- \`src/renderer/src/store/slices/github-cache-key.ts:28-43\`
- GitHub metadata hooks include runtime in cache key:
- \`src/renderer/src/hooks/useIssueMetadata.ts:56-63\`
- \`src/renderer/src/hooks/useIssueMetadata.ts:130-136\`
Risk:
- GitHub metadata options support runtime only, not SSH \`connectionId\`/\`executionHostId\`:
- \`src/renderer/src/hooks/useIssueMetadata.ts:31-33\`
- \`src/renderer/src/hooks/useIssueMetadata.ts:80-90\`
- \`src/renderer/src/hooks/useIssueMetadata.ts:154-164\`
- Smart GitHub submit cache keys by \`repoId:repoPath\` and link/number, but not host id:
- \`src/renderer/src/lib/smart-github-submit.ts:109-124\`
- \`src/renderer/src/lib/smart-github-submit.ts:127-160\`
Impact: repo id often saves this, but the API shape still makes “host ownership” implicit. Any future project-host setup that shares repo id/path semantics or does URL lookup against a non-local host can reuse stale/wrong metadata unless host id is part of every cache/request selector.
### 7. Telemetry appears mostly low-risk, but host/project scope is not represented in relevant events
- Settings telemetry only emits whitelisted key names, no raw value:
- \`src/main/ipc/settings.ts:125-150\`
- Agent resume launch telemetry records agent kind/source/request kind, not host kind:
- \`src/renderer/src/lib/resume-sleeping-agent-session.ts:66-72\`
Impact: privacy posture is good, but diagnostics/product analysis will not be able to distinguish local vs SSH vs runtime behavior for settings-driven host features unless a bounded, non-identifying \`host_kind\`/\`execution_surface\` enum is added where needed. Do not add hostnames, paths, repo names, branch names, account names, or tokens.
## Concrete Recommendations
1. Add a common project-host selector type for provider IPC/preload calls: at minimum \`{ repoId, repoPath, executionHostId? }\`, with main-process validation mirroring GitHub’s path/id mismatch guard. Apply first to GitLab mutating and metadata calls.
2. Split provider/account Settings surfaces by scope in UI copy and data model:
- client-global: telemetry consent, app appearance, local Electron proxy, local-only integrations
- host-scoped: CLI auth diagnostics, provider viewer/rate limit, agent command overrides, discovered CLI models
- project-scoped: repo/project review defaults and source-control prompts
- project-host setup: checkout path, worktree root, hook settings, git username
3. Promote agent launch config from client-global-only to \`hostSettingOverrides\` or a dedicated host-scoped launch settings map. Keep client-global fallback, but make effective resolution explicit: \`host override ?? client default ?? catalog default\`.
4. Preserve full host identity in sleeping agent resume records: add \`executionHostId\` and/or \`runtimeEnvironmentId\`, populate it when capturing, and use it when launching the resume command. The provider session id should never be treated as portable across hosts.
5. Make cache keys and request selectors consistently host-owned. Use execution host id rather than ad hoc runtime-only options for:
- GitHub metadata hooks
- smart GitHub submit lookup cache
- GitLab labels/assignees/details caches if/when added
- cross-window mutation invalidation payloads
6. Add bounded diagnostics telemetry only if it answers a concrete support/product question. Prefer enums like \`host_kind: local|ssh|runtime|cloud\` and avoid hostnames, paths, repo names, branch names, account names, or tokens.
## Unknowns
- I stopped before auditing all Settings components and search metadata. Some UI copy may already clarify scope, but the type/API layer still has ambiguous ownership.
- I did not trace every call site into GitHub/GitLab work item drawers and task lists, so some renderer callers may already pass repo ids where available.
- I did not verify runtime server API contracts for provider auth/model discovery; the local preload/API shapes suggest the client still owns some values that may need to be host-owned.
- I did not inspect persistence migrations for \`ProjectHostSetup\` and host override defaults, so migration-specific risks may remain.
@@ -0,0 +1,48 @@
# Tasks/provider hosts audit
## Summary
The Tasks surface is partway through the project-first host migration, but the data model is still mostly “repo/workspace/site + current runtime focus”. GitHub task lists have the best host-scoping work today: work-item cache keys include an execution host, runtime fetches can fan out through RPC, and issue-source preferences persist to the repo owner host. The main product correctness gap is that task source context is not first-class. Linear/Jira/GitLab and several GitHub drawer mutations still derive the provider account/server from global active runtime settings, selected repo state, or a local path at the moment of action. That can fetch or mutate the wrong account when a durable Project exists on multiple hosts, when runtime focus changes while a drawer is open, or when task source and run host are intentionally different.
## Findings
1. **Task source context is global runtime focus for Linear/Jira, not carried with the task/source.**
\`TaskPage\` derives a single \`providerRuntimeContextKey\` from current settings and treats Linear/Jira status as current only if it matches that focus (\`src/renderer/src/components/TaskPage.tsx:2505-2514\`). The runtime clients route every Linear/Jira call via \`getActiveRuntimeTarget(settings)\` (\`src/renderer/src/runtime/runtime-linear-client.ts:65-73\`, \`src/renderer/src/runtime/runtime-linear-client.ts:223-253\`, \`src/renderer/src/runtime/runtime-jira-client.ts:30-35\`, \`src/renderer/src/runtime/runtime-jira-client.ts:139-149\`). This conflates “which host/account owns the task source” with “where the workspace will run”. A user can open a Linear/Jira issue sourced from one runtime/local account, switch runtime focus for running work, then refresh/mutate through a different provider host.
2. **Linear/Jira cache keys omit provider host context and rely on side invalidation.**
Linear search/list cache keys are workspace/query/filter only (\`src/renderer/src/store/slices/linear.ts:927-1044\`), and Jira search/list cache keys are site/JQL/filter only (\`src/renderer/src/store/slices/jira.ts:363-447\`). The slices guard in-flight writes with \`getProviderRuntimeContextKey\`, and Linear clears caches when status scope changes (\`src/renderer/src/store/slices/linear.ts:500-541\`), which is good, but the persisted cache identity itself cannot represent two hosts with the same Linear workspaceId or Jira siteId at once. Optimistic patches also scan all caches by issue id/key without host/site/workspace scoping (\`src/renderer/src/store/slices/linear.ts:1888-1925\`, \`src/renderer/src/store/slices/jira.ts:500-521\`).
3. **Open Linear drawer hydration keys are tied to active runtime, not task source identity.**
\`LinearIssueWorkspace\` uses \`\${settings?.activeRuntimeEnvironmentId ?? 'local'}:\${issue.workspaceId ?? 'selected'}:\${issue.id}\` as its hydration identity (\`src/renderer/src/components/LinearIssueWorkspace.tsx:561-575\`). This protects against some runtime switches, but it is still global runtime focus, not the issue’s captured provider context. It also has no representation for future non-runtime hosts/cloud VMs or explicit taskSourceHost.
4. **GitLab Tasks are path-only and local-IPC-only.**
The GitLab list effect fans out selected repos but calls \`window.api.gl.listIssues/listMRs\` with only \`repoPath\` (\`src/renderer/src/components/TaskPage.tsx:3641-3690\`). GitLab preload APIs are path-only for most reads and mutations (\`src/preload/api-types.ts:1381-1451\`), with only some deeper MR operations accepting \`projectRef\` (\`src/preload/api-types.ts:1452-1484\`). The drawer update path also mutates by \`repoPath\` only (\`src/renderer/src/components/GitLabItemDialog.tsx:520-525\`). This cannot distinguish the same project path on local/SSH/runtime/future cloud hosts, nor can it express a GitLab account/server separate from the workspace run host.
5. **GitHub work-item list routing is host-scoped, but drawer fallback mutations can still bypass runtime routing.**
Good: work-item cache keys include \`executionHostId\` (\`src/renderer/src/store/slices/github.ts:575-583\`), in-flight keys include the runtime target (\`src/renderer/src/store/slices/github.ts:585-591\`), and list/count route through runtime RPC when the repo owner is runtime (\`src/renderer/src/store/slices/github.ts:204-244\`). However, \`GitHubItemDialog\` non-project fallback mutations call local IPC directly for issue/PR state updates when no \`projectOrigin\` is present (\`src/renderer/src/components/GitHubItemDialog.tsx:4312-4320\`, \`src/renderer/src/components/GitHubItemDialog.tsx:4400-4408\`). Those paths should use the same repo owner/provider context routing as the list source, or they risk mutating via local credentials after reading from a runtime host.
6. **Task resume/open state captures repoId/workItem snapshots, not provider context/project host setup.**
The GitHub drawer key is \`{ id, repoId }\` (\`src/renderer/src/components/TaskPage.tsx:2831-2840\`), and opening a detail page stores \`preselectedRepoId\` plus the work-item snapshot (\`src/renderer/src/components/TaskPage.tsx:2888-2896\`). This is okay for same-session cross-repo disambiguation, but it is not durable enough for the desired model: repoId identifies one setup/repo record, not the task-source host/account or a ProjectHostSetup. Similar global resume state is implied by the task source defaults and selected workspace/site fallback (\`src/renderer/src/components/TaskPage.tsx:2586-2598\`, \`src/renderer/src/components/TaskPage.tsx:2672-2682\`).
## Concrete Recommendations
- Introduce a first-class \`TaskSourceContext\` and pass it through list rows, drawers, mutations, caches, and resume state. It should include provider, provider host/account/server identity, project id, and (when relevant) projectHostSetup/run host separately. Do not infer it from active runtime focus at action time.
- Split “task source host” from “run host” in UI state. Opening/using a task should preserve where the issue was fetched/mutated from, while workspace creation chooses a ProjectHostSetup independently.
- Update Linear/Jira runtime clients to accept an explicit provider context/target instead of only \`settings.activeRuntimeEnvironmentId\`; include that context in cache keys and optimistic patch keys.
- Extend GitLab APIs from \`repoPath\`-only to explicit project/provider context. At minimum carry \`repoId\`/executionHostId/projectRef through reads and mutations; ideally carry the same \`TaskSourceContext\` used by GitHub/Linear/Jira.
- Route all GitHub drawer fallback mutations through repo-owner/provider routing, not direct local IPC, unless the captured task source is explicitly local.
- Store task resume/open-drawer state with provider context and projectHostSetup identity, not just \`repoId\`, \`workspaceId\`, or selected site/workspace fallback.
## Already okay
- GitHub work-item cache and in-flight identity are host-aware (\`src/renderer/src/store/slices/github.ts:575-591\`).
- GitHub work-item list/count can route through runtime RPC for runtime-owned repos (\`src/renderer/src/store/slices/github.ts:204-244\`).
- GitHub issue-source preference persistence uses the repo owner runtime rather than focused runtime (\`src/renderer/src/store/slices/github.ts:3391-3418\`).
- GitLab cross-repo list merging tags each returned item with the selected repoId, avoiding same-IID collisions in the visible list (\`src/renderer/src/components/TaskPage.tsx:3692-3706\`).
- Linear/Jira slices do guard stale in-flight writes against runtime-context changes, which reduces race bugs even though cache identity is not yet provider-context-native (\`src/renderer/src/store/slices/linear.ts:500-541\`, \`src/renderer/src/store/slices/jira.ts:363-447\`).
## Unknowns
- I did not verify the main-process GitHub/GitLab/Linear/Jira handlers; this report focuses on renderer/preload routing visible from the Tasks surface.
- It is unclear whether current \`repoId\` values are already intended to be ProjectHostSetup-scoped. Even if they are, provider account context is still absent from Linear/Jira/GitLab task identities.
- I did not audit workspace creation end-to-end; the key ambiguity remains that “Use task” needs an explicit mapping from captured task source to chosen ProjectHostSetup/run host.
+27
View File
@@ -30,6 +30,11 @@ import {
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
import {
assertWorkspaceTargetFlagsCompatible,
hasWorkspaceProjectTarget,
resolveProjectCreateRepoSelector
} from '../worktree-project-target'
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
repo?: string
@@ -294,10 +299,21 @@ async function resolveDefaultTarget(
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<{ repo?: string; workspace?: string }> {
assertWorkspaceTargetFlagsCompatible(flags)
const repo = getOptionalStringFlag(flags, 'repo')
if (repo && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
}
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --workspace or project target flags, not both.'
)
}
const projectRepo = await resolveProjectCreateRepoSelector(flags, client)
if (projectRepo) {
return { repo: projectRepo }
}
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
if (repo || workspace) {
return { repo, workspace }
@@ -317,10 +333,21 @@ async function getExplicitTarget(
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<{ repo?: string; workspace?: string }> {
assertWorkspaceTargetFlagsCompatible(flags)
const repo = getOptionalStringFlag(flags, 'repo')
if (repo && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
}
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --workspace or project target flags, not both.'
)
}
const projectRepo = await resolveProjectCreateRepoSelector(flags, client)
if (projectRepo) {
return { repo: projectRepo }
}
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
return { repo, workspace }
}
+70 -61
View File
@@ -196,67 +196,6 @@ describe('orca root help', () => {
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
expect(callMock).not.toHaveBeenCalled()
})
it('progressively discloses Linear commands', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['--help'], '/tmp/repo')
const rootHelp = String(logSpy.mock.calls[0][0])
expect(rootHelp).toContain('Linear:')
expect(rootHelp).toContain('linear Read Linear ticket context for agents')
expect(rootHelp).not.toContain('linear issue')
expect(rootHelp).not.toContain('linear search')
expect(rootHelp).not.toContain('linear status set')
logSpy.mockClear()
await main(['linear', '--help'], '/tmp/repo')
const groupHelp = String(logSpy.mock.calls[0][0])
expect(groupHelp).toContain('orca linear')
expect(groupHelp).toContain('issue')
expect(groupHelp).toContain('search')
expect(groupHelp).toContain('status set')
expect(groupHelp).toContain('comment add')
expect(groupHelp).toContain('attach')
expect(groupHelp).toContain('create')
expect(groupHelp).not.toContain('--comments')
expect(groupHelp).not.toContain('--attachments')
logSpy.mockClear()
await main(['linear', 'issue', '--help'], '/tmp/repo')
const issueHelp = String(logSpy.mock.calls[0][0])
expect(issueHelp).toContain('orca linear issue [<id>]')
expect(issueHelp).toContain('--comments Include threaded Linear comments')
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')
logSpy.mockClear()
await main(['linear', 'search', '--help'], '/tmp/repo')
const searchHelp = String(logSpy.mock.calls[0][0])
expect(searchHelp).toContain('orca linear search <query>')
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
logSpy.mockClear()
await main(['linear', 'comment', 'add', '--help'], '/tmp/repo')
const commentHelp = String(logSpy.mock.calls[0][0])
expect(commentHelp).toContain('orca linear comment add [<id>]')
expect(commentHelp).toContain('--body-file <path|-> Read Linear body from a file or stdin')
expect(commentHelp).toContain('--write-id <uuid> Retry id from linear_write_unconfirmed')
logSpy.mockClear()
await main(['linear', 'create', '--help'], '/tmp/repo')
const createHelp = String(logSpy.mock.calls[0][0])
expect(createHelp).toContain('orca linear create --title <title>')
expect(createHelp).toContain('--parent-current Use the current linked issue as parent')
expect(callMock).not.toHaveBeenCalled()
})
})
describe('orca cli worktree awareness', () => {
@@ -2707,6 +2646,76 @@ describe('orca cli worktree awareness', () => {
})
})
it('resolves project and host flags for automation create', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-local',
path: '/tmp/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-gpu',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
}),
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'GPU review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'automations',
'create',
'--name',
'GPU review',
'--trigger',
'daily',
'--prompt',
'Review open changes',
'--provider',
'codex',
'--project',
'github:stablyai/orca',
'--host',
'runtime:gpu',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(
2,
'automation.create',
expect.objectContaining({
repo: 'id:repo-gpu',
workspace: undefined,
workspaceMode: 'new_per_run'
})
)
})
it('rejects invalid automation --day values before calling the runtime', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+11 -2
View File
@@ -1,7 +1,15 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch']
const AUTOMATION_TARGET_FLAGS = [
'repo',
'workspace',
'project',
'host',
'project-host-setup',
'workspace-mode',
'base-branch'
]
const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone']
const AUTOMATION_PRECHECK_FLAGS = ['precheck', 'precheck-timeout']
const AUTOMATION_STATE_FLAGS = [
@@ -32,7 +40,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
path: ['automations', 'create'],
summary: 'Create a scheduled Orca automation',
usage:
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>] [--json]',
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>|--project <id> [--host <id>]|--project-host-setup <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'name',
@@ -46,6 +54,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
notes: [
'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.',
'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.',
'Use --project with --host, or --project-host-setup, to run on a specific project host setup.',
'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.',
'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.',
'Use --reuse-session only with existing-workspace automations to submit later runs to the previous live automation session when it is still available. Use --fresh-session to disable reuse.'
@@ -105,6 +105,46 @@ describe('AutomationService prechecks', () => {
})
})
it('does not run scheduled prechecks when the selected host setup is stale', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
store.addRepo(makeRepo({ path: '/repo/current' }))
const setup = store.getProjectHostSetups()[0]!
const automation = store.createAutomation({
name: 'Conditional check',
prompt: 'Check the repo',
precheck: {
command: 'test -f ready',
timeoutSeconds: 30
},
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: '/repo/old'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
const service = new AutomationService(store, { tickMs: 60_000 })
const result = await service.runPrecheck(automation.id, run.id)
expect(result).toMatchObject({
command: 'test -f ready',
exitCode: null,
error: 'Project path for the selected automation host has changed.'
})
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
})
it('does not run prechecks for manual dispatches', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
+75
View File
@@ -124,6 +124,81 @@ describe('AutomationService', () => {
)
})
it('skips dispatch when the selected project host setup is gone', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Manual check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'local',
projectHostSetupId: 'missing-setup',
repoId: 'r1',
path: '/repo'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.setRendererReady()
const run = await service.runNow(automation.id)
expect(run.status).toBe('skipped_unavailable')
expect(run.error).toBe('Project is not set up on the selected automation host anymore.')
expect(send).not.toHaveBeenCalled()
})
it('skips dispatch when the saved project host setup path is stale', async () => {
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
const store = await createStore()
store.addRepo(makeRepo({ path: '/repo/current' }))
const setup = store.getProjectHostSetups()[0]!
const automation = store.createAutomation({
name: 'Manual check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
runContext: {
kind: 'workspace-run',
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: setup.repoId,
path: '/repo/old'
},
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
})
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.setRendererReady()
const run = await service.runNow(automation.id)
expect(run.status).toBe('skipped_unavailable')
expect(run.error).toBe('Project path for the selected automation host has changed.')
expect(send).not.toHaveBeenCalled()
})
it('attaches provider usage when a completed run can be attributed', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
+81 -6
View File
@@ -13,6 +13,8 @@ import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { runAutomationPrecheck } from './precheck-runner'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { ProjectHostSetup, Repo } from '../../shared/types'
const DEFAULT_TICK_MS = 60 * 1000
@@ -87,8 +89,8 @@ export class AutomationService {
if (run.trigger !== 'scheduled' || !automation.precheck) {
return null
}
const cwd = this.getPrecheckCwd(automation)
if (!cwd) {
const target = this.resolveRunTarget(automation)
if (!target.ok) {
return {
command: automation.precheck.command,
exitCode: null,
@@ -98,7 +100,7 @@ export class AutomationService {
stderr: '',
stdoutTruncated: false,
stderrTruncated: false,
error: 'Automation precheck target is no longer available.',
error: target.error,
startedAt: Date.now(),
completedAt: Date.now()
}
@@ -107,8 +109,8 @@ export class AutomationService {
precheck: automation.precheck,
target:
automation.executionTargetType === 'ssh'
? { type: 'ssh', cwd, connectionId: automation.executionTargetId }
: { type: 'local', cwd }
? { type: 'ssh', cwd: target.cwd, connectionId: automation.executionTargetId }
: { type: 'local', cwd: target.cwd }
})
}
@@ -230,7 +232,7 @@ export class AutomationService {
}
}
private getPrecheckCwd(automation: Automation): string | null {
private getLegacyPrecheckCwd(automation: Automation): string | null {
if (automation.workspaceMode === 'existing') {
const parsed = automation.workspaceId
? splitWorktreeIdForFilesystem(automation.workspaceId)
@@ -240,6 +242,70 @@ export class AutomationService {
return this.store.getRepo(automation.projectId)?.path ?? null
}
private resolveRunTarget(
automation: Automation
):
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
| { ok: false; error: string } {
const context = automation.runContext ?? null
if (!context) {
const repo = this.store.getRepo(automation.projectId)
const cwd = this.getLegacyPrecheckCwd(automation)
if (!repo || !cwd) {
return { ok: false, error: 'Automation run target is no longer available.' }
}
return { ok: true, cwd, repo }
}
const setup = this.store
.getProjectHostSetups()
.find((candidate) => candidate.id === context.projectHostSetupId)
if (!setup) {
return {
ok: false,
error: 'Project is not set up on the selected automation host anymore.'
}
}
if (setup.setupState !== 'ready') {
return {
ok: false,
error: `Project setup on the selected automation host is ${setup.setupState}.`
}
}
if (
setup.projectId !== context.projectId ||
setup.hostId !== context.hostId ||
setup.repoId !== context.repoId
) {
return {
ok: false,
error: 'Automation run target no longer matches the selected project host setup.'
}
}
const repo = this.store.getRepo(context.repoId)
if (!repo) {
return {
ok: false,
error: 'Repository for the selected automation host is no longer available.'
}
}
if (getRepoExecutionHostId(repo) !== context.hostId) {
return {
ok: false,
error: 'Repository is no longer attached to the selected automation host.'
}
}
if (repo.path !== setup.path || context.path !== setup.path) {
return {
ok: false,
error: 'Project path for the selected automation host has changed.'
}
}
return { ok: true, cwd: setup.path, repo, setup }
}
private async evaluateAutomation(automation: Automation, now: number): Promise<void> {
const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now)
if (scheduledFor === null) {
@@ -267,6 +333,15 @@ export class AutomationService {
automation: Automation,
run: AutomationRun
): Promise<AutomationRun> {
const target = this.resolveRunTarget(automation)
if (!target.ok) {
return this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: target.error
})
}
const webContents = this.webContents
if (!webContents || webContents.isDestroyed() || !this.rendererReady) {
return this.store.updateAutomationRun({
+12 -18
View File
@@ -21,8 +21,6 @@ const KILL_WAIT_MS = 3_000
const KILL_POLL_MS = 100
const START_TIME_TOLERANCE_MS = 1_500
export type DaemonHealthCheckResult = 'healthy' | 'unhealthy' | 'pty-spawn-unhealthy'
type ParsedDaemonPid = {
pid: number
startedAtMs: number | null
@@ -67,13 +65,10 @@ function canConnectSocket(socketPath: string): Promise<boolean> {
})
}
export function checkDaemonHealth(
socketPath: string,
tokenPath: string
): Promise<DaemonHealthCheckResult> {
export function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve('unhealthy')
resolve(false)
return
}
@@ -81,13 +76,13 @@ export function checkDaemonHealth(
try {
token = readFileSync(tokenPath, 'utf8').trim()
} catch {
resolve('unhealthy')
resolve(false)
return
}
let settled = false
let sock: Socket | null = null
const settle = (result: DaemonHealthCheckResult): void => {
const settle = (result: boolean): void => {
if (settled) {
return
}
@@ -102,7 +97,7 @@ export function checkDaemonHealth(
sock?.off('connect', onConnect)
sock?.off('data', onData)
}
const onError = (): void => settle('unhealthy')
const onError = (): void => settle(false)
const onConnect = (): void => {
const hello: HelloMessage = {
type: 'hello',
@@ -133,16 +128,19 @@ export function checkDaemonHealth(
try {
message = JSON.parse(line) as Record<string, unknown>
} catch {
settle('unhealthy')
settle(false)
return
}
if (message.type === 'hello') {
if (!(message as HelloResponse).ok) {
settle('unhealthy')
settle(false)
return
}
sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' }))
// Why: a protocol-live daemon with a stale cwd or node-pty helper
// will answer ping but cannot create terminals, so reuse must check
// the PTY spawn prerequisites too.
sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' }))
continue
}
@@ -152,7 +150,7 @@ export function checkDaemonHealth(
}
}
}
const timer = setTimeout(() => settle('unhealthy'), HEALTH_CHECK_TIMEOUT_MS)
const timer = setTimeout(() => settle(false), HEALTH_CHECK_TIMEOUT_MS)
sock = connect({ path: socketPath })
sock.on('error', onError)
@@ -163,10 +161,6 @@ export function checkDaemonHealth(
})
}
export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
}
function isSystemResolverHealth(value: unknown): value is SystemResolverHealth {
return value === 'healthy' || value === 'unhealthy' || value === 'unknown'
}
+20 -52
View File
@@ -28,10 +28,10 @@ import {
type ListSessionsResult
} from './types'
import {
checkDaemonHealth,
getMacDaemonSystemResolverHealth,
getDaemonLaunchIdentity,
getProcessStartedAtMs,
healthCheckDaemon,
isDaemonStaleForCurrentBundle,
killStaleDaemon
} from './daemon-health'
@@ -174,19 +174,20 @@ async function shouldPreserveDaemonWithLiveSessions(
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return async (socketPath, tokenPath) => {
const entryPath = getDaemonEntryPath()
const health = await checkDaemonHealth(socketPath, tokenPath)
if (health !== 'unhealthy') {
if (health === 'pty-spawn-unhealthy') {
if (
await shouldPreserveDaemonWithLiveSessions(
socketPath,
tokenPath,
'that cannot spawn new PTYs'
const healthy = await healthCheckDaemon(socketPath, tokenPath)
if (healthy) {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn('[daemon] Replacing daemon that cannot spawn new PTYs')
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: a protocol-healthy daemon can outlive the app bundle that
@@ -206,49 +207,16 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: a protocol-healthy daemon can outlive the app bundle that
// launched it. In dev this happens after deleting/rebuilding a
// worktree; in packaged apps it happens when the stable
// /Applications/Orca.app path is replaced during update.
const identity = await getDaemonLaunchIdentity(
runtimeDir,
socketPath,
tokenPath,
entryPath
)
const stalePackagedBundle =
app.isPackaged &&
(await isDaemonStaleForCurrentBundle(
runtimeDir,
socketPath,
tokenPath,
app.getVersion()
))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code
// freshness until no live terminal sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (
await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)
) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return createPreservedDaemonHandle(runtimeDir)
}
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return createPreservedDaemonHandle(runtimeDir)
}
}
} else {
+4 -5
View File
@@ -202,11 +202,10 @@ describe('DaemonServer', () => {
await startServer()
const c = await connectClient()
// Why: older app builds still send the removed ptySpawnHealth probe.
// The daemon must reject it gracefully so a downgraded client lands on
// its session-preserving branch instead of crashing the daemon.
await expect(c.request('ptySpawnHealth', undefined)).rejects.toThrow(
'Unknown request type: ptySpawnHealth'
// Why: downgraded clients can send request types this daemon does not
// know. Reject gracefully instead of crashing the session server.
await expect(c.request('definitelyUnknownRequest', undefined)).rejects.toThrow(
'Unknown request type: definitelyUnknownRequest'
)
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
pong: true
+1 -54
View File
@@ -1,6 +1,6 @@
/* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
import { mkdtempSync, realpathSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
@@ -35,7 +35,6 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: getNodePtySpawnHelperCandidatesMock,
validateWorkingDirectory: validateWorkingDirectoryMock
}
})
@@ -94,10 +93,6 @@ describe('createPtySubprocess', () => {
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-'))
const spawnHelperPath = join(userDataPath, 'spawn-helper')
writeFileSync(spawnHelperPath, '')
getNodePtySpawnHelperCandidatesMock.mockReset()
getNodePtySpawnHelperCandidatesMock.mockReturnValue([spawnHelperPath])
process.env.ORCA_USER_DATA_PATH = userDataPath
for (const key of ORCA_SHELL_WRAPPER_ENV) {
savedWrapperEnv[key] = process.env[key]
@@ -188,54 +183,6 @@ describe('createPtySubprocess', () => {
)
})
it('checks macOS PTY spawn health with a short-lived shell', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
try {
const result = checkPtySpawnHealth()
proc._simulateExit(0)
await expect(result).resolves.toBeUndefined()
expect(proc.kill).not.toHaveBeenCalled()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'/bin/sh',
['-c', 'exit 0'],
expect.objectContaining({
cols: 2,
rows: 1,
cwd: userDataPath,
name: 'xterm-256color'
})
)
})
it('surfaces stale node-pty helper failures during macOS PTY spawn health', async () => {
spawnMock.mockImplementation(() => {
throw new Error(
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'"
)
})
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
try {
await expect(checkPtySpawnHealth()).rejects.toThrow('Daemon failed to spawn shell "/bin/sh"')
await expect(checkPtySpawnHealth()).rejects.toThrow('posix_spawn failed: ENOENT')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('returns a SubprocessHandle with correct pid', () => {
const proc = mockPtyProcess(42)
spawnMock.mockReturnValue(proc)
+1
View File
@@ -33,6 +33,7 @@ import { isShellProcess } from '../../shared/shell-process-detection'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
export type PtySubprocessOptions = {
sessionId: string
+19
View File
@@ -156,6 +156,25 @@ describe('registerGitHubHandlers', () => {
expect(getIssueMock).not.toHaveBeenCalled()
})
it('rejects GitHub source context from a different host', async () => {
registerGitHubHandlers(store as never, stats as never)
expect(() =>
handlers['gh:listWorkItems'](null, {
repoPath: '/workspace/repo',
sourceContext: {
kind: 'task-source',
provider: 'github',
projectId: 'project-1',
hostId: 'ssh:openclaw-2',
repoId: 'repo-1'
}
})
).toThrow('Access denied: GitHub source host does not match repository host')
expect(listWorkItemsMock).not.toHaveBeenCalled()
})
it('forwards listIssues for registered repositories and unwraps items', async () => {
listIssuesMock.mockResolvedValue({ items: [] })
+14 -1
View File
@@ -14,6 +14,8 @@ import type {
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../shared/types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import {
@@ -130,7 +132,11 @@ function broadcastWorkItemMutated(
// Why: returns the full Repo object instead of just the path string so that
// callers have access to repo.id for stat tracking and other context.
type RepoScopedArgs = { repoPath: string; repoId?: string }
type RepoScopedArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
const repoPath = typeof args === 'string' ? args : args.repoPath
@@ -145,6 +151,13 @@ function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo
if (repoId && resolve(repo.path) !== resolvedRepoPath) {
throw new Error('Access denied: repository path does not match repo id')
}
if (
typeof args !== 'string' &&
args.sourceContext?.provider === 'github' &&
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
) {
throw new Error('Access denied: GitHub source host does not match repository host')
}
return repo
}
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/types'
import { toSshExecutionHostId } from '../../shared/execution-host'
const { ipcHandlers, listWorkItemsMock } = vi.hoisted(() => ({
ipcHandlers: new Map<string, (...args: unknown[]) => unknown>(),
listWorkItemsMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
ipcHandlers.set(channel, handler)
})
}
}))
vi.mock('../gitlab/client', () => ({
addIssueComment: vi.fn(),
addMRInlineComment: vi.fn(),
addMRComment: vi.fn(),
closeMR: vi.fn(),
createIssue: vi.fn(),
diagnoseAuth: vi.fn(),
getAuthenticatedViewer: vi.fn(),
getJobTrace: vi.fn(),
getIssue: vi.fn(),
getMergeRequest: vi.fn(),
getMergeRequestForBranch: vi.fn(),
getProjectSlug: vi.fn(),
getRateLimit: vi.fn(),
getWorkItemByProjectRef: vi.fn(),
listAssignableUsers: vi.fn(),
listIssues: vi.fn(),
listLabels: vi.fn(),
listMergeRequests: vi.fn(),
listTodos: vi.fn(),
listWorkItems: listWorkItemsMock,
mergeMR: vi.fn(),
reopenMR: vi.fn(),
resolveMRDiscussion: vi.fn(),
retryJob: vi.fn(),
updateIssue: vi.fn(),
updateMR: vi.fn(),
updateMRReviewers: vi.fn()
}))
vi.mock('../gitlab/work-item-details', () => ({
getWorkItemDetails: vi.fn()
}))
vi.mock('../gitlab/gitlab-project-recents', () => ({
recordGitLabProjectRecent: vi.fn()
}))
import { registerGitLabHandlers } from './gitlab'
function repo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-local',
path: '/local/orca',
displayName: 'Orca',
badgeColor: '#737373',
addedAt: 1,
...overrides
}
}
function storeWithRepos(repos: Repo[]): Pick<Store, 'getRepos' | 'getRepo'> {
return {
getRepos: () => repos,
getRepo: (id: string) => repos.find((candidate) => candidate.id === id)
}
}
describe('GitLab IPC handlers', () => {
it('resolves repoId and source host context before listing work items', async () => {
const remoteRepo = repo({
id: 'repo-ssh',
path: '/ssh/orca',
connectionId: 'builder',
executionHostId: toSshExecutionHostId('builder')
})
listWorkItemsMock.mockResolvedValueOnce({ items: [] })
registerGitLabHandlers(storeWithRepos([repo(), remoteRepo]) as Store)
const handler = ipcHandlers.get('gitlab:listWorkItems')
await expect(
handler?.(null, {
repoPath: '/does/not/matter',
repoId: 'repo-ssh',
sourceContext: {
kind: 'task-source',
provider: 'gitlab',
projectId: 'gitlab:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
repoId: 'repo-ssh'
}
})
).resolves.toEqual({ items: [] })
expect(listWorkItemsMock).toHaveBeenCalledWith(
'/ssh/orca',
'opened',
1,
20,
undefined,
undefined,
'builder'
)
})
it('rejects source context for a different host', async () => {
registerGitLabHandlers(
storeWithRepos([repo({ id: 'repo-local', path: '/local/orca' })]) as Store
)
const handler = ipcHandlers.get('gitlab:listWorkItems')
await expect(
handler?.(null, {
repoPath: '/local/orca',
repoId: 'repo-local',
sourceContext: {
kind: 'task-source',
provider: 'gitlab',
projectId: 'gitlab:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
repoId: 'repo-local'
}
})
).rejects.toThrow('source host does not match')
})
})
+113 -54
View File
@@ -10,6 +10,8 @@ import type {
GitLabWorkItem,
Repo
} from '../../shared/types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import {
normalizeGitLabIssueAssignee,
@@ -50,15 +52,41 @@ import {
import { getWorkItemDetails } from '../gitlab/work-item-details'
import type { ProjectRef } from '../gitlab/gl-utils'
type GitLabRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): Repo | undefined {
const sourceRepoId =
args.sourceContext?.provider === 'gitlab' ? args.sourceContext.repoId?.trim() : null
const repoId = args.repoId?.trim() || sourceRepoId || null
if (repoId) {
const repo = store.getRepo(repoId)
if (repo) {
return repo
}
}
const resolvedRepoPath = resolve(args.repoPath)
return store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
}
// Why: mirror github.ts assertRegisteredRepo — main-process handlers
// must never operate on a path the user hasn't explicitly registered as
// a repo (filesystem-auth boundary).
function assertRegisteredRepo(repoPath: string, store: Store): Repo {
const resolvedRepoPath = resolve(repoPath)
const repo = store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
// a repo (filesystem-auth boundary). Source context adds a host check so a
// task fetched from one machine cannot mutate a same-path repo on another.
function assertRegisteredRepo(args: GitLabRepoSelectorArgs, store: Store): Repo {
const repo = findRegisteredGitLabRepo(args, store)
if (!repo) {
throw new Error('Access denied: unknown repository path')
}
if (
args.sourceContext?.provider === 'gitlab' &&
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
) {
throw new Error('Access denied: GitLab source host does not match repository host')
}
return repo
}
@@ -79,15 +107,18 @@ export function registerGitLabHandlers(store: Store): void {
getRateLimit({ force: Boolean(args?.force), host: args?.host ?? null })
)
ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
ipcMain.handle('gitlab:projectSlug', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return getProjectSlug(repo.path, repoConnectionId(repo))
})
ipcMain.handle(
'gitlab:mrForBranch',
async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (
_event,
args: GitLabRepoSelectorArgs & { branch: string; linkedMRIid?: number | null }
) => {
const repo = assertRegisteredRepo(args, store)
return getMergeRequestForBranch(
repo.path,
args.branch,
@@ -97,8 +128,8 @@ export function registerGitLabHandlers(store: Store): void {
}
)
ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
ipcMain.handle('gitlab:mr', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return getMergeRequest(repo.path, args.iid, repoConnectionId(repo))
})
@@ -108,12 +139,14 @@ export function registerGitLabHandlers(store: Store): void {
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
const state = normalizeGitLabMRListState(args.state)
const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000)
const perPage = normalizeGitLabPositiveInteger(args.perPage, 20, 100)
@@ -129,10 +162,13 @@ export function registerGitLabHandlers(store: Store): void {
}
)
ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getIssue(repo.path, args.number, repoConnectionId(repo))
})
ipcMain.handle(
'gitlab:issue',
async (_event, args: GitLabRepoSelectorArgs & { number: number }) => {
const repo = assertRegisteredRepo(args, store)
return getIssue(repo.path, args.number, repoConnectionId(repo))
}
)
ipcMain.handle(
'gitlab:listIssues',
@@ -140,12 +176,14 @@ export function registerGitLabHandlers(store: Store): void {
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'closed' | 'all'
assignee?: string
limit?: number
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100)
const state = normalizeGitLabIssueListState(args.state)
const assignee = normalizeGitLabIssueAssignee(args.assignee)
@@ -178,8 +216,8 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle(
'gitlab:createIssue',
async (_event, args: { repoPath: string; title: string; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (_event, args: GitLabRepoSelectorArgs & { title: string; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return createIssue(
repo.path,
args.title,
@@ -192,8 +230,11 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle(
'gitlab:updateIssue',
async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (
_event,
args: GitLabRepoSelectorArgs & { number: number; updates: GitLabIssueUpdate }
) => {
const repo = assertRegisteredRepo(args, store)
return updateIssue(
repo.path,
args.number,
@@ -206,8 +247,8 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle(
'gitlab:addIssueComment',
async (_event, args: { repoPath: string; number: number; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (_event, args: GitLabRepoSelectorArgs & { number: number; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return addIssueComment(
repo.path,
args.number,
@@ -218,13 +259,13 @@ export function registerGitLabHandlers(store: Store): void {
}
)
ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
ipcMain.handle('gitlab:listLabels', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
})
@@ -237,12 +278,14 @@ export function registerGitLabHandlers(store: Store): void {
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
return listWorkItems(
repo.path,
normalizeGitLabMRListState(args.state),
@@ -259,8 +302,8 @@ export function registerGitLabHandlers(store: Store): void {
// Powers GitLabItemDialog's tabs.
ipcMain.handle(
'gitlab:workItemDetails',
async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (_event, args: GitLabRepoSelectorArgs & { iid: number; type: 'issue' | 'mr' }) => {
const repo = assertRegisteredRepo(args, store)
return getWorkItemDetails(
repo.path,
args.iid,
@@ -271,23 +314,29 @@ export function registerGitLabHandlers(store: Store): void {
}
)
ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle(
'gitlab:closeMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
}
)
ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle(
'gitlab:reopenMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
}
)
ipcMain.handle(
'gitlab:mergeMR',
async (
_event,
args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' }
args: GitLabRepoSelectorArgs & { iid: number; method?: 'merge' | 'squash' | 'rebase' }
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
return mergeMR(
repo.path,
args.iid,
@@ -300,8 +349,8 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle(
'gitlab:updateMR',
async (_event, args: { repoPath: string; iid: number; updates: GitLabMRUpdate }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (_event, args: GitLabRepoSelectorArgs & { iid: number; updates: GitLabMRUpdate }) => {
const repo = assertRegisteredRepo(args, store)
return updateMR(
repo.path,
args.iid,
@@ -318,12 +367,14 @@ export function registerGitLabHandlers(store: Store): void {
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
iid: number
reviewerIds: number[]
projectRef?: ProjectRef | null
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
return updateMRReviewers(
repo.path,
args.iid,
@@ -337,8 +388,8 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle(
'gitlab:addMRComment',
async (_event, args: { repoPath: string; iid: number; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (_event, args: GitLabRepoSelectorArgs & { iid: number; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return addMRComment(
repo.path,
args.iid,
@@ -355,12 +406,14 @@ export function registerGitLabHandlers(store: Store): void {
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
iid: number
input: GitLabMRInlineCommentInput
projectRef?: ProjectRef | null
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
return addMRInlineComment(
repo.path,
args.iid,
@@ -376,9 +429,9 @@ export function registerGitLabHandlers(store: Store): void {
'gitlab:resolveMRDiscussion',
async (
_event,
args: { repoPath: string; iid: number; discussionId: string; resolved: boolean }
args: GitLabRepoSelectorArgs & { iid: number; discussionId: string; resolved: boolean }
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
return resolveMRDiscussion(
repo.path,
args.iid,
@@ -392,8 +445,11 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle(
'gitlab:jobTrace',
async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (
_event,
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null }
) => {
const repo = assertRegisteredRepo(args, store)
return getJobTrace(
repo.path,
args.jobId,
@@ -406,8 +462,11 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle(
'gitlab:retryJob',
async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
async (
_event,
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null }
) => {
const repo = assertRegisteredRepo(args, store)
return retryJob(
repo.path,
args.jobId,
@@ -421,8 +480,8 @@ export function registerGitLabHandlers(store: Store): void {
// Why: My Todos surface — cross-project, user-scoped. The repoPath is
// only used for the registered-repo guard; `glab api todos` doesn't
// care about cwd because the endpoint is user-scoped.
ipcMain.handle('gitlab:todos', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
ipcMain.handle('gitlab:todos', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return listTodos(repo.path, repoConnectionId(repo))
})
@@ -442,7 +501,7 @@ export function registerGitLabHandlers(store: Store): void {
type: 'issue' | 'mr'
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args, store)
const projectRef: ProjectRef = { host: args.host, path: args.path }
const result = await getWorkItemByProjectRef(
repo.path,
+24
View File
@@ -101,6 +101,7 @@ describe('registerHostedReviewHandlers', () => {
await handlers['hostedReview:getCreationEligibility'](null, {
repoPath,
repoId: repo.id,
worktreePath,
branch: 'feature/pr',
base: 'main'
@@ -128,6 +129,7 @@ describe('registerHostedReviewHandlers', () => {
await handlers['hostedReview:create'](null, {
repoPath,
repoId: repo.id,
worktreePath,
provider: 'github',
base: 'main',
@@ -158,4 +160,26 @@ describe('registerHostedReviewHandlers', () => {
})
)
})
it('rejects creation when repoId and repoPath point at different registered repos', async () => {
store.getRepo.mockImplementation((repoId: string) =>
repoId === repo.id ? { ...repo, path: '/other/repo' } : null
)
registerHostedReviewHandlers(store as never, stats as never)
await expect(
handlers['hostedReview:create'](null, {
repoPath,
repoId: repo.id,
worktreePath,
provider: 'github',
base: 'main',
head: 'feature/pr',
title: 'Feature PR'
})
).rejects.toThrow('Access denied: unknown repository')
expect(createHostedReviewMock).not.toHaveBeenCalled()
})
})
+2 -2
View File
@@ -98,7 +98,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
ipcMain.handle(
'hostedReview:getCreationEligibility',
async (_event, args: HostedReviewCreationEligibilityArgs) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args.repoPath, store, args.repoId)
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
return getHostedReviewCreationEligibility({
...args,
@@ -109,7 +109,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
)
ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const repo = assertRegisteredRepo(args.repoPath, store, args.repoId)
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
const result = await createHostedReview(
worktreePath,
+7 -57
View File
@@ -102,7 +102,7 @@ vi.mock('./filesystem-auth', () => ({
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1' || id === 'conn-2') {
if (id === 'conn-1') {
return mockGitProvider
}
return undefined
@@ -111,7 +111,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1' || id === 'conn-2') {
if (id === 'conn-1') {
return mockFilesystemProvider
}
return undefined
@@ -120,7 +120,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
vi.mock('./ssh', () => ({
getActiveMultiplexer: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1' || id === 'conn-2') {
if (id === 'conn-1') {
return mockMultiplexer
}
return undefined
@@ -1298,59 +1298,6 @@ describe('repos:addRemote', () => {
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('allows the same resolved remote path on a different SSH connection', async () => {
const existing = {
id: 'machine-1-project',
path: '/home/user/project',
connectionId: 'conn-1',
displayName: 'project',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-2',
remotePath: '/home/user/project'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/project',
connectionId: 'conn-2'
})
)
expect(result).toHaveProperty('repo.connectionId', 'conn-2')
expect(result).toHaveProperty('repo.id')
expect(result).not.toEqual({ repo: existing })
})
it('dedupes remote projects after git root resolution on the same SSH connection', async () => {
const existing = {
id: 'existing-id',
path: '/home/user/project',
connectionId: 'conn-1',
displayName: 'project',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
isRepo: true,
rootPath: '/home/user/project'
})
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
remotePath: '/home/user/project/src'
})
expect(result).toEqual({ repo: existing })
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('throws when SSH connection is not found', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'unknown-conn',
@@ -1886,7 +1833,10 @@ describe('repos:add + repos:clone', () => {
proc.emit('close', 128, null)
await expect(clonePromise).rejects.toThrow(
"Clone failed: fatal: destination path 'orca' already exists and is not an empty directory."
`Clone failed: Destination already exists and is not empty: ${join(
destination,
'orca'
)}. Choose a different parent folder, delete the existing folder, or add the existing repository instead.`
)
})
+375 -144
View File
@@ -31,6 +31,11 @@ import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai'
import {
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
@@ -73,11 +78,15 @@ import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import {
assertFolderWorkspacePathUsable,
getFolderWorkspacePathStatus,
getFolderWorkspacePathStatusForPath
} from '../project-groups/folder-workspace-path-status'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
// Why: `method` answers "which entry point did the user take?", not "what did
// they add?" — so the IPC the renderer invoked IS the method. We never send
@@ -112,6 +121,193 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?:
track('repo_added', props)
}
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
const project = store.getProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
}
function alignRepoWithRequestedProject(
store: Store,
repo: Repo,
projectId: string,
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
): ProjectHostSetupResult {
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
if (setup.projectId !== projectId) {
const project = store.getProjects().find((entry) => entry.id === projectId)
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
throw new Error('Imported folder does not match the selected project identity.')
}
// Why: setup-on-host is an explicit user action for this project. When the
// folder lacks upstream metadata but the selected project has provider
// identity, stamp that identity so compatibility projection can merge it.
const updated = store.updateRepo(repo.id, {
upstream: {
owner: project.providerIdentity.owner,
repo: project.providerIdentity.repo
}
})
if (!updated) {
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
}
repo = updated
setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
}
const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
if (!updated) {
throw new Error(
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
)
}
repo = updated
return buildProjectHostSetupResult(store, repo)
}
async function addLocalRepoFromPath(
store: Store,
path: string,
kind: 'git' | 'folder' = 'git'
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const repoKind = kind === 'folder' ? 'folder' : 'git'
if (repoKind === 'git' && !isGitRepo(path)) {
return { error: `Not a valid git repository: ${path}` }
}
const existing = store.getRepos().find((r) => r.path === path)
if (existing) {
return { repo: existing, alreadyExisted: true }
}
const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind })
const repo: Repo = {
id: randomUUID(),
path,
displayName: getRepoName(path),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
// Why: new Add Project imports should become explicit ready host
// setups; `legacy-repo` is reserved for older records/projection.
projectHostSetupMethod: 'imported-existing-folder' as const
}
: {})
}
store.addRepo(repo)
return { repo, alreadyExisted: false }
}
async function addRemoteRepoFromPath(
store: Store,
args: {
connectionId: string
remotePath: string
displayName?: string
kind?: 'git' | 'folder'
setupMethod?: Repo['projectHostSetupMethod']
}
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
}
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath)
const existing = store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existing) {
return { repo: existing, alreadyExisted: true }
}
if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}
const existingAfterRootResolve = store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existingAfterRootResolve) {
return { repo: existingAfterRootResolve, alreadyExisted: true }
}
const folderName = getRemoteRepoFolderName(resolvedPath)
let displayName = args.displayName || folderName
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
const sshTarget = store.getSshTarget(args.connectionId)
if (sshTarget) {
displayName = sshTarget.label
}
}
const detected = await detectRepoIconAndUpstream({
repoPath: resolvedPath,
kind: repoKind,
connectionId: args.connectionId
})
const repo: Repo = {
id: randomUUID(),
path: resolvedPath,
displayName,
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const)
}
: {})
}
store.addRepo(repo)
const mux = getActiveMultiplexer(args.connectionId)
if (mux) {
mux.notify('session.registerRoot', { rootPath: resolvedPath })
}
return { repo, alreadyExisted: false }
}
function getRemoteRepoFolderName(remotePath: string): string {
const trimmed = remotePath.replace(/[\\/]+$/, '')
if (!trimmed) {
@@ -198,6 +394,10 @@ async function cloneRemoteRepo(
if (controller.signal.aborted) {
throw new Error('Clone aborted')
}
const message = err instanceof Error ? err.message : String(err)
if (message.startsWith('Clone failed:')) {
throw new Error(`Clone failed: ${getGitCloneFailureMessage(message, { clonePath })}`)
}
throw err
} finally {
if (activeRemoteClone === metadata) {
@@ -425,6 +625,18 @@ const completedNestedRepoScans = new Map<string, CompletedNestedRepoScan>()
const MAX_COMPLETED_NESTED_SCAN_RESULTS = 50
const GIT_AVAILABILITY_TIMEOUT_MS = 1500
function emitCloneProgressFromText(mainWindow: BrowserWindow, text: string): void {
for (const line of text.split(/[\r\n]+/)) {
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
if (match && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:clone-progress', {
phase: match[1].trim(),
percent: parseInt(match[2], 10)
})
}
}
}
const ProjectGroupCreateArgs = z.object({
name: z.string().min(1),
parentPath: z.string().nullable().optional(),
@@ -453,6 +665,57 @@ const ProjectGroupMoveProjectArgs = z.object({
order: z.number().finite().optional()
})
const ProjectHostSetupExistingFolderIpcArgs = z.object({
projectId: z.string().min(1),
hostId: z.string().min(1),
path: z.string().min(1),
kind: z.enum(['git', 'folder']).optional(),
displayName: z.string().min(1).optional(),
setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
})
const ProjectHostSetupCreateIpcArgs = z.object({
projectId: z.string().min(1),
hostId: z
.string()
.min(1)
.transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
return z.NEVER
}
return hostId
}),
setupId: z.string().min(1).optional(),
path: z.string().optional(),
kind: z.enum(['git', 'folder']).optional(),
displayName: z.string().min(1).optional(),
worktreeBasePath: z.string().optional(),
gitUsername: z.string().optional(),
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional()
})
const ProjectHostSetupUpdateIpcArgs = z.object({
setupId: z.string().min(1),
updates: z.object({
displayName: z.string().optional(),
path: z.string().optional(),
worktreeBasePath: z.string().optional(),
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
setupMethod: z
.enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned'])
.optional(),
gitUsername: z.string().optional(),
kind: z.enum(['git', 'folder']).optional()
})
})
const ProjectHostSetupDeleteIpcArgs = z.object({
setupId: z.string().min(1)
})
const FolderWorkspaceLinkedTaskArgs = z
.object({
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
@@ -836,6 +1099,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:pickDirectory')
ipcMain.removeHandler('repos:clone')
ipcMain.removeHandler('repos:cloneAbort')
ipcMain.removeHandler('repos:cloneRemote')
ipcMain.removeHandler('repos:isGitAvailable')
ipcMain.removeHandler('repos:getDefaultCreateProjectParent')
ipcMain.removeHandler('repos:getGitUsername')
@@ -853,6 +1117,105 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return store.getRepos()
})
ipcMain.handle('projects:list', () => store.getProjects())
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
ipcMain.handle(
'projectHostSetups:create',
(_event, rawArgs: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupCreateIpcArgs,
rawArgs,
'project_host_setup_create_invalid_args'
)
const result = store.createProjectHostSetup(args)
if (!result) {
throw new Error(`Project not found: ${args.projectId}`)
}
notifyReposChanged(mainWindow)
return result
}
)
ipcMain.handle(
'projectHostSetups:update',
(_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupUpdateIpcArgs,
rawArgs,
'project_host_setup_update_invalid_args'
)
const result = store.updateProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
notifyReposChanged(mainWindow)
return result
}
)
ipcMain.handle(
'projectHostSetups:delete',
(_event, rawArgs: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupDeleteIpcArgs,
rawArgs,
'project_host_setup_delete_invalid_args'
)
const result = store.deleteProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
notifyReposChanged(mainWindow)
return result
}
)
ipcMain.handle(
'projectHostSetups:setupExistingFolder',
async (
_event,
rawArgs: ProjectHostSetupExistingFolderArgs
): Promise<ProjectHostSetupResult> => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupExistingFolderIpcArgs,
rawArgs,
'project_host_setup_invalid_args'
)
const parsedHost = parseExecutionHostId(args.hostId)
if (!parsedHost) {
throw new Error(`Unsupported host: ${args.hostId}`)
}
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
if (!existingProject) {
throw new Error(`Project not found: ${args.projectId}`)
}
const result =
parsedHost.kind === 'local'
? await addLocalRepoFromPath(store, args.path, args.kind)
: parsedHost.kind === 'ssh'
? await addRemoteRepoFromPath(store, {
connectionId: parsedHost.targetId,
remotePath: args.path,
displayName: args.displayName,
kind: args.kind
})
: {
error:
'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.'
}
if ('error' in result) {
throw new Error(result.error)
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted)
return alignRepoWithRequestedProject(store, result.repo, args.projectId, args.setupMethod)
}
)
ipcMain.handle('repos:isGitAvailable', () => isGitAvailable())
ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent())
@@ -1178,38 +1541,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
return result
}
// Check if already added
const existing = store.getRepos().find((r) => r.path === args.path)
if (existing) {
emitRepoAdded('folder_picker', true, repoKind === 'git')
return { repo: existing }
}
const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind })
const repo: Repo = {
id: randomUUID(),
path: args.path,
displayName: getRepoName(args.path),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
// Why: `repos:add` validates git-ness via `isGitRepo(args.path)` above
// when kind is 'git', and `repoKind` reflects that resolved choice.
emitRepoAdded('folder_picker', false, repoKind === 'git')
return { repo }
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
return { repo: result.repo }
}
)
@@ -1228,113 +1563,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
return result
}
const findExistingRemoteRepo = (path: string): Repo | undefined =>
store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(path)
)
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
let resolvedPath = args.remotePath
// Why: `~` is a shell expansion that Node's fs APIs don't understand.
// Resolve tilde paths to absolute paths via the relay before storing,
// so all downstream fs operations (readDir, stat, etc.) work correctly.
if (resolvedPath === '~' || resolvedPath === '~/' || resolvedPath.startsWith('~/')) {
const mux = getActiveMultiplexer(args.connectionId)
if (mux) {
try {
const result = (await mux.request('session.resolveHome', {
path: resolvedPath
})) as { resolvedPath: string }
resolvedPath = result.resolvedPath
} catch {
// Relay may not support resolveHome yet — fall through to raw path
}
}
}
// Why: check for duplicates after tilde resolution so that adding `~/`
// when `/home/ubuntu` is already stored correctly detects the duplicate.
const existing = findExistingRemoteRepo(resolvedPath)
if (existing) {
// Why: duplicate hit is suppressed by `emitRepoAdded` anyway, and for
// remote adds git-ness isn't resolved until the isGitRepoAsync check
// below — pass `undefined` rather than guess.
emitRepoAdded('folder_picker', true, undefined)
return { repo: existing }
}
if (args.kind !== 'folder') {
// Why: when kind is not explicitly 'folder', verify the remote path is
// a git repo. Return an error on failure so the renderer can show the "Open as
// Folder" confirmation dialog — matching the local add-repo behavior
// where non-git directories require explicit user consent.
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
const existingAfterRootResolution = findExistingRemoteRepo(resolvedPath)
if (existingAfterRootResolution) {
// Why: users may browse inside a repo; store identity is the Git root,
// but different SSH targets can legitimately share that same path.
emitRepoAdded('folder_picker', true, true)
return { repo: existingAfterRootResolution }
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}
const folderName = getRemoteRepoFolderName(resolvedPath)
// When folderName is the home directory basename (e.g. 'ubuntu'),
// use SSH target label for a more descriptive name
let displayName = args.displayName || folderName
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
const sshTarget = store.getSshTarget(args.connectionId)
if (sshTarget) {
displayName = sshTarget.label
}
}
const detected = await detectRepoIconAndUpstream({
repoPath: resolvedPath,
kind: repoKind,
connectionId: args.connectionId
})
const repo: Repo = {
id: randomUUID(),
path: resolvedPath,
displayName,
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
return { repo: result.repo }
@@ -1351,12 +1579,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
name: string
kind: 'git' | 'folder'
}
// Why: `repoKind` here reflects the SSH/remote-aware isGitRepoAsync
// result resolved above (or an explicit 'folder' kind), so it's the real
// git-vs-folder signal for this remote add.
emitRepoAdded('folder_picker', false, repoKind === 'git')
return { repo }
): Promise<{ repo: Repo } | { error: string }> => {
const result = await createRemoteRepo(store, args)
if ('error' in result) {
return result
}
notifyReposChanged(mainWindow)
return result
}
)
@@ -1906,7 +2135,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
} else if (code === 0) {
resolve()
} else {
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail)}`))
reject(
new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`)
)
}
}
+186 -1
View File
@@ -3185,7 +3185,11 @@ describe('registerWorktreeHandlers', () => {
})
])
expect(store.getWorktreeMeta).not.toHaveBeenCalled()
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', {
projectId: 'repo:repo-ssh',
hostId: 'ssh:conn-1',
projectHostSetupId: 'repo-ssh'
})
})
it('falls back to reconstructed SSH rows when provider listing throws', async () => {
@@ -3486,6 +3490,187 @@ describe('registerWorktreeHandlers', () => {
})
})
it('repairs legacy project ids when discovery now resolves the same host setup to a logical project', async () => {
// Why: provider identity can become available after metadata was written.
// Existing workspaces should move from repo-scoped IDs to the logical
// project ID without losing activity ordering.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/existing-wt',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
store.getProjectHostSetups.mockReturnValue([
{
id: 'repo-1',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 0,
updatedAt: 0
}
])
store.getWorktreeMeta.mockReturnValue({
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
instanceId: 'existing-instance',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'repo-1',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 42
})
store.setWorktreeMeta.mockReturnValue({
instanceId: 'existing-instance',
projectId: 'github:stablyai/orca',
hostId: 'local',
projectHostSetupId: 'repo-1',
lastActivityAt: 42
})
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
id: string
lastActivityAt: number
projectId?: string
hostId?: string
projectHostSetupId?: string
}[]
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', {
projectId: 'github:stablyai/orca'
})
expect(listed[0]).toMatchObject({
id: 'repo-1::/workspace/existing-wt',
projectId: 'github:stablyai/orca',
hostId: 'local',
projectHostSetupId: 'repo-1',
lastActivityAt: 42
})
})
it('does not repair ownership when discovery points at a different project-host setup', async () => {
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/existing-wt',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
store.getProjectHostSetups.mockReturnValue([
{
id: 'repo-1',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 0,
updatedAt: 0
}
])
store.getWorktreeMeta.mockReturnValue({
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
instanceId: 'existing-instance',
projectId: 'github:other/project',
hostId: 'ssh:ssh-target-1',
projectHostSetupId: 'repo-other-host',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 42
})
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
})
it('repairs legacy project ids when SSH worktree listing falls back to persisted metadata', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/orca',
displayName: 'orca',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-target-1'
}
store.getRepo.mockReturnValue(repo)
store.getAllWorktreeMeta.mockReturnValue({
'repo-ssh::/remote/orca': makeWorktreeMeta({
instanceId: 'existing-instance',
projectId: 'repo:repo-ssh',
hostId: 'ssh:ssh-target-1',
projectHostSetupId: 'repo-ssh',
lastActivityAt: 42
})
})
store.getProjectHostSetups.mockReturnValue([
{
id: 'repo-ssh',
projectId: 'github:stablyai/orca',
hostId: 'ssh:ssh-target-1',
repoId: 'repo-ssh',
path: '/remote/orca',
displayName: 'orca',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 0,
updatedAt: 0
}
])
store.setWorktreeMeta.mockReturnValue(
makeWorktreeMeta({
instanceId: 'existing-instance',
projectId: 'github:stablyai/orca',
hostId: 'ssh:ssh-target-1',
projectHostSetupId: 'repo-ssh',
lastActivityAt: 42
})
)
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as {
id: string
projectId?: string
hostId?: string
projectHostSetupId?: string
lastActivityAt: number
}[]
expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-target-1')
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-ssh::/remote/orca', {
projectId: 'github:stablyai/orca'
})
expect(listed).toEqual([
expect.objectContaining({
id: 'repo-ssh::/remote/orca',
projectId: 'github:stablyai/orca',
hostId: 'ssh:ssh-target-1',
projectHostSetupId: 'repo-ssh',
lastActivityAt: 42
})
])
})
it('does not rewrite discovery metadata when instance and project-host ownership already exist', async () => {
listWorktreesMock.mockResolvedValue([
{
+41 -23
View File
@@ -130,15 +130,22 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin
deleteWorktreeHistoryDir(worktreeId)
}
function getMissingProjectHostSetupMeta(
function getProjectHostSetupMetaUpdates(
store: Store,
repo: Repo,
existing?: WorktreeMeta
): Partial<Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'>> {
const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
const sameSetup =
existing?.projectHostSetupId === undefined ||
existing.projectHostSetupId === ownership.projectHostSetupId
return {
...(existing?.projectId === undefined ? { projectId: ownership.projectId } : {}),
...(existing?.hostId === undefined ? { hostId: ownership.hostId } : {}),
// Why: project IDs can be upgraded from legacy repo IDs to provider-backed
// logical IDs. If the host setup is the same, repair ownership on discovery.
...(sameSetup && existing?.projectId !== ownership.projectId
? { projectId: ownership.projectId }
: {}),
...(sameSetup && existing?.hostId !== ownership.hostId ? { hostId: ownership.hostId } : {}),
...(existing?.projectHostSetupId === undefined
? { projectHostSetupId: ownership.projectHostSetupId }
: {})
@@ -156,11 +163,11 @@ function resolveWorktreeMetaWithDiscoveryBackfill(
worktreeId: string
): WorktreeMeta {
const existing = store.getWorktreeMeta(worktreeId)
const missingOwnership = getMissingProjectHostSetupMeta(store, repo, existing)
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing)
if (existing) {
const updates = {
...(!existing.instanceId ? { instanceId: randomUUID() } : {}),
...missingOwnership
...ownershipUpdates
}
if (Object.keys(updates).length > 0) {
// Why: profiles created before lineage shipped already have WorktreeMeta
@@ -172,7 +179,7 @@ function resolveWorktreeMetaWithDiscoveryBackfill(
}
return store.setWorktreeMeta(worktreeId, {
lastActivityAt: Date.now(),
...missingOwnership
...ownershipUpdates
})
}
@@ -392,6 +399,7 @@ function pruneLineageForMissingRepoWorktrees(
}
type SshWorktreeMetaCandidate = {
id: string
path: string
meta: WorktreeMeta
}
@@ -415,7 +423,7 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt
}
const candidates = index.get(parsed.repoId) ?? []
candidates.push({ path: parsed.worktreePath, meta })
candidates.push({ id: worktreeId, path: parsed.worktreePath, meta })
index.set(parsed.repoId, candidates)
}
return index
@@ -437,15 +445,24 @@ function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta):
}
function listDisconnectedSshWorktrees(
store: Store,
repo: Repo,
metaIndex: SshWorktreeMetaIndex
): ReturnType<typeof mergeWorktree>[] {
const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>()
for (const candidate of metaIndex.get(repo.id) ?? []) {
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta)
const meta =
Object.keys(ownershipUpdates).length > 0
? { ...candidate.meta, ...ownershipUpdates }
: candidate.meta
if (Object.keys(ownershipUpdates).length > 0) {
store.setWorktreeMeta(candidate.id, ownershipUpdates)
}
const worktree = mergeWorktree(
repo.id,
synthesizeSshGitWorktree(repo, candidate.path, candidate.meta),
candidate.meta
synthesizeSshGitWorktree(repo, candidate.path, meta),
meta
)
byWorktreeId.delete(worktree.id)
byWorktreeId.set(worktree.id, worktree)
@@ -569,14 +586,14 @@ function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
return ids
.map((worktreeId) => {
const existing = allMeta[worktreeId]
const missingOwnership = getMissingProjectHostSetupMeta(store, repo, existing)
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing)
const meta =
existing?.instanceId && Object.keys(missingOwnership).length === 0
existing?.instanceId && Object.keys(ownershipUpdates).length === 0
? existing
: store.setWorktreeMeta(worktreeId, {
instanceId:
existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId),
...missingOwnership,
...ownershipUpdates,
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
})
return mergeFolderWorkspace(repo, worktreeId, meta)
@@ -611,11 +628,12 @@ function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
.filter((worktree) => worktree.visible)
.map((worktree) => {
const meta = store.getWorktreeMeta(worktree.id)
return mergeFolderWorkspace(
repo,
worktree.id,
meta ?? store.setWorktreeMeta(worktree.id, getMissingProjectHostSetupMeta(store, repo))
)
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta)
const repairedMeta =
meta && Object.keys(ownershipUpdates).length === 0
? meta
: store.setWorktreeMeta(worktree.id, ownershipUpdates)
return mergeFolderWorkspace(repo, worktree.id, repairedMeta)
})
}
@@ -726,7 +744,7 @@ export function registerWorktreeHandlers(
`${repo.connectionId}:${repo.id}`,
`[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}`
)
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
}
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
try {
@@ -738,7 +756,7 @@ export function registerWorktreeHandlers(
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
}
} else {
gitWorktrees = await listRepoWorktrees(repo)
@@ -791,7 +809,7 @@ export function registerWorktreeHandlers(
`${repo.connectionId}:${repo.id}`,
`[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}`
)
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
}
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
try {
@@ -803,7 +821,7 @@ export function registerWorktreeHandlers(
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
}
} else {
gitWorktrees = await listRepoWorktrees(repo)
@@ -855,7 +873,7 @@ export function registerWorktreeHandlers(
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
const worktrees = listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
return {
repoId: repo.id,
authoritative: false,
@@ -884,7 +902,7 @@ export function registerWorktreeHandlers(
err
)
if (repo.connectionId) {
const worktrees = listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
return {
repoId: repo.id,
authoritative: false,
+201 -20
View File
@@ -35,6 +35,7 @@ import {
ONBOARDING_FLOW_VERSION
} from '../shared/constants'
import { folderWorkspaceKey } from '../shared/workspace-scope'
import { toSshExecutionHostId } from '../shared/execution-host'
import { SshConnectionStore } from './ssh/ssh-connection-store'
// Shared mutable state so the electron mock can reference a per-test directory
@@ -314,6 +315,87 @@ describe('Store', () => {
expect(store.getRepos()).toEqual([])
}, 15_000)
it('backfills project host setup compatibility records from legacy repos on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({
id: 'local-repo',
path: '/Users/alice/orca',
displayName: 'Orca',
upstream: { owner: 'StablyAI', repo: 'Orca' }
}),
makeRepo({
id: 'remote-repo',
path: '/home/alice/orca',
displayName: 'orca',
connectionId: 'gpu-vm',
upstream: { owner: 'stablyai', repo: 'orca' }
})
]
})
const store = await createStore()
expect(store.getProjects()).toEqual([
expect.objectContaining({
id: 'github:stablyai/orca',
sourceRepoIds: ['local-repo', 'remote-repo']
})
])
expect(store.getProjectHostSetups()).toEqual([
expect.objectContaining({
id: 'local-repo',
projectId: 'github:stablyai/orca',
hostId: 'local',
path: '/Users/alice/orca'
}),
expect.objectContaining({
id: 'remote-repo',
projectId: 'github:stablyai/orca',
hostId: 'ssh:gpu-vm',
path: '/home/alice/orca'
})
])
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.projects).toEqual(store.getProjects())
expect(persisted.projectHostSetups).toEqual(store.getProjectHostSetups())
})
it('preserves independent project host setup records on load', async () => {
const independentProject = makeProject({
id: 'cloud-project',
displayName: 'Cloud Project'
})
const independentSetup = makeProjectHostSetup({
id: 'cloud-project::gpu-vm',
projectId: independentProject.id,
hostId: 'runtime:gpu-vm',
repoId: '',
path: '/srv/cloud-project',
displayName: 'GPU VM'
})
writeDataFile({
...getDefaultPersistedState(testState.dir),
repos: [makeRepo({ id: 'r1', path: '/repo', displayName: 'Repo' })],
projects: [independentProject],
projectHostSetups: [independentSetup]
})
const store = await createStore()
expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project'])
expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([
'r1',
'cloud-project::gpu-vm'
])
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.projectHostSetups).toContainEqual(independentSetup)
})
it('returns default settings when no data file exists', async () => {
const store = await createStore()
const settings = store.getSettings()
@@ -698,26 +780,6 @@ describe('Store', () => {
expect(store.getUI().projectOrderBy).toBe('manual')
})
it('shows the manual-default notice for upgraded profiles without projectOrderBy', async () => {
writeDataFile({
schemaVersion: 1,
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
ui: {}
})
const store = await createStore()
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(false)
})
it('hides the manual-default notice for profiles that already chose recent', async () => {
writeDataFile({
schemaVersion: 1,
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
ui: { projectOrderBy: 'recent' }
})
const store = await createStore()
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(true)
})
// ── 2. Load from existing valid file ─────────────────────────────────
it('reads repos from an existing data file', async () => {
@@ -1097,6 +1159,125 @@ describe('Store', () => {
expect(reloaded.listAutomations()[0].reuseSession).toBe(false)
})
it('derives automation source and run contexts from the project host setup', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
upstream: { owner: 'stablyai', repo: 'orca' },
connectionId: 'builder'
})
)
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
expect(automation.runContext).toMatchObject({
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
path: '/repo'
})
expect(automation.sourceContext).toMatchObject({
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
})
})
it('snapshots automation contexts onto runs', async () => {
const store = await createStore()
store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } }))
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
store.updateAutomation(automation.id, { sourceContext: null, runContext: null })
expect(run.runContext).toEqual(automation.runContext)
expect(run.sourceContext).toEqual(automation.sourceContext)
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
runContext: automation.runContext,
sourceContext: automation.sourceContext
})
})
it('backfills legacy automation contexts on load', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
upstream: { owner: 'stablyai', repo: 'orca' },
connectionId: 'builder'
})
)
const automation = store.createAutomation({
name: 'Legacy nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const persisted = readDataFile() as {
automations: Record<string, unknown>[]
automationRuns: Record<string, unknown>[]
}
delete persisted.automations[0].runContext
delete persisted.automations[0].sourceContext
delete persisted.automationRuns[0].runContext
delete persisted.automationRuns[0].sourceContext
writeDataFile(persisted)
const reloaded = await createStore()
const migratedAutomation = reloaded.listAutomations().find((entry) => entry.id === automation.id)
const migratedRun = reloaded.listAutomationRuns(automation.id).find((entry) => entry.id === run.id)
expect(migratedAutomation?.runContext).toMatchObject({
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
path: '/repo'
})
expect(migratedAutomation?.sourceContext).toMatchObject({
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: toSshExecutionHostId('builder'),
projectHostSetupId: 'r1',
repoId: 'r1',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
})
expect(migratedRun?.runContext).toEqual(migratedAutomation?.runContext)
expect(migratedRun?.sourceContext).toEqual(migratedAutomation?.sourceContext)
})
it('persists automation precheck config and run results', async () => {
const store = await createStore()
store.addRepo(makeRepo())
+133 -73
View File
@@ -63,6 +63,10 @@ import type {
WorkspaceSessionState
} from '../shared/types'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
} from '../shared/task-source-context'
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
import type { SshRemotePtyLease, SshTarget } from '../shared/ssh-types'
import { isFolderRepo } from '../shared/repo-kind'
@@ -114,11 +118,6 @@ import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeAppIconId } from '../shared/app-icon'
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
import {
normalizeLeftSidebarAppearanceMode,
normalizeLeftSidebarTintColor,
normalizeLeftSidebarTintOpacity
} from '../shared/left-sidebar-appearance'
import {
compareFeatureInteractionUsageBuckets,
getFeatureInteractionCategory,
@@ -484,11 +483,6 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][
return getDefaultUIState().projectOrderBy
}
import {
isExistingPersistedProfile,
resolveProjectOrderManualDefaultNoticeDismissed
} from '../shared/project-order-manual-default-notice'
function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
if (
tab === 'explorer' ||
@@ -622,6 +616,102 @@ function normalizeAutomationSessionReuse(automation: Automation): Automation {
}
}
function getAutomationContextsForRepo(
repo: Repo | undefined,
projectHostSetups: readonly ProjectHostSetup[]
): Pick<Automation, 'runContext' | 'sourceContext'> {
if (!repo) {
return {
runContext: null,
sourceContext: null
}
}
const projection = projectHostSetupProjectionFromRepos([repo])
const projectedProject = projection.projects[0]
const projectedSetup = projection.setups[0]
const setup =
projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup
const runContext = setup
? buildWorkspaceRunContext({
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: repo.id,
path: setup.path
})
: null
const providerIdentity = projectedProject?.providerIdentity
const sourceContext = providerIdentity
? buildTaskSourceContextFromRepo({
provider: providerIdentity.provider,
projectId: providerIdentity.provider === 'github' ? (setup?.projectId ?? repo.id) : repo.id,
repo,
projectHostSetupId: setup?.id,
providerIdentity
})
: null
return {
runContext,
sourceContext
}
}
function backfillLegacyAutomationContexts(
state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
): {
state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
changed: boolean
} {
let changed = false
const contextsByAutomationId = new Map<string, Pick<Automation, 'runContext' | 'sourceContext'>>()
const automations = (state.automations ?? []).map((automation) => {
const contexts = getAutomationContextsForRepo(
state.repos.find((repo) => repo.id === automation.projectId),
state.projectHostSetups ?? []
)
const next: Automation = { ...automation }
if (!Object.hasOwn(next, 'runContext')) {
// Why: pre-host-context automations only stored a repo id. Backfill the
// explicit run target once so dispatch/precheck no longer infer it later.
next.runContext = contexts.runContext
changed = true
}
if (!Object.hasOwn(next, 'sourceContext')) {
next.sourceContext = contexts.sourceContext
changed = true
}
contextsByAutomationId.set(next.id, {
runContext: next.runContext ?? null,
sourceContext: next.sourceContext ?? null
})
return next
})
const automationRuns = (state.automationRuns ?? []).map((run) => {
const automationContexts = contextsByAutomationId.get(run.automationId)
const next: AutomationRun = { ...run }
if (!Object.hasOwn(next, 'runContext')) {
next.runContext = automationContexts?.runContext ?? null
changed = true
}
if (!Object.hasOwn(next, 'sourceContext')) {
next.sourceContext = automationContexts?.sourceContext ?? null
changed = true
}
return next
})
if (!changed) {
return { state, changed: false }
}
return {
state: {
...state,
automations,
automationRuns
},
changed: true
}
}
type LegacySshTarget = SshTarget & {
remoteWorkspaceSyncEnabled?: unknown
remoteWorkspaceSyncGracePeriodSeconds?: unknown
@@ -2371,14 +2461,6 @@ export class Store {
parsed.settings?.disabledTuiAgents
)
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call(
parsed.settings ?? {},
'openLinksInApp'
)
const migratedOpenLinksInAppPreferencePrompted =
typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean'
? parsed.settings.openLinksInAppPreferencePrompted
: openLinksInAppWasPersisted
if (
parsed.settings?.agentYoloDefaultsMigrated !== true ||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
@@ -2395,12 +2477,6 @@ export class Store {
if (!autoRenameBranchFromWorkDefaultedOn) {
this.loadNeedsSave = true
}
if (
parsed.settings?.openLinksInAppPreferencePrompted !==
migratedOpenLinksInAppPreferencePrompted
) {
this.loadNeedsSave = true
}
const normalizedOnboarding = normalizeLoadedOnboardingState(
parsed.onboarding,
defaults.onboarding
@@ -2464,15 +2540,6 @@ export class Store {
terminalCustomThemes: normalizeTerminalCustomThemes(
parsed.settings?.terminalCustomThemes
),
leftSidebarAppearanceMode: normalizeLeftSidebarAppearanceMode(
parsed.settings?.leftSidebarAppearanceMode
),
leftSidebarTintColor: normalizeLeftSidebarTintColor(
parsed.settings?.leftSidebarTintColor
),
leftSidebarTintOpacity: normalizeLeftSidebarTintOpacity(
parsed.settings?.leftSidebarTintOpacity
),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
@@ -2487,7 +2554,6 @@ export class Store {
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
seedDefaults: true
}),
openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted,
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
sourceControlAi: migratedSourceControlAi,
// Why: new builds read sourceControlAi, but rollback builds still
@@ -2628,28 +2694,12 @@ export class Store {
parsed.ui?.setupGuideSidebarDismissed,
normalizedOnboarding
)
const projectOrderManualDefaultNoticeDismissed =
resolveProjectOrderManualDefaultNoticeDismissed({
rawDismissed: parsed.ui?.projectOrderManualDefaultNoticeDismissed,
rawProjectOrderBy: parsed.ui?.projectOrderBy,
isExistingProfile: isExistingPersistedProfile({
repoCount: parsed.repos?.length ?? 0,
onboardingClosedAt: normalizedOnboarding.closedAt,
ui: parsed.ui
})
})
if (
parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed &&
(setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined)
) {
this.loadNeedsSave = true
}
if (
parsed.ui?.projectOrderManualDefaultNoticeDismissed !==
projectOrderManualDefaultNoticeDismissed
) {
this.loadNeedsSave = true
}
return {
...defaults.ui,
...stripMainOwnedTelemetryMarkerFromUI(parsed.ui),
@@ -2658,7 +2708,6 @@ export class Store {
rightSidebarOpen,
rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab),
setupGuideSidebarDismissed,
projectOrderManualDefaultNoticeDismissed,
setupGuideBrowserMilestoneMigrated:
typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean'
? parsed.ui.setupGuideBrowserMilestoneMigrated
@@ -2767,15 +2816,25 @@ export class Store {
this.loadNeedsSave = true
}
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
...result,
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
workspaceSession: migratedScrollback.session
})
if (folderScopeConnectionMigration.changed) {
const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? [])
const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos)
if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) {
this.loadNeedsSave = true
}
result = folderScopeConnectionMigration.state
const automationContextMigration = backfillLegacyAutomationContexts({
...result,
repos,
...projectHostSetupCompatibility
})
if (automationContextMigration.changed) {
this.loadNeedsSave = true
}
result = {
...result,
automations: automationContextMigration.state.automations,
automationRuns: automationContextMigration.state.automationRuns
}
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
...result,
@@ -3686,12 +3745,15 @@ export class Store {
const repo = this.state.repos.find((entry) => entry.id === input.projectId)
const now = Date.now()
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? [])
const automation: Automation = {
id: randomUUID(),
name: input.name.trim() || 'Untitled automation',
prompt: input.prompt,
precheck: normalizeAutomationPrecheck(input.precheck),
agentId: input.agentId,
runContext: input.runContext ?? contexts.runContext,
sourceContext: input.sourceContext ?? contexts.sourceContext,
projectId: input.projectId,
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
@@ -3725,6 +3787,7 @@ export class Store {
const repoId = updates.projectId ?? current.projectId
const repo = this.state.repos.find((entry) => entry.id === repoId)
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? [])
const rrule = updates.rrule ?? current.rrule
const dtstart = updates.dtstart ?? current.dtstart
const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined
@@ -3738,6 +3801,16 @@ export class Store {
? normalizeAutomationPrecheck(updates.precheck)
: normalizeAutomationPrecheck(current.precheck),
projectId: repoId,
runContext: Object.hasOwn(updates, 'runContext')
? (updates.runContext ?? null)
: updates.projectId !== undefined
? contexts.runContext
: (current.runContext ?? contexts.runContext),
sourceContext: Object.hasOwn(updates, 'sourceContext')
? (updates.sourceContext ?? null)
: updates.projectId !== undefined
? contexts.sourceContext
: (current.sourceContext ?? contexts.sourceContext),
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service',
@@ -3796,6 +3869,8 @@ export class Store {
const run: AutomationRun = {
id: randomUUID(),
automationId: automation.id,
runContext: automation.runContext ?? null,
sourceContext: automation.sourceContext ?? null,
title: `${automation.name} run ${runNumber}`,
scheduledFor,
status: 'pending',
@@ -4010,21 +4085,6 @@ export class Store {
updates.terminalCustomThemes
)
}
if ('leftSidebarAppearanceMode' in updates) {
sanitizedUpdates.leftSidebarAppearanceMode = normalizeLeftSidebarAppearanceMode(
updates.leftSidebarAppearanceMode
)
}
if ('leftSidebarTintColor' in updates) {
sanitizedUpdates.leftSidebarTintColor = normalizeLeftSidebarTintColor(
updates.leftSidebarTintColor
)
}
if ('leftSidebarTintOpacity' in updates) {
sanitizedUpdates.leftSidebarTintOpacity = normalizeLeftSidebarTintOpacity(
updates.leftSidebarTintOpacity
)
}
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
+1
View File
@@ -21,6 +21,7 @@ import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import {
describeMaxBufferOverflowError,
isMaxBufferOverflowError
+74 -121
View File
@@ -1877,6 +1877,8 @@ export class OrcaRuntimeService {
prompt: input.prompt,
precheck: input.precheck,
agentId: input.agentId,
runContext: input.runContext,
sourceContext: input.sourceContext,
projectId: target.projectId,
workspaceMode: target.workspaceMode,
workspaceId: target.workspaceId,
@@ -1908,6 +1910,12 @@ export class OrcaRuntimeService {
if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) {
patch.agentId = updates.agentId
}
if (hasRuntimeAutomationUpdateValue(updates, 'runContext')) {
patch.runContext = updates.runContext
}
if (hasRuntimeAutomationUpdateValue(updates, 'sourceContext')) {
patch.sourceContext = updates.sourceContext
}
if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) {
patch.baseBranch = updates.baseBranch
}
@@ -2825,8 +2833,6 @@ export class OrcaRuntimeService {
throw new Error('tab_not_found')
}
let activatedTab: RuntimeMobileSessionSnapshotTab = tab
if (tab.type === 'terminal') {
const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find(
(candidate) => candidate.type === 'terminal' && candidate.id === tab.id
@@ -2867,7 +2873,6 @@ export class OrcaRuntimeService {
)
const targetTab = activeSibling ?? tab
this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId)
activatedTab = targetTab
} else if (tab.type === 'browser') {
// Why: browser mobile tabs are renderer-owned unified tabs; focusing the
// session tab keeps desktop tab order/group state authoritative.
@@ -2875,56 +2880,9 @@ export class OrcaRuntimeService {
} else {
this.notifier?.focusEditorTab?.(tab.id, worktreeId)
}
// Why: serve/headless snapshots have no renderer to re-publish focus, but
// merged epochs can still contain renderer-owned group state.
if (
!this.getAvailableAuthoritativeWindow() &&
this.isPureHeadlessMobileSessionPublication(snapshot!.publicationEpoch)
) {
this.persistHeadlessMobileSessionActiveTab(worktreeId, snapshot!, activatedTab)
}
return this.getMobileSessionTabsForWorktree(worktreeId)
}
private persistHeadlessMobileSessionActiveTab(
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
activeTab: RuntimeMobileSessionSnapshotTab
): void {
const alreadyActive =
snapshot.activeTabId === activeTab.id &&
snapshot.activeTabType === activeTab.type &&
snapshot.tabs.every((candidate) => candidate.isActive === (candidate.id === activeTab.id))
if (alreadyActive) {
// Why: re-activating the already-active tab must not bump snapshotVersion,
// or every redundant activation would force a remote re-render.
return
}
const tabs = snapshot.tabs.map((candidate) => ({
...candidate,
isActive: candidate.id === activeTab.id
}))
const terminalTabs = tabs.filter(
(candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal'
)
const next: RuntimeMobileSessionTabsSnapshot = {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1,
activeTabId: activeTab.id,
activeTabType: activeTab.type,
tabGroups: this.buildHeadlessMobileSessionTabGroups(
worktreeId,
terminalTabs,
activeTab.type === 'terminal' ? activeTab : null,
snapshot.tabGroups
),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.notifyMobileSessionTabsChanged(worktreeId)
}
private shouldMaterializeHeadlessMobileSessionTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab
@@ -7316,7 +7274,7 @@ export class OrcaRuntimeService {
} else if (code === 0) {
resolve()
} else {
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail)}`))
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`))
}
}
proc.on('error', (error) => {
@@ -13519,15 +13477,10 @@ export class OrcaRuntimeService {
)
}
private isPureHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
return (
publicationEpoch.startsWith('headless:') || publicationEpoch.startsWith('headless-hydrated:')
)
}
private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
return (
this.isPureHeadlessMobileSessionPublication(publicationEpoch) ||
publicationEpoch.startsWith('headless:') ||
publicationEpoch.startsWith('headless-hydrated:') ||
publicationEpoch.includes(':headless-merge:')
)
}
@@ -15017,6 +14970,69 @@ export class OrcaRuntimeService {
return link
}
private async resolveWorktreeForContainedPath(cwd: string): Promise<ResolvedWorktree | null> {
const currentPath = resolve(cwd)
let best: ResolvedWorktree | null = null
for (const candidate of await this.listResolvedWorktrees()) {
if (!isPathInsideOrEqual(candidate.path, currentPath)) {
continue
}
if (!best || candidate.path.length > best.path.length) {
best = candidate
}
}
return best
}
linearListIssues(
filter?: LinearListFilter,
limit = 20,
workspaceId?: LinearWorkspaceSelection
): ReturnType<typeof listLinearIssues> {
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId)
}
linearCreateIssue(
teamId: string,
title: string,
description?: string,
workspaceId?: string,
parentIssueId?: string,
projectId?: string | null,
options?: {
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
): ReturnType<typeof createLinearIssue> {
return createLinearIssue(teamId, title, description, workspaceId, {
parentId: parentIssueId,
projectId,
...options
})
}
linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> {
return getLinearIssue(id, workspaceId)
}
linearUpdateIssue(
id: string,
updates: LinearIssueUpdate,
workspaceId?: string
): ReturnType<typeof updateLinearIssue> {
return updateLinearIssue(id, updates, workspaceId)
}
linearAddIssueComment(
issueId: string,
body: string,
workspaceId?: string
): ReturnType<typeof addLinearIssueComment> {
return addLinearIssueComment(issueId, body, workspaceId)
}
async linearIssueSetState(params: {
input?: string
current?: boolean
@@ -15873,69 +15889,6 @@ export class OrcaRuntimeService {
}
}
private async resolveWorktreeForContainedPath(cwd: string): Promise<ResolvedWorktree | null> {
const currentPath = resolve(cwd)
let best: ResolvedWorktree | null = null
for (const candidate of await this.listResolvedWorktrees()) {
if (!isPathInsideOrEqual(candidate.path, currentPath)) {
continue
}
if (!best || candidate.path.length > best.path.length) {
best = candidate
}
}
return best
}
linearListIssues(
filter?: LinearListFilter,
limit = 20,
workspaceId?: LinearWorkspaceSelection
): ReturnType<typeof listLinearIssues> {
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId)
}
linearCreateIssue(
teamId: string,
title: string,
description?: string,
workspaceId?: string,
parentIssueId?: string,
projectId?: string | null,
options?: {
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
): ReturnType<typeof createLinearIssue> {
return createLinearIssue(teamId, title, description, workspaceId, {
parentId: parentIssueId,
projectId,
...options
})
}
linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> {
return getLinearIssue(id, workspaceId)
}
linearUpdateIssue(
id: string,
updates: LinearIssueUpdate,
workspaceId?: string
): ReturnType<typeof updateLinearIssue> {
return updateLinearIssue(id, updates, workspaceId)
}
linearAddIssueComment(
issueId: string,
body: string,
workspaceId?: string
): ReturnType<typeof addLinearIssueComment> {
return addLinearIssueComment(issueId, body, workspaceId)
}
linearIssueComments(
issueId: string,
workspaceId?: string
@@ -30,6 +30,23 @@ describe('automation RPC methods', () => {
prompt: 'Review changes',
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
agentId: 'codex',
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
projectHostSetupId: 'setup-gpu',
repoId: 'repo-gpu',
path: '/srv/orca'
},
sourceContext: {
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: 'local',
projectHostSetupId: 'setup-local',
repoId: 'repo-local',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
},
repo: 'repo-1',
reuseSession: true,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
@@ -59,6 +76,8 @@ describe('automation RPC methods', () => {
prompt: 'Review changes',
precheck: { command: 'test -f ready', timeoutSeconds: 30 },
agentId: 'codex',
runContext: expect.objectContaining({ hostId: 'runtime:gpu' }),
sourceContext: expect.objectContaining({ hostId: 'local' }),
repo: 'repo-1',
reuseSession: true
})
@@ -4,6 +4,8 @@ import {
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
normalizeAutomationPrecheckTimeoutSeconds
} from '../../../../shared/automation-precheck'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { defineMethod, type RpcMethod } from '../core'
import {
@@ -20,6 +22,14 @@ const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
})
const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
return z.NEVER
}
return hostId
})
const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, {
message: 'Invalid automation trigger'
@@ -43,6 +53,43 @@ const OptionalNullablePlainString = z
.pipe(z.union([z.string(), z.null(), z.undefined()]))
.optional()
const TaskProviderIdentity = z
.custom<SharedTaskProviderIdentity>(
(value) =>
value !== null &&
typeof value === 'object' &&
'provider' in value &&
['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider))
)
.optional()
.nullable()
const TaskSourceContext = z
.object({
kind: z.literal('task-source'),
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
projectId: requiredString('Missing source project id'),
hostId: ExecutionHostId,
projectHostSetupId: OptionalNullablePlainString,
repoId: OptionalNullablePlainString,
providerIdentity: TaskProviderIdentity,
accountLabel: OptionalNullablePlainString
})
.optional()
.nullable()
const WorkspaceRunContext = z
.object({
kind: z.literal('workspace-run'),
projectId: requiredString('Missing run project id'),
hostId: ExecutionHostId,
projectHostSetupId: requiredString('Missing project host setup id'),
repoId: requiredString('Missing repo id'),
path: requiredString('Missing run path')
})
.optional()
.nullable()
const AutomationId = z.object({
id: requiredString('Missing automation id')
})
@@ -56,6 +103,8 @@ const AutomationCreate = z.object({
prompt: requiredString('Missing automation prompt'),
precheck: AutomationPrecheck,
agentId: TuiAgent,
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
@@ -73,6 +122,8 @@ const AutomationUpdateFields = z.object({
prompt: OptionalString,
precheck: AutomationPrecheck,
agentId: TuiAgent.optional(),
runContext: WorkspaceRunContext,
sourceContext: TaskSourceContext,
repo: OptionalString,
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
+1
View File
@@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
const RepoSelector = z.object({
+253 -211
View File
@@ -10,6 +10,7 @@ import type {
import type { NativeFileDropPayload } from '../shared/native-file-drop'
import type { AppIdentity } from '../shared/app-identity'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
import type { TaskSourceContext } from '../shared/task-source-context'
import type {
FolderWorkspacePathStatus,
FolderWorkspacePathStatusRequest
@@ -127,6 +128,15 @@ import type {
Project,
Repo,
ProjectGroup,
ProjectHostSetup,
ProjectHostSetupCreateArgs,
ProjectHostSetupCreateResult,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult,
FolderWorkspace,
ProjectGroupImportResult,
ProjectGroupImportMode,
@@ -151,6 +161,18 @@ import type {
WorkspaceSessionPatch,
WorkspaceSessionState
} from '../shared/types'
type GitLabRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
type GitHubRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
import type {
WarpThemeImportPreview,
WarpThemeImportSource
@@ -1130,22 +1152,22 @@ export type PreloadApi = {
number: number
type: 'issue' | 'pr'
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
workItemDetails: (args: {
repoPath: string
repoId?: string
number: number
type?: 'issue' | 'pr'
}) => Promise<GitHubWorkItemDetails | null>
prFileContents: (args: {
repoPath: string
repoId?: string
prNumber: number
path: string
oldPath?: string
status: GitHubPRFile['status']
headSha: string
baseSha: string
}) => Promise<GitHubPRFileContents>
workItemDetails: (
args: GitHubRepoSelectorArgs & {
number: number
type?: 'issue' | 'pr'
}
) => Promise<GitHubWorkItemDetails | null>
prFileContents: (
args: GitHubRepoSelectorArgs & {
prNumber: number
path: string
oldPath?: string
status: GitHubPRFile['status']
headSha: string
baseSha: string
}
) => Promise<GitHubPRFileContents>
listIssues: (args: {
repoPath: string
repoId?: string
@@ -1168,14 +1190,14 @@ export type PreloadApi = {
before?: string
noCache?: boolean
}) => Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>>
prChecks: (args: {
repoPath: string
repoId?: string
prNumber: number
headSha?: string
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}) => Promise<PRCheckDetail[]>
prChecks: (
args: GitHubRepoSelectorArgs & {
prNumber: number
headSha?: string
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}
) => Promise<PRCheckDetail[]>
prCheckDetails: (args: {
repoPath: string
repoId?: string
@@ -1185,13 +1207,13 @@ export type PreloadApi = {
url?: string | null
prRepo?: GitHubOwnerRepo | null
}) => Promise<PRCheckRunDetails | null>
rerunPRChecks: (args: {
repoPath: string
repoId?: string
prNumber: number
headSha?: string
failedOnly?: boolean
}) => Promise<{ ok: true; count: number } | { ok: false; error: string }>
rerunPRChecks: (
args: GitHubRepoSelectorArgs & {
prNumber: number
headSha?: string
failedOnly?: boolean
}
) => Promise<{ ok: true; count: number } | { ok: false; error: string }>
prComments: (args: {
repoPath: string
repoId?: string
@@ -1205,14 +1227,14 @@ export type PreloadApi = {
threadId: string
resolve: boolean
}) => Promise<boolean>
setPRFileViewed: (args: {
repoPath: string
repoId?: string
prNumber: number
pullRequestId: string
path: string
viewed: boolean
}) => Promise<boolean>
setPRFileViewed: (
args: GitHubRepoSelectorArgs & {
prNumber: number
pullRequestId: string
path: string
viewed: boolean
}
) => Promise<boolean>
updatePRTitle: (args: {
repoPath: string
repoId?: string
@@ -1220,70 +1242,73 @@ export type PreloadApi = {
title: string
prRepo?: GitHubOwnerRepo | null
}) => Promise<boolean>
mergePR: (args: {
repoPath: string
repoId?: string
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
prRepo?: GitHubOwnerRepo | null
}) => Promise<{ ok: true } | { ok: false; error: string }>
setPRAutoMerge: (args: {
repoPath: string
repoId?: string
prNumber: number
enabled: boolean
prRepo?: GitHubOwnerRepo | null
}) => Promise<{ ok: true } | { ok: false; error: string }>
updatePRState: (args: {
repoPath: string
repoId?: string
prNumber: number
updates: { state: 'open' | 'closed' }
}) => Promise<{ ok: true } | { ok: false; error: string }>
requestPRReviewers: (args: {
repoPath: string
repoId?: string
prNumber: number
reviewers: string[]
}) => Promise<{ ok: true } | { ok: false; error: string }>
removePRReviewers: (args: {
repoPath: string
repoId?: string
prNumber: number
reviewers: string[]
}) => Promise<{ ok: true } | { ok: false; error: string }>
updateIssue: (args: {
repoPath: string
repoId?: string
number: number
updates: GitHubIssueUpdate
}) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (args: {
repoPath: string
repoId?: string
number: number
body: string
/** Why: GitHub stores PR conversation comments under `/issues/N/comments`
* too, so the IPC and `gh` call paths are identical. The renderer cache
* key is keyed by the drawer's `type`, so callers pass it through to
* scope the cross-window invalidation broadcast correctly and avoid
* evicting an unrelated PR/issue that happens to share the number. */
type?: 'issue' | 'pr'
prRepo?: GitHubOwnerRepo | null
}) => Promise<GitHubCommentResult>
addPRReviewCommentReply: (args: {
repoPath: string
repoId?: string
prNumber: number
commentId: number
body: string
threadId?: string
path?: string
line?: number
prRepo?: GitHubOwnerRepo | null
}) => Promise<GitHubCommentResult>
mergePR: (
args: GitHubRepoSelectorArgs & {
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
prRepo?: GitHubOwnerRepo | null
}
) => Promise<{ ok: true } | { ok: false; error: string }>
setPRAutoMerge: (
args: GitHubRepoSelectorArgs & {
prNumber: number
enabled: boolean
prRepo?: GitHubOwnerRepo | null
}
) => Promise<{ ok: true } | { ok: false; error: string }>
updatePRState: (
args: GitHubRepoSelectorArgs & {
prNumber: number
updates: { state: 'open' | 'closed' }
}
) => Promise<{ ok: true } | { ok: false; error: string }>
requestPRReviewers: (
args: GitHubRepoSelectorArgs & {
prNumber: number
reviewers: string[]
}
) => Promise<{ ok: true } | { ok: false; error: string }>
removePRReviewers: (
args: GitHubRepoSelectorArgs & {
prNumber: number
reviewers: string[]
}
) => Promise<{ ok: true } | { ok: false; error: string }>
updateIssue: (
args: GitHubRepoSelectorArgs & {
number: number
updates: GitHubIssueUpdate
}
) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (
args: GitHubRepoSelectorArgs & {
number: number
body: string
/** Why: GitHub stores PR conversation comments under `/issues/N/comments`
* too, so the IPC and `gh` call paths are identical. The renderer cache
* key is keyed by the drawer's `type`, so callers pass it through to
* scope the cross-window invalidation broadcast correctly and avoid
* evicting an unrelated PR/issue that happens to share the number. */
type?: 'issue' | 'pr'
prRepo?: GitHubOwnerRepo | null
}
) => Promise<GitHubCommentResult>
addPRReviewCommentReply: (
args: GitHubRepoSelectorArgs & {
prNumber: number
commentId: number
body: string
threadId?: string
path?: string
line?: number
prRepo?: GitHubOwnerRepo | null
}
) => Promise<GitHubCommentResult>
addPRReviewComment: (
args: GitHubPRReviewCommentInput & { repoId?: string }
args: GitHubPRReviewCommentInput & {
repoId?: string
sourceContext?: TaskSourceContext | null
}
) => Promise<GitHubCommentResult>
listLabels: (args: { repoPath: string; repoId?: string }) => Promise<string[]>
listAssignableUsers: (args: {
@@ -1369,117 +1394,136 @@ export type PreloadApi = {
force?: boolean
host?: string | null
}) => Promise<GetGitLabRateLimitResult>
projectSlug: (args: { repoPath: string }) => Promise<GitLabProjectRef | null>
mrForBranch: (args: {
repoPath: string
branch: string
linkedMRIid?: number | null
}) => Promise<MRInfo | null>
mr: (args: { repoPath: string; iid: number }) => Promise<MRInfo | null>
listMRs: (args: {
repoPath: string
state?: MRListState
page?: number
perPage?: number
}) => Promise<ListMergeRequestsResult>
projectSlug: (args: GitLabRepoSelectorArgs) => Promise<GitLabProjectRef | null>
mrForBranch: (
args: GitLabRepoSelectorArgs & {
branch: string
linkedMRIid?: number | null
}
) => Promise<MRInfo | null>
mr: (args: GitLabRepoSelectorArgs & { iid: number }) => Promise<MRInfo | null>
listMRs: (
args: GitLabRepoSelectorArgs & {
state?: MRListState
page?: number
perPage?: number
}
) => Promise<ListMergeRequestsResult>
/** Combined MR + issue list filtered by state. Issues are skipped
* when state is 'merged' (issues don't merge). */
listWorkItems: (args: {
repoPath: string
state?: MRListState
page?: number
perPage?: number
}) => Promise<ListMergeRequestsResult>
issue: (args: { repoPath: string; number: number }) => Promise<GitLabIssueInfo | null>
listIssues: (args: {
repoPath: string
state?: 'opened' | 'closed' | 'all'
assignee?: string
limit?: number
}) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }>
createIssue: (args: {
repoPath: string
title: string
body: string
}) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }>
updateIssue: (args: {
repoPath: string
number: number
updates: GitLabIssueUpdate
}) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (args: {
repoPath: string
number: number
body: string
}) => Promise<GitLabCommentResult>
listLabels: (args: { repoPath: string }) => Promise<string[]>
listAssignableUsers: (args: { repoPath: string }) => Promise<GitLabAssignableUser[]>
listWorkItems: (
args: GitLabRepoSelectorArgs & {
state?: MRListState
page?: number
perPage?: number
}
) => Promise<ListMergeRequestsResult>
issue: (args: GitLabRepoSelectorArgs & { number: number }) => Promise<GitLabIssueInfo | null>
listIssues: (
args: GitLabRepoSelectorArgs & {
state?: 'opened' | 'closed' | 'all'
assignee?: string
limit?: number
}
) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }>
createIssue: (
args: GitLabRepoSelectorArgs & {
title: string
body: string
}
) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }>
updateIssue: (
args: GitLabRepoSelectorArgs & {
number: number
updates: GitLabIssueUpdate
}
) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (
args: GitLabRepoSelectorArgs & {
number: number
body: string
}
) => Promise<GitLabCommentResult>
listLabels: (args: GitLabRepoSelectorArgs) => Promise<string[]>
listAssignableUsers: (args: GitLabRepoSelectorArgs) => Promise<GitLabAssignableUser[]>
/** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */
todos: (args: { repoPath: string }) => Promise<GitLabTodo[]>
todos: (args: GitLabRepoSelectorArgs) => Promise<GitLabTodo[]>
/** Aggregated dialog payload — body + discussions + pipeline jobs. */
workItemDetails: (args: {
repoPath: string
iid: number
type: 'issue' | 'mr'
}) => Promise<GitLabWorkItemDetails | null>
closeMR: (args: {
repoPath: string
iid: number
}) => Promise<{ ok: true } | { ok: false; error: string }>
reopenMR: (args: {
repoPath: string
iid: number
}) => Promise<{ ok: true } | { ok: false; error: string }>
mergeMR: (args: {
repoPath: string
iid: number
method?: 'merge' | 'squash' | 'rebase'
}) => Promise<{ ok: true } | { ok: false; error: string }>
updateMR: (args: {
repoPath: string
iid: number
updates: GitLabMRUpdate
}) => Promise<{ ok: true } | { ok: false; error: string }>
updateMRReviewers: (args: {
repoPath: string
iid: number
reviewerIds: number[]
projectRef?: GitLabProjectRef | null
}) => Promise<GitLabMRReviewersUpdateResult>
addMRComment: (args: {
repoPath: string
iid: number
body: string
}) => Promise<GitLabCommentResult>
addMRInlineComment: (args: {
repoPath: string
iid: number
input: GitLabMRInlineCommentInput
projectRef?: GitLabProjectRef | null
}) => Promise<GitLabCommentResult>
resolveMRDiscussion: (args: {
repoPath: string
iid: number
discussionId: string
resolved: boolean
}) => Promise<GitLabDiscussionResolveResult>
jobTrace: (args: {
repoPath: string
jobId: number
projectRef?: GitLabProjectRef | null
}) => Promise<GitLabJobTraceResult>
retryJob: (args: {
repoPath: string
jobId: number
projectRef?: GitLabProjectRef | null
}) => Promise<GitLabRetryJobResult>
workItemByPath: (args: {
repoPath: string
host: string
path: string
iid: number
type: 'issue' | 'mr'
}) => Promise<Omit<GitLabWorkItem, 'repoId'> | null>
workItemDetails: (
args: GitLabRepoSelectorArgs & {
iid: number
type: 'issue' | 'mr'
}
) => Promise<GitLabWorkItemDetails | null>
closeMR: (
args: GitLabRepoSelectorArgs & {
iid: number
}
) => Promise<{ ok: true } | { ok: false; error: string }>
reopenMR: (
args: GitLabRepoSelectorArgs & {
iid: number
}
) => Promise<{ ok: true } | { ok: false; error: string }>
mergeMR: (
args: GitLabRepoSelectorArgs & {
iid: number
method?: 'merge' | 'squash' | 'rebase'
}
) => Promise<{ ok: true } | { ok: false; error: string }>
updateMR: (
args: GitLabRepoSelectorArgs & {
iid: number
updates: GitLabMRUpdate
}
) => Promise<{ ok: true } | { ok: false; error: string }>
updateMRReviewers: (
args: GitLabRepoSelectorArgs & {
iid: number
reviewerIds: number[]
projectRef?: GitLabProjectRef | null
}
) => Promise<GitLabMRReviewersUpdateResult>
addMRComment: (
args: GitLabRepoSelectorArgs & {
iid: number
body: string
}
) => Promise<GitLabCommentResult>
addMRInlineComment: (
args: GitLabRepoSelectorArgs & {
iid: number
input: GitLabMRInlineCommentInput
projectRef?: GitLabProjectRef | null
}
) => Promise<GitLabCommentResult>
resolveMRDiscussion: (
args: GitLabRepoSelectorArgs & {
iid: number
discussionId: string
resolved: boolean
}
) => Promise<GitLabDiscussionResolveResult>
jobTrace: (
args: GitLabRepoSelectorArgs & {
jobId: number
projectRef?: GitLabProjectRef | null
}
) => Promise<GitLabJobTraceResult>
retryJob: (
args: GitLabRepoSelectorArgs & {
jobId: number
projectRef?: GitLabProjectRef | null
}
) => Promise<GitLabRetryJobResult>
workItemByPath: (
args: GitLabRepoSelectorArgs & {
host: string
path: string
iid: number
type: 'issue' | 'mr'
}
) => Promise<Omit<GitLabWorkItem, 'repoId'> | null>
}
linear: {
connect: (args: {
@@ -1646,8 +1690,6 @@ export type PreloadApi = {
dismiss: () => Promise<void>
complete: () => Promise<void>
disable: () => Promise<void>
openWeb: () => Promise<void>
starOrca: () => Promise<boolean>
forceShow: () => Promise<void>
}
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
+119 -94
View File
@@ -3,6 +3,13 @@
conflict on every upstream sync of the much larger central preload
file. Composed back into `api.gl` from `index.ts`. */
import { ipcRenderer } from 'electron'
import type { TaskSourceContext } from '../shared/task-source-context'
type GitLabRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
export const glApi = {
viewer: (): Promise<unknown> => ipcRenderer.invoke('gitlab:viewer'),
@@ -10,136 +17,154 @@ export const glApi = {
rateLimit: (args?: { force?: boolean; host?: string | null }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:rateLimit', args),
projectSlug: (args: { repoPath: string }): Promise<unknown> =>
projectSlug: (args: GitLabRepoSelectorArgs): Promise<unknown> =>
ipcRenderer.invoke('gitlab:projectSlug', args),
mrForBranch: (args: {
repoPath: string
branch: string
linkedMRIid?: number | null
}): Promise<unknown> => ipcRenderer.invoke('gitlab:mrForBranch', args),
mrForBranch: (
args: GitLabRepoSelectorArgs & {
branch: string
linkedMRIid?: number | null
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:mrForBranch', args),
mr: (args: { repoPath: string; iid: number }): Promise<unknown> =>
mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:mr', args),
listMRs: (args: {
repoPath: string
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}): Promise<unknown> => ipcRenderer.invoke('gitlab:listMRs', args),
listMRs: (
args: GitLabRepoSelectorArgs & {
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:listMRs', args),
listWorkItems: (args: {
repoPath: string
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}): Promise<unknown> => ipcRenderer.invoke('gitlab:listWorkItems', args),
listWorkItems: (
args: GitLabRepoSelectorArgs & {
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:listWorkItems', args),
issue: (args: { repoPath: string; number: number }): Promise<unknown> =>
issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:issue', args),
listIssues: (args: {
repoPath: string
state?: 'opened' | 'closed' | 'all'
assignee?: string
limit?: number
}): Promise<{ items: unknown[]; error?: unknown }> =>
listIssues: (
args: GitLabRepoSelectorArgs & {
state?: 'opened' | 'closed' | 'all'
assignee?: string
limit?: number
}
): Promise<{ items: unknown[]; error?: unknown }> =>
ipcRenderer.invoke('gitlab:listIssues', args),
createIssue: (args: {
repoPath: string
title: string
body: string
}): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> =>
createIssue: (
args: GitLabRepoSelectorArgs & {
title: string
body: string
}
): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:createIssue', args),
updateIssue: (args: {
repoPath: string
number: number
updates: unknown
}): Promise<{ ok: true } | { ok: false; error: string }> =>
updateIssue: (
args: GitLabRepoSelectorArgs & {
number: number
updates: unknown
}
): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:updateIssue', args),
addIssueComment: (args: { repoPath: string; number: number; body: string }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:addIssueComment', args),
addIssueComment: (
args: GitLabRepoSelectorArgs & { number: number; body: string }
): Promise<unknown> => ipcRenderer.invoke('gitlab:addIssueComment', args),
listLabels: (args: { repoPath: string }): Promise<string[]> =>
listLabels: (args: GitLabRepoSelectorArgs): Promise<string[]> =>
ipcRenderer.invoke('gitlab:listLabels', args),
listAssignableUsers: (args: { repoPath: string }): Promise<unknown[]> =>
listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise<unknown[]> =>
ipcRenderer.invoke('gitlab:listAssignableUsers', args),
todos: (args: { repoPath: string }): Promise<unknown[]> =>
todos: (args: GitLabRepoSelectorArgs): Promise<unknown[]> =>
ipcRenderer.invoke('gitlab:todos', args),
workItemDetails: (args: {
repoPath: string
iid: number
type: 'issue' | 'mr'
}): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemDetails', args),
workItemDetails: (
args: GitLabRepoSelectorArgs & {
iid: number
type: 'issue' | 'mr'
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemDetails', args),
closeMR: (args: {
repoPath: string
iid: number
}): Promise<{ ok: true } | { ok: false; error: string }> =>
closeMR: (
args: GitLabRepoSelectorArgs & {
iid: number
}
): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:closeMR', args),
reopenMR: (args: {
repoPath: string
iid: number
}): Promise<{ ok: true } | { ok: false; error: string }> =>
reopenMR: (
args: GitLabRepoSelectorArgs & {
iid: number
}
): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:reopenMR', args),
mergeMR: (args: {
repoPath: string
iid: number
method?: 'merge' | 'squash' | 'rebase'
}): Promise<{ ok: true } | { ok: false; error: string }> =>
mergeMR: (
args: GitLabRepoSelectorArgs & {
iid: number
method?: 'merge' | 'squash' | 'rebase'
}
): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:mergeMR', args),
updateMR: (args: {
repoPath: string
iid: number
updates: unknown
}): Promise<{ ok: true } | { ok: false; error: string }> =>
updateMR: (
args: GitLabRepoSelectorArgs & {
iid: number
updates: unknown
}
): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:updateMR', args),
updateMRReviewers: (args: {
repoPath: string
iid: number
reviewerIds: number[]
projectRef?: unknown
}): Promise<unknown> => ipcRenderer.invoke('gitlab:updateMRReviewers', args),
updateMRReviewers: (
args: GitLabRepoSelectorArgs & {
iid: number
reviewerIds: number[]
projectRef?: unknown
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:updateMRReviewers', args),
addMRComment: (args: { repoPath: string; iid: number; body: string }): Promise<unknown> =>
addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:addMRComment', args),
addMRInlineComment: (args: {
repoPath: string
iid: number
input: unknown
projectRef?: unknown
}): Promise<unknown> => ipcRenderer.invoke('gitlab:addMRInlineComment', args),
addMRInlineComment: (
args: GitLabRepoSelectorArgs & {
iid: number
input: unknown
projectRef?: unknown
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:addMRInlineComment', args),
resolveMRDiscussion: (args: {
repoPath: string
iid: number
discussionId: string
resolved: boolean
}): Promise<unknown> => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args),
resolveMRDiscussion: (
args: GitLabRepoSelectorArgs & {
iid: number
discussionId: string
resolved: boolean
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args),
jobTrace: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:jobTrace', args),
jobTrace: (
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }
): Promise<unknown> => ipcRenderer.invoke('gitlab:jobTrace', args),
retryJob: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:retryJob', args),
retryJob: (
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }
): Promise<unknown> => ipcRenderer.invoke('gitlab:retryJob', args),
workItemByPath: (args: {
repoPath: string
host: string
path: string
iid: number
type: 'issue' | 'mr'
}): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemByPath', args)
workItemByPath: (
args: GitLabRepoSelectorArgs & {
host: string
path: string
iid: number
type: 'issue' | 'mr'
}
): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemByPath', args)
}
+15 -2
View File
@@ -70,6 +70,7 @@ import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limi
import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types'
import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type { TaskSourceContext } from '../shared/task-source-context'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
@@ -961,6 +962,7 @@ const api = {
workItemDetails: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
number: number
type?: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemDetails', args),
@@ -968,6 +970,7 @@ const api = {
prFileContents: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
path: string
oldPath?: string
@@ -1008,6 +1011,7 @@ const api = {
prChecks: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
headSha?: string
prRepo?: { owner: string; repo: string } | null
@@ -1027,6 +1031,7 @@ const api = {
rerunPRChecks: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
headSha?: string
failedOnly?: boolean
@@ -1051,6 +1056,7 @@ const api = {
setPRFileViewed: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
pullRequestId: string
path: string
@@ -1068,6 +1074,7 @@ const api = {
mergePR: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
prRepo?: { owner: string; repo: string } | null
@@ -1077,6 +1084,7 @@ const api = {
setPRAutoMerge: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
enabled: boolean
prRepo?: { owner: string; repo: string } | null
@@ -1086,6 +1094,7 @@ const api = {
updatePRState: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
updates: { state: 'open' | 'closed' }
}): Promise<{ ok: true } | { ok: false; error: string }> =>
@@ -1094,6 +1103,7 @@ const api = {
requestPRReviewers: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
reviewers: string[]
}): Promise<{ ok: true } | { ok: false; error: string }> =>
@@ -1102,6 +1112,7 @@ const api = {
removePRReviewers: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
reviewers: string[]
}): Promise<{ ok: true } | { ok: false; error: string }> =>
@@ -1110,6 +1121,7 @@ const api = {
updateIssue: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
number: number
updates: unknown
}): Promise<{ ok: true } | { ok: false; error: string }> =>
@@ -1118,6 +1130,7 @@ const api = {
addIssueComment: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
number: number
body: string
type?: 'issue' | 'pr'
@@ -1127,6 +1140,7 @@ const api = {
addPRReviewCommentReply: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
commentId: number
body: string
@@ -1139,6 +1153,7 @@ const api = {
addPRReviewComment: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
prNumber: number
commitId: string
path: string
@@ -1489,8 +1504,6 @@ const api = {
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'),
starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'),
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
},
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,7 @@
close/reopen, merge, and a top-level comment composer. Files /
inline review-comment positioning / approvals are deferred to v1.5
since they mirror substantial GitHub-side surface area. */
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
Check,
CircleDot,
@@ -40,15 +40,27 @@ import type {
GitLabWorkItemDetails,
MRComment
} from '../../../shared/types'
import {
getTaskSourceCacheScope,
type TaskSourceContext
} from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
type Props = {
item: GitLabWorkItem | null
repoPath: string | null
repoId?: string | null
sourceContext?: TaskSourceContext | null
onClose: () => void
onCreateWorkspace?: (item: GitLabWorkItem) => void
}
type GitLabDialogRepoSelector = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
type JobTraceState = {
loading: boolean
trace?: string
@@ -311,6 +323,8 @@ function PipelineJobRow({
export default function GitLabItemDialog({
item,
repoPath,
repoId,
sourceContext,
onClose,
onCreateWorkspace
}: Props): React.JSX.Element {
@@ -351,6 +365,17 @@ export default function GitLabItemDialog({
const [retryingJobId, setRetryingJobId] = useState<number | null>(null)
const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null)
const mountedRef = useMountedRef()
const sourceContextKey = sourceContext ? getTaskSourceCacheScope(sourceContext) : ''
const repoSelector = useMemo<GitLabDialogRepoSelector | null>(() => {
if (!repoPath) {
return null
}
return {
repoPath,
...(repoId ? { repoId } : {}),
...(sourceContext ? { sourceContext } : {})
}
}, [repoId, repoPath, sourceContext, sourceContextKey])
const updateCommentDraft = useCallback(
(value: string): void => {
setCommentDraftState({ itemId, value })
@@ -359,7 +384,7 @@ export default function GitLabItemDialog({
)
useEffect(() => {
if (!item || !repoPath) {
if (!item || !repoSelector) {
setDetails(null)
setLoading(false)
setError(null)
@@ -370,7 +395,7 @@ export default function GitLabItemDialog({
setLoading(true)
setError(null)
void window.api.gl
.workItemDetails({ repoPath, iid: item.number, type: item.type })
.workItemDetails({ ...repoSelector, iid: item.number, type: item.type })
.then((data) => {
if (stale) {
return
@@ -394,7 +419,7 @@ export default function GitLabItemDialog({
return () => {
stale = true
}
}, [item, repoPath, refreshNonce])
}, [item, repoSelector, refreshNonce])
// Why: clear item-scoped dialog state when the sheet target changes. The
// top-level comment draft is reconciled during render so it cannot flash stale.
@@ -423,12 +448,12 @@ export default function GitLabItemDialog({
}, [])
const loadGitLabLabelOptions = useCallback(async (): Promise<void> => {
if (!repoPath || labelOptions !== null || labelOptionsLoading) {
if (!repoSelector || labelOptions !== null || labelOptionsLoading) {
return
}
setLabelOptionsLoading(true)
try {
const labels = await window.api.gl.listLabels({ repoPath })
const labels = await window.api.gl.listLabels(repoSelector)
if (mountedRef.current) {
setLabelOptions(normalizeGitLabLabels(labels))
}
@@ -441,15 +466,15 @@ export default function GitLabItemDialog({
setLabelOptionsLoading(false)
}
}
}, [labelOptions, labelOptionsLoading, mountedRef, repoPath])
}, [labelOptions, labelOptionsLoading, mountedRef, repoSelector])
const loadGitLabReviewerOptions = useCallback(async (): Promise<void> => {
if (!repoPath || reviewerOptions !== null || reviewerOptionsLoading) {
if (!repoSelector || reviewerOptions !== null || reviewerOptionsLoading) {
return
}
setReviewerOptionsLoading(true)
try {
const users = await window.api.gl.listAssignableUsers({ repoPath })
const users = await window.api.gl.listAssignableUsers(repoSelector)
if (mountedRef.current) {
setReviewerOptions(dedupeGitLabUsers(users))
}
@@ -462,7 +487,7 @@ export default function GitLabItemDialog({
setReviewerOptionsLoading(false)
}
}
}, [mountedRef, repoPath, reviewerOptions, reviewerOptionsLoading])
}, [mountedRef, repoSelector, reviewerOptions, reviewerOptionsLoading])
const handleStartDetailsEdit = useCallback((): void => {
if (!item || !details || item.type !== 'mr') {
@@ -483,7 +508,7 @@ export default function GitLabItemDialog({
}, [])
const handleSaveDetails = useCallback(async (): Promise<void> => {
if (!item || !details || !repoPath || item.type !== 'mr') {
if (!item || !details || !repoSelector || item.type !== 'mr') {
return
}
const currentTitle = details.item.title || item.title
@@ -521,7 +546,7 @@ export default function GitLabItemDialog({
setDetailsSaving(true)
try {
const res = await window.api.gl.updateMR({ repoPath, iid: item.number, updates })
const res = await window.api.gl.updateMR({ ...repoSelector, iid: item.number, updates })
if (res.ok) {
if (mountedRef.current) {
setDetails((current) =>
@@ -557,7 +582,7 @@ export default function GitLabItemDialog({
item,
labelDraft,
mountedRef,
repoPath,
repoSelector,
titleDraft
])
@@ -568,7 +593,7 @@ export default function GitLabItemDialog({
return
}
setExpandedJobId(job.id)
if (!repoPath || !item || jobTraceById[job.id]?.trace || jobTraceById[job.id]?.error) {
if (!repoSelector || !item || jobTraceById[job.id]?.trace || jobTraceById[job.id]?.error) {
return
}
setJobTraceById((current) => ({
@@ -577,7 +602,7 @@ export default function GitLabItemDialog({
}))
try {
const result = await window.api.gl.jobTrace({
repoPath,
...repoSelector,
jobId: job.id,
projectRef: details?.item.projectRef ?? item.projectRef ?? null
})
@@ -602,18 +627,18 @@ export default function GitLabItemDialog({
}
}
},
[details?.item.projectRef, expandedJobId, item, jobTraceById, mountedRef, repoPath]
[details?.item.projectRef, expandedJobId, item, jobTraceById, mountedRef, repoSelector]
)
const handleRetryJob = useCallback(
async (job: GitLabPipelineJob): Promise<void> => {
if (!repoPath || !item) {
if (!repoSelector || !item) {
return
}
setRetryingJobId(job.id)
try {
const result = await window.api.gl.retryJob({
repoPath,
...repoSelector,
jobId: job.id,
projectRef: details?.item.projectRef ?? item.projectRef ?? null
})
@@ -648,12 +673,12 @@ export default function GitLabItemDialog({
}
}
},
[details?.item.projectRef, handleRefresh, item, mountedRef, repoPath]
[details?.item.projectRef, handleRefresh, item, mountedRef, repoSelector]
)
const handleSetReviewers = useCallback(
async (nextReviewers: GitLabAssignableUser[]): Promise<void> => {
if (!repoPath || !item || !details || item.type !== 'mr') {
if (!repoSelector || !item || !details || item.type !== 'mr') {
return
}
const reviewerIds = nextReviewers
@@ -671,7 +696,7 @@ export default function GitLabItemDialog({
setReviewerUpdating(true)
try {
const result = await window.api.gl.updateMRReviewers({
repoPath,
...repoSelector,
iid: item.number,
reviewerIds,
projectRef: details.item.projectRef ?? item.projectRef ?? null
@@ -697,11 +722,11 @@ export default function GitLabItemDialog({
}
}
},
[details, item, mountedRef, repoPath]
[details, item, mountedRef, repoSelector]
)
const handleSubmitInlineComment = useCallback(async (): Promise<void> => {
if (!repoPath || !item || !details || item.type !== 'mr') {
if (!repoSelector || !item || !details || item.type !== 'mr') {
return
}
const file = (details.files ?? []).find((row) => row.path === inlineCommentFilePath)
@@ -728,7 +753,7 @@ export default function GitLabItemDialog({
setInlineCommentSubmitting(true)
try {
const result = await window.api.gl.addMRInlineComment({
repoPath,
...repoSelector,
iid: item.number,
projectRef: details.item.projectRef ?? item.projectRef ?? null,
input: {
@@ -768,16 +793,16 @@ export default function GitLabItemDialog({
inlineCommentLine,
item,
mountedRef,
repoPath
repoSelector
])
const handleClose = useCallback(async (): Promise<void> => {
if (!item || !repoPath || item.type !== 'mr') {
if (!item || !repoSelector || item.type !== 'mr') {
return
}
setActionInFlight('close')
try {
const res = await window.api.gl.closeMR({ repoPath, iid: item.number })
const res = await window.api.gl.closeMR({ ...repoSelector, iid: item.number })
if (res.ok) {
if (mountedRef.current) {
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
@@ -798,15 +823,15 @@ export default function GitLabItemDialog({
setActionInFlight(null)
}
}
}, [item, repoPath, mountedRef, handleRefresh])
}, [item, repoSelector, mountedRef, handleRefresh])
const handleReopen = useCallback(async (): Promise<void> => {
if (!item || !repoPath || item.type !== 'mr') {
if (!item || !repoSelector || item.type !== 'mr') {
return
}
setActionInFlight('reopen')
try {
const res = await window.api.gl.reopenMR({ repoPath, iid: item.number })
const res = await window.api.gl.reopenMR({ ...repoSelector, iid: item.number })
if (res.ok) {
if (mountedRef.current) {
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
@@ -827,15 +852,15 @@ export default function GitLabItemDialog({
setActionInFlight(null)
}
}
}, [item, repoPath, mountedRef, handleRefresh])
}, [item, repoSelector, mountedRef, handleRefresh])
const handleMerge = useCallback(async (): Promise<void> => {
if (!item || !repoPath || item.type !== 'mr') {
if (!item || !repoSelector || item.type !== 'mr') {
return
}
setActionInFlight('merge')
try {
const res = await window.api.gl.mergeMR({ repoPath, iid: item.number })
const res = await window.api.gl.mergeMR({ ...repoSelector, iid: item.number })
if (res.ok) {
if (mountedRef.current) {
useAppStore.getState().recordFeatureInteraction('gitlab-tasks')
@@ -856,11 +881,11 @@ export default function GitLabItemDialog({
setActionInFlight(null)
}
}
}, [item, repoPath, mountedRef, handleRefresh])
}, [item, repoSelector, mountedRef, handleRefresh])
const handleSubmitComment = useCallback(async (): Promise<void> => {
const body = commentDraft.trim()
if (!body || !item || !repoPath) {
if (!body || !item || !repoSelector) {
return
}
setCommentSubmitting(true)
@@ -869,8 +894,8 @@ export default function GitLabItemDialog({
// Branch on the item type to hit the right channel.
const res =
item.type === 'mr'
? await window.api.gl.addMRComment({ repoPath, iid: item.number, body })
: await window.api.gl.addIssueComment({ repoPath, number: item.number, body })
? await window.api.gl.addMRComment({ ...repoSelector, iid: item.number, body })
: await window.api.gl.addIssueComment({ ...repoSelector, number: item.number, body })
if (res.ok) {
if (mountedRef.current) {
setCommentDraftState((current) =>
@@ -889,17 +914,17 @@ export default function GitLabItemDialog({
setCommentSubmitting(false)
}
}
}, [commentDraft, item, itemId, repoPath, mountedRef, handleRefresh])
}, [commentDraft, item, itemId, repoSelector, mountedRef, handleRefresh])
const handleResolveDiscussion = useCallback(
async (threadId: string, resolved: boolean): Promise<void> => {
if (!item || !repoPath || item.type !== 'mr') {
if (!item || !repoSelector || item.type !== 'mr') {
return
}
setResolvingThreadId(threadId)
try {
const res = await window.api.gl.resolveMRDiscussion({
repoPath,
...repoSelector,
iid: item.number,
discussionId: threadId,
resolved
@@ -927,7 +952,7 @@ export default function GitLabItemDialog({
}
}
},
[item, repoPath, mountedRef]
[item, repoSelector, mountedRef]
)
// Why: GitMerge for MRs visually disambiguates from GitBranch (and
@@ -22,12 +22,14 @@ import type {
WorkspaceCreateTelemetrySource,
WorkspaceStatus
} from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
type ComposerModalData = {
prefilledName?: string
initialRepoId?: string
linkedWorkItem?: LinkedWorkItemSummary | null
taskSourceContext?: TaskSourceContext | null
initialBaseBranch?: string
initialWorkspaceStatus?: WorkspaceStatus
/** Telemetry surface that opened the composer. Set by each
@@ -124,6 +126,7 @@ function QuickTabBody({
// intentionally ignored even if older callers still send it.
initialPrompt: '',
initialLinkedWorkItem: modalData.linkedWorkItem ?? null,
initialTaskSourceContext: modalData.taskSourceContext ?? null,
initialRepoId: modalData.initialRepoId,
initialWorkspaceStatus: modalData.initialWorkspaceStatus,
...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}),
+89 -64
View File
@@ -11,6 +11,7 @@ import React, {
useSyncExternalStore
} from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useShallow } from 'zustand/react/shallow'
import type { editor as monacoEditor } from 'monaco-editor'
import {
ArrowDown,
@@ -84,11 +85,6 @@ import {
import type { DiffSection } from '@/components/editor/diff-section-types'
import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model'
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
import {
REVIEW_ACTION_MERGE_BUTTON_CLASS,
REVIEW_ACTION_STATE_BUTTON_CLASS,
RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS
} from '@/components/right-sidebar/right-sidebar-primary-action-layout'
import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog'
import {
createGitHubChecksTabState,
@@ -138,8 +134,6 @@ import { useAllWorktrees } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent'
import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent'
import {
getGitHubPRReviewerRows,
normalizeGitHubReviewerLogins
@@ -208,25 +202,6 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null {
}
}
function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null {
try {
const parsed = new URL(itemUrl)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return null
}
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 2) {
return null
}
parsed.pathname = `/${segments[0]}/${segments[1]}/labels`
parsed.search = ''
parsed.hash = ''
return parsed.toString()
} catch {
return null
}
}
const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt'))
export type ItemDialogTab = 'conversation' | 'checks' | 'files'
@@ -549,8 +524,8 @@ function PRReviewersPanel({
}))
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const settings = useAppStore((s) => s.settings)
const repoOwnerSettings = useAppStore((s) =>
getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null))
)
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
@@ -3493,7 +3468,7 @@ function PRActionsPanel({
<WorkItemStateBadge item={actionItem} />
</div>
<div className="grid gap-2 justify-items-start">
<div className="grid gap-2">
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
@@ -3502,8 +3477,7 @@ function PRActionsPanel({
type="button"
size="sm"
className={cn(
REVIEW_ACTION_MERGE_BUTTON_CLASS,
'gap-2 bg-green-600 text-white hover:bg-green-700',
'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
@@ -3512,13 +3486,11 @@ function PRActionsPanel({
) : (
<GitMerge className="size-3.5" />
)}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: mergePresentation.label)}
</span>
<ChevronDown className="size-3 shrink-0 opacity-60" />
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: mergePresentation.label)}
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -3564,8 +3536,7 @@ function PRActionsPanel({
variant={nextState === 'closed' ? 'outline' : 'secondary'}
size="sm"
className={cn(
REVIEW_ACTION_STATE_BUTTON_CLASS,
'gap-2',
'w-full justify-center gap-2',
nextState === 'closed' &&
'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50'
)}
@@ -3579,11 +3550,9 @@ function PRActionsPanel({
) : (
<CircleDot className="size-3.5" />
)}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{nextState === 'closed'
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
</span>
{nextState === 'closed'
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
</Button>
</div>
</aside>
@@ -4992,7 +4961,6 @@ function GHEditSection({
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const { isPending, run } = useImmediateMutation()
const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url])
// Why: when the dialog opens from a Project view, mutations route through
// *BySlug IPCs and we must keep `projectViewCache` in sync alongside
// `workItemsCache` — `patchWorkItem` only walks the latter, so without this
@@ -5233,6 +5201,18 @@ function GHEditSection({
return null
}
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
{/* State */}
@@ -5302,16 +5282,34 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
<GitHubWorkItemLabelPopoverContent
open={labelPopoverOpen}
labels={repoLabels.data}
selectedLabels={localLabels}
error={repoLabels.error}
loading={repoLabels.loading}
repositoryLabelsUrl={repositoryLabelsUrl}
onToggleLabel={handleLabelToggle}
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
/>
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
@@ -5342,14 +5340,41 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
<GitHubWorkItemAssigneePopoverContent
open={assigneePopoverOpen}
assignees={repoAssignees.data}
selectedLogins={localAssignees}
error={repoAssignees.error}
loading={repoAssignees.loading}
onToggleAssignee={handleAssigneeToggle}
/>
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
+11 -32
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { ExternalLink, Star, X } from 'lucide-react'
import { Card } from './ui/card'
import { Button } from './ui/button'
@@ -39,24 +39,13 @@ export function StarNagCard(): React.JSX.Element | null {
})
}, [])
const handleClose = useCallback((): void => {
if (busy) {
return
}
const handleClose = (): void => {
setVisible(false)
// Why: fire-and-forget. If persisting the dismissal fails the worst case
// is we re-fire the same threshold on next launch — not worth blocking
// the close animation on.
void window.api.starNag.dismiss()
}, [busy])
const handleDisable = useCallback((): void => {
if (busy) {
return
}
setVisible(false)
void window.api.starNag.disable()
}, [busy])
}
const handleDisable = (): void => {
setVisible(false)
@@ -74,7 +63,9 @@ export function StarNagCard(): React.JSX.Element | null {
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [handleClose, visible])
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes
// over stable refs; re-binding on each render is unnecessary.
}, [visible])
if (!visible) {
return null
@@ -87,7 +78,7 @@ export function StarNagCard(): React.JSX.Element | null {
if (mode === 'web') {
setBusy(true)
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
await window.api.starNag.openWeb()
await window.api.starNag.disable()
if (mountedRef.current) {
setBusy(false)
setVisible(false)
@@ -95,7 +86,7 @@ export function StarNagCard(): React.JSX.Element | null {
return
}
setBusy(true)
const ok = await window.api.starNag.starOrca()
const ok = await window.api.gh.starOrca('star_nag')
if (mountedRef.current) {
setBusy(false)
}
@@ -105,6 +96,7 @@ export function StarNagCard(): React.JSX.Element | null {
}
return
}
await window.api.starNag.complete()
if (mountedRef.current) {
setVisible(false)
}
@@ -134,7 +126,6 @@ export function StarNagCard(): React.JSX.Element | null {
size="icon"
className="size-7 shrink-0"
onClick={handleClose}
disabled={busy}
aria-label={translate('auto.components.StarNagCard.b5e685e4d9', 'Dismiss')}
>
<X className="size-3.5" />
@@ -165,22 +156,10 @@ export function StarNagCard(): React.JSX.Element | null {
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
</Button>
<div className="flex items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleClose}
disabled={busy}
>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleClose}>
{translate('auto.components.StarNagCard.8c967b4d15', 'Not now')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleDisable}
disabled={busy}
>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleDisable}>
{translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")}
</Button>
</div>
+282 -63
View File
@@ -43,6 +43,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { getSettingsFocusedExecutionHostId } from '../../../shared/execution-host'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Input } from '@/components/ui/input'
@@ -89,6 +90,8 @@ import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/I
import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector'
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection'
import { getTaskSourceContextSummary } from './task-source-context-summary'
import type { TaskSourceHostAvailability } from './task-source-context-summary'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import {
getGitHubPRPrimaryReviewer,
@@ -119,6 +122,8 @@ import PullRequestPage from '@/components/PullRequestPage'
import GitLabItemDialog from '@/components/GitLabItemDialog'
import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import LinearIssueWorkspace from '@/components/LinearIssueWorkspace'
import {
LinearCollectionNotice,
@@ -139,6 +144,13 @@ import {
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { getRepoExecutionHostId } from '../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection'
import {
getTaskSourceCacheScope,
normalizeTaskSourceContext,
type TaskSourceContext
} from '../../../shared/task-source-context'
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
import {
buildTaskPageRepoSourceState,
@@ -155,6 +167,8 @@ import {
} from '@/components/task-page-cache-selectors'
import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility'
import { findTaskPageJiraIssue } from '@/components/task-page-jira-cache-selectors'
import { getRepoBackedTaskEmptyState } from '@/components/task-page-empty-state'
import { getDefaultTaskRepoSelection } from '@/components/task-page-default-repo-selection'
import {
createTaskPageGitHubStatusStateDraft,
resolveTaskPageGitHubStatusStateDraft,
@@ -301,6 +315,46 @@ function getJiraIssueWorkspaceSeed(issue: JiraIssue): string {
)
}
function getTaskPageRepoSourceContext(
repo: Repo | null | undefined,
provider: 'github' | 'gitlab'
): TaskSourceContext | null {
if (!repo) {
return null
}
const projection = projectHostSetupProjectionFromRepos([repo])
const project = projection.projects[0]
const setup = projection.setups[0]
const providerIdentity =
provider === 'github' && project?.providerIdentity?.provider === 'github'
? project.providerIdentity
: null
return normalizeTaskSourceContext({
provider,
projectId: setup?.projectId ?? project?.id ?? repo.id,
hostId: setup?.hostId ?? getRepoExecutionHostId(repo),
projectHostSetupId: setup?.id,
repoId: repo.id,
providerIdentity
})
}
function getTaskPageRepoCacheInput(repo: Repo): {
id: string
path: string
executionHostId?: string | null
sourceCacheScope?: string | null
} {
const sourceContext = getTaskPageRepoSourceContext(repo, 'github')
return {
id: repo.id,
path: repo.path,
executionHostId: repo.executionHostId,
sourceCacheScope:
sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null
}
}
// Why: the row's px-3 left padding leaves a 12px gap between the scroll-viewport
// edge and the sticky ID column; without a covering ::before, scrolled cell text
// bleeds through that strip. Same trick as the title column for its 8px gap.
@@ -846,7 +900,9 @@ function GHStatusCell({
repo: Repo | null
}): React.JSX.Element {
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const repoOwnerSettings = useAppStore((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
const [statusStateDraft, setStatusStateDraft] = useState(() =>
createTaskPageGitHubStatusStateDraft(item)
)
@@ -1278,7 +1334,9 @@ function GHAssigneesCell({
repo: Repo | null
}): React.JSX.Element {
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const repoOwnerSettings = useAppStore((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
const [open, setOpen] = useState(false)
const [pendingLogin, setPendingLogin] = useState<string | null>(null)
const assignees = useMemo(() => item.assignees ?? [], [item.assignees])
@@ -1604,7 +1662,9 @@ function PRReviewCell({
const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 })
const [submitting, setSubmitting] = useState(false)
const settings = useAppStore((s) => s.settings)
const repoOwnerSettings = useAppStore((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
@@ -2454,6 +2514,9 @@ export default function TaskPage(): React.JSX.Element {
const closeTaskPage = useAppStore((s) => s.closeTaskPage)
const activeModal = useAppStore((s) => s.activeModal)
const repos = useAppStore((s) => s.repos)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const repoMap = useRepoMap()
const allWorktrees = useAllWorktrees()
const openModal = useAppStore((s) => s.openModal)
@@ -2532,10 +2595,10 @@ export default function TaskPage(): React.JSX.Element {
return new Set(filtered)
}
// Why: empty after filtering (e.g. all persisted repos were removed)
// falls through to "all eligible" so the page never renders with an
// empty selection — see the multi-combobox invariant.
// falls through to the automatic default so the page never renders with
// an empty selection — see the multi-combobox invariant.
}
return new Set(eligibleRepos.map((r) => r.id))
return getDefaultTaskRepoSelection(eligibleRepos)
}, [eligibleRepos, pageData.preselectedRepoId, settings?.defaultRepoSelection])
const [repoSelection, setRepoSelection] = useState<ReadonlySet<string>>(resolvedInitialSelection)
@@ -2566,7 +2629,7 @@ export default function TaskPage(): React.JSX.Element {
return
}
if (pruned.size === 0 && eligibleIds.size > 0) {
setRepoSelection(new Set(eligibleIds))
setRepoSelection(getDefaultTaskRepoSelection(eligibleRepos))
return
}
if (pruned.size !== repoSelection.size) {
@@ -2596,6 +2659,10 @@ export default function TaskPage(): React.JSX.Element {
const jiraSites = jiraStatus.sites ?? []
const selectedJiraSiteId =
jiraStatus.selectedSiteId ?? jiraStatus.activeSiteId ?? jiraSites[0]?.id ?? null
const selectedJiraSite =
selectedJiraSiteId && selectedJiraSiteId !== 'all'
? (jiraSites.find((site) => site.id === selectedJiraSiteId) ?? null)
: null
const preferredVisibleTaskProviders = useMemo(
() => normalizeVisibleTaskProviders(settings?.visibleTaskProviders),
[settings?.visibleTaskProviders]
@@ -2673,6 +2740,104 @@ export default function TaskPage(): React.JSX.Element {
const [taskSource, setTaskSource] = useState<TaskProvider>(
resolveVisibleTaskProvider(preferredTaskSource, visibleTaskProviders)
)
const taskSourceRepoContexts = useMemo(
() =>
taskSource === 'github' || taskSource === 'gitlab'
? selectedRepos
.map((repo) => getTaskPageRepoSourceContext(repo, taskSource))
.filter((context): context is TaskSourceContext => context !== null)
: [],
[selectedRepos, taskSource]
)
const hostRegistryById = useMemo(
() =>
new Map(
buildExecutionHostRegistry({
repos,
settings,
sshTargetLabels,
sshConnectionStates,
runtimeStatusByEnvironmentId,
hostLabelOverrides: getHostDisplayLabelOverrides(settings)
}).map((host) => [host.id, host])
),
[repos, settings, sshConnectionStates, sshTargetLabels, runtimeStatusByEnvironmentId]
)
const taskSourceHostAvailability = useMemo<TaskSourceHostAvailability[]>(
() =>
taskSource === 'github' || taskSource === 'gitlab'
? taskSourceRepoContexts.flatMap((context) => {
const host = hostRegistryById.get(context.hostId)
if (!host || (host.health === 'local' || host.health === 'available')) {
return []
}
return [
{
hostId: context.hostId,
health: host.health,
status: host.connectionStatus
}
]
})
: [],
[hostRegistryById, taskSource, taskSourceRepoContexts]
)
const accountBackedTaskSourceHostId = useMemo(
() => getSettingsFocusedExecutionHostId(settings),
[settings]
)
const accountBackedTaskSourceHostAvailability = useMemo<TaskSourceHostAvailability[]>(() => {
if (taskSource !== 'linear' && taskSource !== 'jira') {
return []
}
const host = hostRegistryById.get(accountBackedTaskSourceHostId)
if (!host || host.health === 'local' || host.health === 'available') {
return []
}
return [
{
hostId: host.id,
health: host.health,
status: host.connectionStatus
}
]
}, [accountBackedTaskSourceHostId, hostRegistryById, taskSource])
const taskSourceContextSummary = useMemo(() => {
const providerLabel =
sourceOptions.find((source) => source.id === taskSource)?.label ?? taskSource
return getTaskSourceContextSummary({
provider: taskSource,
providerLabel,
repoContexts: taskSourceRepoContexts,
hostAvailability:
taskSource === 'linear' || taskSource === 'jira'
? accountBackedTaskSourceHostAvailability
: taskSourceHostAvailability,
accountHostId: accountBackedTaskSourceHostId,
selectedRepoCount: selectedRepos.length,
linearWorkspaceName:
selectedLinearWorkspace?.organizationName ?? selectedLinearWorkspace?.id ?? null,
jiraSiteName: selectedJiraSite?.displayName ?? selectedJiraSite?.siteUrl ?? null
})
}, [
selectedJiraSite,
selectedLinearWorkspace,
selectedRepos.length,
sourceOptions,
taskSource,
accountBackedTaskSourceHostAvailability,
accountBackedTaskSourceHostId,
taskSourceHostAvailability,
taskSourceRepoContexts
])
const githubEmptyState = useMemo(
() =>
getRepoBackedTaskEmptyState({
provider: 'github',
selectedRepoCount: selectedRepos.length
}),
[selectedRepos.length]
)
const taskSourceManuallyChangedRef = useRef(false)
const lastPageTaskSourceRef = useRef(pageData.taskSource)
const taskResumeAppliedRef = useRef(false)
@@ -2742,6 +2907,15 @@ export default function TaskPage(): React.JSX.Element {
const [gitlabView, setGitlabView] = useState<'issues' | 'mrs' | 'todos'>('mrs')
const [gitlabTodos, setGitlabTodos] = useState<GitLabTodo[]>([])
const [gitlabTodosLoading, setGitlabTodosLoading] = useState(false)
const gitlabEmptyState = useMemo(
() =>
getRepoBackedTaskEmptyState({
provider: 'gitlab',
selectedRepoCount: selectedRepos.length,
gitlabView
}),
[gitlabView, selectedRepos.length]
)
const gitlabFilterIsValid =
gitlabView === 'issues'
@@ -2802,7 +2976,13 @@ export default function TaskPage(): React.JSX.Element {
const trimmed = initialTaskQuery.trim()
const merged: GitHubWorkItem[] = []
for (const r of selectedRepos) {
const cached = getCachedWorkItems(r.id, PER_REPO_FETCH_LIMIT, trimmed, r.path)
const cached = getCachedWorkItems(
r.id,
PER_REPO_FETCH_LIMIT,
trimmed,
r.path,
getTaskPageRepoSourceContext(r, 'github')
)
if (cached) {
merged.push(...cached)
}
@@ -2847,7 +3027,7 @@ export default function TaskPage(): React.JSX.Element {
useShallow((s) =>
selectTaskPageWorkItemsCacheEntries(
s.workItemsCache,
selectedRepos,
selectedRepos.map(getTaskPageRepoCacheInput),
PER_REPO_FETCH_LIMIT,
appliedWorkItemsCacheQuery
)
@@ -2866,8 +3046,31 @@ export default function TaskPage(): React.JSX.Element {
const dialogWorkItem = dialogWorkItemKey
? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem)
: null
const pageGitHubDetailWorkItem = pageData.openGitHubWorkItem ? dialogWorkItem : null
const dialogRepoPath = dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null
const dialogSourceContext = useMemo(() => {
if (!dialogWorkItem) {
return null
}
if (
pageData.openGitHubSourceContext?.provider === 'github' &&
pageData.openGitHubWorkItem?.id === dialogWorkItem.id &&
pageData.openGitHubWorkItem.repoId === dialogWorkItem.repoId
) {
return pageData.openGitHubSourceContext
}
return getTaskPageRepoSourceContext(repoMap.get(dialogWorkItem.repoId), 'github')
}, [dialogWorkItem, pageData.openGitHubSourceContext, pageData.openGitHubWorkItem, repoMap])
const gitlabDialogRepo = useMemo(
() =>
gitlabDialogItem
? (selectedRepos.find((r) => r.id === gitlabDialogItem.repoId) ?? primaryRepo)
: null,
[gitlabDialogItem, primaryRepo, selectedRepos]
)
const gitlabDialogSourceContext = useMemo(
() => getTaskPageRepoSourceContext(gitlabDialogRepo, 'gitlab'),
[gitlabDialogRepo]
)
const setDialogWorkItem = useCallback(
(item: GitHubWorkItem | null, initialTab: ItemDialogTab = 'conversation') => {
@@ -2888,13 +3091,18 @@ export default function TaskPage(): React.JSX.Element {
const openGitHubDetailPage = useCallback(
(item: GitHubWorkItem, initialTab: ItemDialogTab = 'conversation') => {
// Why: in-list opens should float over the Tasks list. Direct opens
// from outside Tasks still pass `openGitHubWorkItem` through page state.
useAppStore.getState().recordFeatureInteraction('github-tasks')
setGithubMode('items')
setDialogWorkItem(item, initialTab)
openTaskPage(
{
taskSource: 'github',
preselectedRepoId: item.repoId,
openGitHubWorkItem: item,
openGitHubSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'),
openGitHubInitialTab: initialTab
},
{ recordTasksInteraction: false }
)
},
[setDialogWorkItem]
[openTaskPage, repoMap]
)
const patchTaskPageWorkItemRows = useCallback(
@@ -3151,6 +3359,7 @@ export default function TaskPage(): React.JSX.Element {
taskPageData: {
...s.taskPageData,
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openLinearIssue: undefined
}
@@ -3653,6 +3862,8 @@ export default function TaskPage(): React.JSX.Element {
return window.api.gl
.listIssues({
repoPath: repo.path,
repoId: repo.id,
sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'),
state: 'opened',
assignee: isAssignedToMe ? '@me' : undefined,
limit: 50
@@ -3673,6 +3884,8 @@ export default function TaskPage(): React.JSX.Element {
window.api.gl
.listMRs({
repoPath: repo.path,
repoId: repo.id,
sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'),
state: activeMRFilter ?? 'opened',
page: 1,
perPage: 50
@@ -3740,7 +3953,11 @@ export default function TaskPage(): React.JSX.Element {
let stale = false
setGitlabTodosLoading(true)
void window.api.gl
.todos({ repoPath: primaryRepo.path })
.todos({
repoPath: primaryRepo.path,
repoId: primaryRepo.id,
sourceContext: getTaskPageRepoSourceContext(primaryRepo, 'gitlab')
})
.then((todos) => {
if (!stale) {
setGitlabTodos(todos as GitLabTodo[])
@@ -3759,7 +3976,7 @@ export default function TaskPage(): React.JSX.Element {
return () => {
stale = true
}
}, [taskSource, gitlabView, gitlabRefreshNonce, primaryRepo?.path])
}, [taskSource, gitlabView, gitlabRefreshNonce, primaryRepo])
const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection
const [linearTeamSelection, setLinearTeamSelection] = useState<ReadonlySet<string>>(() => {
@@ -4859,7 +5076,12 @@ export default function TaskPage(): React.JSX.Element {
return
}
const q = stripRepoQualifiers(appliedTaskSearch.trim())
const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, path: r.path }))
const repoArgs = selectedRepos.map((r) => ({
repoId: r.id,
path: r.path,
executionHostId: r.executionHostId,
sourceContext: getTaskPageRepoSourceContext(r, 'github')
}))
const requestGeneration = paginationGenerationRef.current
const target = targetPage ?? pages.length
@@ -4975,7 +5197,13 @@ export default function TaskPage(): React.JSX.Element {
let anyUncached = false
let anyRepoCached = false
for (const r of selectedRepos) {
const cached = getCachedWorkItems(r.id, PER_REPO_FETCH_LIMIT, q, r.path)
const cached = getCachedWorkItems(
r.id,
PER_REPO_FETCH_LIMIT,
q,
r.path,
getTaskPageRepoSourceContext(r, 'github')
)
if (cached === null) {
anyUncached = true
} else {
@@ -5013,7 +5241,8 @@ export default function TaskPage(): React.JSX.Element {
const repoArgs = selectedRepos.map((r) => ({
repoId: r.id,
path: r.path,
executionHostId: r.executionHostId
executionHostId: r.executionHostId,
sourceContext: getTaskPageRepoSourceContext(r, 'github')
}))
const landingRefreshKey = `${repoArgs.map((r) => `${r.repoId}:${r.path}`).join('|')}::${q}`
const shouldProbeOnLanding =
@@ -5110,7 +5339,8 @@ export default function TaskPage(): React.JSX.Element {
selectedRepos.map((r) => ({
repoId: r.id,
path: r.path,
executionHostId: r.executionHostId
executionHostId: r.executionHostId,
sourceContext: getTaskPageRepoSourceContext(r, 'github')
})),
q
).then((count) => {
@@ -5325,12 +5555,13 @@ export default function TaskPage(): React.JSX.Element {
}
openModal('new-workspace-composer', {
linkedWorkItem,
taskSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'),
prefilledName: getGitHubWorkItemWorkspaceSeed(item),
initialRepoId: item.repoId,
telemetrySource: 'sidebar'
})
},
[openModal]
[openModal, repoMap]
)
const handleUseWorkItem = useCallback(
@@ -5391,12 +5622,13 @@ export default function TaskPage(): React.JSX.Element {
}
openModal('new-workspace-composer', {
linkedWorkItem,
taskSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'gitlab'),
prefilledName: getGitLabWorkItemWorkspaceSeed(item),
initialRepoId: item.repoId,
telemetrySource: 'sidebar'
})
},
[openModal]
[openModal, repoMap]
)
const handleUseGitLabItem = useCallback(
@@ -6679,6 +6911,12 @@ export default function TaskPage(): React.JSX.Element {
</Tooltip>
)
})}
<div
className="hidden min-w-0 max-w-[min(420px,40vw)] items-center rounded-md border border-border/50 bg-muted/35 px-2 py-1 text-xs text-muted-foreground sm:flex"
title={taskSourceContextSummary.title}
>
<span className="truncate">{taskSourceContextSummary.label}</span>
</div>
</div>
{taskSource === 'linear' && linearConnected ? (
<div className="flex items-center gap-2">
@@ -7646,13 +7884,13 @@ export default function TaskPage(): React.JSX.Element {
</section>
</div>
{taskSource === 'github' && pageGitHubDetailWorkItem ? (
pageGitHubDetailWorkItem.type === 'pr' ? (
{taskSource === 'github' && dialogWorkItem ? (
dialogWorkItem.type === 'pr' ? (
<PullRequestPage
workItem={pageGitHubDetailWorkItem}
workItem={dialogWorkItem}
initialTab={dialogInitialTab}
repoPath={dialogRepoPath}
repoId={pageGitHubDetailWorkItem.repoId}
repoId={dialogWorkItem.repoId}
backLabel="Pull requests"
onUse={(item) => {
setDialogWorkItem(null)
@@ -7663,10 +7901,10 @@ export default function TaskPage(): React.JSX.Element {
/>
) : (
<GitHubItemDialog
workItem={pageGitHubDetailWorkItem}
workItem={dialogWorkItem}
initialTab={dialogInitialTab}
repoPath={dialogRepoPath}
repoId={pageGitHubDetailWorkItem.repoId}
repoId={dialogWorkItem.repoId}
variant="page"
backLabel="GitHub list"
onUse={(item) => {
@@ -7842,13 +8080,10 @@ export default function TaskPage(): React.JSX.Element {
perRepoSourceState.every((s) => !s.error) ? (
<div className="px-4 py-10 text-center">
<p className="text-base font-medium text-foreground">
{translate('auto.components.TaskPage.d0e3c8f933', 'No matching GitHub work')}
{githubEmptyState.title}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{translate(
'auto.components.TaskPage.285bc21dc5',
'Change the query or clear it.'
)}
{githubEmptyState.description}
</p>
</div>
) : null}
@@ -8320,26 +8555,13 @@ export default function TaskPage(): React.JSX.Element {
</div>
) : null}
{!gitlabLoading && displayedGitLabItems.length === 0 && !gitlabError ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{primaryRepo
? gitlabView === 'issues'
? translate(
'auto.components.TaskPage.a9f256ecea',
'No GitLab issues match this filter.'
)
: gitlabView === 'mrs'
? translate(
'auto.components.TaskPage.cd7dc432a3',
'No GitLab MRs match this filter.'
)
: translate(
'auto.components.TaskPage.f294c500ef',
'No GitLab work matches this filter.'
)
: translate(
'auto.components.TaskPage.d6d08c1650',
'Select a project to see GitLab work items.'
)}
<div className="px-4 py-12 text-center">
<p className="text-base font-medium text-foreground">
{gitlabEmptyState.title}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{gitlabEmptyState.description}
</p>
</div>
) : null}
<div className="divide-y divide-border/50">
@@ -11145,7 +11367,7 @@ export default function TaskPage(): React.JSX.Element {
</Dialog>
<GitHubItemDialog
workItem={pageGitHubDetailWorkItem ? null : dialogWorkItem}
workItem={dialogWorkItem}
repoPath={
// Why: the dialog is for a single item — resolve its repoPath from the
// item's own repoId (set when fan-out merged the list) so it works in
@@ -11154,6 +11376,7 @@ export default function TaskPage(): React.JSX.Element {
dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null
}
repoId={dialogWorkItem?.repoId ?? null}
sourceContext={dialogSourceContext}
onUse={(item) => {
setDialogWorkItem(null)
handleUseWorkItem(item)
@@ -11166,13 +11389,9 @@ export default function TaskPage(): React.JSX.Element {
// Why: dialog's repoPath has to come from the clicked item's
// own repo, not primaryRepo — items may originate in any of
// the selected repos now that the GitLab fetch is multi-repo.
repoPath={
gitlabDialogItem
? (selectedRepos.find((r) => r.id === gitlabDialogItem.repoId)?.path ??
primaryRepo?.path ??
null)
: null
}
repoPath={gitlabDialogRepo?.path ?? null}
repoId={gitlabDialogItem?.repoId ?? null}
sourceContext={gitlabDialogSourceContext}
onCreateWorkspace={(item) => {
setGitlabDialogItem(null)
handleUseGitLabItem(item)
@@ -13,6 +13,7 @@ import {
formatAutomationTokens,
summarizeAutomationRunUsage
} from './automation-usage-model'
import type { AutomationTargetAvailability } from './automation-target-availability'
import { translate } from '@/i18n/i18n'
type AutomationDetailProps = {
@@ -21,6 +22,7 @@ type AutomationDetailProps = {
projectName: string
workspaceName: string
projectDefaultBaseRef: string | null
runNowAvailability: AutomationTargetAvailability | null
now: number
onRunNow: (automation: Automation) => void
onEdit: (automation: Automation) => void
@@ -86,6 +88,7 @@ export function AutomationDetail({
projectName,
workspaceName,
projectDefaultBaseRef,
runNowAvailability,
now,
onRunNow,
onEdit,
@@ -115,6 +118,7 @@ export function AutomationDetail({
automation.workspaceMode === 'new_per_run'
? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default')
: workspaceName
const runNowDisabled = runNowAvailability?.canRunNow === false
return (
<div className="flex w-full flex-col gap-4">
@@ -133,10 +137,26 @@ export function AutomationDetail({
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="secondary" size="sm" onClick={() => onRunNow(automation)}>
<Play className="size-4" />
{translate('auto.components.automations.AutomationDetail.2fb1605beb', 'Run Now')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
size="sm"
onClick={() => onRunNow(automation)}
disabled={runNowDisabled}
>
<Play className="size-4" />
{translate('auto.components.automations.AutomationDetail.2fb1605beb', 'Run Now')}
</Button>
</span>
</TooltipTrigger>
{runNowDisabled ? (
<TooltipContent side="bottom" sideOffset={6}>
{runNowAvailability.message}
</TooltipContent>
) : null}
</Tooltip>
<ToolbarIconButton
label={translate(
'auto.components.automations.AutomationDetail.4b1ea02d2e',
@@ -184,6 +204,12 @@ export function AutomationDetail({
</div>
) : null}
{runNowAvailability?.canRunNow === false ? (
<div className="rounded-md border border-border/50 bg-muted/40 p-3 text-sm text-muted-foreground shadow-sm">
{runNowAvailability.message}
</div>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-5 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
<DetailMetric
label={translate('auto.components.automations.AutomationDetail.18763ded26', 'Schedule')}
@@ -89,6 +89,7 @@ import {
import { AutomationRunPageFrame } from './AutomationRunPageFrame'
import { AutomationRunHistory } from './AutomationRunHistory'
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
@@ -123,6 +124,10 @@ function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null {
return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null
}
function getAutomationRunRepoId(automation: Automation): string {
return automation.runContext?.repoId ?? automation.projectId
}
function formatTimeInput(hour: number, minute: number): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
@@ -191,7 +196,7 @@ function getExternalProviderLabel(manager: ExternalAutomationManager): string {
}
function getExternalTargetKindLabel(manager: ExternalAutomationManager): string {
return manager.target.type === 'ssh' ? 'Remote SSH' : 'Local'
return manager.target.type === 'ssh' ? 'SSH host' : 'Local'
}
function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean {
@@ -482,9 +487,17 @@ export default function AutomationsPage(): React.JSX.Element {
canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage })
const isSelectedAutomationRunPageRerunPending =
selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id)
const selectedRepo = selected ? (repoMap.get(selected.projectId) ?? null) : null
const selectedRepo = selected ? (repoMap.get(getAutomationRunRepoId(selected)) ?? null) : null
const selectedWorktree =
selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null
const selectedRunNowAvailability = selected
? getAutomationTargetAvailability({
automation: selected,
repo: selectedRepo,
workspace: selectedWorktree,
sshConnectionStates
})
: null
const canSaveDraft =
editingAutomationId === null ||
!draftAtOpen ||
@@ -497,10 +510,14 @@ export default function AutomationsPage(): React.JSX.Element {
sourceKey: getExternalAutomationSourceKey(selectedExternal.manager)
}
: null
const selectedExternalSshStatus = selectedExternalSshSource
? sshConnectionStates.get(selectedExternalSshSource.connectionId)?.status
: undefined
const selectedExternalSshConnected = selectedExternalSshStatus === 'connected'
const isSelectedExternalSshConnecting =
selectedExternalSshSource !== null &&
(connectingExternalSourceKey === selectedExternalSshSource.sourceKey ||
isSshConnectionBusy(sshConnectionStates.get(selectedExternalSshSource.connectionId)?.status))
isSshConnectionBusy(selectedExternalSshStatus))
useEffect(() => {
if ((!selected || selectedExternal) && activePaneTab === 'runs') {
@@ -783,7 +800,7 @@ export default function AutomationsPage(): React.JSX.Element {
name: latest.name,
prompt: latest.prompt,
agentId: latest.agentId,
projectId: latest.projectId,
projectId: getAutomationRunRepoId(latest),
workspaceMode: latest.workspaceMode,
workspaceId: latest.workspaceId ?? '',
baseBranch: latest.baseBranch ?? '',
@@ -1195,6 +1212,18 @@ export default function AutomationsPage(): React.JSX.Element {
}
const runNow = async (automation: Automation): Promise<void> => {
const repo = repoMap.get(getAutomationRunRepoId(automation)) ?? null
const workspace = automation.workspaceId ? (worktreeMap.get(automation.workspaceId) ?? null) : null
const availability = getAutomationTargetAvailability({
automation,
repo,
workspace,
sshConnectionStates
})
if (!availability.canRunNow) {
toast.error(availability.message)
return
}
await window.api.automations.runNow({ id: automation.id })
useAppStore.getState().recordFeatureInteraction('automation-run')
await hydratePersistedUIState()
@@ -1373,6 +1402,16 @@ export default function AutomationsPage(): React.JSX.Element {
const sourceKey = getExternalAutomationSourceKey(manager)
setConnectingExternalSourceKey(sourceKey)
try {
if (sshConnectionStates.get(manager.target.connectionId)?.status === 'connected') {
await refresh()
toast.success(
translate(
'auto.components.automations.AutomationsPage.a21f6c33ad',
'Automation source refreshed.'
)
)
return
}
const state = await window.api.ssh.connect({ targetId: manager.target.connectionId })
if (!state || state.status !== 'connected') {
toast.error(
@@ -1753,10 +1792,16 @@ export default function AutomationsPage(): React.JSX.Element {
</div>
) : null}
{automations.map((automation) => {
const automationRepo = repoMap.get(automation.projectId)
const automationRepo = repoMap.get(getAutomationRunRepoId(automation))
const automationWorktree = automation.workspaceId
? worktreeMap.get(automation.workspaceId)
: null
const automationRunAvailability = getAutomationTargetAvailability({
automation,
repo: automationRepo,
workspace: automationWorktree,
sshConnectionStates
})
const workspaceLabel =
automation.workspaceMode === 'new_per_run'
? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}`
@@ -1836,12 +1881,25 @@ export default function AutomationsPage(): React.JSX.Element {
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onSelect={() => void runNow(automation)}>
<ContextMenuItem
disabled={!automationRunAvailability.canRunNow}
onSelect={(event) => {
if (!automationRunAvailability.canRunNow) {
event.preventDefault()
return
}
void runNow(automation)
}}
>
<Play className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)}
<span className="min-w-0 truncate">
{automationRunAvailability.canRunNow
? translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)
: automationRunAvailability.message}
</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => void openEditDialog(automation)}>
<Pencil className="size-3.5" />
@@ -1882,8 +1940,18 @@ export default function AutomationsPage(): React.JSX.Element {
const providerLabel = getExternalProviderLabel(entry.manager)
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
if (entry.kind === 'source') {
const sshStatus =
entry.manager.target.type === 'ssh'
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
: undefined
const sourceStatus =
entry.manager.target.type === 'ssh' ? 'Connect to load jobs' : 'Unavailable'
entry.manager.target.type === 'ssh'
? isSshConnectionBusy(sshStatus)
? 'Connecting...'
: sshStatus === 'connected'
? 'Source unavailable'
: 'Connect to load jobs'
: 'Unavailable'
const sourceSummary =
entry.manager.error ??
`${providerLabel} source unavailable until ${targetKindLabel.toLowerCase()} connects.`
@@ -2162,6 +2230,11 @@ export default function AutomationsPage(): React.JSX.Element {
'auto.components.automations.AutomationsPage.f93ed7a6f8',
'Connecting...'
)
: selectedExternalSshConnected
? translate(
'auto.components.automations.AutomationsPage.53f06f0ad5',
'Retry source'
)
: translate(
'auto.components.automations.AutomationsPage.7934ee0d81',
'Connect SSH'
@@ -2170,10 +2243,15 @@ export default function AutomationsPage(): React.JSX.Element {
) : null}
</div>
<div className="px-3 py-6 text-sm text-muted-foreground">
{translate(
'auto.components.automations.AutomationsPage.97ff587ee3',
'Connect this source to check for Hermes automations in the remote profile.'
)}
{selectedExternalSshConnected
? translate(
'auto.components.automations.AutomationsPage.f7f6fd7bf8',
'Install or repair the remote automation source, then retry to load jobs.'
)
: translate(
'auto.components.automations.AutomationsPage.97ff587ee3',
'Connect this source to check for Hermes automations in the remote profile.'
)}
</div>
</div>
)}
@@ -2213,6 +2291,7 @@ export default function AutomationsPage(): React.JSX.Element {
? 'New workspace each run'
: (selectedWorktree?.displayName ?? 'Missing workspace')
}
runNowAvailability={selectedRunNowAvailability}
now={relativeNow}
onRunNow={(automation) => void runNow(automation)}
onEdit={(automation) => void openEditDialog(automation)}
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import { CreateFromPicker } from './CreateFromPicker'
import {
getRuntimeRepoBaseRefDefault,
searchRuntimeRepoBaseRefs
} from '@/runtime/runtime-repo-client'
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/command', () => ({
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandInput: () => <input />,
CommandItem: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
const storeState = {
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
repos: [] as Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
}
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState)
}))
vi.mock('@/runtime/runtime-repo-client', () => ({
getRuntimeRepoBaseRefDefault: vi.fn().mockResolvedValue({
defaultBaseRef: 'main',
remoteCount: 1
}),
searchRuntimeRepoBaseRefs: vi.fn().mockResolvedValue([])
}))
let container: HTMLDivElement
let root: Root
function repoMapFor(repo: Repo): Map<string, Repo> {
return new Map([[repo.id, repo]])
}
function makeRepo(overrides: Partial<Repo>): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000000',
addedAt: 1,
...overrides
}
}
async function renderPicker(repo: Repo): Promise<void> {
await act(async () => {
root.render(
<CreateFromPicker
repoId={repo.id}
repoMap={repoMapFor(repo)}
worktrees={[]}
value=""
onValueChange={vi.fn()}
/>
)
})
}
describe('CreateFromPicker host routing', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
vi.clearAllMocks()
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
storeState.repos = []
})
it('uses the selected runtime-owned repo host instead of the focused runtime', async () => {
const repo = makeRepo({ executionHostId: 'runtime:owner-runtime' })
storeState.repos = [repo]
await renderPicker(repo)
expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: 'owner-runtime' },
repo.id
)
})
it('keeps an explicit local repo on the local client even when a runtime is focused', async () => {
const repo = makeRepo({ executionHostId: 'local' })
storeState.repos = [repo]
await renderPicker(repo)
expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
repo.id
)
expect(searchRuntimeRepoBaseRefs).not.toHaveBeenCalled()
})
})
@@ -59,7 +59,7 @@ function getProviderLabel(manager: ExternalAutomationManager): string {
}
function getTargetKindLabel(manager: ExternalAutomationManager): string {
return manager.target.type === 'ssh' ? 'Remote SSH' : 'Local'
return manager.target.type === 'ssh' ? 'SSH host' : 'Local'
}
function ExternalActionButton({
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest'
import type { Automation } from '../../../../shared/automations-types'
import type { Repo, Worktree } from '../../../../shared/types'
import { getAutomationTargetAvailability } from './automation-target-availability'
function makeAutomation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'automation-1',
name: 'Nightly',
prompt: 'Run checks',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'existing',
workspaceId: 'worktree-1',
baseBranch: null,
reuseSession: false,
timezone: 'America/Los_Angeles',
rrule: 'FREQ=DAILY',
dtstart: 1,
enabled: true,
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
...overrides
}
}
function makeWorkspace(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'worktree-1',
repoId: 'repo-1',
path: '/repo',
displayName: 'Main',
...overrides
} as Worktree
}
describe('automation target availability', () => {
it('allows local automations with an available existing workspace', () => {
expect(
getAutomationTargetAvailability({
automation: makeAutomation(),
repo: makeRepo(),
workspace: makeWorkspace(),
sshConnectionStates: new Map()
})
).toEqual({ canRunNow: true, reason: 'available', message: null })
})
it('blocks missing projects and missing existing workspaces', () => {
expect(
getAutomationTargetAvailability({
automation: makeAutomation(),
repo: null,
workspace: makeWorkspace(),
sshConnectionStates: new Map()
}).reason
).toBe('missing-project')
expect(
getAutomationTargetAvailability({
automation: makeAutomation(),
repo: makeRepo(),
workspace: null,
sshConnectionStates: new Map()
}).reason
).toBe('missing-workspace')
})
it('blocks a saved run context that no longer matches the repo host setup', () => {
expect(
getAutomationTargetAvailability({
automation: makeAutomation({
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:devbox',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo'
}
}),
repo: makeRepo(),
workspace: makeWorkspace(),
sshConnectionStates: new Map()
}).reason
).toBe('host-mismatch')
})
it('requires SSH hosts to be connected before manual runs', () => {
const automation = makeAutomation({
executionTargetType: 'ssh',
executionTargetId: 'devbox',
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:devbox',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo'
}
})
const repo = makeRepo({ connectionId: 'devbox', executionHostId: 'ssh:devbox' })
expect(
getAutomationTargetAvailability({
automation,
repo,
workspace: makeWorkspace(),
sshConnectionStates: new Map([['devbox', { status: 'connected' }]])
}).canRunNow
).toBe(true)
expect(
getAutomationTargetAvailability({
automation,
repo,
workspace: makeWorkspace(),
sshConnectionStates: new Map([['devbox', { status: 'disconnected' }]])
}).reason
).toBe('ssh-unavailable')
expect(
getAutomationTargetAvailability({
automation,
repo,
workspace: makeWorkspace(),
sshConnectionStates: new Map([['devbox', { status: 'auth-failed' }]])
}).reason
).toBe('ssh-auth-needed')
expect(
getAutomationTargetAvailability({
automation,
repo,
workspace: makeWorkspace(),
sshConnectionStates: new Map([['devbox', { status: 'reconnecting' }]])
}).reason
).toBe('ssh-connecting')
})
})
@@ -0,0 +1,102 @@
import type { Automation } from '../../../../shared/automations-types'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { Repo, Worktree } from '../../../../shared/types'
export type AutomationTargetAvailability =
| {
canRunNow: true
reason: 'available'
message: null
}
| {
canRunNow: false
reason:
| 'missing-project'
| 'missing-workspace'
| 'host-mismatch'
| 'unsupported-host'
| 'ssh-auth-needed'
| 'ssh-unavailable'
| 'ssh-connecting'
message: string
}
type AutomationTargetAvailabilityArgs = {
automation: Automation
repo: Repo | null | undefined
workspace: Worktree | null | undefined
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
}
export function getAutomationTargetAvailability({
automation,
repo,
workspace,
sshConnectionStates
}: AutomationTargetAvailabilityArgs): AutomationTargetAvailability {
if (!repo) {
return unavailable('missing-project', 'The target project is no longer available.')
}
if (automation.runContext) {
const parsedHost = parseExecutionHostId(automation.runContext.hostId)
if (parsedHost?.kind === 'runtime') {
return unavailable(
'unsupported-host',
'This automation targets a remote server that this client cannot run manually yet.'
)
}
if (
automation.runContext.repoId !== repo.id ||
automation.runContext.path !== repo.path ||
automation.runContext.hostId !== getRepoExecutionHostId(repo)
) {
return unavailable(
'host-mismatch',
'The saved run host no longer matches this project setup.'
)
}
}
if (automation.workspaceMode === 'existing' && !workspace) {
return unavailable('missing-workspace', 'The target workspace is no longer available.')
}
const sshTargetId = getAutomationSshTargetId(automation, repo)
if (!sshTargetId) {
return { canRunNow: true, reason: 'available', message: null }
}
const status = sshConnectionStates.get(sshTargetId)?.status ?? 'disconnected'
switch (status) {
case 'connected':
return { canRunNow: true, reason: 'available', message: null }
case 'auth-failed':
case 'reconnection-failed':
return unavailable('ssh-auth-needed', 'Connect this SSH host before running manually.')
case 'connecting':
case 'deploying-relay':
case 'reconnecting':
return unavailable('ssh-connecting', 'This SSH host is still connecting.')
case 'disconnected':
case 'error':
return unavailable('ssh-unavailable', 'Connect this SSH host before running manually.')
}
}
function getAutomationSshTargetId(automation: Automation, repo: Repo): string | null {
const parsedHost = parseExecutionHostId(automation.runContext?.hostId)
if (parsedHost?.kind === 'ssh') {
return parsedHost.targetId
}
if (automation.executionTargetType === 'ssh' && automation.executionTargetId.trim()) {
return automation.executionTargetId
}
return repo.connectionId?.trim() || null
}
function unavailable(
reason: Exclude<AutomationTargetAvailability['reason'], 'available'>,
message: string
): AutomationTargetAvailability {
return { canRunNow: false, reason, message }
}
@@ -5,6 +5,7 @@
// through to `fieldValuesByFieldId[field.id].kind` as a safety net so a
// fetched value is never silently dropped.
import React, { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { CircleDot, FileText, GitPullRequest, Lock, Plus } from 'lucide-react'
import { TYPE_FIELD_DATA_TYPE } from './columns'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -294,8 +295,8 @@ function IssueTypeCell({
() => lookupSlug(row.content.repository)[0] ?? null,
[lookupSlug, row.content.repository]
)
const ownerSettings = useAppStore((s) =>
getSettingsForRepoRuntimeOwner(s, matchedRepo?.id ?? null)
const ownerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, matchedRepo?.id ?? null))
)
React.useEffect(() => {
@@ -75,6 +75,11 @@ function listProjectViewsForRuntime(
: window.api.gh.listProjectViews(args)
}
function getProjectViewSourceScope(settings: Parameters<typeof getActiveRuntimeTarget>[0]): string {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
}
export default function ProjectViewWrapper(_props: Props = {} as Props): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const projectViewCache = useAppStore((s) => s.projectViewCache)
@@ -89,6 +94,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
const mountedRef = useMountedRef()
const activeProject = settings?.githubProjects?.activeProject ?? null
const projectViewSourceScope = useMemo(() => getProjectViewSourceScope(settings), [settings])
const lastViewByProject = useMemo(
() => settings?.githubProjects?.lastViewByProject ?? {},
[settings?.githubProjects?.lastViewByProject]
@@ -172,14 +178,15 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
if (!viewId) {
return
}
const projectViewKey = `${key}:${viewId}`
const projectViewKey = `${projectViewSourceScope}:${key}:${viewId}`
const queryOverride = appliedQueryByView[projectViewKey]
const cacheKey = projectViewCacheKey(
activeProject.ownerType,
activeProject.owner,
activeProject.number,
viewId,
queryOverride
queryOverride,
projectViewSourceScope
)
if (projectViewCache[cacheKey]?.data) {
return
@@ -194,7 +201,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
false,
queryOverride
)
}, [activeProject, lastViewByProject, projectViewCache, doFetch, appliedQueryByView])
}, [
activeProject,
lastViewByProject,
projectViewCache,
doFetch,
appliedQueryByView,
projectViewSourceScope
])
// Load the project's view list whenever the active project changes so the
// tab strip can render. The list is small and rarely changes — fetched once
@@ -203,7 +217,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
if (!activeProject) {
return
}
const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
const projectKey = `${projectViewSourceScope}:${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
if (viewListByProject[projectKey]) {
return
}
@@ -234,7 +248,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
return () => {
cancelled = true
}
}, [activeProject, viewListByProject, settings])
}, [activeProject, viewListByProject, settings, projectViewSourceScope])
const handleSwitchView = useCallback(
async (viewId: string) => {
@@ -286,8 +300,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
if (!viewId) {
return null
}
return `${key}:${viewId}`
}, [activeProject, lastViewByProject])
return `${projectViewSourceScope}:${key}:${viewId}`
}, [activeProject, lastViewByProject, projectViewSourceScope])
const currentAppliedOverride = currentProjectViewKey
? appliedQueryByView[currentProjectViewKey]
@@ -307,9 +321,10 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
activeProject.owner,
activeProject.number,
viewId,
currentAppliedOverride
currentAppliedOverride,
projectViewSourceScope
)
}, [activeProject, lastViewByProject, currentAppliedOverride])
}, [activeProject, lastViewByProject, currentAppliedOverride, projectViewSourceScope])
const table: GitHubProjectTable | null = currentCacheKey
? (projectViewCache[currentCacheKey]?.data ?? null)
@@ -746,7 +761,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
{activeProject
? (() => {
const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
const views = viewListByProject[projectKey] ?? []
const scopedProjectKey = `${projectViewSourceScope}:${projectKey}`
const views = viewListByProject[scopedProjectKey] ?? []
const activeViewId = lastViewByProject[projectKey]?.viewId ?? null
return (
<ViewTabStrip
@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { Send } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -25,8 +26,10 @@ function useRuntimeSettingsForSlug(owner: string, repo: string) {
() => lookupSlug(`${owner}/${repo}`)[0] ?? null,
[lookupSlug, owner, repo]
)
return useAppStore((s) =>
matchedRepo ? getSettingsForRepoRuntimeOwner(s, matchedRepo.id) : s.settings
return useAppStore(
useShallow((s) =>
matchedRepo ? getSettingsForRepoRuntimeOwner(s, matchedRepo.id) : s.settings
)
)
}
@@ -42,10 +42,6 @@ import {
PRCommentsList,
PRTriageStrip
} from './checks-panel-content'
import {
clearPRCommentsListSelection,
type PRCommentsListSelectionClearRequest
} from './pr-comments-list-selection'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
import type {
GitLabDiscussionResolveResult,
@@ -89,21 +85,14 @@ import {
restorePRCommentThreadSnapshot
} from './pr-comment-thread-resolution'
import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller'
import {
CHECKS_PANEL_BASE_POLL_INTERVAL_MS,
nextChecksPanelPollInterval
} from './checks-panel-polling'
import {
getChecksPanelEmptyStateCopy,
shouldShowChecksPanelPublishBranchAction
} from './checks-panel-empty-state'
import {
cancelRuntimeGeneratePullRequestFields,
generateRuntimePullRequestFields,
getRuntimeGitScope,
getRuntimeGitStatus,
getRuntimeGitUpstreamStatus,
type RuntimeGeneratePullRequestFieldsOverrides
getRuntimeGitUpstreamStatus
} from '@/runtime/runtime-git-client'
import {
buildChecksPanelGitStatusContextKey,
@@ -138,24 +127,10 @@ import {
type SourceControlAiWriteTarget
} from '../../../../shared/source-control-ai-recipe-save'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
import { formatCreateError } from './create-pull-request-review-copy'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import {
resolveChecksPanelHostedReviewBaseRef,
shouldOpenChecksPanelCreateComposer
} from './checks-panel-review-creation'
import {
createRunningPullRequestGenerationRecord,
getPullRequestGenerationRecordKey,
resolvePullRequestGenerationCancel,
resolvePullRequestGenerationFailure,
resolvePullRequestGenerationSuccess,
shouldHydratePullRequestGenerationResult,
type PullRequestFieldRevisions,
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
import { translate } from '@/i18n/i18n'
import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups'
@@ -206,8 +181,7 @@ export function ChecksPanelReviewHeader({
const reviewNumberLabel = review.provider === 'gitlab' ? `!${review.number}` : `#${review.number}`
const ReviewIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
const reviewHostLabel = review.provider === 'gitlab' ? 'GitLab' : 'GitHub'
const showPullRequestMenu = review.provider === 'github' || review.provider === 'gitlab'
const shortReviewLabel = review.provider === 'gitlab' ? 'MR' : 'PR'
const showPullRequestMenu = review.provider === 'github'
return (
<div className="flex items-center gap-2">
@@ -249,14 +223,12 @@ export function ChecksPanelReviewHeader({
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1',
'More {{value0}} actions',
{ value0: shortReviewLabel }
'auto.components.right.sidebar.ChecksPanel.653c105ecc',
'More PR actions'
)}
title={translate(
'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1',
'More {{value0}} actions',
{ value0: shortReviewLabel }
'auto.components.right.sidebar.ChecksPanel.653c105ecc',
'More PR actions'
)}
className="text-muted-foreground hover:text-foreground"
>
@@ -266,21 +238,12 @@ export function ChecksPanelReviewHeader({
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem disabled={!canUnlinkPullRequest} onSelect={onUnlinkPullRequest}>
<Unlink className="size-3.5" />
{translate(
'auto.components.right.sidebar.ChecksPanel.a9d7c128e4',
'unlink {{value0}}',
{ value0: shortReviewLabel }
)}
{translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onLinkAnotherPullRequest}>
<Link className="size-3.5" />
{translate('auto.components.right.sidebar.ChecksPanel.07871c0589', 'Link another PR')}
</DropdownMenuItem>
{review.provider === 'github' ? (
<DropdownMenuItem onSelect={onLinkAnotherPullRequest}>
<Link className="size-3.5" />
{translate(
'auto.components.right.sidebar.ChecksPanel.07871c0589',
'Link another PR'
)}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -378,12 +341,6 @@ export default function ChecksPanel(): React.JSX.Element {
(s) => s.getHostedReviewCreationEligibility
)
const createHostedReview = useAppStore((s) => s.createHostedReview)
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
(s) => s.allocatePullRequestGenerationRequestId
)
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
const conflictOperation = useAppStore((s) =>
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
@@ -440,8 +397,6 @@ export default function ChecksPanel(): React.JSX.Element {
const [agentComposerState, setAgentComposerState] = useState<ChecksAgentComposerState | null>(
null
)
const [commentSelectionClearRequest, setCommentSelectionClearRequest] =
useState<PRCommentsListSelectionClearRequest | null>(null)
const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] =
useState<HostedReviewCreationSnapshot | null>(null)
const [gitStatusSnapshot, setGitStatusSnapshot] = useState<ChecksPanelGitStatusSnapshot | null>(
@@ -453,7 +408,7 @@ export default function ChecksPanel(): React.JSX.Element {
const [titleSaving, setTitleSaving] = useState(false)
const titleInputRef = useRef<HTMLInputElement>(null)
const titleInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollIntervalRef = useRef(CHECKS_PANEL_BASE_POLL_INTERVAL_MS)
const pollIntervalRef = useRef(30_000) // start at 30s, backs off to 120s
const mountedRef = useMountedRef()
const confirm = useConfirmationDialog()
const prevChecksRef = useRef<string>('')
@@ -501,25 +456,6 @@ export default function ChecksPanel(): React.JSX.Element {
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const activeWorktreePath = activeWorktree?.path ?? null
const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null
const hostedReviewCreationBaseRef = resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: activeWorktree?.baseRef,
repoBaseRef: repo?.worktreeBaseRef
})
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
worktreeId: activeWorktreeId,
worktreePath: activeWorktreePath,
repoId: repo?.id,
branch
})
const activePullRequestGenerationRecordCandidate = activePullRequestGenerationKey
? (prGenerationRecords[activePullRequestGenerationKey] ?? null)
: null
const activePullRequestGenerationRecord =
activePullRequestGenerationRecordCandidate &&
activePullRequestGenerationRecordCandidate.context.repoId === repo?.id &&
activePullRequestGenerationRecordCandidate.context.branch === branch
? activePullRequestGenerationRecordCandidate
: null
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
connectionId: activeConnectionId,
worktreePath: activeWorktreePath
@@ -597,7 +533,7 @@ export default function ChecksPanel(): React.JSX.Element {
setHostedReviewCreationSnapshot(null)
setGitStatusSnapshot(null)
setGitStatusRefreshNonce((value) => value + 1)
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
conflictSummaryRefreshKeyRef.current = null
refreshRequestKeyRef.current = null
@@ -703,7 +639,7 @@ export default function ChecksPanel(): React.JSX.Element {
runtimeEnvironmentId,
connectionId: repoConnectionId,
branch,
base: hostedReviewCreationBaseRef,
base: repo.worktreeBaseRef ?? null,
hasUncommittedChanges:
gitStatusSnapshot?.contextKey === panelContextKey
? gitStatusSnapshot.hasUncommittedChanges
@@ -758,141 +694,6 @@ export default function ChecksPanel(): React.JSX.Element {
const connectionId = activeConnectionId ?? undefined
await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId)
}, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus])
const handleGeneratePullRequestFieldsForActive = useCallback(
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<void> => {
if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) {
return
}
const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
}
const requestId = allocatePullRequestGenerationRequestId()
const context: PullRequestGenerationContext = {
worktreeId: activeWorktreeId,
worktreePath: activeWorktreePath,
connectionId: activeConnectionId ?? undefined,
requestId,
repoId: repo.id,
branch
}
const seed = { ...fields }
// Why: Checks stays mounted across tab switches, but the composer itself
// can unmount when eligibility refreshes; keep generation tied to the branch.
setPullRequestGenerationRecord(
generationKey,
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
)
try {
const result = await generateRuntimePullRequestFields(
{
settings: useAppStore.getState().settings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
},
{
base: stripBaseRef((seed.base ?? '').trim()),
title: seed.title,
body: seed.body,
draft: seed.draft
},
overrides
)
if (result.branchChangedByPreparation) {
await handleBranchChangedByPullRequestGeneration()
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
updatePullRequestGenerationRecord(generationKey, (record) => {
if (!result.success) {
return resolvePullRequestGenerationFailure({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
})
}
if (!record) {
return null
}
return resolvePullRequestGenerationSuccess({
record,
requestId,
result: {
base: stripBaseRef(result.fields.base),
title: result.fields.title,
body: result.fields.body,
draft: result.fields.draft
}
})
})
} catch (error) {
updatePullRequestGenerationRecord(generationKey, (record) =>
resolvePullRequestGenerationFailure({
record,
requestId,
error:
error instanceof Error ? error.message : 'Failed to generate pull request details'
})
)
}
},
[
activeConnectionId,
activePullRequestGenerationKey,
activeWorktreeId,
activeWorktreePath,
allocatePullRequestGenerationRequestId,
branch,
handleBranchChangedByPullRequestGeneration,
repo,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord
]
)
const handleCancelGeneratePullRequestFieldsForActive = useCallback((): void => {
if (!activePullRequestGenerationKey) {
return
}
const record = prGenerationRecords[activePullRequestGenerationKey]
if (!record || record.status !== 'running') {
return
}
const generationKey = activePullRequestGenerationKey
updatePullRequestGenerationRecord(generationKey, (current) => {
if (!current || current.context.requestId !== record.context.requestId) {
return null
}
return resolvePullRequestGenerationCancel(current)
})
void cancelRuntimeGeneratePullRequestFields({
settings: useAppStore.getState().settings,
worktreeId: record.context.worktreeId,
worktreePath: record.context.worktreePath,
connectionId: record.context.connectionId
}).catch((error) => {
updatePullRequestGenerationRecord(generationKey, (current) => {
if (!current || current.context.requestId !== record.context.requestId) {
return null
}
return {
...current,
status: 'failed',
error: error instanceof Error ? error.message : 'Failed to stop pull request generation',
hydrated: false
}
})
})
}, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord])
const prCreationDefaults = useMemo(() => {
if (!settings) {
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
@@ -915,12 +716,12 @@ export default function ChecksPanel(): React.JSX.Element {
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
})
}, [repo, settings])
const createComposerOpen = shouldOpenChecksPanelCreateComposer({
activeReview,
isFolder,
branch,
hostedReviewCreation
})
const createComposerOpen =
!activeReview &&
!isFolder &&
Boolean(branch) &&
(hostedReviewCreation?.canCreate === true ||
hostedReviewCreation?.blockedReason === 'needs_push')
const {
aiGenerationEnabled: prAiGenerationEnabled,
base: prBase,
@@ -941,8 +742,7 @@ export default function ChecksPanel(): React.JSX.Element {
generateDisabled: prGenerateDisabled,
generateDisabledReason: prGenerateDisabledReason,
handleGenerate: handleGeneratePullRequestFields,
handleCancelGenerate: handleCancelGeneratePullRequestFields,
applyGeneratedFields: applyGeneratedPullRequestFields
handleCancelGenerate: handleCancelGeneratePullRequestFields
} = useCreatePullRequestDialogFields({
open: createComposerOpen,
repoId: repo?.id ?? '',
@@ -954,57 +754,8 @@ export default function ChecksPanel(): React.JSX.Element {
settings,
submitting: isCreatingPr,
prCreationDefaults,
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration,
generation: {
generating: activePullRequestGenerationRecord?.status === 'running',
generateError: activePullRequestGenerationRecord?.error ?? null,
onGenerate: (fields, fieldRevisions, overrides) => {
void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides)
},
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
}
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration
})
useEffect(() => {
if (
!activePullRequestGenerationKey ||
!activePullRequestGenerationRecord ||
activePullRequestGenerationRecord.status !== 'succeeded' ||
!activePullRequestGenerationRecord.result ||
activePullRequestGenerationRecord.hydrated
) {
return
}
if (
!shouldHydratePullRequestGenerationResult({
record: activePullRequestGenerationRecord
})
) {
return
}
applyGeneratedPullRequestFields(
activePullRequestGenerationRecord.result,
activePullRequestGenerationRecord.seedFieldRevisions
)
updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) => {
if (
!record ||
record.context.requestId !== activePullRequestGenerationRecord.context.requestId
) {
return null
}
return {
...record,
hydrated: true
}
})
}, [
activePullRequestGenerationKey,
activePullRequestGenerationRecord,
applyGeneratedPullRequestFields,
updatePullRequestGenerationRecord
])
const handlePrBaseChange = useCallback(
(value: string): void => {
setCreatePrError(null)
@@ -1253,9 +1004,10 @@ export default function ChecksPanel(): React.JSX.Element {
let stale = false
void getHostedReviewCreationEligibility({
repoPath: repo.path,
repoId: repo.id,
...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}),
branch,
base: hostedReviewCreationBaseRef,
base: repo.worktreeBaseRef ?? null,
hasUncommittedChanges,
hasUpstream: remoteStatus?.hasUpstream,
ahead: remoteStatus?.ahead,
@@ -1293,7 +1045,6 @@ export default function ChecksPanel(): React.JSX.Element {
getHostedReviewCreationEligibility,
gitStatusReadyForPanelContext,
hasUncommittedChanges,
hostedReviewCreationBaseRef,
hostedReviewCreationRequestKey,
isFolder,
isPanelVisible,
@@ -1392,13 +1143,14 @@ export default function ChecksPanel(): React.JSX.Element {
}
setChecks(result)
const poll = nextChecksPanelPollInterval({
checks: result,
previousSignature: prevChecksRef.current,
currentIntervalMs: pollIntervalRef.current
})
pollIntervalRef.current = poll.intervalMs
prevChecksRef.current = poll.signature
// Exponential backoff: if checks haven't changed, double the interval (cap 120s).
// If they changed, reset to 30s.
const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
prevChecksRef.current = signature
} catch (err) {
if (
!isCurrentAsyncResult(
@@ -1471,13 +1223,12 @@ export default function ChecksPanel(): React.JSX.Element {
const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? [])
setChecks(result)
setComments(gitLabMRCommentsToPRComments(details?.comments))
const poll = nextChecksPanelPollInterval({
checks: result,
previousSignature: prevChecksRef.current,
currentIntervalMs: pollIntervalRef.current
})
pollIntervalRef.current = poll.intervalMs
prevChecksRef.current = poll.signature
const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
prevChecksRef.current = signature
} catch (err) {
if (!isCurrentAsyncResult(requestKey)) {
return
@@ -1514,7 +1265,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
// Reset backoff state on PR change
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
// Why: PR check status is user-visible when the panel is open. Keep visible
// unfocused windows fresh, but stop timers and API work while hidden.
@@ -1529,7 +1280,7 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
return installWindowVisibilityTimeoutPoller({
run: () => fetchGitLabDetails(),
@@ -1781,13 +1532,14 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
setChecks(result)
const poll = nextChecksPanelPollInterval({
checks: result,
previousSignature: prevChecksRef.current,
currentIntervalMs: pollIntervalRef.current
})
pollIntervalRef.current = poll.intervalMs
prevChecksRef.current = poll.signature
const signature = JSON.stringify(
result.map((c) => `${c.name}:${c.status}:${c.conclusion}`)
)
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
prevChecksRef.current = signature
},
(err) => {
if (!isCurrentRequest() || !isCurrentAsyncResult(prRequestKey)) {
@@ -1865,8 +1617,9 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
// Why: entering the Checks tab is automatic UI behavior, not an explicit
// user refresh. Keep check loads cacheable so the GitHub CLI cache can
// absorb visible polling; only the manual refresh button bypasses it.
// user refresh. Route PR refresh through the coordinator so rate-limit
// guards still apply; only force detail panes that the entry freshness rule
// already proved stale, so tab entry stays fresh without broad fan-out.
if (isGitLabReviewContext) {
void fetchHostedReviewForBranch(repo.path, branch, {
force: true,
@@ -1882,7 +1635,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
enqueueGitHubPRRefresh(activeWorktreeId, 'active', 80)
if (options.refreshChecks) {
void fetchChecks()
void fetchChecks({ force: true })
}
if (options.refreshComments) {
void fetchComments({ force: true })
@@ -1946,9 +1699,9 @@ export default function ChecksPanel(): React.JSX.Element {
const refreshComments =
prNumber !== null && (commentsFetchedAt === undefined || commentsFetchedAt < cutoff)
// Reset polling attention state so this entry refresh establishes a fresh
// baseline rather than colliding with the previous PR's backoff.
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
// Reset polling attention state so the forced fetch's signature establishes
// a fresh baseline rather than colliding with the previous PR's backoff.
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
handleEntryRefresh({ refreshChecks, refreshComments })
}, [entryKey, prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, handleEntryRefresh])
@@ -2360,7 +2113,7 @@ export default function ChecksPanel(): React.JSX.Element {
),
description: translate(
'auto.components.right.sidebar.ChecksPanel.abf59262fb',
'Review the prompt before starting an agent.'
'Review and edit the full command input before starting an agent.'
),
prompt: buildResolvePullRequestConflictsPrompt({
reviewKind: activeConflictReview.provider === 'gitlab' ? 'MR' : 'PR',
@@ -2735,17 +2488,11 @@ export default function ChecksPanel(): React.JSX.Element {
}, [activeReview, activeWorktreeId])
const handleUnlinkPullRequest = useCallback(() => {
if (!activeWorktreeId || !activeReview) {
if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) {
return
}
if (activeReview.provider === 'github' && linkedPR !== null) {
void updateWorktreeMeta(activeWorktreeId, { linkedPR: null })
return
}
if (activeReview.provider === 'gitlab' && linkedGitLabMR !== null) {
void updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: null })
}
}, [activeReview, activeWorktreeId, linkedGitLabMR, linkedPR, updateWorktreeMeta])
void updateWorktreeMeta(activeWorktreeId, { linkedPR: null })
}, [activeReview?.provider, activeWorktreeId, linkedPR, updateWorktreeMeta])
const handleLinkAnotherPullRequest = useCallback(() => {
if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') {
@@ -2940,6 +2687,7 @@ export default function ChecksPanel(): React.JSX.Element {
pushed = true
}
const result = await createHostedReview(repo.path, {
repoId: repo.id,
provider: hostedReviewCreateProvider,
base,
head: normalizeHostedReviewHeadRef(branch),
@@ -3226,9 +2974,7 @@ export default function ChecksPanel(): React.JSX.Element {
<ChecksPanelReviewHeader
review={activeReview}
isRefreshing={isRefreshing}
canUnlinkPullRequest={
activeReview.provider === 'gitlab' ? linkedGitLabMR !== null : linkedPR !== null
}
canUnlinkPullRequest={linkedPR !== null}
onRefresh={() => void handleRefresh()}
onOpenReview={handleOpenPR}
onUnlinkPullRequest={handleUnlinkPullRequest}
@@ -3328,7 +3074,7 @@ export default function ChecksPanel(): React.JSX.Element {
</>
)}
{/* Why: when the hosted review has merge conflicts and no checks have been fetched,
showing an empty checks state is misleading — checks may exist but
showing "No checks configured" is misleading — checks may exist but
simply cannot run until conflicts are resolved. Hide the empty state. */}
{!(activeConflictReview && checks.length === 0 && !checksLoading) && (
<ChecksList
@@ -3345,7 +3091,6 @@ export default function ChecksPanel(): React.JSX.Element {
commentsDisabled={!canTargetPRComments}
commentsDisabledReason={commentsDisabledReason}
selectionContextKey={stateRequestKey}
selectionClearRequest={commentSelectionClearRequest}
resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)}
resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason}
onAddComment={pr ? handleAddPRComment : undefined}
@@ -3409,14 +3154,6 @@ export default function ChecksPanel(): React.JSX.Element {
onLaunched={() => {
const launchedState = agentComposerState
if (launchedState?.actionId === 'resolveComments' && launchedState.commentResolution) {
const { reviewContextKey } = launchedState.commentResolution
// Why: once the selected comments are sent to the agent, the queue
// is spent even if the host later rejects thread resolution.
clearPRCommentsListSelection(reviewContextKey)
setCommentSelectionClearRequest((prev) => ({
contextKey: reviewContextKey,
token: (prev?.token ?? 0) + 1
}))
void resolveSelectedThreadsAfterLaunch(launchedState.commentResolution).catch((err) => {
console.warn('Failed to resolve selected review comments after AI launch:', err)
toast.error(
@@ -146,11 +146,6 @@ import {
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
import { PullRequestIcon } from './checks-panel-content'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import {
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS,
RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS,
RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS
} from './right-sidebar-primary-action-layout'
import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel'
import type { GitHistoryItem } from '../../../../shared/git-history'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
@@ -161,8 +156,6 @@ import type {
GitBranchCompareSummary,
GitConflictOperation,
GitStatusEntry,
GlobalSettings,
Repo,
SourceControlViewMode,
TuiAgent
} from '../../../../shared/types'
@@ -215,14 +208,6 @@ import {
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import {
createRunningCommitMessageGenerationRecord,
getCommitMessageGenerationRecordKey,
markCommitMessageGenerationHydrated,
resolveCommitMessageGenerationCancel,
resolveCommitMessageGenerationFailure,
resolveCommitMessageGenerationSuccess
} from '@/store/slices/commit-message-generation'
export {
appendCommitFailureCustomInstruction,
@@ -239,30 +224,6 @@ export {
export type SourceControlScope = 'all' | 'uncommitted'
type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'>
type AbortActionErrorKind = 'abort_merge' | 'abort_rebase'
type CommitMessageGenerationTargetSnapshot = {
worktreeId: string
worktreePath: string
connectionId?: string
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
settings: GlobalSettings | null
discoveryHostKey: string
}
type CommitMessageGenerationOptions = RuntimeGenerateCommitMessageOverrides & {
target?: CommitMessageGenerationTargetSnapshot
}
type PullRequestGenerationTargetSnapshot = {
worktreeId: string | null
worktreePath: string
connectionId?: string
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
repoId: string
branch: string
settings: GlobalSettings | null
discoveryHostKey: string
fields: PullRequestGenerationFields
fieldRevisions: PullRequestFieldRevisions
}
export type SourceControlActionError = {
kind: RemoteOpKind | AbortActionErrorKind
message: string
@@ -890,6 +851,15 @@ function SourceControlInner(): React.JSX.Element {
const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const confirmAction = useConfirmationDialog()
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
// Why: parallel state to commit. Same per-worktree shape so navigating between
// worktrees mid-generation never silently cancels the in-flight request.
const generateInFlightRef = useRef<Record<string, boolean>>({})
const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const [generateErrors, setGenerateErrors] = useState<Record<string, string | null>>({})
const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const generateError = generateErrors[activeWorktreeId ?? ''] ?? null
const [hostedReviewCreationState, setHostedReviewCreationState] =
useState<HostedReviewCreationState | null>(null)
const createPrInFlightRef = useRef<Record<string, boolean>>({})
@@ -899,24 +869,12 @@ function SourceControlInner(): React.JSX.Element {
const [createPrErrors, setCreatePrErrors] = useState<Record<string, string | null>>({})
const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null
const commitGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords)
const allocateCommitMessageGenerationRequestId = useAppStore(
(s) => s.allocateCommitMessageGenerationRequestId
)
const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord)
const updateCommitMessageGenerationRecord = useAppStore(
(s) => s.updateCommitMessageGenerationRecord
)
const pruneCommitMessageGenerationRecords = useAppStore(
(s) => s.pruneCommitMessageGenerationRecords
)
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
(s) => s.allocatePullRequestGenerationRequestId
)
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
const prunePullRequestGenerationRecords = useAppStore((s) => s.prunePullRequestGenerationRecords)
const filterInputRef = useRef<HTMLInputElement>(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
@@ -943,16 +901,6 @@ function SourceControlInner(): React.JSX.Element {
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const activeCommitGenerationKey = getCommitMessageGenerationRecordKey(
activeWorktreeId,
worktreePath
)
const activeCommitGenerationRecord = activeCommitGenerationKey
? (commitGenerationRecords[activeCommitGenerationKey] ?? null)
: null
const isGenerating = activeCommitGenerationRecord?.status === 'running'
const generateError =
activeCommitGenerationRecord?.status === 'failed' ? activeCommitGenerationRecord.error : null
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
worktreeId: activeWorktreeId,
worktreePath,
@@ -1352,70 +1300,17 @@ function SourceControlInner(): React.JSX.Element {
openSettingsPage
})
const [commitGenerationDialogTarget, setCommitGenerationDialogTarget] =
useState<CommitMessageGenerationTargetSnapshot | null>(null)
const [pullRequestGenerationDialogTarget, setPullRequestGenerationDialogTarget] =
useState<PullRequestGenerationTargetSnapshot | null>(null)
const createCommitMessageGenerationTarget = useCallback(() => {
if (!activeWorktreeId || !worktreePath) {
return null
}
return {
worktreeId: activeWorktreeId,
worktreePath,
connectionId: activeConnectionId ?? undefined,
repo: activeRepo ?? null,
settings,
discoveryHostKey: sourceControlAiDiscoveryHostKey
}
}, [
activeConnectionId,
activeRepo,
activeWorktreeId,
settings,
sourceControlAiDiscoveryHostKey,
worktreePath
])
const handleCommitGenerationDialogOpenChange = useCallback(
(open: boolean): void => {
setCommitGenerationDialogOpen(open)
if (!open) {
setCommitGenerationDialogTarget(null)
}
},
[setCommitGenerationDialogOpen]
)
const handlePullRequestGenerationDialogOpenChange = useCallback(
(open: boolean): void => {
setPullRequestGenerationDialogOpen(open)
if (!open) {
setPullRequestGenerationDialogTarget(null)
}
},
[setPullRequestGenerationDialogOpen]
)
// Why: orphaned draft/error/in-flight entries accumulate when worktrees are
// removed from the store (long sessions with many create/destroy cycles).
// Prune them so a deleted-then-reused worktree ID doesn't inherit stale
// state — especially commitInFlightRef, which would permanently disable
// Commit for that ID if left stuck at `true`.
useEffect(() => {
const liveWorktreeKeys = new Set<string>()
for (const worktree of worktreeMap.values()) {
liveWorktreeKeys.add(worktree.id)
if (worktree.path.trim()) {
liveWorktreeKeys.add(worktree.path)
}
}
const pruneRecord = <T,>(prev: Record<string, T>): Record<string, T> => {
let changed = false
const next: Record<string, T> = {}
for (const key of Object.keys(prev)) {
if (liveWorktreeKeys.has(key)) {
if (worktreeMap.has(key)) {
next[key] = prev[key]
} else {
changed = true
@@ -1428,21 +1323,26 @@ function SourceControlInner(): React.JSX.Element {
setRemoteActionErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateErrors((prev) => pruneRecord(prev))
setGitHistoryByWorktree((prev) => pruneRecord(prev))
pruneCommitMessageGenerationRecords(liveWorktreeKeys)
prunePullRequestGenerationRecords(liveWorktreeKeys)
// Refs don't need setState — mutate in place to drop stale keys.
for (const key of Object.keys(commitInFlightRef.current)) {
if (!liveWorktreeKeys.has(key)) {
if (!worktreeMap.has(key)) {
delete commitInFlightRef.current[key]
}
}
for (const key of Object.keys(generateInFlightRef.current)) {
if (!worktreeMap.has(key)) {
delete generateInFlightRef.current[key]
}
}
for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) {
if (!liveWorktreeKeys.has(key)) {
if (!worktreeMap.has(key)) {
delete gitHistoryRequestByWorktreeRef.current[key]
}
}
}, [pruneCommitMessageGenerationRecords, prunePullRequestGenerationRecords, worktreeMap])
}, [worktreeMap])
useEffect(() => {
// Why: users often finish merge/rebase conflicts in a terminal. Once git
@@ -1589,223 +1489,119 @@ function SourceControlInner(): React.JSX.Element {
])
const handleGenerate = useCallback(
async (options?: CommitMessageGenerationOptions): Promise<void> => {
const target = options?.target ?? createCommitMessageGenerationTarget()
if (!target) {
async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise<void> => {
if (!activeWorktreeId || !worktreePath) {
return
}
const overrides: RuntimeGenerateCommitMessageOverrides = {
...(options?.sourceControlAiResolvedParams
? { sourceControlAiResolvedParams: options.sourceControlAiResolvedParams }
: {}),
...(options?.sourceControlAi ? { sourceControlAi: options.sourceControlAi } : {}),
...(options?.agentCmdOverrides ? { agentCmdOverrides: options.agentCmdOverrides } : {})
}
const { worktreeId, worktreePath: generationWorktreePath, connectionId } = target
const generationKey = getCommitMessageGenerationRecordKey(worktreeId, generationWorktreePath)
if (!generationKey) {
if (generateInFlightRef.current[activeWorktreeId]) {
return
}
if (
useAppStore.getState().commitMessageGenerationRecords[generationKey]?.status === 'running'
) {
return
}
if (!overrides.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
return
}
if (
!overrides.sourceControlAiResolvedParams &&
!overrides?.sourceControlAiResolvedParams &&
resolvedCommitMessageAi?.ok === true &&
isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)
) {
const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? ''
if (!command) {
const requestId = allocateCommitMessageGenerationRequestId()
const failedRecord = resolveCommitMessageGenerationFailure({
record: createRunningCommitMessageGenerationRecord({
worktreeId,
worktreePath: generationWorktreePath,
connectionId,
requestId,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
}),
requestId,
error: translate(
'auto.components.right.sidebar.SourceControl.e9e238b260',
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]:
'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
)
})
if (failedRecord) {
setCommitMessageGenerationRecord(generationKey, failedRecord)
}
}))
return
}
}
const requestId = allocateCommitMessageGenerationRequestId()
// Why: Stop must route to the runtime selected at generation start, even
// if the user changes settings before cancellation.
setCommitMessageGenerationRecord(
generationKey,
createRunningCommitMessageGenerationRecord({
worktreeId,
worktreePath: generationWorktreePath,
connectionId,
requestId,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
})
)
generateInFlightRef.current[activeWorktreeId] = true
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
const result = await generateRuntimeCommitMessage(
{
settings: target.settings ?? useAppStore.getState().settings,
worktreeId,
worktreePath: generationWorktreePath,
// Why: route generation by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
overrides
)
if (!result.success) {
updateCommitMessageGenerationRecord(generationKey, (record) =>
resolveCommitMessageGenerationFailure({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
})
)
// Why: cancellation is a deliberate user action, not a failure to
// surface. Clear any prior error and stay quiet.
if (result.canceled) {
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
return
}
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]: result.error
}))
return
}
// Why: race protection — the user may have started typing into the
// textarea while the agent was running. In that case we silently drop
// the generated message rather than overwrite their in-progress edits.
setCommitDrafts((prev) => {
const current = prev[activeWorktreeId]
if (current && current.length > 0) {
return prev
}
return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
})
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
updateCommitMessageGenerationRecord(generationKey, (record) =>
resolveCommitMessageGenerationSuccess({
record,
requestId,
message: result.message
})
)
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
} catch (error) {
updateCommitMessageGenerationRecord(generationKey, (record) =>
resolveCommitMessageGenerationFailure({
record,
requestId,
error: error instanceof Error ? error.message : 'Failed to generate commit message'
})
)
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]:
error instanceof Error ? error.message : 'Failed to generate commit message'
}))
} finally {
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
generateInFlightRef.current[activeWorktreeId] = false
}
},
[
allocateCommitMessageGenerationRequestId,
createCommitMessageGenerationTarget,
resolvedCommitMessageAi,
setCommitMessageGenerationRecord,
updateCommitMessageGenerationRecord
]
[activeRepoSettings, activeWorktreeId, resolvedCommitMessageAi, worktreePath]
)
useEffect(() => {
if (
!activeCommitGenerationKey ||
!activeCommitGenerationRecord ||
activeCommitGenerationRecord.status !== 'succeeded' ||
!activeCommitGenerationRecord.message ||
activeCommitGenerationRecord.hydrated
) {
return
}
const {
context: { worktreeId },
message
} = activeCommitGenerationRecord
// Why: generation can finish while Source Control is showing a different
// worktree or is temporarily unmounted; hydrate the saved result when its
// originating worktree is visible again without overwriting user edits.
setCommitDrafts((prev) => {
const current = prev[worktreeId]
if (current && current.length > 0) {
return prev
}
return writeCommitDraftForWorktree(prev, worktreeId, message)
})
updateCommitMessageGenerationRecord(
activeCommitGenerationKey,
markCommitMessageGenerationHydrated
)
}, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord])
const handleGenerateCommitMessageClick = useCallback((): void => {
const target = createCommitMessageGenerationTarget()
if (!target) {
return
}
if (
hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) &&
resolvedCommitMessageAi?.ok
) {
void handleGenerate({
sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params,
target
})
void handleGenerate({ sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params })
return
}
// Why: the dialog remains open while users can switch worktrees; keep it
// bound to the worktree that requested generation.
setCommitGenerationDialogTarget(target)
openCommitGenerationDialog()
}, [
activeRepo,
createCommitMessageGenerationTarget,
handleGenerate,
openCommitGenerationDialog,
resolvedCommitMessageAi,
settings
])
}, [activeRepo, handleGenerate, openCommitGenerationDialog, resolvedCommitMessageAi, settings])
const handleCancelGenerate = useCallback((): void => {
if (
!activeCommitGenerationKey ||
!activeCommitGenerationRecord ||
activeCommitGenerationRecord.status !== 'running'
) {
if (!activeWorktreeId || !worktreePath) {
return
}
const { context } = activeCommitGenerationRecord
updateCommitMessageGenerationRecord(
activeCommitGenerationKey,
resolveCommitMessageGenerationCancel
)
if (!generateInFlightRef.current[activeWorktreeId]) {
return
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
// Why: fire-and-forget — the in-flight generateCommitMessage promise
// resolves with `{canceled: true}` once the kill propagates, while the
// durable record lets the visible spinner clear immediately.
// resolves with `{canceled: true}` once the kill propagates, which is
// where the spinner is cleared. Awaiting here would just delay UI feedback.
void cancelRuntimeGenerateCommitMessage({
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
}).catch((error) => {
updateCommitMessageGenerationRecord(activeCommitGenerationKey, (record) => {
if (!record || record.context.requestId !== context.requestId) {
return null
}
return {
...record,
status: 'failed',
error:
error instanceof Error ? error.message : 'Failed to stop commit message generation',
hydrated: false
}
})
// Why: route the cancel by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
})
}, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord])
}, [activeRepoSettings, activeWorktreeId, worktreePath])
// Why: a single dispatcher for every remote-only action the split button or
// chevron dropdown can trigger. Keeps the error-swallow pattern in one
@@ -2135,90 +1931,43 @@ function SourceControlInner(): React.JSX.Element {
await refreshActiveGitStatusAfterMutation()
}, [refreshActiveGitStatusAfterMutation])
const createPullRequestGenerationTarget = useCallback(
(
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions
): PullRequestGenerationTargetSnapshot | null => {
if (!activeRepo || !worktreePath || !branchName) {
return null
}
return {
worktreeId: activeWorktreeId,
worktreePath,
connectionId: activeConnectionId ?? undefined,
repo: activeRepo,
repoId: activeRepo.id,
branch: branchName,
settings,
discoveryHostKey: sourceControlAiDiscoveryHostKey,
fields: { ...fields },
fieldRevisions: { ...fieldRevisions }
}
},
[
activeConnectionId,
activeRepo,
activeWorktreeId,
branchName,
settings,
sourceControlAiDiscoveryHostKey,
worktreePath
]
)
const handleGeneratePullRequestFieldsForActive = useCallback(
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides,
targetOverride?: PullRequestGenerationTargetSnapshot
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<void> => {
const target = targetOverride ?? createPullRequestGenerationTarget(fields, fieldRevisions)
if (!target) {
return
}
const generationKey = getPullRequestGenerationRecordKey({
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
repoId: target.repoId,
branch: target.branch
})
if (!generationKey) {
if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) {
return
}
const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
}
const requestId = allocatePullRequestGenerationRequestId()
// Why: Stop must route to the runtime selected at generation start, even
// if the user changes settings before cancellation.
const context: PullRequestGenerationContext = {
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
requestId,
repoId: target.repoId,
branch: target.branch,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
repoId: activeRepo.id,
branch: branchName
}
const seed = { ...target.fields }
// Why: SourceControl can unmount on tab/worktree switches; the record is
// keyed to the originating repo/worktree/branch so stale UI cannot retarget
// or orphan the generated hosted-review fields.
const seed = { ...fields }
// Why: SourceControl can unmount on tab switches; persisting the running
// record lets the embedded PR composer resume when the user returns.
setPullRequestGenerationRecord(
generationKey,
createRunningPullRequestGenerationRecord(context, seed, target.fieldRevisions)
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
)
try {
const result = await generateRuntimePullRequestFields(
{
settings: target.settings ?? useAppStore.getState().settings,
// Why: route generation by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
@@ -2272,11 +2021,16 @@ function SourceControlInner(): React.JSX.Element {
}
},
[
activePullRequestGenerationKey,
activeRepo,
activeRepoSettings,
activeWorktreeId,
allocatePullRequestGenerationRequestId,
createPullRequestGenerationTarget,
branchName,
refreshGitStatusAfterPullRequestGeneration,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord
updatePullRequestGenerationRecord,
worktreePath
]
)
@@ -2296,7 +2050,8 @@ function SourceControlInner(): React.JSX.Element {
return resolvePullRequestGenerationCancel(current)
})
void cancelRuntimeGeneratePullRequestFields({
settings: record.context.runtimeTargetSettings,
// Why: route the cancel by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: record.context.worktreeId,
worktreePath: record.context.worktreePath,
connectionId: record.context.connectionId
@@ -2313,7 +2068,12 @@ function SourceControlInner(): React.JSX.Element {
}
})
})
}, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord])
}, [
activeRepoSettings,
activePullRequestGenerationKey,
prGenerationRecords,
updatePullRequestGenerationRecord
])
const {
aiGenerationEnabled: prAiGenerationEnabled,
@@ -2325,7 +2085,6 @@ function SourceControlInner(): React.JSX.Element {
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
fieldRevisions: prFieldRevisions,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
@@ -2371,36 +2130,10 @@ function SourceControlInner(): React.JSX.Element {
void handleGeneratePullRequestFields()
return
}
const target = createPullRequestGenerationTarget(
{ base: prBase, title: prTitle, body: prBody, draft: prDraft },
prFieldRevisions
)
if (!target) {
return
}
// Why: the agent-picker dialog can stay open while the user switches
// worktrees; keep Generate bound to the PR draft that opened it.
setPullRequestGenerationDialogTarget(target)
openPullRequestGenerationDialog()
}, [
activeRepo,
createPullRequestGenerationTarget,
handleGeneratePullRequestFields,
openPullRequestGenerationDialog,
prBase,
prBody,
prDraft,
prFieldRevisions,
prTitle,
settings
])
}, [activeRepo, handleGeneratePullRequestFields, openPullRequestGenerationDialog, settings])
useEffect(() => {
// Why: after a SourceControl remount, the PR composer first reseeds from
// eligibility; hydrate generated fields only after that seed exists.
if (hostedReviewCreation?.canCreate !== true) {
return
}
if (
!activePullRequestGenerationKey ||
!activePullRequestGenerationRecord ||
@@ -2435,7 +2168,6 @@ function SourceControlInner(): React.JSX.Element {
activePullRequestGenerationKey,
activePullRequestGenerationRecord,
applyGeneratedPullRequestFields,
hostedReviewCreation?.canCreate,
updatePullRequestGenerationRecord
])
@@ -2456,6 +2188,7 @@ function SourceControlInner(): React.JSX.Element {
let stale = false
void getHostedReviewCreationEligibility({
repoPath: activeRepo.path,
repoId: activeRepo.id,
...(worktreePath ? { worktreePath } : {}),
branch: branchName,
base: effectiveBaseRef ?? null,
@@ -2550,6 +2283,7 @@ function SourceControlInner(): React.JSX.Element {
setCreatePrErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
const result = await createHostedReview(activeRepo.path, {
repoId: activeRepo.id,
provider: hostedReviewCreateProvider,
base,
head: normalizeHostedReviewHeadRef(branchName),
@@ -4766,7 +4500,7 @@ function SourceControlInner(): React.JSX.Element {
)}
description={translate(
'auto.components.right.sidebar.SourceControl.901140f47d',
'Review the prompt before starting an agent.'
'Review and edit the full command input before starting an agent.'
)}
baseCommandInput={resolveConflictsPrompt}
worktreeId={activeWorktreeId}
@@ -4796,7 +4530,7 @@ function SourceControlInner(): React.JSX.Element {
/>
<SourceControlTextGenerationDialog
open={commitGenerationDialogOpen}
onOpenChange={handleCommitGenerationDialogOpenChange}
onOpenChange={setCommitGenerationDialogOpen}
actionId="commitMessage"
title={translate(
'auto.components.right.sidebar.SourceControl.6b122529d4',
@@ -4807,23 +4541,17 @@ function SourceControlInner(): React.JSX.Element {
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={commitGenerationDialogTarget?.settings ?? settings}
repo={commitGenerationDialogTarget?.repo ?? activeRepo ?? null}
discoveryHostKey={
commitGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey
}
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
onGenerate={(params) => {
const target = commitGenerationDialogTarget ?? createCommitMessageGenerationTarget()
if (!target) {
return
}
void handleGenerate({ sourceControlAiResolvedParams: params, target })
void handleGenerate({ sourceControlAiResolvedParams: params })
}}
onSaveDefaults={handleSaveCommitMessageGenerationDefaults}
/>
<SourceControlTextGenerationDialog
open={pullRequestGenerationDialogOpen}
onOpenChange={handlePullRequestGenerationDialogOpenChange}
onOpenChange={setPullRequestGenerationDialogOpen}
actionId="pullRequest"
title={translate(
'auto.components.right.sidebar.SourceControl.1a6a6e0bc5',
@@ -4834,27 +4562,11 @@ function SourceControlInner(): React.JSX.Element {
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={pullRequestGenerationDialogTarget?.settings ?? settings}
repo={pullRequestGenerationDialogTarget?.repo ?? activeRepo ?? null}
discoveryHostKey={
pullRequestGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey
}
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
onGenerate={(params) => {
const target =
pullRequestGenerationDialogTarget ??
createPullRequestGenerationTarget(
{ base: prBase, title: prTitle, body: prBody, draft: prDraft },
prFieldRevisions
)
if (!target) {
return
}
void handleGeneratePullRequestFieldsForActive(
target.fields,
target.fieldRevisions,
{ sourceControlAiResolvedParams: params },
target
)
void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params })
}}
onSaveDefaults={handleSavePullRequestGenerationDefaults}
/>
@@ -4921,7 +4633,7 @@ function CommitFailureFixSplitButton({
return (
<>
<DropdownMenu>
<div className="inline-flex shrink-0 max-w-full items-stretch">
<div className="flex shrink-0 items-stretch">
<Button
type="button"
variant={variant}
@@ -4998,7 +4710,7 @@ function CommitFailureFixSplitButton({
)}
description={translate(
'auto.components.right.sidebar.SourceControl.15b7f210d7',
'Review the prompt before starting an agent.'
'Choose the agent and edit the full command input before launch.'
)}
baseCommandInput={prompt}
worktreeId={worktreeId}
@@ -5314,7 +5026,7 @@ export function CommitArea({
chevron exposes the full action surface (fetch, pull, sync,
publish, compound commits) without forcing morphing labels to
carry every possible intent. */}
<div className={cn(RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS, showComposer && 'mt-1')}>
<div className={cn(showComposer ? 'mt-1 flex items-stretch' : 'flex items-stretch')}>
{/* Why: match the hosted-review action buttons in Checks
(size="xs", px-3 text-[11px]) so the sidebar has a consistent
action-button shape across Source Control and Checks. The primary
@@ -5323,16 +5035,13 @@ export function CommitArea({
pair read as one split button instead of two detached buttons. */}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex min-w-0 max-w-full shrink">
<span className="flex flex-1">
<Button
type="button"
size="xs"
disabled={primaryAction.disabled}
onClick={() => onPrimaryAction()}
className={cn(
'rounded-r-none px-3 text-[11px]',
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS
)}
className="w-full rounded-r-none px-3 text-[11px]"
title={primaryAction.title}
>
{showSpinner ? (
@@ -5340,9 +5049,7 @@ export function CommitArea({
) : PrimaryIcon ? (
<PrimaryIcon className="size-3.5" aria-hidden="true" />
) : null}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{primaryAction.label}
</span>
{primaryAction.label}
</Button>
</span>
</TooltipTrigger>
@@ -5566,7 +5273,7 @@ export function CommitArea({
id="commit-area-remote-error"
role="alert"
aria-live="polite"
className="mt-1 min-w-0 text-[11px] leading-4 break-words text-destructive [overflow-wrap:anywhere]"
className="mt-1 text-[11px] text-destructive"
>
{remoteActionError}
</p>
@@ -6034,6 +5741,14 @@ function DiffCommentsInlineList({
)
}
function conflictAbortButtonVariant(
conflictOperation: GitConflictOperation
): 'outline' | 'destructive' {
// Why: aborting a rebase is the escape hatch for this state, so it should
// match the quiet outline conflict-review action instead of reading as red.
return conflictOperation === 'rebase' ? 'outline' : 'destructive'
}
export function ConflictSummaryCard({
conflictOperation,
unresolvedCount,
@@ -6080,12 +5795,12 @@ export function ConflictSummaryCard({
</div>
</div>
</div>
<div className="mt-2 flex flex-col items-start">
<div className="mt-2">
<Button
type="button"
variant="default"
size="sm"
className="h-7 text-xs"
className="h-7 w-full text-xs"
disabled={isResolvingWithAI}
onClick={onResolveWithAI}
>
@@ -6100,7 +5815,7 @@ export function ConflictSummaryCard({
type="button"
variant="outline"
size="sm"
className="mt-1.5 h-7 text-xs"
className="mt-1.5 h-7 w-full text-xs"
onClick={onReview}
>
<GitMerge className="size-3.5" />
@@ -6109,11 +5824,9 @@ export function ConflictSummaryCard({
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
variant={conflictAbortButtonVariant(conflictOperation)}
size="sm"
className="mt-1.5 h-7 text-xs"
className="mt-1.5 h-7 w-full text-xs"
disabled={isResolvingWithAI || isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
@@ -6155,28 +5868,24 @@ export function OperationBanner({
return (
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center gap-2">
<Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<div className="mt-2 flex flex-col items-start">
<Button
type="button"
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
{conflictOperation === 'rebase'
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
</Button>
</div>
<Button
type="button"
variant={conflictAbortButtonVariant(conflictOperation)}
size="sm"
className="mt-2 h-7 w-full text-xs"
disabled={isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
{conflictOperation === 'rebase'
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
</Button>
) : null}
</div>
)
@@ -83,10 +83,7 @@ import {
RightPanelCommentComposer,
type RightPanelCommentSubmitResult
} from './right-panel-comment-composer'
import {
type PRCommentsListSelectionClearRequest,
usePRCommentsListSelection
} from './pr-comments-list-selection'
import { usePRCommentsListSelection } from './pr-comments-list-selection'
import { translate } from '@/i18n/i18n'
export const PullRequestIcon = GitPullRequest
@@ -1232,7 +1229,7 @@ export function ChecksList({
<div className="flex items-center justify-center py-8 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.checks.panel.content.991f50c7e4',
'No checks reported yet'
'No checks configured'
)}
</div>
) : !checksExpanded ? null : (
@@ -1987,7 +1984,6 @@ export function PRCommentsList({
commentsDisabled,
commentsDisabledReason,
selectionContextKey,
selectionClearRequest,
resolveCommentsWithAIDisabled,
resolveCommentsWithAIDisabledReason,
onAddComment,
@@ -2003,7 +1999,6 @@ export function PRCommentsList({
commentsDisabled?: boolean
commentsDisabledReason?: string
selectionContextKey?: string
selectionClearRequest?: PRCommentsListSelectionClearRequest | null
resolveCommentsWithAIDisabled?: boolean
resolveCommentsWithAIDisabledReason?: string
onAddComment?: (body: string) => Promise<RightPanelCommentSubmitResult>
@@ -2028,7 +2023,7 @@ export function PRCommentsList({
addGroupToSelection,
clearSelection,
toggleGroupSelection
} = usePRCommentsListSelection(comments, selectionContextKey, selectionClearRequest)
} = usePRCommentsListSelection(comments, selectionContextKey)
const visibleComments = React.useMemo(
() => filterPRCommentsByAudience(comments, commentFilter),
[commentFilter, comments]
@@ -2170,7 +2165,7 @@ export function PRCommentsList({
return (
<div className="border-t border-border">
{/* Header */}
<div className="sticky top-0 z-10 flex flex-col gap-2.5 border-b border-border bg-background px-3 py-2.5">
<div className="flex flex-col gap-2.5 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-foreground">
@@ -2191,7 +2186,8 @@ export function PRCommentsList({
className="text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.right.sidebar.checks.panel.content.d7a2f9c401',
'Send all unresolved'
'Send unresolved {{value0}} comments',
{ value0: reviewKind }
)}
disabled={commentsLoading || resolveCommentsWithAIDisabled}
title={
@@ -2209,7 +2205,8 @@ export function PRCommentsList({
? resolveCommentsWithAIDisabledReason
: translate(
'auto.components.right.sidebar.checks.panel.content.d7a2f9c401',
'Send all unresolved'
'Send unresolved {{value0}} comments',
{ value0: reviewKind }
)}
</TooltipContent>
</Tooltip>
@@ -7,10 +7,6 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import type { PRComment } from '../../../../shared/types'
import type { PRCommentGroup } from '@/lib/pr-comment-groups'
import { PRCommentsList } from './checks-panel-content'
import {
clearPRCommentsListSelection,
type PRCommentsListSelectionClearRequest
} from './pr-comments-list-selection'
let container: HTMLDivElement
let root: Root
@@ -26,8 +22,6 @@ afterEach(() => {
root.unmount()
})
container.remove()
clearPRCommentsListSelection('review:42')
clearPRCommentsListSelection('review:43')
})
function comment(overrides: Partial<PRComment>): PRComment {
@@ -44,8 +38,6 @@ function comment(overrides: Partial<PRComment>): PRComment {
function renderList(props: {
comments: PRComment[]
selectionContextKey?: string
selectionClearRequest?: PRCommentsListSelectionClearRequest | null
onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void
}): void {
act(() => {
@@ -54,8 +46,7 @@ function renderList(props: {
<PRCommentsList
comments={props.comments}
commentsLoading={false}
selectionContextKey={props.selectionContextKey ?? 'review:42'}
selectionClearRequest={props.selectionClearRequest}
selectionContextKey="review:42"
onResolveSelectedCommentsWithAI={props.onResolveSelectedCommentsWithAI ?? vi.fn()}
/>
</TooltipProvider>
@@ -63,13 +54,6 @@ function renderList(props: {
})
}
function remountList(): void {
act(() => {
root.unmount()
})
root = createRoot(container)
}
function clickButton(label: string): void {
const button = [...container.querySelectorAll('button')].find(
(candidate) =>
@@ -101,13 +85,13 @@ describe('PRCommentsList comment resolution selection', () => {
]
})
expect(hasButton('Send all unresolved')).toBe(false)
expect(hasButton('Send unresolved PR comments')).toBe(false)
renderList({
comments: [comment({ id: 4 })]
})
expect(hasButton('Send all unresolved')).toBe(true)
expect(hasButton('Send unresolved PR comments')).toBe(true)
expect(container.textContent).toContain('Add')
})
@@ -145,7 +129,7 @@ describe('PRCommentsList comment resolution selection', () => {
})
clickButton('Humans')
clickButton('Send all unresolved')
clickButton('Send unresolved PR comments')
expect(onResolveSelectedCommentsWithAI).toHaveBeenCalledTimes(1)
const selectedGroups = onResolveSelectedCommentsWithAI.mock.calls[0]?.[0] as PRCommentGroup[]
@@ -227,61 +211,6 @@ describe('PRCommentsList comment resolution selection', () => {
expect(container.querySelector('button[role="checkbox"]')).toBeNull()
})
it('keeps queued comments when the comments list remounts', () => {
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
renderList({ comments })
clickButton('Add')
remountList()
renderList({ comments })
expect(hasButton('Send 1 queued comments')).toBe(true)
})
it('restores the queued comments for the matching review context after switching contexts', () => {
const review42Comments = [
comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })
]
renderList({ comments: review42Comments, selectionContextKey: 'review:42' })
clickButton('Add')
renderList({
comments: [comment({ id: 2, threadId: 'thread-2', path: 'src/b.ts', isResolved: false })],
selectionContextKey: 'review:43'
})
expect(hasButton('Send 1 queued comments')).toBe(false)
renderList({ comments: review42Comments, selectionContextKey: 'review:42' })
expect(hasButton('Send 1 queued comments')).toBe(true)
})
it('does not drop persisted queued comments while comments reload empty', () => {
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
renderList({ comments })
clickButton('Add')
renderList({ comments: [] })
renderList({ comments })
expect(hasButton('Send 1 queued comments')).toBe(true)
})
it('clears persisted queued comments when the launch path marks them sent', () => {
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
renderList({ comments })
clickButton('Add')
expect(hasButton('Send 1 queued comments')).toBe(true)
clearPRCommentsListSelection('review:42')
renderList({
comments,
selectionClearRequest: { contextKey: 'review:42', token: 1 }
})
expect(hasButton('Send 1 queued comments')).toBe(false)
})
it('exits selection mode when refresh leaves no eligible loaded threads', () => {
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
@@ -33,6 +33,7 @@ import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../../shared/tui-agent-launch-defaults'
import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
export { getAgentsPaneSearchEntries } from './agents-search'
@@ -612,6 +613,7 @@ export function AgentsPane({
)
const defaultAgent = settings.defaultTuiAgent
const agentOwnership = getSettingOwnershipSummary('agentLaunchDefaults')
const cmdOverrides = settings.agentCmdOverrides ?? {}
const agentDefaultArgs = settings.agentDefaultArgs ?? {}
const agentDefaultEnv = settings.agentDefaultEnv ?? {}
@@ -695,10 +697,7 @@ export function AgentsPane({
<section className="space-y-4">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AgentsPane.385212c7a1', 'Default Agent')}
description={translate(
'auto.components.settings.AgentsPane.9b175d0f5e',
'Pre-selected agent when opening a new workspace.'
)}
description={agentOwnership.description}
/>
<div className="flex flex-wrap gap-2">
@@ -1,8 +1,22 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { FolderOpen, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import type { GlobalSettings } from '../../../../shared/types'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
ensureOrcaCliAvailableForAgentSkillTerminal,
isOrcaCliAvailableOnPath
} from '@/lib/agent-skill-cli-prerequisite'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '../ui/button'
import {
@@ -15,7 +29,14 @@ import {
} from '../ui/dialog'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { CliAgentSkillSetup } from './CliAgentSkillSetup'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import {
buildSkillInstallCommandForRuntime,
CliSkillRuntimeControl,
ensureWslCliAvailableForAgentSkillTerminal,
getAgentSkillTerminalShellOverride,
getSelectedAgentRuntime
} from './CliSkillRuntimeSetup'
import { WslCliRegistration } from './WslCliRegistration'
import { translate } from '@/i18n/i18n'
@@ -68,6 +89,40 @@ export function CliSection({
const [dialogOpen, setDialogOpen] = useState(false)
const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null)
const mountedRef = useMountedRef()
const agentRuntime = useMemo(
() =>
getSelectedAgentRuntime(settings, wslSupportedPlatform, wslAvailable, wslCapabilitiesLoading),
[settings, wslAvailable, wslCapabilitiesLoading, wslSupportedPlatform]
)
const cliSkillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>(
() => (agentRuntime.runtime === 'wsl' ? { runtime: 'wsl' } : undefined),
[agentRuntime.runtime]
)
const {
installed: cliSkillDetected,
loading: cliSkillLoading,
error: cliSkillError,
refresh: refreshCliSkill
} = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, {
discoveryTarget: cliSkillDiscoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const cliSkillInstallCommand = buildSkillInstallCommandForRuntime(
ORCA_CLI_SKILL_INSTALL_COMMAND,
agentRuntime
)
const cliSkillTerminalShellOverride = getAgentSkillTerminalShellOverride(
currentPlatform,
settings,
agentRuntime
)
const getCliSkillPrerequisiteStatus = useCallback(
() =>
agentRuntime.runtime === 'wsl'
? window.api.cli.getWslInstallStatus()
: window.api.cli.getInstallStatus(),
[agentRuntime.runtime]
)
const handleStatusChange = useCallback(
(nextStatus: CliInstallStatus): void => {
@@ -84,14 +139,7 @@ export function CliSection({
handleStatusChange(await window.api.cli.getInstallStatus())
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.CliSection.7baec27029',
'Failed to load CLI status.'
)
)
toast.error(error instanceof Error ? error.message : translate("auto.components.settings.CliSection.7baec27029", "Failed to load CLI status."))
}
} finally {
if (mountedRef.current) {
@@ -119,24 +167,12 @@ export function CliSection({
if (mountedRef.current) {
setStatus(next)
setDialogOpen(false)
toast.success(
translate(
'auto.components.settings.CliSection.9cbcd31338',
'Registered `{{value0}}` in PATH.',
{ value0: next.commandName }
)
)
toast.success(translate("auto.components.settings.CliSection.9cbcd31338", "Registered `{{value0}}` in PATH.", { value0: next.commandName }))
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.CliSection.a2b13efa94',
'Failed to register `{{value0}}` in PATH.',
{ value0: commandName }
)
error instanceof Error ? error.message : translate("auto.components.settings.CliSection.a2b13efa94", "Failed to register `{{value0}}` in PATH.", { value0: commandName })
)
}
} finally {
@@ -153,24 +189,12 @@ export function CliSection({
if (mountedRef.current) {
setStatus(next)
setDialogOpen(false)
toast.success(
translate(
'auto.components.settings.CliSection.af5540930c',
'Removed `{{value0}}` from PATH.',
{ value0: next.commandName }
)
)
toast.success(translate("auto.components.settings.CliSection.af5540930c", "Removed `{{value0}}` from PATH.", { value0: next.commandName }))
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.CliSection.d77352f2df',
'Failed to remove `{{value0}}` from PATH.',
{ value0: commandName }
)
error instanceof Error ? error.message : translate("auto.components.settings.CliSection.d77352f2df", "Failed to remove `{{value0}}` from PATH.", { value0: commandName })
)
}
} finally {
@@ -183,29 +207,18 @@ export function CliSection({
return (
<section className="space-y-4" data-settings-section="cli">
<div className="space-y-1">
<h2 className="text-sm font-semibold">
{translate('auto.components.settings.CliSection.c5c0f2641d', 'Orca CLI')}
</h2>
<h2 className="text-sm font-semibold">{translate("auto.components.settings.CliSection.c5c0f2641d", "Orca CLI")}</h2>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.CliSection.6930feda9e',
'Use Orca from your terminal to open the app, manage worktrees, and interact with Orca terminals.'
)}
</p>
{translate("auto.components.settings.CliSection.6930feda9e", "Use Orca from your terminal to open the app, manage worktrees, and interact with Orca terminals.")}</p>
</div>
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>
{translate('auto.components.settings.CliSection.38edbb5721', 'Shell command')}
</Label>
<Label>{translate("auto.components.settings.CliSection.38edbb5721", "Shell command")}</Label>
<p className="text-xs text-muted-foreground">
{loading
? translate(
'auto.components.settings.CliSection.d363e5929b',
'Checking CLI registration…'
)
? translate("auto.components.settings.CliSection.d363e5929b", "Checking CLI registration…")
: (status?.detail ?? getInstallDescription(currentPlatform))}
</p>
</div>
@@ -218,17 +231,13 @@ export function CliSection({
size="icon-xs"
onClick={() => void refreshStatus()}
disabled={loading || busyAction !== null}
aria-label={translate(
'auto.components.settings.CliSection.52e640f3a0',
'Refresh CLI status'
)}
aria-label={translate("auto.components.settings.CliSection.52e640f3a0", "Refresh CLI status")}
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.settings.CliSection.5dae812f50', 'Refresh')}
</TooltipContent>
{translate("auto.components.settings.CliSection.5dae812f50", "Refresh")}</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isBrowserManaged ? (
@@ -253,29 +262,20 @@ export function CliSection({
{status?.commandPath ? (
<p className="text-xs text-muted-foreground">
{translate('auto.components.settings.CliSection.15eaad0d31', 'Command path:')}{' '}
{translate("auto.components.settings.CliSection.15eaad0d31", "Command path:")}{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
</p>
) : null}
{status?.state === 'stale' && status.currentTarget ? (
{status?.state === "stale" && status.currentTarget ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
{translate(
'auto.components.settings.CliSection.b0c310ab46',
'Existing launcher target:'
)}
<code>{status.currentTarget}</code>
{translate("auto.components.settings.CliSection.b0c310ab46", "Existing launcher target:")}<code>{status.currentTarget}</code>
</p>
) : null}
{status?.state === 'installed' && !status.pathConfigured && status.pathDirectory ? (
{status?.state === "installed" && !status.pathConfigured && status.pathDirectory ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
{status.pathDirectory}{' '}
{translate(
'auto.components.settings.CliSection.7f2747f7dd',
'is not currently visible on PATH for this shell.'
)}
</p>
{status.pathDirectory} {translate("auto.components.settings.CliSection.7f2747f7dd", "is not currently visible on PATH for this shell.")}</p>
) : null}
{!loading && !isSupported && !isBrowserManaged && status?.detail ? (
@@ -298,15 +298,47 @@ export function CliSection({
</div>
{!isBrowserManaged ? (
<CliAgentSkillSetup
currentPlatform={currentPlatform}
settings={settings}
updateSettings={updateSettings}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={wslAvailable}
wslCapabilitiesLoading={wslCapabilitiesLoading}
onHostStatusChange={handleStatusChange}
/>
<div className="border-t border-border/60 pt-3">
<div className="space-y-0.5">
<Label>{translate("auto.components.settings.CliSection.04873eea3e", "Agent skills")}</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.CliSection.36a6f919ba", "Give agents Orca-aware workspace, terminal, and progress workflows.")}</p>
</div>
<CliSkillRuntimeControl
runtime={agentRuntime}
updateSettings={updateSettings}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={wslAvailable}
wslCapabilitiesLoading={wslCapabilitiesLoading}
/>
<AgentSkillSetupPanel
className="mt-3"
variant="inline"
title={translate("auto.components.settings.CliSection.6053cf736c", "CLI skill")}
description={translate("auto.components.settings.CliSection.e8012c03a1", "Enables agents to use Orca workspace, terminal, and progress commands.")}
command={cliSkillInstallCommand}
terminalTitle="CLI skill setup"
terminalAriaLabel="CLI skill install terminal"
terminalWorktreeId={`settings-cli-skill-terminal-${agentRuntime.runtime}`}
terminalShellOverride={cliSkillTerminalShellOverride}
installed={cliSkillDetected}
loading={cliSkillLoading}
error={cliSkillError}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
getPrerequisiteStatus={getCliSkillPrerequisiteStatus}
isPrerequisiteAvailable={isOrcaCliAvailableOnPath}
onBeforeOpenTerminal={async () => {
await (agentRuntime.runtime === 'wsl'
? ensureWslCliAvailableForAgentSkillTerminal()
: ensureOrcaCliAvailableForAgentSkillTerminal({
onStatusChange: handleStatusChange
}))
}}
onRecheck={refreshCliSkill}
/>
</div>
) : null}
</div>
@@ -317,33 +349,18 @@ export function CliSection({
<DialogHeader>
<DialogTitle>
{isEnabled
? translate(
'auto.components.settings.CliSection.14444243ba',
'Remove `{{value0}}` from PATH?',
{ value0: commandName }
)
: translate(
'auto.components.settings.CliSection.fa87db3d6e',
'Register `{{value0}}` in PATH?',
{ value0: commandName }
)}
? translate("auto.components.settings.CliSection.14444243ba", "Remove `{{value0}}` from PATH?", { value0: commandName })
: translate("auto.components.settings.CliSection.fa87db3d6e", "Register `{{value0}}` in PATH?", { value0: commandName })}
</DialogTitle>
<DialogDescription>
{isEnabled
? translate(
'auto.components.settings.CliSection.a030816e3e',
'This removes the shell command symlink. Orca itself remains installed.'
)
: translate(
'auto.components.settings.CliSection.aa6536977e',
'Orca will register {{value0}} so the command works from your terminal.',
{ value0: status?.commandPath ?? commandName }
)}
? translate("auto.components.settings.CliSection.a030816e3e", "This removes the shell command symlink. Orca itself remains installed.")
: translate("auto.components.settings.CliSection.aa6536977e", "Orca will register {{value0}} so the command works from your terminal.", { value0: status?.commandPath ?? commandName })}
</DialogDescription>
</DialogHeader>
{status?.commandPath ? (
<p className="text-xs text-muted-foreground">
{translate('auto.components.settings.CliSection.a4aafe46e3', 'Target path:')}{' '}
{translate("auto.components.settings.CliSection.a4aafe46e3", "Target path:")}{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
</p>
) : null}
@@ -353,19 +370,18 @@ export function CliSection({
onClick={() => setDialogOpen(false)}
disabled={busyAction !== null}
>
{translate('auto.components.settings.CliSection.8671e406f0', 'Cancel')}
</Button>
{translate("auto.components.settings.CliSection.8671e406f0", "Cancel")}</Button>
<Button
onClick={() => void (isEnabled ? handleRemove() : handleInstall())}
disabled={busyAction !== null || !isSupported}
>
{busyAction === 'remove'
? translate('auto.components.settings.CliSection.068552b191', 'Removing…')
: busyAction === 'install'
? translate('auto.components.settings.CliSection.b0fca411a0', 'Registering…')
{busyAction === "remove"
? translate("auto.components.settings.CliSection.068552b191", "Removing…")
: busyAction === "install"
? translate("auto.components.settings.CliSection.b0fca411a0", "Registering…")
: isEnabled
? translate('auto.components.settings.CliSection.9a5f8a4568', 'Remove')
: translate('auto.components.settings.CliSection.d00df2e397', 'Register')}
? translate("auto.components.settings.CliSection.9a5f8a4568", "Remove")
: translate("auto.components.settings.CliSection.d00df2e397", "Register")}
</Button>
</DialogFooter>
</DialogContent>
@@ -24,6 +24,7 @@ import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
import { SourceControlAiActionRecipeDefaults } from './SourceControlAiActionRecipeDefaults'
import { matchesSettingsSearch } from './settings-search'
import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
type CommitMessageAiPaneProps = {
@@ -99,6 +100,7 @@ export function CommitMessageAiPane({
const storeSearchQuery = useAppStore((s) => s.settingsSearchQuery)
const searchQuery = settingsSearchQuery ?? storeSearchQuery
const config = readSettings(settings)
const ownership = getSettingOwnershipSummary('sourceControlAiDefaults')
const settingsWriteQueueRef = useRef<Promise<void>>(Promise.resolve())
const localWriteConfig = (patch: SourceControlAiSettingsPatch): Promise<void> => {
@@ -150,24 +152,51 @@ export function CommitMessageAiPane({
if (
matchesSettingsSearch(searchQuery, {
title: translate("auto.components.settings.CommitMessageAiPane.d5b45a3628", "Show Source Control AI actions"),
description:
translate("auto.components.settings.CommitMessageAiPane.7bcad2b200", "Adds action recipes for Source Control commit, pull request, branch-name, and fix actions."),
keywords: [translate("auto.components.settings.CommitMessageAiPane.0b7eafe55f", "ai"), translate("auto.components.settings.CommitMessageAiPane.ca433708cb", "commit"), translate("auto.components.settings.CommitMessageAiPane.8cd2be0948", "message"), translate("auto.components.settings.CommitMessageAiPane.34d0348e34", "generate"), translate("auto.components.settings.CommitMessageAiPane.4ec89c319e", "agent"), translate("auto.components.settings.CommitMessageAiPane.d54c64163d", "enabled")]
title: translate(
'auto.components.settings.CommitMessageAiPane.d5b45a3628',
'Show Source Control AI actions'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.7bcad2b200',
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.'
),
keywords: [
translate('auto.components.settings.CommitMessageAiPane.0b7eafe55f', 'ai'),
translate('auto.components.settings.CommitMessageAiPane.ca433708cb', 'commit'),
translate('auto.components.settings.CommitMessageAiPane.8cd2be0948', 'message'),
translate('auto.components.settings.CommitMessageAiPane.34d0348e34', 'generate'),
translate('auto.components.settings.CommitMessageAiPane.4ec89c319e', 'agent'),
translate('auto.components.settings.CommitMessageAiPane.d54c64163d', 'enabled')
]
})
) {
sections.push(
<SearchableSetting
key="enabled"
title={translate("auto.components.settings.CommitMessageAiPane.d5b45a3628", "Show Source Control AI actions")}
description={translate("auto.components.settings.CommitMessageAiPane.7bcad2b200", "Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.")}
title={translate(
'auto.components.settings.CommitMessageAiPane.d5b45a3628',
'Show Source Control AI actions'
)}
description={translate(
'auto.components.settings.CommitMessageAiPane.7bcad2b200',
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.'
)}
keywords={['ai', 'commit', 'message', 'generate', 'agent', 'enabled']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-1">
<Label>{translate("auto.components.settings.CommitMessageAiPane.d5b45a3628", "Show Source Control AI actions")}</Label>
<Label>
{translate(
'auto.components.settings.CommitMessageAiPane.d5b45a3628',
'Show Source Control AI actions'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.CommitMessageAiPane.2339a89104", "Adds AI buttons that run the selected agent with the command template for that action.")}</p>
{translate(
'auto.components.settings.CommitMessageAiPane.2339a89104',
'Adds AI buttons that run the selected agent with the command template for that action.'
)}
</p>
</div>
<button
role="switch"
@@ -203,23 +232,55 @@ export function CommitMessageAiPane({
config.enabled &&
(customCommandInUse ||
matchesSettingsSearch(searchQuery, {
title: translate("auto.components.settings.CommitMessageAiPane.47e45cbd5a", "Custom command"),
description: translate("auto.components.settings.CommitMessageAiPane.1ef29f8c29", "Command line Orca runs when a text recipe uses Custom command."),
keywords: [translate("auto.components.settings.CommitMessageAiPane.25350d670f", "custom"), translate("auto.components.settings.CommitMessageAiPane.54038660e0", "command"), translate("auto.components.settings.CommitMessageAiPane.407d28bde6", "cli"), translate("auto.components.settings.CommitMessageAiPane.1df7d71313", "binary"), translate("auto.components.settings.CommitMessageAiPane.a69e1fe91a", "prompt"), translate("auto.components.settings.CommitMessageAiPane.fc1a525fa5", "placeholder")]
title: translate(
'auto.components.settings.CommitMessageAiPane.47e45cbd5a',
'Custom command'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.1ef29f8c29',
'Command line Orca runs when a text recipe uses Custom command.'
),
keywords: [
translate('auto.components.settings.CommitMessageAiPane.25350d670f', 'custom'),
translate('auto.components.settings.CommitMessageAiPane.54038660e0', 'command'),
translate('auto.components.settings.CommitMessageAiPane.407d28bde6', 'cli'),
translate('auto.components.settings.CommitMessageAiPane.1df7d71313', 'binary'),
translate('auto.components.settings.CommitMessageAiPane.a69e1fe91a', 'prompt'),
translate('auto.components.settings.CommitMessageAiPane.fc1a525fa5', 'placeholder')
]
}))
) {
sections.push(
<SearchableSetting
key="custom-command"
title={translate("auto.components.settings.CommitMessageAiPane.47e45cbd5a", "Custom command")}
description={translate("auto.components.settings.CommitMessageAiPane.1ef29f8c29", "Command line Orca runs when a text recipe uses Custom command.")}
title={translate(
'auto.components.settings.CommitMessageAiPane.47e45cbd5a',
'Custom command'
)}
description={translate(
'auto.components.settings.CommitMessageAiPane.1ef29f8c29',
'Command line Orca runs when a text recipe uses Custom command.'
)}
keywords={['custom', 'command', 'cli', 'binary', 'prompt', 'placeholder']}
className="space-y-2 py-2"
>
<div className="space-y-0.5">
<Label htmlFor="source-control-ai-custom-command">{translate("auto.components.settings.CommitMessageAiPane.47e45cbd5a", "Custom command")}</Label>
<Label htmlFor="source-control-ai-custom-command">
{translate('auto.components.settings.CommitMessageAiPane.47e45cbd5a', 'Custom command')}
</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.CommitMessageAiPane.4f722a5f53", "Used by commit-message, pull-request, and branch-name recipes that select Custom command. Use")}<code className="font-mono">{translate("auto.components.settings.CommitMessageAiPane.b8b6fd55b4", "{prompt}")}</code> {translate("auto.components.settings.CommitMessageAiPane.3f1b26cc91", "to pass the command input as an argument; otherwise Orca pipes it on stdin.")}</p>
{translate(
'auto.components.settings.CommitMessageAiPane.4f722a5f53',
'Used by commit-message, pull-request, and branch-name recipes that select Custom command. Use'
)}
<code className="font-mono">
{translate('auto.components.settings.CommitMessageAiPane.b8b6fd55b4', '{prompt}')}
</code>{' '}
{translate(
'auto.components.settings.CommitMessageAiPane.3f1b26cc91',
'to pass the command input as an argument; otherwise Orca pipes it on stdin.'
)}
</p>
</div>
<Input
id="source-control-ai-custom-command"
@@ -228,7 +289,10 @@ export function CommitMessageAiPane({
autoCapitalize="off"
value={config.customAgentCommand}
onChange={(event) => onCustomCommandChange(event.target.value)}
placeholder={translate("auto.components.settings.CommitMessageAiPane.15b60d54b2", "e.g. ollama run llama3.1 {prompt}")}
placeholder={translate(
'auto.components.settings.CommitMessageAiPane.15b60d54b2',
'e.g. ollama run llama3.1 {prompt}'
)}
className="h-8 font-mono text-xs"
/>
</SearchableSetting>
@@ -238,17 +302,23 @@ export function CommitMessageAiPane({
if (
config.enabled &&
matchesSettingsSearch(searchQuery, {
title: translate("auto.components.settings.CommitMessageAiPane.2dafc7646e", "Hosted-review creation defaults"),
description: translate("auto.components.settings.CommitMessageAiPane.e9d46a544d", "Defaults used when the hosted-review composer opens."),
title: translate(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.e9d46a544d',
'Defaults used when the hosted-review composer opens.'
),
keywords: [
translate("auto.components.settings.CommitMessageAiPane.19e10a12bb", "hosted review"),
translate("auto.components.settings.CommitMessageAiPane.b388463881", "pull request"),
translate("auto.components.settings.CommitMessageAiPane.fdee745b87", "merge request"),
translate("auto.components.settings.CommitMessageAiPane.02bab6542c", "pr"),
translate("auto.components.settings.CommitMessageAiPane.ebed4d2a29", "draft"),
translate("auto.components.settings.CommitMessageAiPane.6c84ba6de3", "template"),
translate("auto.components.settings.CommitMessageAiPane.34d0348e34", "generate"),
translate("auto.components.settings.CommitMessageAiPane.2c5436c018", "open")
translate('auto.components.settings.CommitMessageAiPane.19e10a12bb', 'hosted review'),
translate('auto.components.settings.CommitMessageAiPane.b388463881', 'pull request'),
translate('auto.components.settings.CommitMessageAiPane.fdee745b87', 'merge request'),
translate('auto.components.settings.CommitMessageAiPane.02bab6542c', 'pr'),
translate('auto.components.settings.CommitMessageAiPane.ebed4d2a29', 'draft'),
translate('auto.components.settings.CommitMessageAiPane.6c84ba6de3', 'template'),
translate('auto.components.settings.CommitMessageAiPane.34d0348e34', 'generate'),
translate('auto.components.settings.CommitMessageAiPane.2c5436c018', 'open')
]
})
) {
@@ -260,30 +330,60 @@ export function CommitMessageAiPane({
}[] = [
{
key: 'draft',
label: translate("auto.components.settings.CommitMessageAiPane.6ba48f07a4", "Draft by default"),
description: translate("auto.components.settings.CommitMessageAiPane.e001734396", "Create hosted reviews as drafts unless changed in the composer.")
label: translate(
'auto.components.settings.CommitMessageAiPane.6ba48f07a4',
'Draft by default'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.e001734396',
'Create hosted reviews as drafts unless changed in the composer.'
)
},
{
key: 'useTemplate',
label: translate("auto.components.settings.CommitMessageAiPane.d8b6764d79", "Use review template when available"),
description: translate("auto.components.settings.CommitMessageAiPane.6278c0ce43", "Prefer repository pull request templates when no description is set.")
label: translate(
'auto.components.settings.CommitMessageAiPane.d8b6764d79',
'Use review template when available'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.6278c0ce43',
'Prefer repository pull request templates when no description is set.'
)
},
{
key: 'generateDetailsOnOpen',
label: translate("auto.components.settings.CommitMessageAiPane.d5f0de6309", "Generate details when opening Create PR"),
description: translate("auto.components.settings.CommitMessageAiPane.b27b0809f3", "Run hosted-review detail generation once when the composer opens.")
label: translate(
'auto.components.settings.CommitMessageAiPane.d5f0de6309',
'Generate details when opening Create PR'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.b27b0809f3',
'Run hosted-review detail generation once when the composer opens.'
)
},
{
key: 'openAfterCreate',
label: translate("auto.components.settings.CommitMessageAiPane.7662715213", "Open hosted review after creation"),
description: translate("auto.components.settings.CommitMessageAiPane.b125eabffa", "Open the created hosted review in your browser after submit.")
label: translate(
'auto.components.settings.CommitMessageAiPane.7662715213',
'Open hosted review after creation'
),
description: translate(
'auto.components.settings.CommitMessageAiPane.b125eabffa',
'Open the created hosted review in your browser after submit.'
)
}
]
sections.push(
<SearchableSetting
key="pr-creation-defaults"
title={translate("auto.components.settings.CommitMessageAiPane.2dafc7646e", "Hosted-review creation defaults")}
description={translate("auto.components.settings.CommitMessageAiPane.e9d46a544d", "Defaults used when the hosted-review composer opens.")}
title={translate(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
)}
description={translate(
'auto.components.settings.CommitMessageAiPane.e9d46a544d',
'Defaults used when the hosted-review composer opens.'
)}
keywords={[
'hosted review',
'pull request',
@@ -297,9 +397,18 @@ export function CommitMessageAiPane({
className="space-y-3 px-1 py-2"
>
<div className="space-y-0.5">
<Label>{translate("auto.components.settings.CommitMessageAiPane.2dafc7646e", "Hosted-review creation defaults")}</Label>
<Label>
{translate(
'auto.components.settings.CommitMessageAiPane.2dafc7646e',
'Hosted-review creation defaults'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.CommitMessageAiPane.347094560b", "Used by repositories that inherit global hosted-review defaults.")}</p>
{translate(
'auto.components.settings.CommitMessageAiPane.347094560b',
'Used by repositories that inherit global hosted-review defaults.'
)}
</p>
</div>
<div className="space-y-2">
{rows.map((row) => (
@@ -331,9 +440,13 @@ export function CommitMessageAiPane({
className="space-y-4 border-t border-border/40 pt-4"
>
<div className="space-y-0.5">
<h3 className="text-sm font-semibold">{translate("auto.components.settings.CommitMessageAiPane.ad66ff886d", "Source Control AI defaults")}</h3>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.CommitMessageAiPane.841ed9884a", "Used by repositories that have not customized Source Control AI.")}</p>
<h3 className="text-sm font-semibold">
{translate(
'auto.components.settings.CommitMessageAiPane.ad66ff886d',
'Source Control AI defaults'
)}
</h3>
<p className="text-xs text-muted-foreground">{ownership.description}</p>
</div>
{sections}
</div>
@@ -1,11 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ExternalLink, MonitorCog, RefreshCw, ShieldCheck } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
Accessibility,
Camera,
ExternalLink,
MonitorCog,
RefreshCw,
ShieldCheck
} from 'lucide-react'
import { toast } from 'sonner'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import type { GlobalSettings } from '../../../../shared/types'
import type {
ComputerUsePermissionId,
ComputerUsePermissionState
ComputerUsePermissionState,
ComputerUsePermissionStatus
} from '../../../../shared/computer-use-permissions-types'
import {
COMPUTER_USE_SKILL_INSTALL_COMMAND,
@@ -13,8 +19,7 @@ import {
} from '@/lib/agent-feature-install-commands'
import {
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
ensureOrcaCliAvailableForAgentSkillTerminal,
isOrcaCliAvailableOnPath
ensureOrcaCliAvailableForAgentSkillTerminal
} from '@/lib/agent-skill-cli-prerequisite'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
@@ -24,32 +29,57 @@ import { useAppStore } from '@/store'
import { Button } from '../ui/button'
import { Badge } from '../ui/badge'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import {
buildSkillInstallCommandForRuntime,
ensureWslCliAvailableForAgentSkillTerminal,
getAgentSkillTerminalShellOverride,
getSkillDiscoveryTargetForRuntime
} from './CliSkillRuntimeSetup'
import { getDesktopPlatformFromUserAgent } from './GeneralPane'
import {
COMPUTER_USE_PERMISSIONS,
getComputerUsePermissionStatusClass,
getComputerUsePermissionStatusLabel
} from './computer-use-permission-definitions'
import { getComputerUseSkillRuntime } from './computer-use-skill-runtime'
import { getComputerUseSummary } from './computer-use-summary'
import { translate } from '@/i18n/i18n'
export { getComputerUsePaneSearchEntries } from './computer-use-search'
type ComputerUsePaneProps = {
currentPlatform?: string
settings?: GlobalSettings | null
wslSupportedPlatform?: boolean
wslAvailable?: boolean
wslCapabilitiesLoading?: boolean
type PermissionDefinition = {
id: ComputerUsePermissionId
label: string
description: string
icon: ReactNode
}
export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Element {
const PERMISSIONS: PermissionDefinition[] = [
{
id: 'accessibility',
label: translate('auto.components.settings.ComputerUsePane.6b5a2cd3a5', 'Accessibility'),
description: translate(
'auto.components.settings.ComputerUsePane.4d03dec2d0',
'Read app interface trees and perform requested actions.'
),
icon: <Accessibility className="size-4" />
},
{
id: 'screenshots',
label: translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots'),
description: translate(
'auto.components.settings.ComputerUsePane.0c9a33f468',
'Capture app windows so agents can inspect visual state.'
),
icon: <Camera className="size-4" />
}
]
function statusLabel(status: ComputerUsePermissionStatus | undefined): string {
switch (status) {
case 'granted':
return 'Granted'
case 'unsupported':
return 'macOS only'
case 'not-granted':
case undefined:
return 'Not enabled'
}
}
function statusClass(status: ComputerUsePermissionStatus | undefined): string {
if (status === 'granted') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
}
return 'border-border bg-muted text-muted-foreground'
}
export function ComputerUsePane(): React.JSX.Element {
const [platform, setPlatform] = useState<NodeJS.Platform | null>(null)
const [states, setStates] = useState<ComputerUsePermissionState[]>([])
const [loading, setLoading] = useState(true)
@@ -60,35 +90,12 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele
const permissionOperationSequence = useRef(0)
const mountedRef = useRef(true)
const [helperUnavailableReason, setHelperUnavailableReason] = useState<string | null>(null)
const currentPlatform =
props.currentPlatform ??
(typeof navigator === 'undefined'
? 'other'
: getDesktopPlatformFromUserAgent(navigator.userAgent))
const skillRuntime = useMemo(() => getComputerUseSkillRuntime(props), [props])
const skillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>(
() => getSkillDiscoveryTargetForRuntime(skillRuntime),
[skillRuntime]
)
const skillInstallCommand = buildSkillInstallCommandForRuntime(
COMPUTER_USE_SKILL_INSTALL_COMMAND,
skillRuntime
)
const skillTerminalShellOverride = props.settings
? getAgentSkillTerminalShellOverride(currentPlatform, props.settings, skillRuntime)
: undefined
const getSkillPrerequisiteStatus = () =>
skillRuntime.runtime === 'wsl'
? window.api.cli.getWslInstallStatus()
: window.api.cli.getInstallStatus()
const {
installed: computerUseSkillDetected,
loading: computerUseSkillLoading,
error: computerUseSkillError,
refresh: refreshComputerUseSkill
} = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, {
discoveryTarget: skillDiscoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
@@ -96,21 +103,30 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele
() => new Map(states.map((state) => [state.id, state.status] as const)),
[states]
)
const grantedCount = COMPUTER_USE_PERMISSIONS.filter(
const grantedCount = PERMISSIONS.filter(
(permission) => stateById.get(permission.id) === 'granted'
).length
const allGranted = grantedCount === COMPUTER_USE_PERMISSIONS.length
const allGranted = grantedCount === PERMISSIONS.length
const checking = loading && states.length === 0
const setupUnavailable = helperUnavailableReason !== null
const resetAccessDisabled =
resetting || loading || states.length === 0 || pendingId !== null || setupUnavailable
const { title: summaryTitle, description: summaryDescription } = getComputerUseSummary({
checking,
setupUnavailable,
allGranted,
helperUnavailableReason,
requiredPermissionCount: COMPUTER_USE_PERMISSIONS.length - grantedCount
})
const summaryTitle = checking
? 'Checking Computer Use access.'
: setupUnavailable
? 'Computer Use is unavailable.'
: allGranted
? 'Computer Use is ready.'
: 'Finish setup to use local apps.'
const summaryDescription = checking
? 'Orca is checking macOS privacy permissions for the Computer Use helper.'
: setupUnavailable
? `Computer Use permissions are unavailable because ${helperUnavailableReason}.`
: allGranted
? 'Agents can inspect and operate app windows when you ask.'
: `${PERMISSIONS.length - grantedCount} permission${
PERMISSIONS.length - grantedCount === 1 ? '' : 's'
} required before agents can operate app windows.`
useEffect(() => {
mountedRef.current = true
@@ -299,7 +315,7 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele
<div className="space-y-2">
<div className="divide-y divide-border/60 rounded-lg border border-border/60">
{COMPUTER_USE_PERMISSIONS.map((permission) => {
{PERMISSIONS.map((permission) => {
const status = stateById.get(permission.id)
const pending = pendingId === permission.id
@@ -314,11 +330,11 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{permission.label}</span>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${getComputerUsePermissionStatusClass(
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusClass(
status
)}`}
>
{getComputerUsePermissionStatusLabel(status)}
{statusLabel(status)}
</span>
</div>
<p className="text-xs text-muted-foreground">{permission.description}</p>
@@ -371,23 +387,18 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele
'auto.components.settings.ComputerUsePane.1735461723',
'Enables agents to inspect and operate local desktop apps.'
)}
command={skillInstallCommand}
command={COMPUTER_USE_SKILL_INSTALL_COMMAND}
terminalTitle="Computer Use setup"
terminalAriaLabel="Computer Use skill install terminal"
terminalWorktreeId={`settings-computer-use-skill-terminal-${skillRuntime.runtime}`}
terminalShellOverride={skillTerminalShellOverride}
terminalWorktreeId="settings-computer-use-skill-terminal"
installed={computerUseSkillDetected}
loading={computerUseSkillLoading}
error={computerUseSkillError}
icon={<MonitorCog className="size-5" />}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
getPrerequisiteStatus={getSkillPrerequisiteStatus}
isPrerequisiteAvailable={isOrcaCliAvailableOnPath}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('computer-use-setup')
await (skillRuntime.runtime === 'wsl'
? ensureWslCliAvailableForAgentSkillTerminal()
: ensureOrcaCliAvailableForAgentSkillTerminal())
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
onRecheck={refreshComputerUseSkill}
/>
@@ -28,50 +28,7 @@ export function GeneralWorkspaceSettingsSection({
)}
/>
<SearchableSetting
title={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.0e9fc0eadc',
'Workspace Directory'
)}
description={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.a246f5ce6f',
'Root directory where workspace folders are created.'
)}
keywords={['workspace', 'folder', 'path', 'worktree']}
className="space-y-2"
>
<Label>
{translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.0e9fc0eadc',
'Workspace Directory'
)}
</Label>
<div className="flex gap-2">
<Input
value={settings.workspaceDir}
onChange={(e) => updateSettings({ workspaceDir: e.target.value })}
className="flex-1 text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={handleBrowseWorkspace}
className="shrink-0 gap-1.5"
>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.5567191a6e',
'Browse'
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.a246f5ce6f',
'Root directory where workspace folders are created.'
)}
</p>
</SearchableSetting>
<WorkspaceDirectorySetting settings={settings} updateSettings={updateSettings} />
<SearchableSetting
title={translate(
@@ -1,12 +1,9 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { ArrowRightLeft, GitBranch, ListChecks, Workflow } from 'lucide-react'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import type { GlobalSettings } from '../../../../shared/types'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import {
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
ensureOrcaCliAvailableForAgentSkillTerminal,
isOrcaCliAvailableOnPath
ensureOrcaCliAvailableForAgentSkillTerminal
} from '@/lib/agent-skill-cli-prerequisite'
import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command'
import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-examples'
@@ -23,15 +20,6 @@ import { OrchestrationSkillAgentCoverage } from './OrchestrationSkillAgentCovera
import { OrchestrationExampleDialog } from './OrchestrationExamplesDialog'
import { OrchestrationSkillPromptDialog } from './OrchestrationSkillPromptDialog'
import { translate } from '@/i18n/i18n'
import {
buildSkillInstallCommandForRuntime,
ensureWslCliAvailableForAgentSkillTerminal,
getAgentSkillTerminalShellOverride,
getSelectedAgentRuntime,
getSkillDiscoveryTargetForRuntime,
type LocalAgentRuntime
} from './CliSkillRuntimeSetup'
import { getDesktopPlatformFromUserAgent } from './GeneralPane'
const EXAMPLE_ICONS = {
handoff: ArrowRightLeft,
@@ -41,55 +29,11 @@ const EXAMPLE_ICONS = {
'child-worktrees': Workflow
} as const
type OrchestrationPaneProps = {
currentPlatform?: string
settings?: GlobalSettings | null
wslSupportedPlatform?: boolean
wslAvailable?: boolean
wslCapabilitiesLoading?: boolean
}
function getOrchestrationSkillRuntime(props: OrchestrationPaneProps): LocalAgentRuntime {
if (!props.settings) {
return {
runtime: 'host',
label: translate('auto.components.settings.OrchestrationPane.thisDevice', 'This device')
}
}
return getSelectedAgentRuntime(
props.settings,
props.wslSupportedPlatform ?? false,
props.wslAvailable ?? false,
props.wslCapabilitiesLoading ?? false
)
}
export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX.Element {
export function OrchestrationPane(): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const showOrchestration = matchesSettingsSearch(searchQuery, getOrchestrationPaneSearchEntries())
const [selectedExampleId, setSelectedExampleId] = useState<string | null>(null)
const [skillPromptOpen, setSkillPromptOpen] = useState(false)
const currentPlatform =
props.currentPlatform ??
(typeof navigator === 'undefined'
? 'other'
: getDesktopPlatformFromUserAgent(navigator.userAgent))
const skillRuntime = useMemo(() => getOrchestrationSkillRuntime(props), [props])
const skillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>(
() => getSkillDiscoveryTargetForRuntime(skillRuntime),
[skillRuntime]
)
const skillInstallCommand = buildSkillInstallCommandForRuntime(
ORCHESTRATION_SKILL_INSTALL_COMMAND,
skillRuntime
)
const skillTerminalShellOverride = props.settings
? getAgentSkillTerminalShellOverride(currentPlatform, props.settings, skillRuntime)
: undefined
const getSkillPrerequisiteStatus = () =>
skillRuntime.runtime === 'wsl'
? window.api.cli.getWslInstallStatus()
: window.api.cli.getInstallStatus()
const {
installed: orchestrationSkillDetected,
@@ -98,7 +42,6 @@ export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX
skills: discoveredSkills,
refresh: refreshOrchestrationSkill
} = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, {
discoveryTarget: skillDiscoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
@@ -128,23 +71,18 @@ export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX
'auto.components.settings.OrchestrationPane.9bedd2a6e5',
'Enables agents to hand off context and coordinate work through Orca.'
)}
command={skillInstallCommand}
command={ORCHESTRATION_SKILL_INSTALL_COMMAND}
terminalTitle="Orchestration setup"
terminalAriaLabel="Orchestration skill install terminal"
terminalWorktreeId={`settings-orchestration-skill-terminal-${skillRuntime.runtime}`}
terminalShellOverride={skillTerminalShellOverride}
terminalWorktreeId="settings-orchestration-skill-terminal"
installed={orchestrationSkillDetected}
loading={orchestrationSkillLoading}
error={orchestrationSkillError}
icon={<Workflow className="size-5" />}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
getPrerequisiteStatus={getSkillPrerequisiteStatus}
isPrerequisiteAvailable={isOrcaCliAvailableOnPath}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup')
await (skillRuntime.runtime === 'wsl'
? ensureWslCliAvailableForAgentSkillTerminal()
: ensureOrcaCliAvailableForAgentSkillTerminal())
await ensureOrcaCliAvailableForAgentSkillTerminal()
}}
actionHint={
<p className="text-[12px] leading-snug text-muted-foreground">
@@ -175,7 +113,7 @@ export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX
/>
<OrchestrationSkillPromptDialog
command={skillInstallCommand}
command={ORCHESTRATION_SKILL_INSTALL_COMMAND}
open={skillPromptOpen}
onOpenChange={setSkillPromptOpen}
/>
@@ -25,6 +25,7 @@ import RepoBadgeLabel, { RepoBadgeMark } from '../repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
type QuickCommandsPaneProps = {
@@ -76,6 +77,7 @@ export function QuickCommandsPane({
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const commands = settings.terminalQuickCommands ?? []
const ownership = getSettingOwnershipSummary('terminalQuickCommands')
const confirm = useConfirmationDialog()
const [editor, setEditor] = useState<EditorState>(null)
@@ -164,7 +166,11 @@ export function QuickCommandsPane({
const renderTriggerLabel = (): React.JSX.Element => {
if (showAll) {
return <span>{translate("auto.components.settings.QuickCommandsPane.c6b155911b", "All commands")}</span>
return (
<span>
{translate('auto.components.settings.QuickCommandsPane.c6b155911b', 'All commands')}
</span>
)
}
const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY)
const selectedRepos = repos.filter((r) => effectiveSelection.has(r.id))
@@ -176,7 +182,12 @@ export function QuickCommandsPane({
const [first, ...rest] = selectedRepos
parts.push(rest.length > 0 ? `${first.displayName} +${rest.length}` : first.displayName)
}
return <span className="truncate">{parts.join(', ') || translate("auto.components.settings.QuickCommandsPane.d1d0976320", "None")}</span>
return (
<span className="truncate">
{parts.join(', ') ||
translate('auto.components.settings.QuickCommandsPane.d1d0976320', 'None')}
</span>
)
}
const saveCommand = (next: TerminalQuickCommand): void => {
@@ -193,9 +204,16 @@ export function QuickCommandsPane({
const removeCommand = async (command: TerminalQuickCommand): Promise<void> => {
const confirmed = await confirm({
title: translate("auto.components.settings.QuickCommandsPane.3edf3deaf8", "Delete \"{{value0}}\"?", { value0: command.label || 'Untitled' }),
description: translate("auto.components.settings.QuickCommandsPane.3d9dc558e8", "This quick command will be removed from your saved list."),
confirmLabel: translate("auto.components.settings.QuickCommandsPane.ec1ed99e70", "Delete"),
title: translate(
'auto.components.settings.QuickCommandsPane.3edf3deaf8',
'Delete "{{value0}}"?',
{ value0: command.label || 'Untitled' }
),
description: translate(
'auto.components.settings.QuickCommandsPane.3d9dc558e8',
'This quick command will be removed from your saved list.'
),
confirmLabel: translate('auto.components.settings.QuickCommandsPane.ec1ed99e70', 'Delete'),
confirmVariant: 'destructive'
})
if (!confirmed) {
@@ -214,9 +232,10 @@ export function QuickCommandsPane({
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 py-2">
<div className="space-y-1">
<Label>{translate("auto.components.settings.QuickCommandsPane.f91b649324", "Saved Commands")}</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.QuickCommandsPane.c36912efd5", "Run them from the Quick Commands button in the tab bar, or right-click inside any terminal.")}</p>
<Label>
{translate('auto.components.settings.QuickCommandsPane.f91b649324', 'Saved Commands')}
</Label>
<p className="text-xs text-muted-foreground">{ownership.description}</p>
</div>
<Button
type="button"
@@ -225,7 +244,8 @@ export function QuickCommandsPane({
onClick={() => setEditor({ mode: 'add', command: createDraftForCurrentFilter() })}
>
<Plus />
{translate("auto.components.settings.QuickCommandsPane.5aacc8f7dc", "Add Command")}</Button>
{translate('auto.components.settings.QuickCommandsPane.5aacc8f7dc', 'Add Command')}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -263,7 +283,12 @@ export function QuickCommandsPane({
showAll ? 'opacity-70' : 'opacity-0'
)}
/>
<span>{translate("auto.components.settings.QuickCommandsPane.c6b155911b", "All commands")}</span>
<span>
{translate(
'auto.components.settings.QuickCommandsPane.c6b155911b',
'All commands'
)}
</span>
</button>
</div>
<CommandList>
@@ -278,7 +303,9 @@ export function QuickCommandsPane({
effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0'
)}
/>
<span>{translate("auto.components.settings.QuickCommandsPane.8c877dec41", "Global")}</span>
<span>
{translate('auto.components.settings.QuickCommandsPane.8c877dec41', 'Global')}
</span>
</CommandItem>
{repos.map((repo) => {
const isSelected = effectiveSelection.has(repo.id)
@@ -313,8 +340,14 @@ export function QuickCommandsPane({
{visibleCommands.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{commands.length === 0
? translate("auto.components.settings.QuickCommandsPane.38d61927e6", "No quick commands saved.")
: translate("auto.components.settings.QuickCommandsPane.3eb9897ab0", "No commands in the selected scopes.")}
? translate(
'auto.components.settings.QuickCommandsPane.38d61927e6',
'No quick commands saved.'
)
: translate(
'auto.components.settings.QuickCommandsPane.3eb9897ab0',
'No commands in the selected scopes.'
)}
</div>
) : (
<div className="max-h-[60vh] space-y-2 overflow-y-auto p-2 scrollbar-sleek">
@@ -328,7 +361,11 @@ export function QuickCommandsPane({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{command.label || translate("auto.components.settings.QuickCommandsPane.2bb9e38e93", "Untitled")}
{command.label ||
translate(
'auto.components.settings.QuickCommandsPane.2bb9e38e93',
'Untitled'
)}
</div>
<Badge variant="outline" className="max-w-44 gap-1.5">
{scope.type === 'repo' ? (
@@ -355,22 +392,36 @@ export function QuickCommandsPane({
>
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}`
: getTerminalQuickCommandBody(command) || translate("auto.components.settings.QuickCommandsPane.0252ddd578", "No command text")}
: getTerminalQuickCommandBody(command) ||
translate(
'auto.components.settings.QuickCommandsPane.0252ddd578',
'No command text'
)}
</span>
</div>
</div>
<div className="shrink-0 text-[11px] font-medium text-foreground/75">
{isTerminalAgentQuickCommand(command)
? translate("auto.components.settings.QuickCommandsPane.4ccc63da87", "Agent")
? translate('auto.components.settings.QuickCommandsPane.4ccc63da87', 'Agent')
: command.appendEnter
? translate("auto.components.settings.QuickCommandsPane.9b3e338d62", "Enter")
: translate("auto.components.settings.QuickCommandsPane.9fcfc29519", "Insert")}
? translate(
'auto.components.settings.QuickCommandsPane.9b3e338d62',
'Enter'
)
: translate(
'auto.components.settings.QuickCommandsPane.9fcfc29519',
'Insert'
)}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate("auto.components.settings.QuickCommandsPane.7d90fd5299", "Edit {{value0}}", { value0: command.label || 'quick command' })}
aria-label={translate(
'auto.components.settings.QuickCommandsPane.7d90fd5299',
'Edit {{value0}}',
{ value0: command.label || 'quick command' }
)}
onClick={() => setEditor({ mode: 'edit', command })}
>
<Pencil />
@@ -379,7 +430,11 @@ export function QuickCommandsPane({
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate("auto.components.settings.QuickCommandsPane.8764c6e9e4", "Remove {{value0}}", { value0: command.label || 'quick command' })}
aria-label={translate(
'auto.components.settings.QuickCommandsPane.8764c6e9e4',
'Remove {{value0}}',
{ value0: command.label || 'quick command' }
)}
onClick={() => void removeCommand(command)}
className="text-muted-foreground hover:text-destructive"
>
@@ -34,6 +34,7 @@ import {
completeRepoActionRecipe,
readInheritedCommandTemplate
} from './repository-source-control-ai-labels'
import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
export {
@@ -89,6 +90,7 @@ export function RepositorySourceControlAiSection({
}: RepositorySourceControlAiSectionProps): React.JSX.Element {
const mountedRef = useMountedRef()
const settings = useAppStore((state) => state.settings)
const ownership = getSettingOwnershipSummary('repositorySourceControlAi')
const source = normalizeSourceControlAiSettings(
settings?.sourceControlAi,
settings?.commitMessageAi
@@ -302,12 +304,7 @@ export function RepositorySourceControlAiSection({
'Source Control AI'
)}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RepositorySourceControlAiSection.8b8bc5913a',
'Repository action recipes. Global settings are used until this repository customizes them.'
)}
</p>
<p className="text-xs text-muted-foreground">{ownership.description}</p>
{saveError ? <p className="text-xs text-destructive">{saveError}</p> : null}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
@@ -5,7 +5,9 @@ import {
} from '../../../../shared/protocol-version'
import {
evaluateHostDetails,
getHostDetailsDescription,
getHostDetailsSummary,
getRuntimeCapabilitiesSummary,
type RuntimeHostDetails
} from './RuntimeEnvironmentsPane'
@@ -78,4 +80,51 @@ describe('RuntimeEnvironmentsPane host details', () => {
})
).toMatchObject({ kind: 'blocked', reason: 'server-too-old' })
})
it('explains blocked runtime compatibility with required protocol versions', () => {
expect(
getHostDetailsDescription(
details({
compatibility: {
kind: 'blocked',
reason: 'server-too-old',
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
serverProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1,
requiredServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION
}
})
)
).toContain('client requires server protocol')
})
it('summarizes runtime capabilities by name with overflow count', () => {
expect(
getRuntimeCapabilitiesSummary({
runtimeId: 'runtime',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
capabilities: ['runtime.environments.v1', 'terminal.multiplex.v1']
})
).toBe('runtime.environments.v1, terminal.multiplex.v1')
expect(
getRuntimeCapabilitiesSummary({
runtimeId: 'runtime',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
capabilities: [
'runtime.environments.v1',
'browser.screencast.v1',
'terminal.multiplex.v1',
'project-host-setup.v1'
]
})
).toBe('runtime.environments.v1, browser.screencast.v1, terminal.multiplex.v1 +1')
})
})
@@ -9,6 +9,7 @@ import type { GlobalSettings } from '../../../../shared/types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import {
describeRuntimeCompatBlock,
evaluateRuntimeCompat,
type RuntimeCompatVerdict
} from '../../../../shared/protocol-compat'
@@ -83,6 +84,32 @@ export function getHostDetailsSummary(details: RuntimeHostDetails | undefined):
return translate('auto.components.settings.RuntimeEnvironmentsPane.9a91c4a0eb', 'Compatible')
}
export function getHostDetailsDescription(details: RuntimeHostDetails | undefined): string | null {
if (!details || details.status === 'loading') {
return null
}
if (details.status === 'error') {
return details.error
}
if (details.compatibility?.kind === 'blocked') {
return describeRuntimeCompatBlock(details.compatibility)
}
return null
}
export function getRuntimeCapabilitiesSummary(status: RuntimeStatus | null | undefined): string {
const capabilities = status?.capabilities ?? []
if (capabilities.length === 0) {
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.4b5c6d7e8f',
'No capabilities reported'
)
}
const visibleCapabilities = capabilities.slice(0, 3).join(', ')
const hiddenCount = capabilities.length - 3
return hiddenCount > 0 ? `${visibleCapabilities} +${hiddenCount}` : visibleCapabilities
}
export function RuntimeEnvironmentsPane({
settings,
switchRuntimeEnvironment,
@@ -634,66 +661,69 @@ export function RuntimeEnvironmentsPane({
key={environment.id}
className="flex items-start justify-between gap-3 px-3 py-2"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">{environment.name}</div>
{detailsByEnvironmentId[environment.id]?.compatibility?.kind === 'blocked' ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : detailsByEnvironmentId[environment.id]?.status === 'ready' ? (
<CheckCircle2 className="size-3.5 shrink-0 text-status-success" />
) : detailsByEnvironmentId[environment.id]?.status === 'loading' ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{environment.endpoints[0]?.endpoint ??
translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da',
'No endpoint'
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
<span>{getHostDetailsSummary(detailsByEnvironmentId[environment.id])}</span>
{detailsByEnvironmentId[environment.id]?.runtimeStatus ? (
<>
<span>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a',
'Protocol {{value0}}',
{
value0:
detailsByEnvironmentId[environment.id].runtimeStatus
?.runtimeProtocolVersion ??
detailsByEnvironmentId[environment.id].runtimeStatus
?.protocolVersion ??
0
}
)}
</span>
{detailsByEnvironmentId[environment.id].runtimeStatus?.hostPlatform ? (
<span>
{detailsByEnvironmentId[environment.id].runtimeStatus?.hostPlatform}
</span>
{(() => {
const details = detailsByEnvironmentId[environment.id]
const detailsDescription = getHostDetailsDescription(details)
return (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">{environment.name}</div>
{details?.compatibility?.kind === 'blocked' ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : details?.status === 'ready' ? (
<CheckCircle2 className="size-3.5 shrink-0 text-status-success" />
) : details?.status === 'loading' ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
<span>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.f3a3d6d834',
'{{value0}} capabilities',
{
value0:
detailsByEnvironmentId[environment.id].runtimeStatus?.capabilities
?.length ?? 0
}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{environment.endpoints[0]?.endpoint ??
translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da',
'No endpoint'
)}
</span>
</>
) : detailsByEnvironmentId[environment.id]?.error ? (
<span className="truncate text-destructive">
{detailsByEnvironmentId[environment.id]?.error}
</span>
) : null}
</div>
</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
<span>{getHostDetailsSummary(details)}</span>
{details?.runtimeStatus ? (
<>
<span>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a',
'Protocol {{value0}}',
{
value0:
details.runtimeStatus?.runtimeProtocolVersion ??
details.runtimeStatus?.protocolVersion ??
0
}
)}
</span>
{details.runtimeStatus?.hostPlatform ? (
<span>{details.runtimeStatus?.hostPlatform}</span>
) : null}
<span>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.5c6d7e8f9a',
'Capabilities: {{value0}}',
{
value0: getRuntimeCapabilitiesSummary(details.runtimeStatus)
}
)}
</span>
</>
) : detailsDescription ? (
<span className="truncate text-destructive">{detailsDescription}</span>
) : null}
</div>
{detailsDescription && details?.runtimeStatus ? (
<div className="mt-1 max-w-xl text-[11px] text-muted-foreground">
{detailsDescription}
</div>
) : null}
</div>
)
})()}
<Button
type="button"
variant="ghost"
@@ -2,7 +2,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
import { toast } from 'sonner'
import type { GlobalSettings, OrcaHooks } from '../../../../shared/types'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import type { SpeechModelState } from '../../../../shared/speech-types'
import type {
SourceControlAiSettings,
@@ -17,7 +16,7 @@ import { applyDocumentTheme } from '@/lib/document-theme'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants'
import { GeneralPane, getDesktopPlatformFromUserAgent } from './GeneralPane'
import { GeneralPane } from './GeneralPane'
import { BrowserPane } from './BrowserPane'
import { AppearancePane } from './AppearancePane'
import { InputPane } from './InputPane'
@@ -86,60 +85,33 @@ import {
getRuntimeTargetIdentity
} from './settings-load-performance'
import { translate } from '@/i18n/i18n'
import {
getSelectedAgentRuntime,
getSkillDiscoveryTargetForRuntime,
type LocalAgentRuntime
} from './CliSkillRuntimeSetup'
const SETTINGS_NAV_GROUPS = [
{
id: 'capabilities',
get title() {
return translate('auto.components.settings.Settings.23c6874fdf', 'AI Capabilities')
}
},
{
id: 'setup',
get title() {
return translate('auto.components.settings.Settings.9abb9be3bc', 'Set Up')
}
title: translate('auto.components.settings.Settings.23c6874fdf', 'AI Capabilities')
},
{ id: 'setup', title: translate('auto.components.settings.Settings.9abb9be3bc', 'Set Up') },
{
id: 'workflows',
get title() {
return translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows')
}
title: translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows')
},
{
id: 'interface',
get title() {
return translate('auto.components.settings.Settings.8bd117d669', 'Interface')
}
title: translate('auto.components.settings.Settings.8bd117d669', 'Interface')
},
{
id: 'remote',
get title() {
return translate('auto.components.settings.Settings.23931df7e8', 'Remote Access')
}
title: translate('auto.components.settings.Settings.23931df7e8', 'Remote Access')
},
{
id: 'security',
get title() {
return translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security')
}
},
{
id: 'advanced',
get title() {
return translate('auto.components.settings.Settings.1c87f8d024', 'Advanced')
}
title: translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security')
},
{ id: 'advanced', title: translate('auto.components.settings.Settings.1c87f8d024', 'Advanced') },
{
id: 'experimental',
get title() {
return translate('auto.components.settings.Settings.8b017f2506', 'Experimental')
}
title: translate('auto.components.settings.Settings.8b017f2506', 'Experimental')
}
] as const
@@ -167,19 +139,6 @@ function getSkillNavInstallStatus(skill: {
return skill.installed ? 'installed' : 'install'
}
function getSettingsAgentSkillRuntime(args: {
settings: GlobalSettings | null
isWindows: boolean
}): LocalAgentRuntime {
if (!args.settings) {
return {
runtime: 'host',
label: translate('auto.components.settings.Settings.thisDevice', 'This device')
}
}
return getSelectedAgentRuntime(args.settings, args.isWindows, args.isWindows, false)
}
function hasReadyVoiceModel(
settings: GlobalSettings,
modelStates: readonly SpeechModelState[]
@@ -275,21 +234,10 @@ function Settings(): React.JSX.Element {
const isMac = isMacUserAgent()
const isWebClient = isWebClientLocation()
const showDesktopOnlySettings = !isWebClient
const currentPlatform = getDesktopPlatformFromUserAgent(navigator.userAgent)
const agentSkillRuntime = useMemo(
() => getSettingsAgentSkillRuntime({ settings, isWindows }),
[settings, isWindows]
)
const agentSkillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>(
() => getSkillDiscoveryTargetForRuntime(agentSkillRuntime),
[agentSkillRuntime]
)
const orchestrationSkill = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, {
discoveryTarget: agentSkillDiscoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const computerUseSkill = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, {
discoveryTarget: agentSkillDiscoveryTarget,
enabled: showDesktopOnlySettings,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
@@ -976,8 +924,8 @@ function Settings(): React.JSX.Element {
className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background"
>
<SettingsSidebar
activeSectionId={activeSectionId}
settings={settings}
activeSectionId={activeSectionId}
generalGroups={generalNavGroups}
repoSections={repoNavSections}
hasRepos={repos.length > 0}
@@ -1072,15 +1020,7 @@ function Settings(): React.JSX.Element {
)}
searchEntries={getSectionSearchEntries('orchestration')}
>
{isSectionMounted('orchestration') ? (
<OrchestrationPane
currentPlatform={currentPlatform}
settings={settings}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
/>
) : null}
{isSectionMounted('orchestration') ? <OrchestrationPane /> : null}
</SettingsSection>
{showDesktopOnlySettings ? (
@@ -1097,15 +1037,7 @@ function Settings(): React.JSX.Element {
)}
searchEntries={getSectionSearchEntries('computer-use')}
>
{isSectionMounted('computer-use') ? (
<ComputerUsePane
currentPlatform={currentPlatform}
settings={settings}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
/>
) : null}
{isSectionMounted('computer-use') ? <ComputerUsePane /> : null}
</SettingsSection>
<SettingsSection
@@ -10,6 +10,7 @@ import {
} from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { getProviderAccountScope } from './provider-account-scope'
import { translate } from '@/i18n/i18n'
type VerificationResult = { state: 'ok' | 'error'; error?: string }
@@ -33,6 +34,7 @@ export function JiraIntegrationCard(): React.JSX.Element {
const connected = contextMatches && jiraStatus.connected
const sites = jiraStatus.sites ?? []
const siteCount = sites.length || (connected ? 1 : 0)
const accountScope = getProviderAccountScope(settings)
const credentialCopy = hasRemoteProviderRuntime(settings)
? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.'
: 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.'
@@ -106,6 +108,16 @@ export function JiraIntegrationCard(): React.JSX.Element {
) : null
}
>
<div className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs">
<div className="font-medium text-foreground">
{translate(
'auto.components.settings.task.tracker.integration.cards.account_scope',
'Account scope: {{value0}}',
{ value0: accountScope.label }
)}
</div>
<div className="mt-0.5 text-muted-foreground">{accountScope.description}</div>
</div>
{connected && sites.length > 0 ? (
<div className="mt-3 space-y-2">
{sites.map((site) => {
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { getProviderAccountScope } from './provider-account-scope'
describe('getProviderAccountScope', () => {
it('describes provider accounts as client-owned without an active runtime', () => {
expect(getProviderAccountScope({ activeRuntimeEnvironmentId: null })).toEqual({
label: 'Local Mac',
description:
'Credentials and account checks for this provider are owned by this desktop client.'
})
})
it('describes provider accounts as remote-server-owned with an active runtime', () => {
expect(getProviderAccountScope({ activeRuntimeEnvironmentId: ' env-1 ' })).toEqual({
label: 'Remote server: env-1',
description:
'Credentials and account checks for this provider are owned by this remote server.'
})
})
})
@@ -0,0 +1,22 @@
import type { GlobalSettings } from '../../../../shared/types'
export type ProviderAccountScope = {
label: string
description: string
}
export function getProviderAccountScope(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): ProviderAccountScope {
const runtimeId = settings?.activeRuntimeEnvironmentId?.trim()
if (runtimeId) {
return {
label: `Remote server: ${runtimeId}`,
description: 'Credentials and account checks for this provider are owned by this remote server.'
}
}
return {
label: 'Local Mac',
description: 'Credentials and account checks for this provider are owned by this desktop client.'
}
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { getSettingOwnershipSummary } from './setting-ownership'
describe('getSettingOwnershipSummary', () => {
it('documents Source Control AI as client defaults with host-scoped model choices', () => {
const summary = getSettingOwnershipSummary('sourceControlAiDefaults')
expect(summary.ownership).toBe('client-default')
expect(summary.description).toContain('shared by this client')
expect(summary.description).toContain('model choices and discovery stay scoped to the host')
})
it('documents repository Source Control AI as project-host setup scoped', () => {
const summary = getSettingOwnershipSummary('repositorySourceControlAi')
expect(summary.ownership).toBe('project-host-setup')
expect(summary.description).toContain('this project setup')
})
it('documents agent launch defaults as client-owned with run-time host validation', () => {
const summary = getSettingOwnershipSummary('agentLaunchDefaults')
expect(summary.ownership).toBe('client-default')
expect(summary.description).toContain('SSH and remote server launches')
expect(summary.description).toContain('validate host availability')
})
it('keeps workspace directories and provider accounts explicitly host-aware', () => {
expect(getSettingOwnershipSummary('workspaceDirectory').ownership).toBe('host-override')
expect(getSettingOwnershipSummary('providerAccounts').ownership).toBe('provider-host')
})
})
@@ -0,0 +1,55 @@
export type SettingOwnership =
| 'client-default'
| 'host-override'
| 'project-host-setup'
| 'provider-host'
type SettingOwnershipSummary = {
ownership: SettingOwnership
label: string
description: string
}
const SUMMARIES = {
sourceControlAiDefaults: {
ownership: 'client-default',
label: 'Client default',
description:
'Recipes, prompts, and hosted-review defaults are shared by this client; model choices and discovery stay scoped to the host where the agent runs.'
},
repositorySourceControlAi: {
ownership: 'project-host-setup',
label: 'Project on this host',
description:
'These overrides apply to this project setup and inherit the client Source Control AI defaults until customized.'
},
agentLaunchDefaults: {
ownership: 'client-default',
label: 'Client default',
description:
'Default agent, command overrides, CLI arguments, and launch environment are client preferences. SSH and remote server launches still validate host availability at run time.'
},
terminalQuickCommands: {
ownership: 'client-default',
label: 'Client default + project scopes',
description:
'Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context.'
},
workspaceDirectory: {
ownership: 'host-override',
label: 'Host override',
description: 'The client default is inherited until a host needs its own worktree directory.'
},
providerAccounts: {
ownership: 'provider-host',
label: 'Provider host',
description:
'Credentials and account checks belong to the local client or selected remote server that owns the provider integration.'
}
} satisfies Record<string, SettingOwnershipSummary>
export type SettingOwnershipKey = keyof typeof SUMMARIES
export function getSettingOwnershipSummary(key: SettingOwnershipKey): SettingOwnershipSummary {
return SUMMARIES[key]
}
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { LinearIntegrationCard } from './task-tracker-integration-cards'
@@ -14,25 +14,14 @@ type StoreState = {
linearStatusChecked: boolean
linearStatusContextKey: string | null
disconnectLinear: () => Promise<void>
disconnectLinearWorkspace: () => Promise<void>
checkLinearConnection: () => Promise<void>
testLinearConnection: () => Promise<{ ok: boolean; error?: string }>
settings: {
activeRuntimeEnvironmentId: string | null
localAgentRuntime?: 'host' | 'wsl'
localAgentWslDistro?: string | null
terminalWindowsShell?: string
terminalWindowsWslDistro?: string | null
}
disconnectLinearWorkspace: (workspaceId?: string) => Promise<void>
checkLinearConnection: (force?: boolean) => Promise<void>
testLinearConnection: (workspaceId: string) => Promise<{ ok: boolean; error?: string }>
settings: { activeRuntimeEnvironmentId: string | null }
}
const mocks = vi.hoisted(() => ({
store: { current: null as StoreState | null },
panelProps: [] as Record<string, unknown>[],
skillRefresh: vi.fn(async () => {}),
useInstalledAgentSkill: vi.fn(),
ensureCli: vi.fn(async () => {}),
ensureWslCli: vi.fn(async () => {})
store: { current: null as StoreState | null }
}))
vi.mock('@/store', () => ({
@@ -44,33 +33,6 @@ vi.mock('@/store', () => ({
}
}))
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'],
useInstalledAgentSkill: mocks.useInstalledAgentSkill
}))
vi.mock('@/lib/agent-skill-cli-prerequisite', () => ({
AGENT_SKILL_CLI_PREREQUISITE_NOTICE: 'CLI registration notice',
ensureOrcaCliAvailableForAgentSkillTerminal: mocks.ensureCli,
isOrcaCliAvailableOnPath: (status: { state?: string; pathConfigured?: boolean } | null) =>
status?.state === 'installed' && status.pathConfigured === true
}))
vi.mock('./CliSkillRuntimeSetup', () => ({
buildSkillInstallCommandForRuntime: (
command: string,
runtime: { runtime: string; wslDistro?: string | null }
) =>
runtime.runtime === 'wsl'
? `wsl.exe${runtime.wslDistro ? ` -d '${runtime.wslDistro}'` : ''} -- bash -lc '${command}'`
: command,
ensureWslCliAvailableForAgentSkillTerminal: mocks.ensureWslCli,
getWslCliDistroRequest: (runtime?: { runtime: string; wslDistro?: string | null }) =>
runtime?.runtime === 'wsl' && runtime.wslDistro?.trim()
? { distro: runtime.wslDistro.trim() }
: undefined
}))
vi.mock('@/components/linear-api-key-dialog', () => ({
LinearApiKeyDialog: ({ onConnected }: { onConnected?: () => void }) => (
<button type="button" data-testid="simulate-linear-connected" onClick={onConnected}>
@@ -79,35 +41,14 @@ vi.mock('@/components/linear-api-key-dialog', () => ({
)
}))
vi.mock('./AgentSkillSetupPanel', () => ({
AgentSkillSetupPanel: (props: Record<string, unknown> & { actionHint?: ReactNode }) => {
mocks.panelProps.push(props)
return (
<section data-testid="linear-skill-panel">
<h2>{String(props.title)}</h2>
<p>{String(props.description)}</p>
<code>{String(props.command)}</code>
<button type="button" onClick={() => void (props.onBeforeOpenTerminal as () => void)()}>
Open installer
</button>
<button type="button" onClick={() => void (props.onRecheck as () => void)()}>
Panel re-check
</button>
{props.actionHint}
</section>
)
}
}))
let root: Root | null = null
let container: HTMLDivElement | null = null
const defaultUserAgent = navigator.userAgent
function installStore(
connected: boolean,
settings: StoreState['settings'] = { activeRuntimeEnvironmentId: null }
): void {
mocks.store.current = {
): StoreState {
const state: StoreState = {
linearStatus: {
connected,
workspaces: connected
@@ -129,6 +70,8 @@ function installStore(
testLinearConnection: vi.fn(async () => ({ ok: true })),
settings
}
mocks.store.current = state
return state
}
async function renderCard(): Promise<HTMLDivElement> {
@@ -141,29 +84,7 @@ async function renderCard(): Promise<HTMLDivElement> {
return container
}
describe('LinearIntegrationCard skill setup', () => {
beforeEach(() => {
mocks.panelProps.length = 0
mocks.skillRefresh.mockClear()
mocks.ensureCli.mockClear()
mocks.ensureWslCli.mockClear()
mocks.useInstalledAgentSkill.mockReset()
mocks.useInstalledAgentSkill.mockReturnValue({
installed: false,
loading: false,
error: null,
refresh: mocks.skillRefresh
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
cli: {
getWslInstallStatus: vi.fn(async () => undefined)
}
}
})
})
describe('LinearIntegrationCard account scope', () => {
afterEach(async () => {
if (root) {
await act(async () => {
@@ -174,100 +95,59 @@ describe('LinearIntegrationCard skill setup', () => {
container?.remove()
container = null
mocks.store.current = null
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: defaultUserAgent
})
Reflect.deleteProperty(window, 'api')
})
it('keeps Linear skill setup out of the disconnected state', async () => {
installStore(false)
it('shows local-client account ownership when Linear is disconnected', async () => {
const state = installStore(false)
const rendered = await renderCard()
expect(rendered.querySelector('[data-testid="linear-skill-panel"]')).toBeNull()
expect(mocks.useInstalledAgentSkill).toHaveBeenCalledWith(
'linear-tickets',
expect.objectContaining({ enabled: false, sourceKinds: ['home'] })
expect(rendered.textContent).toContain('Account scope: Local Mac')
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client.'
)
})
expect(rendered.textContent).toContain('Add access with a Personal API key')
it('renders connected Linear skill setup with installer wiring', async () => {
installStore(true)
const rendered = await renderCard()
expect(rendered.textContent).toContain('Linear agent skill')
expect(rendered.textContent).toContain('linear-tickets')
expect(mocks.useInstalledAgentSkill).toHaveBeenCalledWith(
'linear-tickets',
expect.objectContaining({ enabled: true, sourceKinds: ['home'] })
)
const openInstallerButton = Array.from(rendered.querySelectorAll('button')).find(
(button) => button.textContent === 'Open installer'
)
await act(async () => {
openInstallerButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Re-check')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.ensureCli).toHaveBeenCalledTimes(1)
expect(state.checkLinearConnection).toHaveBeenCalledWith(true)
})
it('uses the WSL skill location for connected Linear setup when selected', async () => {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: 'Windows'
})
installStore(true, {
activeRuntimeEnvironmentId: null,
localAgentRuntime: 'wsl',
localAgentWslDistro: 'Fedora',
terminalWindowsShell: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
it('shows remote-server account ownership and connected workspace rows', async () => {
const state = installStore(true, { activeRuntimeEnvironmentId: 'runtime-1' })
const rendered = await renderCard()
expect(mocks.useInstalledAgentSkill).toHaveBeenCalledWith(
'linear-tickets',
expect.objectContaining({
discoveryTarget: { runtime: 'wsl', wslDistro: 'Fedora' },
enabled: true,
sourceKinds: ['home']
})
expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1')
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this remote server.'
)
expect(rendered.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add")
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({
terminalShellOverride: 'powershell.exe',
getPrerequisiteStatus: expect.any(Function)
})
)
const getPrerequisiteStatus = mocks.panelProps.at(-1)?.getPrerequisiteStatus
expect(getPrerequisiteStatus).toEqual(expect.any(Function))
await expect((getPrerequisiteStatus as () => Promise<unknown>)()).resolves.toBeUndefined()
expect(window.api.cli.getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Fedora' })
expect(rendered.textContent).toContain('Acme')
expect(rendered.textContent).toContain('Acme workspace · linear@example.test')
const openInstallerButton = Array.from(rendered.querySelectorAll('button')).find(
(button) => button.textContent === 'Open installer'
)
await act(async () => {
openInstallerButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Test')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.ensureWslCli).toHaveBeenCalledWith(
expect.objectContaining({ runtime: 'wsl', wslDistro: 'Fedora' })
)
expect(mocks.ensureCli).not.toHaveBeenCalled()
expect(state.testLinearConnection).toHaveBeenCalledWith('workspace-1')
})
it('shows and dismisses the optional post-connect setup note', async () => {
it('clears verification state after adding another Linear workspace', async () => {
installStore(true)
const rendered = await renderCard()
expect(rendered.textContent).not.toContain('Optional next step')
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Test')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(rendered.textContent).toContain('Verified')
await act(async () => {
rendered
@@ -275,16 +155,6 @@ describe('LinearIntegrationCard skill setup', () => {
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(rendered.textContent).toContain('Optional next step')
await act(async () => {
rendered
.querySelector<HTMLButtonElement>(
'button[aria-label="Dismiss optional Linear agent skill setup note"]'
)
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(rendered.textContent).not.toContain('Optional next step')
expect(rendered.textContent).not.toContain('Verified')
})
})
@@ -1,34 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, CheckCircle2, LoaderCircle, TicketCheck, Unlink, X } from 'lucide-react'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import type { GlobalSettings } from '../../../../shared/types'
import { useState } from 'react'
import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
import { Button } from '@/components/ui/button'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { useMountedRef } from '@/hooks/useMountedRef'
import {
LINEAR_TICKETS_SKILL_NAME,
buildAgentFeatureSkillInstallCommand
} from '@/lib/agent-feature-install-commands'
import {
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
ensureOrcaCliAvailableForAgentSkillTerminal,
isOrcaCliAvailableOnPath
} from '@/lib/agent-skill-cli-prerequisite'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import {
buildSkillInstallCommandForRuntime,
ensureWslCliAvailableForAgentSkillTerminal,
getWslCliDistroRequest,
type LocalAgentRuntime
} from './CliSkillRuntimeSetup'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { getProviderAccountScope } from './provider-account-scope'
import { translate } from '@/i18n/i18n'
type VerificationResult = { state: 'ok' | 'error'; error?: string }
@@ -45,7 +24,6 @@ export function LinearIntegrationCard(): React.JSX.Element {
const mountedRef = useMountedRef()
const [dialogOpen, setDialogOpen] = useState(false)
const [showPostConnectSkillPrompt, setShowPostConnectSkillPrompt] = useState(false)
const [testingWorkspaceId, setTestingWorkspaceId] = useState<string | null>(null)
const [testResultByWorkspace, setTestResultByWorkspace] = useState<
Record<string, VerificationResult>
@@ -55,43 +33,12 @@ export function LinearIntegrationCard(): React.JSX.Element {
const checking = !contextMatches || !linearStatusChecked
const connected = contextMatches && linearStatus.connected
const workspaces = linearStatus.workspaces ?? []
const agentRuntime = useMemo(() => getLinearSettingsAgentRuntime(settings), [settings])
const skillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>(
() =>
agentRuntime.runtime === 'wsl'
? { runtime: 'wsl', wslDistro: agentRuntime.wslDistro }
: undefined,
[agentRuntime.runtime, agentRuntime.wslDistro]
)
const linearTicketsSkill = useInstalledAgentSkill(LINEAR_TICKETS_SKILL_NAME, {
enabled: connected,
discoveryTarget: skillDiscoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const linearTicketsInstallCommand = useMemo(
() =>
buildSkillInstallCommandForRuntime(
buildAgentFeatureSkillInstallCommand([LINEAR_TICKETS_SKILL_NAME]),
agentRuntime
),
[agentRuntime]
)
const linearTicketsTerminalShellOverride = getLinearSettingsTerminalShellOverride(
settings,
agentRuntime
)
useEffect(() => {
if (!connected) {
setShowPostConnectSkillPrompt(false)
}
}, [connected])
const accountScope = getProviderAccountScope(settings)
const handleDisconnect = async (workspaceId?: string): Promise<void> => {
await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear())
if (mountedRef.current) {
setTestResultByWorkspace({})
setShowPostConnectSkillPrompt(false)
}
}
@@ -159,6 +106,7 @@ export function LinearIntegrationCard(): React.JSX.Element {
) : null
}
>
<ProviderAccountScopeRow scope={accountScope} />
{connected ? (
<div className="mt-3 space-y-2">
{workspaces.map((workspace) => {
@@ -234,80 +182,6 @@ export function LinearIntegrationCard(): React.JSX.Element {
'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.'
)}
</p>
<AgentSkillSetupPanel
title={translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillTitle',
'Linear agent skill'
)}
description={
agentRuntime.runtime === 'wsl'
? translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillWslDescription',
'Install the WSL agent skill that agents use for richer linked Linear task handoffs.'
)
: translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillDescription',
'Install the host agent skill that agents use for richer linked Linear task handoffs.'
)
}
command={linearTicketsInstallCommand}
terminalTitle={translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillTerminalTitle',
'Install Linear agent skill'
)}
terminalAriaLabel={translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillTerminalAria',
'Linear agent skill installer terminal'
)}
terminalWorktreeId="settings-linear-agent-skill-setup"
terminalShellOverride={linearTicketsTerminalShellOverride}
installed={linearTicketsSkill.installed}
loading={linearTicketsSkill.loading}
error={linearTicketsSkill.error}
icon={<TicketCheck className="size-4" />}
installLabel={translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillInstall',
'Install CLI & Skill'
)}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
getPrerequisiteStatus={
agentRuntime.runtime === 'wsl'
? () => window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime))
: undefined
}
isPrerequisiteAvailable={isOrcaCliAvailableOnPath}
onBeforeOpenTerminal={async () => {
setShowPostConnectSkillPrompt(false)
await (agentRuntime.runtime === 'wsl'
? ensureWslCliAvailableForAgentSkillTerminal(agentRuntime)
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
actionHint={
showPostConnectSkillPrompt && !linearTicketsSkill.installed ? (
<div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-background/70 px-3 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">
{translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillOptionalHint',
'Optional next step: install the Linear agent skill for ticket-aware agent handoffs.'
)}
</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillDismissHint',
'Dismiss optional Linear agent skill setup note'
)}
onClick={() => setShowPostConnectSkillPrompt(false)}
>
<X className="size-3.5" />
</Button>
</div>
) : null
}
onRecheck={linearTicketsSkill.refresh}
/>
</div>
) : !checking ? (
<IntegrationCardDetails>
@@ -330,10 +204,7 @@ export function LinearIntegrationCard(): React.JSX.Element {
open={dialogOpen}
onOpenChange={setDialogOpen}
connectLabel="Add Linear access"
onConnected={() => {
setTestResultByWorkspace({})
setShowPostConnectSkillPrompt(true)
}}
onConnected={() => setTestResultByWorkspace({})}
overlayClassName="z-[110]"
contentClassName="z-[120]"
/>
@@ -341,58 +212,19 @@ export function LinearIntegrationCard(): React.JSX.Element {
)
}
function ProviderAccountScopeRow({ scope }: { scope: ReturnType<typeof getProviderAccountScope> }) {
return (
<div className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs">
<div className="font-medium text-foreground">
{translate(
'auto.components.settings.task.tracker.integration.cards.account_scope',
'Account scope: {{value0}}',
{ value0: scope.label }
)}
</div>
<div className="mt-0.5 text-muted-foreground">{scope.description}</div>
</div>
)
}
export { JiraIntegrationCard } from './jira-integration-card'
function getCurrentPlatform(): NodeJS.Platform {
if (navigator.userAgent.includes('Windows')) {
return 'win32'
}
return navigator.userAgent.includes('Linux') ? 'linux' : 'darwin'
}
function getLinearSettingsAgentRuntime(
settings:
| Pick<
GlobalSettings,
| 'localAgentRuntime'
| 'localAgentWslDistro'
| 'terminalWindowsShell'
| 'terminalWindowsWslDistro'
>
| null
| undefined
): LocalAgentRuntime {
const selectedRuntime =
settings?.localAgentRuntime ?? (settings?.terminalWindowsShell === 'wsl.exe' ? 'wsl' : 'host')
if (getCurrentPlatform() === 'win32' && selectedRuntime === 'wsl') {
const selectedDistro =
settings?.localAgentWslDistro?.trim() || settings?.terminalWindowsWslDistro?.trim() || null
return {
runtime: 'wsl',
wslDistro: selectedDistro,
label: selectedDistro
? `WSL ${selectedDistro}`
: translate(
'auto.components.settings.task.tracker.integration.cards.linearSkillWslLabel',
'WSL default'
)
}
}
return {
runtime: 'host',
label: getCurrentPlatform() === 'win32' ? 'Windows' : 'This device'
}
}
function getLinearSettingsTerminalShellOverride(
settings: Pick<GlobalSettings, 'terminalWindowsShell'> | null | undefined,
runtime: LocalAgentRuntime
): string | undefined {
if (getCurrentPlatform() !== 'win32') {
return undefined
}
if (runtime.runtime === 'wsl') {
return 'powershell.exe'
}
return settings?.terminalWindowsShell?.toLowerCase() === 'wsl.exe' ? 'powershell.exe' : undefined
}
@@ -209,7 +209,7 @@ describe('AddProjectFromFolderDialog', () => {
closeModal: mocks.state.closeModal,
setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('Remote project added', {
expect(mocks.toastSuccess).toHaveBeenCalledWith('Project added on SSH host', {
description: repo.displayName
})
})
@@ -91,7 +91,7 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo
toast.success(
translate(
'auto.components.sidebar.AddProjectFromFolderDialog.e643b30398',
'Remote project added'
'Project added on SSH host'
),
{ description: repo.displayName }
)
@@ -57,7 +57,7 @@ export function CloneStep({
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoSteps.a93ef169b5',
'Browse server filesystem'
'Browse host filesystem'
)}
</DialogTitle>
<DialogDescription>
@@ -165,7 +165,7 @@ export function CloneStep({
canBrowseRemoteDestination
? translate(
'auto.components.sidebar.AddRepoSteps.a93ef169b5',
'Browse server filesystem'
'Browse host filesystem'
)
: translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder')
}
@@ -173,7 +173,7 @@ export function CloneStep({
canBrowseRemoteDestination
? translate(
'auto.components.sidebar.AddRepoSteps.a93ef169b5',
'Browse server filesystem'
'Browse host filesystem'
)
: translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder')
}
@@ -3,15 +3,17 @@ import { describe, expect, it, vi } from 'vitest'
import { Dialog } from '@/components/ui/dialog'
import { TooltipProvider } from '@/components/ui/tooltip'
import { CreateStep } from './AddRepoCreateStep'
import type { GitAvailability } from './create-project-defaults'
import type { GitAvailability, RepoKind } from './create-project-defaults'
function renderCreateStep({
createName = '',
createKind = 'git',
gitAvailability = 'available',
createParent = '/Users/alice/orca/projects',
parentDefaultPending = false
}: {
createName?: string
createKind?: RepoKind
gitAvailability?: GitAvailability
createParent?: string
parentDefaultPending?: boolean
@@ -22,6 +24,7 @@ function renderCreateStep({
<CreateStep
createName={createName}
createParent={createParent}
createKind={createKind}
createError={null}
isCreating={false}
defaultParent="/Users/alice/orca/projects"
@@ -30,6 +33,7 @@ function renderCreateStep({
parentDefaultPending={parentDefaultPending}
onNameChange={vi.fn()}
onParentChange={vi.fn()}
onKindChange={vi.fn()}
onPickParent={vi.fn()}
onCreate={vi.fn()}
/>
@@ -39,31 +43,24 @@ function renderCreateStep({
}
describe('CreateStep', () => {
it('renders the conductor-style Git project form without templates or kind selection', () => {
it('renders the name-first create UI with advanced controls collapsed', () => {
const html = renderCreateStep()
expect(html).toContain('Create project')
expect(html).toContain('Project name')
expect(html).not.toContain('Git repo:')
expect(html).not.toContain('>project-name</span>')
expect(html).toContain('Parent folder')
expect(html).toContain('Browse')
expect(html).not.toContain('Template')
expect(html).toContain('Create a new project')
expect(html).toContain('Name')
expect(html).toContain('Git repository in ~/orca/projects')
// The summary card itself is the collapsed disclosure for the uncommon settings.
expect(html).toContain('aria-expanded="false"')
expect(html).not.toContain('Project kind')
expect(html).not.toContain('Location</span>')
expect(html).not.toContain('aria-label="Browse host filesystem"')
})
it('shows the repo name in the helper only after a project name is entered', () => {
const html = renderCreateStep({ createName: 'demo-project' })
it('shows the Git fallback explanation in the collapsed summary', () => {
const html = renderCreateStep({ createKind: 'folder', gitAvailability: 'unavailable' })
expect(html).toContain('Git repo:')
expect(html).toContain('demo-project')
})
it('requires Git instead of falling back to folder creation', () => {
const html = renderCreateStep({ gitAvailability: 'unavailable' })
expect(html).toContain('Git is required to create a project.')
expect(html).toContain('disabled=""')
expect(html).toContain('Folder in ~/orca/projects')
expect(html).toContain('Git isn&#x27;t installed, so a plain folder is the default.')
})
it('disables create while an auto-filled parent belongs to a previous target', () => {
@@ -1,17 +1,30 @@
// Step for AddRepoDialog (orca#763), split out so create-project state stays scoped.
import React, { useMemo, useState } from 'react'
import { CornerDownLeft, FolderOpen, Loader2 } from 'lucide-react'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { ChevronDown, Folder, GitBranch, Loader2 } from 'lucide-react'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { CreateProjectParentBrowser } from './CreateProjectLocationField'
import { cn } from '@/lib/utils'
import {
CreateProjectLocationField,
CreateProjectParentBrowser
} from './CreateProjectLocationField'
import { translate } from '@/i18n/i18n'
import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut'
import { formatCreateProjectParentSummary, type GitAvailability } from './create-project-defaults'
import {
formatCreateProjectParentSummary,
joinCreateProjectPath,
type GitAvailability,
type RepoKind
} from './create-project-defaults'
// ── UI helpers ───────────────────────────────────────────────────────
const CREATE_PROJECT_NAME_PLACEHOLDER = 'project-name'
type CreateStepProps = {
createName: string
createParent: string
createKind: RepoKind
createError: string | null
isCreating: boolean
defaultParent?: string
@@ -23,6 +36,7 @@ type CreateStepProps = {
sshTargetId?: string | null
onNameChange: (value: string) => void
onParentChange: (value: string) => void
onKindChange: (kind: RepoKind) => void
onPickParent: () => void
onCreate: () => void
}
@@ -30,6 +44,7 @@ type CreateStepProps = {
export function CreateStep({
createName,
createParent,
createKind,
createError,
isCreating,
defaultParent = '',
@@ -41,19 +56,54 @@ export function CreateStep({
sshTargetId,
onNameChange,
onParentChange,
onKindChange,
onPickParent,
onCreate
}: CreateStepProps): React.JSX.Element {
const radioGroupRef = useRef<HTMLDivElement>(null)
const radioFocusFrameRef = useRef<number | null>(null)
const [browsingParent, setBrowsingParent] = useState(false)
// Why: SSH hosts need a typed remote path; hiding that field behind the
// collapsed defaults makes the create flow look impossible.
const [advancedOpen, setAdvancedOpen] = useState(manualParentEntry)
const cancelRadioFocusFrame = useCallback((): void => {
if (radioFocusFrameRef.current === null) {
return
}
cancelAnimationFrame(radioFocusFrameRef.current)
radioFocusFrameRef.current = null
}, [])
const setRadioGroupNode = useCallback(
(node: HTMLDivElement | null): void => {
// Why: the queued arrow-key focus is only valid while this radiogroup is mounted.
if (!node) {
cancelRadioFocusFrame()
}
radioGroupRef.current = node
},
[cancelRadioFocusFrame]
)
// Arrow keys cycle selection within the radiogroup (WAI-ARIA radio pattern).
const cycleKind = useCallback(() => {
const next = createKind === 'git' ? 'folder' : 'git'
onKindChange(next)
cancelRadioFocusFrame()
radioFocusFrameRef.current = requestAnimationFrame(() => {
radioFocusFrameRef.current = null
const nextEl = radioGroupRef.current?.querySelector<HTMLButtonElement>(
`[data-kind="${next}"]`
)
nextEl?.focus()
})
}, [cancelRadioFocusFrame, createKind, onKindChange])
const canSubmit =
createName.trim().length > 0 &&
createParent.trim().length > 0 &&
gitAvailability !== 'checking' &&
gitAvailability !== 'unavailable' &&
!parentDefaultPending &&
!isCreating
const missingLocationLabel = translate(
@@ -62,32 +112,9 @@ export function CreateStep({
)
const missingServerLocationLabel = translate(
'auto.components.sidebar.AddRepoCreateStep.6ed14c0281',
'server folder not selected'
'host folder not selected'
)
const parentSummary = useMemo(
() =>
formatCreateProjectParentSummary({
parent: createParent,
defaultParent,
runtimeEnvironmentId,
missingLocationLabel,
missingServerLocationLabel
}),
[
createParent,
defaultParent,
missingLocationLabel,
missingServerLocationLabel,
runtimeEnvironmentId
]
)
const repoNamePreview = createName.trim()
const submitShortcutModifierLabel = getScreenSubmitModifierLabel()
const showGitFallback = gitAvailability === 'unavailable'
const showGitChecking = gitAvailability === 'checking'
const showRuntimeMissingParent =
runtimeEnvironmentId && !createParent.trim() && runtimeParentStatus !== 'checking'
const isRemoteHost = Boolean(runtimeEnvironmentId || sshTargetId)
const summaryParent = useMemo(
() =>
@@ -138,14 +165,14 @@ export function CreateStep({
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoCreateStep.createProjectTitle',
'Create project'
'auto.components.sidebar.AddRepoCreateStep.c7b9f94456',
'Create a new project'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoCreateStep.createProjectDescription',
'Create a local Git repo and first workspace.'
'auto.components.sidebar.AddRepoCreateStep.b100311784',
'Name it and Orca will create a real project with sensible defaults.'
)}
</DialogDescription>
</DialogHeader>
@@ -154,16 +181,14 @@ export function CreateStep({
(= content size), so a long path inside the Location row would blow out
the dialog width even with flex + truncate on the row itself. min-w-0
here caps the grid track at the dialog's max-width. */}
<div className="min-w-0 space-y-5 pt-1">
<div className="space-y-2">
<div className="space-y-3.5 pt-1 min-w-0">
{/* Name. Monospaced because it ends up as a directory name. */}
<div className="space-y-1">
<label
htmlFor="create-project-name"
className="block text-sm font-medium text-foreground"
className="text-[11px] font-medium text-muted-foreground block"
>
{translate(
'auto.components.sidebar.AddRepoCreateStep.projectNameLabel',
'Project name'
)}
{translate('auto.components.sidebar.AddRepoCreateStep.a8149a3a5a', 'Name')}
</label>
<Input
id="create-project-name"
@@ -173,110 +198,195 @@ export function CreateStep({
'auto.components.sidebar.AddRepoCreateStep.0ae45b8238',
'my-project'
)}
className="h-11 text-sm"
className="h-11 text-sm font-mono"
disabled={isCreating}
autoFocus
autoComplete="off"
spellCheck={false}
/>
{repoNamePreview ? (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.createsGitRepoHelp',
'Git repo:'
)}{' '}
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono">{repoNamePreview}</span>
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="create-project-parent"
className="block text-sm font-medium text-foreground"
{/* Summary card doubles as the disclosure for the uncommon settings, so the
defaults and the controls to change them live in one place. */}
<div className="min-w-0 rounded-md border border-border bg-muted/30">
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
aria-expanded={advancedOpen}
className="flex w-full min-w-0 items-start gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors cursor-pointer hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{translate(
'auto.components.sidebar.AddRepoCreateStep.parentFolderLabel',
'Parent folder'
)}
</label>
<div className="flex min-w-0 gap-2">
<Input
id="create-project-parent"
value={createParent}
onChange={(e) => onParentChange(e.target.value)}
placeholder={translate(
'auto.components.sidebar.CreateProjectLocationField.2a20a603a3',
'/home/user/projects'
<span className="mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-background/60 text-muted-foreground">
{createKind === 'git' ? (
<GitBranch className="size-3.5" />
) : (
<Folder className="size-3.5" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{translate(
'auto.components.sidebar.AddRepoCreateStep.685b5eefe1',
'{{kind}} in {{parent}}',
{
kind: kindLabel,
parent: summaryParent
}
)}
</p>
{showGitChecking ? (
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{translate(
'auto.components.sidebar.AddRepoCreateStep.2a762f3b19',
'Checking Git on this host...'
)}
</p>
) : showGitFallback ? (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b',
"Git isn't installed, so a plain folder is the default."
)}
</p>
) : showRuntimeMissingParent ? (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.c234df77f7',
'Choose or enter a host parent folder before creating.'
)}
</p>
) : targetPathPreview ? (
<p
className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground"
title={targetPathPreview}
>
{targetPathPreview}
</p>
) : null}
</div>
<ChevronDown
className={cn(
'size-4 shrink-0 self-center text-muted-foreground transition-transform',
advancedOpen && 'rotate-180'
)}
className="h-11 min-w-0 flex-1 font-mono text-sm"
disabled={isCreating}
spellCheck={false}
/>
<Button
type="button"
variant="outline"
onClick={() => {
if (runtimeEnvironmentId) {
setBrowsingParent(true)
} else {
onPickParent()
}
}}
disabled={isCreating || (manualParentEntry && !runtimeEnvironmentId)}
size="sm"
className="h-11 shrink-0 gap-1.5 px-3"
>
<FolderOpen className="size-3.5" />
{translate('auto.components.sidebar.AddRepoCreateStep.browseParentFolder', 'Browse')}
</Button>
</div>
{showGitChecking ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate(
'auto.components.sidebar.AddRepoCreateStep.2a762f3b19',
'Checking Git on this host...'
</button>
{advancedOpen && (
<div className="space-y-3 border-t border-border px-3 py-3">
{/* Real radiogroup so screen readers announce the segmented choice. */}
<div className="space-y-1.5">
<span className="text-[11px] font-medium text-muted-foreground block">
{translate(
'auto.components.sidebar.AddRepoCreateStep.180e9b5e48',
'Project kind'
)}
</span>
<div
ref={setRadioGroupNode}
role="radiogroup"
aria-label={translate(
'auto.components.sidebar.AddRepoCreateStep.180e9b5e48',
'Project kind'
)}
className="grid grid-cols-2 rounded-md border border-border bg-muted/30 p-0.5"
>
{(['git', 'folder'] as const).map((kind) => {
const selected = createKind === kind
const label =
kind === 'git'
? translate(
'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8',
'Git repository'
)
: translate(
'auto.components.sidebar.AddRepoCreateStep.038729c107',
'Folder'
)
const Icon = kind === 'git' ? GitBranch : Folder
return (
<button
key={kind}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => onKindChange(kind)}
onKeyDown={(e) => {
// Why: keep keyboard radio navigation intact inside the compact segmented control.
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown'
) {
e.preventDefault()
cycleKind()
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
onKindChange(kind)
}
}}
disabled={isCreating}
data-kind={kind}
className={cn(
'inline-flex min-w-0 items-center justify-center gap-1.5 rounded-sm border px-2.5 py-2 text-xs font-medium outline-none transition-colors',
// Why: the segment sits on a muted card, so bg-background alone
// is too subtle; the border makes the selected state legible.
selected
? 'border-border bg-background text-foreground shadow-xs'
: 'border-transparent text-muted-foreground hover:text-foreground',
'focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60'
)}
>
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</button>
)
})}
</div>
{showGitFallback && (
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b',
"Git isn't installed, so a plain folder is the default."
)}
</p>
)}
</div>
{/* The local picker returns client paths; runtime servers browse host paths via RPC. */}
<CreateProjectLocationField
createParent={createParent}
isCreating={isCreating}
manualParentEntry={manualParentEntry}
runtimeEnvironmentId={runtimeEnvironmentId}
sshTargetId={sshTargetId}
onParentChange={onParentChange}
onPickParent={onPickParent}
onBrowseServer={() => setBrowsingParent(true)}
/>
{targetPathPreview && (
<p className="min-w-0 break-all rounded-md border border-border bg-background/40 px-2.5 py-2 font-mono text-[11px] text-muted-foreground">
{targetPathPreview}
</p>
)}
</p>
) : showGitFallback ? (
<p className="text-sm text-destructive" role="alert">
{translate(
'auto.components.sidebar.AddRepoCreateStep.gitRequiredError',
'Git is required to create a project.'
)}
</p>
) : showRuntimeMissingParent ? (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.c234df77f7',
'Choose or enter a server parent folder before creating.'
)}
</p>
) : (
<p className="truncate text-sm text-muted-foreground" title={parentSummary}>
{parentSummary}
</p>
</div>
)}
</div>
{createError && (
<p className="mt-6 text-sm text-destructive" role="alert">
<p className="text-[11px] text-destructive" role="alert">
{createError}
</p>
)}
<div className="flex justify-end pt-2">
<Button onClick={onCreate} disabled={!canSubmit} size="lg">
{isCreating
? translate('auto.components.sidebar.AddRepoCreateStep.85085d74d2', 'Creating…')
: translate('auto.components.sidebar.AddRepoCreateStep.createAction', 'Create')}
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-primary-foreground/25 bg-primary-foreground/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground/75">
<span>{submitShortcutModifierLabel}</span>
<CornerDownLeft className="size-3" />
</span>
</Button>
</div>
<Button onClick={onCreate} disabled={!canSubmit} size="lg" className="w-full">
{isCreating
? translate('auto.components.sidebar.AddRepoCreateStep.85085d74d2', 'Creating…')
: translate('auto.components.sidebar.AddRepoCreateStep.45b7c26034', 'Create project')}
</Button>
</div>
</>
)
@@ -2,9 +2,6 @@ import React, { useCallback, useState } from 'react'
import { useAppStore } from '@/store'
import { useRemoteRepo } from './AddRepoSteps'
import { useCreateRepo } from './useCreateRepo'
import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import { AddRepoStepIndicator } from './AddRepoStepIndicator'
import { AddRepoDialogStepContent } from './AddRepoDialogStepContent'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
import { useAddRepoNestedReviewState } from './useAddRepoNestedReviewState'
@@ -12,9 +9,13 @@ import { useAddRepoCloneFlow } from './useAddRepoCloneFlow'
import { useAddRepoLocalFolderFlow } from './useAddRepoLocalFolderFlow'
import { useAddRepoServerPathFlow } from './useAddRepoServerPathFlow'
import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow'
import { buildAddRepoExistingWorkspacesDetectedEvent } from './add-repo-existing-workspaces-telemetry'
import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout'
import { useAddRepoHostSelection } from './use-add-repo-host-selection'
import { useCompleteGitRepoAdd } from './use-complete-git-repo-add'
import { useCreateProjectDefaults } from './useCreateProjectDefaults'
import { useAddRepoHostChangeReset } from './use-add-repo-host-change-reset'
import { AddRepoDialogChrome } from './AddRepoDialogChrome'
import { AddRepoHostSelectorSlot } from './AddRepoHostSelectorSlot'
import { useAddRepoRemoteNestedScan } from './use-add-repo-remote-nested-scan'
const AddRepoDialog = React.memo(function AddRepoDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@@ -62,27 +63,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setStep
})
const completeGitRepoAdd = useCallback(
async (repoId: string, source: AddRepoExistingWorkspaceSource): Promise<void> => {
const worktrees = useAppStore.getState().worktreesByRepo[repoId] ?? []
const existingWorkspaceTelemetry = buildAddRepoExistingWorkspacesDetectedEvent(
source,
worktrees
)
if (existingWorkspaceTelemetry && !detectedTelemetryTrackedRef.current.has(repoId)) {
detectedTelemetryTrackedRef.current.add(repoId)
track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry)
}
await finishProjectAddWithDefaultCheckout({
repoId,
source,
closeModal,
setHideDefaultBranchWorkspace
})
},
[closeModal, setHideDefaultBranchWorkspace]
)
const hostSelection = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep })
const selectedRuntimeEnvironmentId =
hostSelection.selectedParsedHost?.kind === 'runtime'
? hostSelection.selectedParsedHost.environmentId
: null
const { showRemoteNestedRepoReview, trackRemoteNestedScanResult } = useAddRepoRemoteNestedScan({
setActiveNestedScanId,
showNestedRepoReview
})
const {
sshTargets,
selectedTargetId,
@@ -111,10 +100,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const {
createName,
createParent,
createKind,
createError,
isCreating,
setCreateName,
setCreateParent,
setCreateKind,
setCreateError,
resetCreateState,
handlePickParent,
@@ -136,12 +127,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
createRuntimeParentStatus,
createParentDefaultPending,
resetCreateDefaultState,
markCreateParentTouched
markCreateParentTouched,
markCreateKindTouched
} = useCreateProjectDefaults({
step,
activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId,
activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId,
sshTargetId: hostSelection.selectedSshTargetId,
createParent,
setCreateParent
setCreateParent,
setCreateKind
})
const {
@@ -296,108 +290,123 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
onBack={handleBack}
onOpenChange={handleOpenChange}
>
<DialogContent
className={`min-w-0 overflow-hidden sm:max-w-lg [&>*]:min-w-0 ${
step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : ''
}`}
>
<AddRepoStepIndicator step={step} isAdding={isAdding} onBack={handleBack} />
<AddRepoDialogStepContent
step={step}
isRuntimeEnvironmentActive={isRuntimeEnvironmentActive}
activeRuntimeEnvironmentId={settings?.activeRuntimeEnvironmentId}
isSshLikely={isSshLikely}
repoCount={repos.length}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
nestedScanInProgress={nestedScanInProgress}
nestedScanId={nestedScanId}
serverPath={serverPath}
isAddingServerPath={isAddingServerPath}
cloneUrl={cloneUrl}
cloneDestination={cloneDestination}
cloneError={cloneError}
cloneProgress={cloneProgress}
isCloning={isCloning}
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
remotePath={remotePath}
remoteError={remoteError}
isAddingRemote={isAddingRemote}
isScanningRemoteNested={isScanningRemoteNested}
nestedScan={nestedScan}
nestedSelectedPaths={nestedSelectedPaths}
nestedGroupName={nestedGroupName}
createName={createName}
createParent={createParent}
createError={createError}
isCreating={isCreating}
createDefaultParent={createDefaultParent}
createGitAvailability={createGitAvailability}
createRuntimeParentStatus={createRuntimeParentStatus}
createParentDefaultPending={createParentDefaultPending}
onBrowse={handleBrowse}
onOpenCloneStep={() => {
setCloneError(null)
setStep('clone')
}}
onOpenCreateStep={() => {
setCreateError(null)
setStep('create')
}}
onOpenRemoteStep={handleOpenRemoteStep}
onStopNestedScan={handleStopNestedScan}
onServerPathChange={setServerPath}
onAddServerPath={(kind) => void handleAddServerPath(kind)}
onSelectTarget={(id) => {
setSelectedTargetId(id)
setRemoteError(null)
}}
onRemotePathChange={(value) => {
setRemotePath(value)
setRemoteError(null)
}}
onAddRemoteRepo={handleAddRemoteRepo}
onOpenSshSettings={() => {
closeModal()
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
openSettingsPage()
}}
onConnectTarget={handleConnectTarget}
onStopRemoteNestedScan={stopRemoteNestedScan}
onCloneUrlChange={(value) => {
setCloneUrl(value)
setCloneError(null)
}}
onCloneDestinationChange={(value) => {
setCloneDestination(value)
setCloneError(null)
}}
onPickCloneDestination={handlePickDestination}
onClone={handleClone}
onNestedGroupNameChange={setNestedGroupName}
onNestedSelectedPathsChange={setNestedSelectedPaths}
onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)}
onCreateNameChange={(value) => {
setCreateName(value)
setCreateError(null)
}}
onCreateParentChange={(value) => {
markCreateParentTouched(value)
setCreateParent(value)
setCreateError(null)
}}
onPickCreateParent={() => {
void handlePickParent().then((dir) => {
if (dir) {
markCreateParentTouched(dir)
}
})
}}
onCreate={handleCreate}
/>
</DialogContent>
</Dialog>
<AddRepoDialogStepContent
step={step}
isRuntimeEnvironmentActive={isRuntimeEnvironmentActive}
activeRuntimeEnvironmentId={selectedRuntimeEnvironmentId}
isSshLikely={false}
repoCount={repos.length}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
nestedScanInProgress={nestedScanInProgress}
nestedScanId={nestedScanId}
serverPath={serverPath}
isAddingServerPath={isAddingServerPath}
cloneUrl={cloneUrl}
cloneDestination={cloneDestination}
cloneError={cloneError}
cloneProgress={cloneProgress}
isCloning={isCloning}
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
selectedSshTargetId={hostSelection.selectedSshTargetId}
selectedHostLabel={
hostSelection.hostOptions.find((host) => host.id === hostSelection.selectedHostId)
?.label ?? hostSelection.selectedHostId
}
lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'}
remotePath={remotePath}
remoteError={remoteError}
isAddingRemote={isAddingRemote}
isScanningRemoteNested={isScanningRemoteNested}
nestedScan={nestedScan}
nestedSelectedPaths={nestedSelectedPaths}
nestedGroupName={nestedGroupName}
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
hostSelector={<AddRepoHostSelectorSlot hostSelection={hostSelection} />}
showRemoteAction={false}
browseHostKind={hostSelection.selectedParsedHost?.kind === 'ssh' ? 'ssh' : 'local'}
createDefaultParent={createDefaultParent}
createGitAvailability={createGitAvailability}
createRuntimeParentStatus={createRuntimeParentStatus}
createParentDefaultPending={createParentDefaultPending}
manualCreateParentEntry={
isRuntimeEnvironmentActive || hostSelection.selectedParsedHost?.kind === 'ssh'
}
onBrowse={
hostSelection.selectedParsedHost?.kind === 'ssh'
? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId)
: handleBrowse
}
onOpenCloneStep={() => {
setCloneError(null)
setStep('clone')
}}
onOpenCreateStep={() => {
setCreateError(null)
setStep('create')
}}
onOpenRemoteStep={handleOpenRemoteStep}
onStopNestedScan={handleStopNestedScan}
onServerPathChange={setServerPath}
onAddServerPath={(kind) => void handleAddServerPath(kind)}
onSelectTarget={(id) => {
setSelectedTargetId(id)
setRemoteError(null)
}}
onRemotePathChange={(value) => {
setRemotePath(value)
setRemoteError(null)
}}
onAddRemoteRepo={handleAddRemoteRepo}
onOpenSshSettings={() => {
closeModal()
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
openSettingsPage()
}}
onConnectTarget={handleConnectTarget}
onStopRemoteNestedScan={stopRemoteNestedScan}
onCloneUrlChange={(value) => {
setCloneUrl(value)
setCloneError(null)
}}
onCloneDestinationChange={(value) => {
setCloneDestination(value)
setCloneError(null)
}}
onPickCloneDestination={handlePickDestination}
onClone={handleClone}
onNestedGroupNameChange={setNestedGroupName}
onNestedSelectedPathsChange={setNestedSelectedPaths}
onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)}
onCreateNameChange={(value) => {
setCreateName(value)
setCreateError(null)
}}
onCreateParentChange={(value) => {
markCreateParentTouched(value)
setCreateParent(value)
setCreateError(null)
}}
onCreateKindChange={(kind) => {
markCreateKindTouched()
setCreateKind(kind)
setCreateError(null)
}}
onPickCreateParent={() => {
void handlePickParent().then((dir) => {
if (dir) {
markCreateParentTouched(dir)
}
})
}}
onCreate={handleCreate}
/>
</AddRepoDialogChrome>
)
})
@@ -53,6 +53,7 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
nestedGroupName: 'platform',
createName: '',
createParent: '',
createKind: 'git',
createError: null,
isCreating: false,
createDefaultParent: '',
@@ -81,6 +82,7 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
onImportNestedRepos: vi.fn(),
onCreateNameChange: vi.fn(),
onCreateParentChange: vi.fn(),
onCreateKindChange: vi.fn(),
onPickCreateParent: vi.fn(),
onCreate: vi.fn(),
...overrides
@@ -104,8 +106,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
const html = renderNestedStep(0)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Import as group')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
@@ -114,25 +116,39 @@ describe('AddRepoDialogStepContent nested imports', () => {
const html = renderNestedStep(1)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Import as group')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
it('offers server browsing for remote create project locations', () => {
it('offers host browsing for remote create project locations', () => {
const html = renderStepContent({
step: 'create',
isRuntimeEnvironmentActive: true,
activeRuntimeEnvironmentId: 'env-1'
})
expect(html).toContain('Create project')
expect(html).toContain('Choose or enter a server parent folder before creating.')
expect(html).toContain('Browse')
expect(html).toContain('Create a new project')
expect(html).toContain('host folder not selected')
})
it('offers server browsing for remote clone destinations', () => {
it('uses manual path entry for SSH create project locations', () => {
const html = renderStepContent({
step: 'create',
manualCreateParentEntry: true,
selectedSshTargetId: 'openclaw-2',
activeRuntimeEnvironmentId: null
})
expect(html).toContain('Create a new project')
expect(html).toContain('placeholder="/home/user/projects"')
expect(html).toContain('aria-label="Browse host filesystem"')
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*aria-label="Browse host filesystem"/)
expect(html).not.toContain('Choose parent folder')
})
it('offers host browsing for remote clone destinations', () => {
const html = renderStepContent({
step: 'clone',
isRuntimeEnvironmentActive: true,
@@ -140,7 +156,7 @@ describe('AddRepoDialogStepContent nested imports', () => {
})
expect(html).toContain('Clone from URL')
expect(html).toContain('aria-label="Browse server filesystem"')
expect(html).toContain('aria-label="Browse host filesystem"')
})
it('offers SSH browsing for selected-host clone destinations', () => {
@@ -153,7 +169,7 @@ describe('AddRepoDialogStepContent nested imports', () => {
expect(html).toContain('Clone from URL')
expect(html).toContain('choose where to clone it on openclaw 2')
expect(html).toContain('Parent folder')
expect(html).toContain('aria-label="Browse server filesystem"')
expect(html).toContain('aria-label="Browse host filesystem"')
expect(html).not.toContain('aria-label="Choose folder"')
})
@@ -192,9 +208,9 @@ describe('AddRepoDialogStepContent nested imports', () => {
]
})
expect(html).toContain('Open remote project')
expect(html).toContain('Open project on SSH host')
expect(html).toContain('openclaw 2')
expect(html).toContain('Remote path')
expect(html).toContain('Host path')
expect(html).toContain('SSH target')
expect(html).not.toContain('github.com')
expect(html).not.toContain('Connect')
@@ -234,7 +250,7 @@ describe('AddRepoDialogStepContent nested imports', () => {
browseHostKind: 'ssh'
})
expect(html).toContain('Open SSH project')
expect(html).toContain('Open project on SSH host')
expect(html).toContain('Existing Git repository or folder on this SSH host')
expect(html).not.toContain('Local project, Git repo, or folder with many repos')
})
@@ -41,8 +41,14 @@ type AddRepoDialogStepContentProps = {
nestedGroupName: string
createName: string
createParent: string
createKind: 'git' | 'folder'
createError: string | null
isCreating: boolean
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
manualCreateParentEntry?: boolean
browseHostKind?: 'local' | 'ssh'
createDefaultParent: string
createGitAvailability: GitAvailability
createRuntimeParentStatus: 'idle' | 'checking' | 'failed'
@@ -69,6 +75,7 @@ type AddRepoDialogStepContentProps = {
onImportNestedRepos: (mode: 'group' | 'separate') => void
onCreateNameChange: (name: string) => void
onCreateParentChange: (parent: string) => void
onCreateKindChange: (kind: 'git' | 'folder') => void
onPickCreateParent: () => void
onCreate: () => void
}
@@ -104,8 +111,14 @@ export function AddRepoDialogStepContent({
nestedGroupName,
createName,
createParent,
createKind,
createError,
isCreating,
hostSelector,
showRemoteAction = true,
canCreateProject = true,
manualCreateParentEntry = isRuntimeEnvironmentActive,
browseHostKind = 'local',
createDefaultParent,
createGitAvailability,
createRuntimeParentStatus,
@@ -132,6 +145,7 @@ export function AddRepoDialogStepContent({
onImportNestedRepos,
onCreateNameChange,
onCreateParentChange,
onCreateKindChange,
onPickCreateParent,
onCreate
}: AddRepoDialogStepContentProps): React.JSX.Element | null {
@@ -236,17 +250,19 @@ export function AddRepoDialogStepContent({
<CreateStep
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
defaultParent={createDefaultParent}
gitAvailability={createGitAvailability}
runtimeParentStatus={createRuntimeParentStatus}
parentDefaultPending={createParentDefaultPending}
manualParentEntry={isRuntimeEnvironmentActive}
manualParentEntry={manualCreateParentEntry}
runtimeEnvironmentId={activeRuntimeEnvironmentId}
sshTargetId={selectedSshTargetId}
onNameChange={onCreateNameChange}
onParentChange={onCreateParentChange}
onKindChange={onCreateKindChange}
onPickParent={onPickCreateParent}
onCreate={onCreate}
/>
@@ -27,7 +27,7 @@ vi.mock('@/components/ui/command', () => ({
}))
describe('AddRepoHostSelector', () => {
it('shows disconnected state for the selected Add Project host', () => {
it('shows disconnected SSH hosts as disabled choices in Add Project', () => {
const html = renderToStaticMarkup(
<AddRepoHostSelector
hosts={[
@@ -57,9 +57,9 @@ describe('AddRepoHostSelector', () => {
expect(html).toContain('Builder')
expect(html).toContain('Disconnected')
expect(html).toContain('aria-disabled="false"')
expect(html).not.toContain('cursor-not-allowed')
expect(html).not.toContain('opacity-55')
expect(html).toContain('aria-disabled="true"')
expect(html).toContain('cursor-not-allowed')
expect(html).toContain('opacity-55')
})
it('shows exact update guidance for incompatible runtime hosts', () => {
@@ -83,60 +83,83 @@ export function RemoteStep({
<>
<DialogHeader>
<DialogTitle>
{translate('auto.components.sidebar.AddRepoRemoteStep.91b93a90a4', 'Open remote project')}
{translate(
'auto.components.sidebar.AddRepoRemoteStep.91b93a90a4',
'Open project on SSH host'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoRemoteStep.80557be85a',
'Choose a connected SSH target and enter the path to a Git repository.'
)}
{lockSshTargetSelection
? translate(
'auto.components.sidebar.AddRepoRemoteStep.lockedDescription',
'Enter the path to a Git repository on {{value0}}.',
{ value0: selectedTargetLabel ?? 'this SSH target' }
)
: translate(
'auto.components.sidebar.AddRepoRemoteStep.80557be85a',
'Choose a connected SSH target and enter the path to a Git repository.'
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')}
</label>
{sshTargets.length === 0 ? (
<div className="space-y-1.5 py-1">
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoRemoteStep.df6fbcf880',
'No SSH targets configured.'
)}
</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onOpenSshSettings}
>
<Settings className="size-3.5" />
{translate(
'auto.components.sidebar.AddRepoRemoteStep.0416bde073',
'Add in Settings'
)}
</Button>
</div>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
{sshTargets.map((target) => (
<SshTargetRow
key={target.id}
target={target}
isSelected={selectedTargetId === target.id}
onSelect={onSelectTarget}
onConnect={onConnectTarget}
/>
))}
</div>
)}
</div>
{!lockSshTargetSelection ? (
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')}
</label>
{sshTargets.length === 0 ? (
<div className="space-y-1.5 py-1">
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoRemoteStep.df6fbcf880',
'No SSH targets configured.'
)}
</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onOpenSshSettings}
>
<Settings className="size-3.5" />
{translate(
'auto.components.sidebar.AddRepoRemoteStep.0416bde073',
'Add in Settings'
)}
</Button>
</div>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
{sshTargets.map((target) => (
<SshTargetRow
key={target.id}
target={target}
isSelected={selectedTargetId === target.id}
onSelect={onSelectTarget}
onConnect={onConnectTarget}
/>
))}
</div>
)}
</div>
) : selectedTarget ? (
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')}
</label>
<SshTargetRow
target={selectedTarget}
isSelected
onSelect={() => undefined}
onConnect={onConnectTarget}
/>
</div>
) : null}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoRemoteStep.ef410aa881', 'Remote path')}
{translate('auto.components.sidebar.AddRepoRemoteStep.ef410aa881', 'Host path')}
</label>
<div className="flex gap-2">
<Input
@@ -182,7 +205,7 @@ export function RemoteStep({
? translate('auto.components.sidebar.AddRepoRemoteStep.35831a7312', 'Adding...')
: translate(
'auto.components.sidebar.AddRepoRemoteStep.36d427bb66',
'Add remote project'
'Add project on SSH host'
)}
</Button>
{isScanningNested ? (
@@ -40,7 +40,7 @@ export function AddRepoServerPathStartStep({
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d',
'Browse server filesystem'
'Browse host filesystem'
)}
</DialogTitle>
<DialogDescription>
@@ -79,7 +79,7 @@ export function AddRepoServerPathStartStep({
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.8efa930eb5',
'Add another project from the selected runtime server.'
'Add another project from the selected host.'
)}
</DialogDescription>
</DialogHeader>
@@ -91,7 +91,7 @@ export function AddRepoServerPathStartStep({
icon={FolderOpen}
title={translate(
'auto.components.sidebar.AddRepoServerStartStep.0adf083af7',
'Browse server'
'Browse host'
)}
description={translate(
'auto.components.sidebar.AddRepoServerStartStep.516187414c',
@@ -117,7 +117,7 @@ export function AddRepoServerPathStartStep({
icon={Server}
title={translate(
'auto.components.sidebar.AddRepoServerStartStep.a81ffa0a99',
'Create on server'
'Create on host'
)}
description={translate(
'auto.components.sidebar.AddRepoServerStartStep.d40d751517',
@@ -148,7 +148,7 @@ export function AddRepoServerPathStartStep({
>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.438493f214',
'Or enter a server path manually'
'Or enter a host path manually'
)}
</button>
</div>
@@ -162,13 +162,13 @@ export function AddRepoServerPathStartStep({
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.3d0c035483',
'Open server project'
'Open host project'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.423b5d3d31',
'Add a Git repository or folder that already exists on the selected runtime server.'
'Add a Git repository or folder that already exists on the selected host.'
)}
</DialogDescription>
</DialogHeader>
@@ -180,7 +180,7 @@ export function AddRepoServerPathStartStep({
htmlFor="server-project-path"
className="block text-[11px] font-medium text-muted-foreground"
>
{translate('auto.components.sidebar.AddRepoServerStartStep.867692f505', 'Server path')}
{translate('auto.components.sidebar.AddRepoServerStartStep.867692f505', 'Host path')}
</label>
<div className="flex gap-2">
<Input
@@ -207,7 +207,7 @@ export function AddRepoServerPathStartStep({
disabled={isAddingServerPath || !runtimeEnvironmentId}
aria-label={translate(
'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d',
'Browse server filesystem'
'Browse host filesystem'
)}
>
<FolderOpen className="size-4" />
@@ -216,7 +216,7 @@ export function AddRepoServerPathStartStep({
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d',
'Browse server filesystem'
'Browse host filesystem'
)}
</TooltipContent>
</Tooltip>
@@ -150,8 +150,8 @@ describe('AddRepoLocalStartStep', () => {
expect(markup).toContain('Browse folder')
expect(markup).toContain('Clone from URL')
expect(markup).toContain('Remote project')
expect(markup).toContain('Create project')
expect(markup).toContain('Project on SSH host')
expect(markup).toContain('Create new project')
expect(markup).toContain('Other ways to add')
expect(markup).not.toContain('More options')
})
@@ -160,23 +160,23 @@ describe('AddRepoLocalStartStep', () => {
const titles = getActionTitles(false)
expect(titles.primary).toBe('Browse folder')
expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create project'])
expect(titles.secondary).toEqual(['Clone from URL', 'Project on SSH host', 'Create new project'])
})
it('keeps Browse folder primary for SSH-likely users', () => {
const markup = renderLocalStartStep(true)
expect(markup).toContain('Browse folder')
expect(markup).toContain('Remote project')
expect(markup).toContain('Project on SSH host')
expect(markup).toContain('Clone from URL')
expect(markup).toContain('Create project')
expect(markup).toContain('Create new project')
})
it('orders secondary actions remote-first for SSH-likely users', () => {
const titles = getActionTitles(true)
expect(titles.primary).toBe('Browse folder')
expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create project'])
expect(titles.secondary).toEqual(['Project on SSH host', 'Clone from URL', 'Create new project'])
})
it('lets host-aware Add Project replace the separate remote row', () => {
@@ -200,7 +200,7 @@ describe('AddRepoLocalStartStep', () => {
it('focuses Browse folder for SSH-likely users too', async () => {
const { container, root } = await renderLocalStartStepDom(true)
const browseButton = findButton(container, 'Browse folder')
const remoteButton = findButton(container, 'Remote project')
const remoteButton = findButton(container, 'Project on SSH host')
expect(document.activeElement).toBe(browseButton)
expect(document.activeElement).not.toBe(remoteButton)
@@ -214,110 +214,8 @@ describe('AddRepoLocalStartStep', () => {
const { container, root } = await renderLocalStartStepDom(false)
expect(findButton(container, 'Clone from URL').disabled).toBe(false)
expect(findButton(container, 'Remote project').disabled).toBe(false)
expect(findButton(container, 'Create project').disabled).toBe(false)
await act(async () => {
root.unmount()
})
})
it('marks the autofocused Browse action as selected with the ⏎ chip', async () => {
const { container, root } = await renderLocalStartStepDom(false)
expect(findButton(container, 'Browse folder').textContent).toContain('⏎')
expect(findButton(container, 'Clone from URL').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('moves the ⏎ selection to whichever action receives focus', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const cloneButton = findButton(container, 'Clone from URL')
await act(async () => {
cloneButton.focus()
})
expect(findButton(container, 'Clone from URL').textContent).toContain('⏎')
expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('clears the ⏎ selection when focus leaves the action list', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const outsideButton = document.createElement('button')
document.body.appendChild(outsideButton)
await act(async () => {
outsideButton.focus()
})
expect(document.activeElement).toBe(outsideButton)
expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
expect(findButton(container, 'Clone from URL').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('does not show an ⏎ selection while add actions are busy', async () => {
const { container, root } = await renderLocalStartStepDom(false, {
isAdding: true,
addProjectBusyLabel: 'Scanning repositories',
nestedScanInProgress: true,
nestedScanId: 'scan-1'
})
const stopScanButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Stop scan"]'
)
await act(async () => {
stopScanButton?.focus()
})
expect(document.activeElement).toBe(stopScanButton)
expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
expect(findButton(container, 'Clone from URL').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('hides the visual ⏎ chip from assistive technology', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const browseButton = findButton(container, 'Browse folder')
const enterChip = Array.from(browseButton.querySelectorAll('[aria-hidden="true"]')).find(
(entry) => entry.textContent?.includes('⏎')
)
expect(enterChip).toBeTruthy()
await act(async () => {
root.unmount()
})
})
it('roves selection down the action list with the ArrowDown key', async () => {
const { container, root } = await renderLocalStartStepDom(false)
await act(async () => {
findButton(container, 'Browse folder').dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
})
// ArrowDown from Browse moves focus — and the ⏎ chip — to the first secondary action.
const firstSecondary = findButton(container, 'Clone from URL')
expect(document.activeElement).toBe(firstSecondary)
expect(firstSecondary.textContent).toContain('⏎')
expect(findButton(container, 'Project on SSH host').disabled).toBe(false)
expect(findButton(container, 'Create new project').disabled).toBe(false)
await act(async () => {
root.unmount()
@@ -432,12 +330,12 @@ describe('AddRepoServerPathStartStep', () => {
const markup = renderServerPathStartStep('env-1')
expect(markup).toContain('Add a project')
expect(markup).toContain('Add another project from the selected runtime server.')
expect(markup).toContain('Browse server')
expect(markup).toContain('Add another project from the selected host.')
expect(markup).toContain('Browse host')
expect(markup).toContain('Clone from URL')
expect(markup).toContain('Create on server')
expect(markup).toContain('Create on host')
expect(markup).toContain('Want to import many repos at once?')
expect(markup).toContain('Or enter a server path manually')
expect(markup).toContain('Or enter a host path manually')
})
it('disables server entry cards without an active runtime environment', () => {
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ComponentType, type Ref } from 'react'
import { useEffect, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react'
import { CircleStop, Loader2 } from 'lucide-react'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -172,6 +172,7 @@ export function AddRepoLocalStartStep({
onBlur={handleActionsBlur}
onKeyDown={handleArrowNavigation}
>
{hostSelector}
<AddRepoPrimaryStartAction
icon={primaryAction.icon}
title={primaryAction.title}
@@ -198,7 +199,7 @@ export function AddRepoLocalStartStep({
icon={action.icon}
title={action.title}
description={action.description}
disabled={isAdding}
disabled={isAdding || Boolean(action.disabled)}
selected={selectedKind === action.kind}
onClick={action.onClick}
onFocus={() => setSelectedKind(action.kind)}
@@ -9,7 +9,7 @@ import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
// ── Remote project hook ─────────────────────────────────────────────
// ── SSH host project hook ───────────────────────────────────────────
export function useRemoteRepo(
fetchWorktrees: (
@@ -195,7 +195,7 @@ export function useRemoteRepo(
return
}
toast.success(
translate('auto.components.sidebar.AddRepoSteps.df8b0e6c22', 'Remote project added'),
translate('auto.components.sidebar.AddRepoSteps.df8b0e6c22', 'Project added on SSH host'),
{ description: repo.displayName }
)
// Why: the repo is already persisted here; if SSH refresh is temporarily
@@ -27,7 +27,7 @@ export function CreateProjectParentBrowser({
<DialogTitle>
{translate(
'auto.components.sidebar.CreateProjectLocationField.f520f83a97',
'Browse server filesystem'
'Browse host filesystem'
)}
</DialogTitle>
<DialogDescription>
@@ -110,10 +110,10 @@ export function CreateProjectLocationField({
size="icon"
className="h-11 w-11 shrink-0"
onClick={onBrowseServer}
disabled={isCreating || !runtimeEnvironmentId}
disabled={isCreating || (!runtimeEnvironmentId && !sshTargetId)}
aria-label={translate(
'auto.components.sidebar.CreateProjectLocationField.f520f83a97',
'Browse server filesystem'
'Browse host filesystem'
)}
>
<FolderOpen className="size-4" />
@@ -122,7 +122,7 @@ export function CreateProjectLocationField({
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.sidebar.CreateProjectLocationField.f520f83a97',
'Browse server filesystem'
'Browse host filesystem'
)}
</TooltipContent>
</Tooltip>
@@ -13,14 +13,13 @@ import {
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { useAppStore } from '@/store'
import { getLinkedWorkItemProvider, type LinkedWorkItemSummary } from '@/lib/new-workspace'
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import {
pickQuickWorkspaceAgent,
resolveQuickWorkspaceAgentSelection
} from '@/lib/quick-workspace-agent-selection'
import { getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
import { buildNewWorkspaceProjectOptions } from '@/lib/new-workspace-project-options'
import type {
GitHubWorkItem,
GitLabWorkItem,
@@ -32,6 +31,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import {
getFolderSourceRepos,
getFolderWorkspacePrimaryActionLabel,
getLinkedItemDisplayName,
getSmartNameSelection,
toGitHubLinkedWorkItem,
@@ -40,6 +40,8 @@ import {
} from './folder-workspace-composer-helpers'
import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status'
import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import { useFolderWorkspaceComposerKeyboard } from './folder-workspace-composer-keyboard'
type FolderWorkspaceComposerDialogProps = {
projectGroup: ProjectGroup | null
@@ -71,7 +73,24 @@ export function FolderWorkspaceComposerDialog({
[projectGroup, projectGroups, repos]
)
const [repoId, setRepoId] = useState('')
const projectSetupProjection = useMemo(
() => projectHostSetupProjectionFromRepos(sourceRepos),
[sourceRepos]
)
const projectOptions = useMemo(
() =>
buildNewWorkspaceProjectOptions({
projects: projectSetupProjection.projects,
projectHostSetups: projectSetupProjection.setups,
eligibleRepos: sourceRepos
}),
[projectSetupProjection, sourceRepos]
)
const selectedRepo = sourceRepos.find((repo) => repo.id === repoId) ?? null
const selectedProjectHostSetup = projectSetupProjection.setups.find(
(setup) => setup.repoId === repoId
)
const selectedProjectId = selectedProjectHostSetup?.projectId ?? null
const selectedRepoConnectionId =
selectedRepo?.connectionId ??
(sourceRepos.length === 0 ? (projectGroup?.connectionId ?? null) : null)
@@ -83,7 +102,7 @@ export function FolderWorkspaceComposerDialog({
connectionId: selectedRepoConnectionId,
status: selectedRepoSshState?.status ?? null
})
const { detectedIds } = useDetectedAgents(null)
const { detectedIds } = useDetectedAgents(selectedRepoConnectionId)
const detectedAgentIds = useMemo(() => (detectedIds ? new Set(detectedIds) : null), [detectedIds])
const [name, setName] = useState('')
const [note, setNote] = useState('')
@@ -154,6 +173,17 @@ export function FolderWorkspaceComposerDialog({
return provider === 'github' || provider === 'gitlab' ? null : current
})
}, [])
const handleProjectChange = useCallback(
(projectId: string): void => {
const setup = projectSetupProjection.setups.find(
(candidate) => candidate.projectId === projectId
)
if (setup) {
handleRepoChange(setup.repoId)
}
},
[handleRepoChange, projectSetupProjection]
)
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem): void => {
@@ -232,7 +262,6 @@ export function FolderWorkspaceComposerDialog({
quickAgent,
autoRenameBranchFromWork: settings?.autoRenameBranchFromWork,
agentCmdOverrides: settings?.agentCmdOverrides,
isRemote: selectedRepoConnectionId !== null,
createFolderWorkspace,
onOpenChange
})
@@ -247,7 +276,6 @@ export function FolderWorkspaceComposerDialog({
onOpenChange,
projectGroup,
quickAgent,
selectedRepoConnectionId,
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
submitting,
@@ -255,45 +283,13 @@ export function FolderWorkspaceComposerDialog({
selectedRepoRequiresConnection
])
useEffect(() => {
if (!open) {
return
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Enter' && event.key !== 'Escape') {
return
}
const target = event.target
if (!(target instanceof HTMLElement)) {
return
}
if (event.key === 'Escape') {
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
event.preventDefault()
target.blur()
return
}
event.preventDefault()
onOpenChange(false)
return
}
if (!isScreenSubmitShortcut(event)) {
return
}
if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) {
return
}
event.preventDefault()
void handleCreate()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [handleCreate, onOpenChange, open, submitting])
useFolderWorkspaceComposerKeyboard({
open,
submitting,
composerRef,
onOpenChange,
onCreate: () => void handleCreate()
})
const smartNameSelection = useMemo(() => getSmartNameSelection(linkedWorkItem), [linkedWorkItem])
const emptySourceProjectMessage =
@@ -313,7 +309,7 @@ export function FolderWorkspaceComposerDialog({
event.preventDefault()
const content = event.currentTarget as HTMLElement
const trigger = content.querySelector<HTMLElement>(
'[data-repo-combobox-root="true"][role="combobox"]'
'[data-project-combobox-root="true"][role="combobox"]'
)
trigger?.focus({ preventScroll: true })
}}
@@ -335,12 +331,12 @@ export function FolderWorkspaceComposerDialog({
onQuickAgentChange={handleQuickAgentChange}
eligibleRepos={sourceRepos}
repoId={repoId}
projectOptions={projectOptions}
selectedProjectId={selectedProjectId}
selectedRepoIsGit={true}
onRepoChange={handleRepoChange}
primaryActionLabel={translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.create',
'Create workspace'
)}
onProjectChange={handleProjectChange}
primaryActionLabel={getFolderWorkspacePrimaryActionLabel(quickAgent)}
projectLabel={translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.sourceProject',
'Task Source'
@@ -73,6 +73,19 @@ vi.mock('../settings/AgentSkillSetupPanel', () => ({
let root: Root | null = null
let container: HTMLDivElement | null = null
function installLocalStorageShim(): void {
const values = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
removeItem: (key: string) => values.delete(key),
setItem: (key: string, value: string) => values.set(key, value)
}
})
}
function cliStatus(overrides: Partial<CliInstallStatus>): CliInstallStatus {
return {
platform: 'darwin',
@@ -123,6 +136,7 @@ describe('LinearAgentSkillSetupPrompt', () => {
mocks.ensureCli.mockClear()
mocks.ensureWslCli.mockClear()
mocks.panelProps.length = 0
installLocalStorageShim()
window.localStorage.clear()
_linearAgentSkillSetupPromptInternalsForTests.resetSessionSnoozes()
Object.defineProperty(window, 'api', {
@@ -748,7 +748,7 @@ export function RemoteFileBrowser({
? FILE_HINT_TEXT
: translate(
'auto.components.sidebar.RemoteFileBrowser.971d85cc84',
'Opens as a remote project · {{value0}}',
'Opens as a project on this host · {{value0}}',
{ value0: resolvedPath }
)}
</p>
@@ -39,15 +39,6 @@ type SidebarWorkspaceOptionsMenuProps = {
onMenuOpenChange?: (open: boolean) => void
}
import {
AGENT_ACTIVITY_DISPLAY_OPTIONS,
CARD_LAYOUT_OPTIONS,
GROUP_BY_OPTIONS,
PROJECT_ORDER_OPTIONS,
PROPERTY_OPTIONS,
SORT_OPTIONS
} from './sidebar-workspace-options-menu-options'
const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({
preserveWorkspaceBoardOpen = false,
onMenuOpenChange
@@ -175,6 +166,18 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
className="w-72 pb-2"
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
>
{showHostScopeControls && (
<SidebarHostScopeMenuSection
hostOptionsCount={hostOptions.length}
hostVisibilityLabel={hostVisibilityLabel}
hostOptions={hostOptions}
preserveWorkspaceBoardOpen={preserveWorkspaceBoardOpen}
setWorkspaceHostScope={setWorkspaceHostScope}
visibleWorkspaceHostIds={visibleWorkspaceHostIds}
setVisibleWorkspaceHostIds={setVisibleWorkspaceHostIds}
/>
)}
<DropdownMenuLabel>
{translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc', 'Group by')}
</DropdownMenuLabel>
@@ -1,5 +1,5 @@
/**
* Row used in the "Open remote project" step to pick an SSH target.
* Row used in the "Open project on SSH host" step to pick an SSH target.
*
* Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit
* while isolating the inline-connect interaction logic.
@@ -23,7 +23,6 @@ import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer'
import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt'
import WorktreeCardAgents from './WorktreeCardAgents'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { cn } from '@/lib/utils'
@@ -989,7 +988,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
)
: translate(
'auto.components.sidebar.WorktreeCard.ca74db7550',
'Remote project via SSH'
'Project on SSH host'
)}
</TooltipContent>
</Tooltip>
@@ -1217,6 +1216,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
)}
{showHostContextBadge && (
<Badge
variant="secondary"
className="h-[16px] max-w-[7rem] shrink-0 rounded border border-border bg-accent px-1.5 text-[10px] font-medium leading-none text-muted-foreground dark:bg-accent/80 dark:border-border/50"
>
<span className="truncate">{hostContextLabel}</span>
</Badge>
)}
{isFolder ? (
<span
className="min-w-0 truncate font-mono text-[11px] leading-none text-muted-foreground"
@@ -1276,15 +1284,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
)}
{isActive && worktree.linkedLinearIssue ? (
<LinearAgentSkillSetupPrompt
linked
remote={Boolean(repo?.connectionId || settings?.activeRuntimeEnvironmentId?.trim())}
surface="modal"
settings={settings}
/>
) : null}
{/* Why: inline agent list. Gated on the 'inline-agents' card
property so users can hide it. Layout coupling: this block
grows the card height dynamically — WorktreeList uses
@@ -14,6 +14,7 @@ import {
FolderInput,
FolderPlus,
FolderX,
Loader2,
Plus,
Server,
ServerOff,
@@ -128,7 +129,6 @@ import {
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
type ScrollToCurrentWorkspaceRevealRequestDetail
} from '@/lib/scroll-to-current-workspace-status'
import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop'
import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag'
import {
buildManualOrderUpdatesForGroupDrop,
@@ -181,7 +181,15 @@ import {
pruneWorktreeSelection,
updateWorktreeSelection
} from './worktree-multi-selection'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { splitWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-host-split'
import {
ALL_EXECUTION_HOSTS_SCOPE,
getRepoExecutionHostId,
getSettingsFocusedExecutionHostId,
type ExecutionHostId,
parseExecutionHostId
} from '../../../../shared/execution-host'
import { getRepoHeaderCreateState } from './repo-header-create-state'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets'
@@ -209,6 +217,11 @@ import {
getWorktreeCardContentIndent,
getWorktreeCardSurfaceInset
} from './worktree-list-indentation'
import { addHostSectionRows, type HostHeaderRow, type HostSectionRow } from './host-section-rows'
import { orderHostSectionOptions } from './host-section-order'
import { useHostHeaderDrag } from './host-header-drag'
import { buildSidebarHostOptions } from './sidebar-host-options'
import { HostSectionHeaderMenu } from './HostSectionHeaderMenu'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
@@ -463,8 +476,8 @@ type VirtualizedWorktreeViewportProps = {
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
}
type WorktreeItemRow = Extract<Row, { type: 'item' }>
type FolderWorkspaceItemRow = Extract<Row, { type: 'folder-workspace' }>
type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }>
type FolderWorkspaceItemRow = Extract<HostSectionRow, { type: 'folder-workspace' }>
function formatSectionActivityLabel(count: number, label: string): string {
return `${count} ${label}${count === 1 ? '' : 's'}`
@@ -492,6 +505,141 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element {
)
}
function HostHeaderHealthIcon({
health
}: {
health: HostHeaderRow['health']
}): React.JSX.Element | null {
// Why: healthy is the default state — indicating it adds noise. Only states
// needing active attention get a separate mark.
if (health === 'connecting') {
return <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
}
if (health === 'blocked' || health === 'error') {
return <AlertTriangle className="size-3 shrink-0 text-destructive" />
}
return null
}
function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null {
// Why: a blocked compatibility verdict gets a compact warning treatment so one
// skewed host stands out without altering how its siblings render.
if (row.health === 'blocked') {
return {
text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'),
isWarning: true
}
}
// Why: auth-expired SSH hosts must say so in words — the plan requires a clear
// auth-needed status, and the health icon alone doesn't explain the fix.
if (row.connectionStatus === 'auth-failed') {
return {
text: translate(
'auto.components.sidebar.WorktreeList.hostAuthNeeded',
'Authentication needed'
),
isWarning: true
}
}
if (row.health === 'disconnected') {
return {
text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'),
isWarning: false
}
}
// Why: the transport suffix only earns space on remote hosts; "This
// computer" on Local Mac is noise.
if (row.kind !== 'local') {
return { text: row.detail, isWarning: false }
}
return null
}
function HostSectionHeader({
row,
onToggle,
onDragPointerDown,
dragging
}: {
row: HostHeaderRow
onToggle: () => void
onDragPointerDown?: (event: React.PointerEvent<HTMLElement>) => void
dragging?: boolean
}): React.JSX.Element {
const isBlocked = row.health === 'blocked'
const isDisconnected = row.health === 'disconnected'
const detail = getHostHeaderDetail(row)
return (
<div className="px-2 pt-1">
{/* Why: hosts are machines, not just groups — the outlined card with a
server glyph keeps that distinction visible. Status stays quiet: a
mark renders only when the host needs attention. */}
<div
role="button"
tabIndex={0}
data-host-header-drag-id={row.hostId}
aria-expanded={!row.collapsed}
className={cn(
'group/host-header flex h-8 w-full cursor-pointer items-center gap-2 rounded-md border px-2 text-left transition-all',
onDragPointerDown && 'cursor-grab active:cursor-grabbing',
isBlocked
? 'border-destructive/40 bg-destructive/10'
: isDisconnected
? 'border-worktree-sidebar-border/70 bg-worktree-sidebar-accent/35 text-muted-foreground'
: 'border-worktree-sidebar-border bg-worktree-sidebar-accent/70',
dragging && 'pointer-events-none opacity-0'
)}
onPointerDown={onDragPointerDown}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
>
{isDisconnected ? (
<ServerOff className="size-3.5 shrink-0 text-muted-foreground/80" />
) : (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
)}
<HostHeaderHealthIcon health={row.health} />
{/* Why: the badge hugs the label like repo headers do — anchoring it
right would leave it floating beside the hover-only controls. */}
<div className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span
className={cn(
'min-w-0 truncate text-[12px] font-semibold leading-none',
isDisconnected ? 'text-muted-foreground' : 'text-foreground'
)}
>
{row.label}
</span>
{detail ? (
<span
className={cn(
'shrink-0 truncate text-[10px] leading-none',
detail.isWarning ? 'text-destructive' : 'text-muted-foreground/70'
)}
>
{detail.text}
</span>
) : null}
<SectionMetricsBadge count={row.count} />
</div>
<div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/60 opacity-0 transition-opacity group-hover/host-header:opacity-100">
<ChevronDown
className={cn('size-3.5 transition-transform', row.collapsed && '-rotate-90')}
/>
</div>
<span data-host-header-action="">
<HostSectionHeaderMenu row={row} />
</span>
</div>
</div>
)
}
function FolderPathStatusIndicator({
status
}: {
@@ -733,6 +881,7 @@ export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup
continue
}
if (
row.type === 'host-header' ||
row.type === 'imported-worktrees-card' ||
row.type === 'pending-creation' ||
row.type === 'folder-workspace'
@@ -904,8 +1053,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
const suppressWorktreeClickUntilRef = useRef(0)
const hasProjectGroups = projectGroups.length > 0
const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual'
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
const canReorderRepoHeaders =
groupBy === 'repo' && projectOrderBy === 'manual' && !hasProjectGroups
const lastVisibleRefreshKeyRef = useRef('')
const reportVisibleGitHubPRRefreshCandidates = useAppStore(
(s) => s.reportVisibleGitHubPRRefreshCandidates
@@ -948,6 +1097,29 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
},
[reorderRepos]
)
// Drag is only meaningful when repo headers are using manual order. The
// controller is still constructed for hook order stability when inert.
const repoDrag = useRepoHeaderDrag({
orderedRepoIds: allRepoIds,
onCommit: commitRepoReorder,
getScrollContainer: () => scrollRef.current
})
const orderedHostIds = useMemo(
() =>
rows
.filter((row): row is HostHeaderRow => row.type === 'host-header')
.map((row) => row.hostId),
[rows]
)
const hostDrag = useHostHeaderDrag({
orderedHostIds,
onCommit: onReorderHostSections,
getScrollContainer: () => scrollRef.current
})
useEffect(() => {
onHostDragActiveChange(hostDrag.state.draggingHostId !== null)
}, [hostDrag.state.draggingHostId, onHostDragActiveChange])
useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange])
const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows])
const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows])
const worktreeLineageDragRows = useMemo(
@@ -1057,45 +1229,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
[computeWorktreeDropForGroup]
)
const renderRows = useMemo(() => buildRenderableRows(rows), [rows])
const sidebarRepoHeaderIdsByBucket = useMemo(
() => getSidebarOrderedRepoHeaderIdsByBucket(rows),
[rows]
)
const repoHeaderIndexByRepoId = useMemo(() => {
const map = new Map<string, number>()
for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) {
repoIds.forEach((repoId, index) => {
map.set(repoId, index)
})
}
return map
}, [sidebarRepoHeaderIdsByBucket])
const repoHeaderBucketByRepoId = useMemo(() => {
const map = new Map<string, string>()
for (const [bucketKey, repoIds] of sidebarRepoHeaderIdsByBucket) {
for (const repoId of repoIds) {
map.set(repoId, bucketKey)
}
}
return map
}, [sidebarRepoHeaderIdsByBucket])
const commitProjectGroupOrder = useCallback(
(repoId: string, projectGroupId: string | null, order: number) => {
void moveProjectToGroup(repoId, projectGroupId, order)
},
[moveProjectToGroup]
)
// Drag is only meaningful when repo headers are using manual order. The
// controller is still constructed for hook order stability when inert.
const repoDrag = useRepoHeaderDrag({
orderedRepoIds: allRepoIds,
sidebarRepoHeaderIdsByBucket,
repoById: repoMap,
usesProjectGroupOrdering: hasProjectGroups,
onCommitRepoOrder: commitRepoReorder,
onCommitProjectGroupOrder: commitProjectGroupOrder,
getScrollContainer: () => scrollRef.current
})
const firstHeaderIndex = useMemo(
() => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'),
[renderRows]
@@ -3036,21 +3169,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined
const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined
const repoHeaderIndex =
projectIdForHeader !== undefined
? repoHeaderIndexByRepoId.get(projectIdForHeader)
: undefined
const repoHeaderBucketKey =
projectIdForHeader !== undefined
? repoHeaderBucketByRepoId.get(projectIdForHeader)
: undefined
const isDraggableRepoHeader = Boolean(
canReorderRepoHeaders &&
isRepoHeader &&
projectIdForHeader &&
repoHeaderBucketKey &&
(sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1
)
const isDraggingThis =
canReorderRepoHeaders &&
repoDrag.state.draggingRepoId !== null &&
@@ -3104,7 +3222,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-sticky-header=""
data-worktree-sticky-header-active={isActiveStickyHeader ? '' : undefined}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className={cn(
@@ -3129,8 +3246,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
role="button"
tabIndex={0}
data-repo-header-id={projectIdForHeader}
data-repo-header-index={repoHeaderIndex}
data-repo-header-bucket={repoHeaderBucketKey}
data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined}
data-workspace-status={headerWorkspaceStatus ?? undefined}
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
@@ -3148,6 +3263,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
row.repo && 'overflow-hidden'
)}
style={{ paddingLeft: headerPaddingLeft }}
// Why: arm project-header drag from anywhere on the row, not
// just the icon — users grab the name to reorder. The hook
// ignores presses on nested buttons (+/chevron) and only
// promotes to a drag past a 4px threshold, so a plain click
// still toggles collapse via onClick.
onPointerDown={
canReorderRepoHeaders && isRepoHeader && projectIdForHeader
? (e) => repoDrag.onHandlePointerDown(e, projectIdForHeader)
: undefined
}
onDragOver={
isPinnedHeader
? handleWorkspacePinDragOver
@@ -3205,18 +3330,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div
data-repo-header-drag-handle=""
className={cn(
'min-w-0 truncate text-[13px] font-semibold leading-none',
isDraggableRepoHeader && 'cursor-grab'
)}
onPointerDown={
isDraggableRepoHeader && projectIdForHeader
? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader)
: undefined
}
>
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
{row.label}
</div>
<RepoForkIndicator upstream={row.repo?.upstream} />
@@ -3726,7 +3840,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
childNodes.push(renderWorktreeRow(child, true, childLineageChildren))
cursor = nextSiblingIndex
}
return childNodes.length > 0 ? childNodes : undefined
}
@@ -4524,7 +4637,8 @@ const WorktreeList = React.memo(function WorktreeList({
placeholderRepoIds,
importedWorktreesByRepo,
pendingCreations,
folderWorkspaces
projectGrouping,
visibleFolderWorkspacesForRows
),
[
groupBy,
@@ -4543,8 +4657,7 @@ const WorktreeList = React.memo(function WorktreeList({
visibleFolderWorkspacesForRows,
placeholderRepoIds,
importedWorktreesByRepo,
pendingCreations,
folderWorkspaces
pendingCreations
]
)
const hostOptions = useMemo(
@@ -4619,7 +4732,7 @@ const WorktreeList = React.memo(function WorktreeList({
// positions when grouping is active.
const renderedWorktrees = useMemo(
() =>
rows.flatMap((row) => {
sectionRows.flatMap((row) => {
if (row.type === 'item') {
return [row.worktree]
}
@@ -4628,7 +4741,7 @@ const WorktreeList = React.memo(function WorktreeList({
}
return []
}),
[rows]
[sectionRows]
)
const renderedWorktreeIds = useMemo(
() => renderedWorktrees.map((worktree) => worktree.id),
@@ -1,5 +1,5 @@
import type { SidebarHostOption } from './sidebar-host-options'
export function canSelectAddRepoHost(host: Pick<SidebarHostOption, 'health' | 'kind'>): boolean {
return host.health === 'local' || host.health === 'available' || host.kind === 'ssh'
return host.health === 'local' || host.health === 'available'
}

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