From 293f84546c7073220493bdddb865e331a70fb278 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 12 Jun 2026 18:24:21 -0700 Subject: [PATCH] Finish host context merge repair Co-authored-by: Orca --- .gitignore | 4 + .../host-context-implementation-checklist.md | 222 +++ notes/host-model-tasks-automations-plan.html | 1383 +++++++++++++++++ .../multihost-remote-verification-report.html | 278 ++++ notes/project-first-host-model.md | 115 +- .../subagent-automations-host-model-audit.md | 105 ++ ...subagent-composer-pr-launch-hosts-audit.md | 57 + ...subagent-settings-cache-cli-hosts-audit.md | 139 ++ notes/subagent-tasks-provider-hosts-audit.md | 48 + src/cli/handlers/automations.ts | 27 + src/cli/index.test.ts | 131 +- src/cli/specs/automations.ts | 13 +- src/main/automations/service-precheck.test.ts | 40 + src/main/automations/service.test.ts | 75 + src/main/automations/service.ts | 87 +- src/main/daemon/daemon-health.ts | 30 +- src/main/daemon/daemon-init.ts | 72 +- src/main/daemon/daemon-server.test.ts | 9 +- src/main/daemon/pty-subprocess.test.ts | 55 +- src/main/daemon/pty-subprocess.ts | 1 + src/main/ipc/github.test.ts | 19 + src/main/ipc/github.ts | 15 +- src/main/ipc/gitlab.test.ts | 134 ++ src/main/ipc/gitlab.ts | 167 +- src/main/ipc/hosted-review.test.ts | 24 + src/main/ipc/hosted-review.ts | 4 +- src/main/ipc/repos-remote.test.ts | 64 +- src/main/ipc/repos.ts | 519 +++++-- src/main/ipc/worktrees.test.ts | 187 ++- src/main/ipc/worktrees.ts | 64 +- src/main/persistence.test.ts | 221 ++- src/main/persistence.ts | 206 ++- src/main/providers/ssh-git-provider.ts | 1 + src/main/runtime/orca-runtime.ts | 195 +-- .../runtime/rpc/methods/automations.test.ts | 19 + src/main/runtime/rpc/methods/automations.ts | 51 + src/main/runtime/rpc/methods/repo.ts | 1 + src/preload/api-types.ts | 464 +++--- src/preload/gitlab.ts | 213 +-- src/preload/index.ts | 17 +- .../src/components/GitHubItemDialog.tsx | 486 ++++-- .../src/components/GitLabItemDialog.tsx | 107 +- .../components/NewWorkspaceComposerModal.tsx | 3 + .../src/components/PullRequestPage.tsx | 153 +- src/renderer/src/components/StarNagCard.tsx | 43 +- src/renderer/src/components/TaskPage.tsx | 345 +++- .../automations/AutomationDetail.tsx | 34 +- .../automations/AutomationsPage.tsx | 109 +- .../automations/CreateFromPicker.test.tsx | 116 ++ .../ExternalAutomationManagers.tsx | 2 +- .../automation-target-availability.test.ts | 159 ++ .../automation-target-availability.ts | 102 ++ .../components/github-project/ProjectCell.tsx | 5 +- .../github-project/ProjectViewWrapper.tsx | 36 +- .../github-project/slug-dialog/Comments.tsx | 7 +- .../components/right-sidebar/ChecksPanel.tsx | 389 +---- .../right-sidebar/SourceControl.tsx | 609 ++------ .../right-sidebar/checks-panel-content.tsx | 19 +- .../pr-comments-list-selection.test.tsx | 79 +- .../src/components/settings/AgentsPane.tsx | 7 +- .../src/components/settings/CliSection.tsx | 236 +-- .../settings/CommitMessageAiPane.tsx | 195 ++- .../components/settings/ComputerUsePane.tsx | 153 +- .../GeneralWorkspaceSettingsSection.tsx | 45 +- .../components/settings/OrchestrationPane.tsx | 76 +- .../components/settings/QuickCommandsPane.tsx | 95 +- .../RepositorySourceControlAiSection.tsx | 9 +- .../settings/RuntimeEnvironmentsPane.test.ts | 49 + .../settings/RuntimeEnvironmentsPane.tsx | 146 +- .../src/components/settings/Settings.tsx | 92 +- .../settings/jira-integration-card.tsx | 12 + .../settings/provider-account-scope.test.ts | 20 + .../settings/provider-account-scope.ts | 22 + .../settings/setting-ownership.test.ts | 32 + .../components/settings/setting-ownership.ts | 55 + .../task-tracker-integration-cards.test.tsx | 212 +-- .../task-tracker-integration-cards.tsx | 210 +-- .../AddProjectFromFolderDialog.test.tsx | 2 +- .../sidebar/AddProjectFromFolderDialog.tsx | 2 +- .../components/sidebar/AddRepoCloneStep.tsx | 6 +- .../sidebar/AddRepoCreateStep.test.tsx | 37 +- .../components/sidebar/AddRepoCreateStep.tsx | 364 +++-- .../src/components/sidebar/AddRepoDialog.tsx | 271 ++-- .../sidebar/AddRepoDialogStepContent.test.tsx | 44 +- .../sidebar/AddRepoDialogStepContent.tsx | 18 +- .../sidebar/AddRepoHostSelector.test.tsx | 8 +- .../components/sidebar/AddRepoRemoteStep.tsx | 115 +- .../sidebar/AddRepoServerStartStep.tsx | 20 +- .../sidebar/AddRepoStartSteps.test.tsx | 128 +- .../components/sidebar/AddRepoStartSteps.tsx | 5 +- .../src/components/sidebar/AddRepoSteps.tsx | 4 +- .../sidebar/CreateProjectLocationField.tsx | 8 +- .../sidebar/FolderWorkspaceComposerDialog.tsx | 94 +- .../LinearAgentSkillSetupPrompt.test.tsx | 14 + .../components/sidebar/RemoteFileBrowser.tsx | 2 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 21 +- .../src/components/sidebar/SshTargetRow.tsx | 2 +- .../src/components/sidebar/WorktreeCard.tsx | 21 +- .../src/components/sidebar/WorktreeList.tsx | 275 +++- .../sidebar/add-repo-host-availability.ts | 2 +- .../sidebar/add-repo-local-start-actions.ts | 50 +- .../sidebar/create-project-defaults.test.ts | 16 +- .../sidebar/create-project-defaults.ts | 8 +- .../folder-workspace-composer-helpers.ts | 13 +- src/renderer/src/components/sidebar/index.tsx | 14 +- .../sidebar/project-header-drag.test.ts | 121 +- .../components/sidebar/project-header-drag.ts | 435 +++--- .../sidebar/sidebar-project-drop.ts | 2 +- .../sidebar/useAddRepoCloneFlow.test.ts | 10 +- .../components/sidebar/useAddRepoCloneFlow.ts | 15 +- .../sidebar/useAddRepoLocalFolderFlow.ts | 2 +- .../sidebar/useCreateProjectDefaults.test.ts | 41 +- .../sidebar/useCreateProjectDefaults.ts | 75 +- .../useCreateRepo.default-checkout.test.ts | 15 +- .../src/components/sidebar/useCreateRepo.ts | 112 +- .../sidebar/useSidebarProjectDrop.ts | 2 +- .../components/sidebar/worktree-drag-units.ts | 1 + .../sidebar/worktree-list-groups.test.ts | 76 +- .../sidebar/worktree-list-groups.ts | 49 +- .../components/task-page-cache-selectors.ts | 6 +- .../task-page-default-repo-selection.test.ts | 63 + .../task-page-default-repo-selection.ts | 31 + .../components/task-page-empty-state.test.ts | 42 + .../src/components/task-page-empty-state.ts | 43 + .../task-source-context-summary.test.ts | 176 +++ .../components/task-source-context-summary.ts | 232 +++ .../terminal-pane/pty-connection.ts | 35 +- .../src/hooks/useAutomationDispatchEvents.ts | 24 +- src/renderer/src/hooks/useComposerState.ts | 119 +- .../src/hooks/useGitHubSlugMetadata.test.tsx | 130 ++ .../src/hooks/useGitHubSlugMetadata.ts | 25 +- .../hooks/useSettingsNavigationMetadata.ts | 2 +- src/renderer/src/i18n/locales/en.json | 425 ++--- .../src/lib/launch-work-item-direct.ts | 5 + .../src/lib/pending-worktree-creation.ts | 8 + src/renderer/src/lib/worktree-activation.ts | 7 + .../src/runtime/runtime-jira-client.ts | 55 +- .../src/runtime/runtime-linear-client.ts | 69 +- src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 2 + src/renderer/src/store/slices/github.test.ts | 261 ++++ src/renderer/src/store/slices/github.ts | 293 ++-- .../src/store/slices/hosted-review.test.ts | 3 + .../src/store/slices/hosted-review.ts | 22 +- src/renderer/src/store/slices/repos.ts | 230 ++- src/renderer/src/store/slices/settings.ts | 213 --- .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/ui.test.ts | 197 ++- src/renderer/src/store/slices/ui.ts | 23 +- .../worktree-nav-history-view-entries.test.ts | 35 + .../src/store/slices/worktree-nav-history.ts | 8 +- .../src/store/slices/worktrees.test.ts | 98 ++ src/renderer/src/store/slices/worktrees.ts | 39 +- src/renderer/src/store/types.ts | 2 + src/renderer/src/web/web-preload-api.test.ts | 36 - src/renderer/src/web/web-preload-api.ts | 20 +- src/shared/automations-types.ts | 13 + src/shared/git-clone-failure-message.test.ts | 23 +- src/shared/git-clone-failure-message.ts | 23 +- src/shared/hosted-review.ts | 2 + .../project-host-setup-projection.test.ts | 38 + src/shared/project-host-setup-projection.ts | 23 +- src/shared/task-source-context.test.ts | 122 ++ src/shared/task-source-context.ts | 231 +++ 164 files changed, 10835 insertions(+), 5088 deletions(-) create mode 100644 notes/host-context-implementation-checklist.md create mode 100644 notes/host-model-tasks-automations-plan.html create mode 100644 notes/multihost-remote-verification-report.html create mode 100644 notes/subagent-automations-host-model-audit.md create mode 100644 notes/subagent-composer-pr-launch-hosts-audit.md create mode 100644 notes/subagent-settings-cache-cli-hosts-audit.md create mode 100644 notes/subagent-tasks-provider-hosts-audit.md create mode 100644 src/main/ipc/gitlab.test.ts create mode 100644 src/renderer/src/components/automations/CreateFromPicker.test.tsx create mode 100644 src/renderer/src/components/automations/automation-target-availability.test.ts create mode 100644 src/renderer/src/components/automations/automation-target-availability.ts create mode 100644 src/renderer/src/components/settings/provider-account-scope.test.ts create mode 100644 src/renderer/src/components/settings/provider-account-scope.ts create mode 100644 src/renderer/src/components/settings/setting-ownership.test.ts create mode 100644 src/renderer/src/components/settings/setting-ownership.ts create mode 100644 src/renderer/src/components/task-page-default-repo-selection.test.ts create mode 100644 src/renderer/src/components/task-page-default-repo-selection.ts create mode 100644 src/renderer/src/components/task-page-empty-state.test.ts create mode 100644 src/renderer/src/components/task-page-empty-state.ts create mode 100644 src/renderer/src/components/task-source-context-summary.test.ts create mode 100644 src/renderer/src/components/task-source-context-summary.ts create mode 100644 src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx create mode 100644 src/shared/task-source-context.test.ts create mode 100644 src/shared/task-source-context.ts diff --git a/.gitignore b/.gitignore index c7a9a9da3a1..0bfbb0e5e84 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md new file mode 100644 index 00000000000..56c698fff01 --- /dev/null +++ b/notes/host-context-implementation-checklist.md @@ -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`. diff --git a/notes/host-model-tasks-automations-plan.html b/notes/host-model-tasks-automations-plan.html new file mode 100644 index 00000000000..30de8eab09e --- /dev/null +++ b/notes/host-model-tasks-automations-plan.html @@ -0,0 +1,1383 @@ + + + + + + Orca Host Model: Tasks, Automations, Provider Identity + + + +
+
+
Planning artifact · Host model follow-up
+

Tasks, Automations, and Provider Identity Under One Host Model

+

+ The project-first migration gives Orca the durable shape + Project -> ProjectHostSetup -> Workspace. The remaining product gap is + that Tasks, Automations, and provider integrations still route through different + ownership rules. This plan makes Host the user-facing concept across + Local Mac, SSH machines, Remote Servers, and future Orca cloud VMs. +

+
+ Host = Local / SSH / Remote Server / Cloud VM + Project identity stays above hosts + Provider accounts become host-scoped where needed + Automations get explicit run location +
+
+ +
+

Short Answer

+
+

+ We should keep the model simple for users: a project can be available on one or more + hosts, and every host can have its own filesystem, terminals, provider CLIs, auth, and + runtime capabilities. Tasks and Automations should stop relying on ambient global state + where the host matters. +

+

+ The big change is not “make SSH and Remote Server the same internally.” It is “present + both as hosts, then route each workflow through the selected host’s capabilities.” +

+
+
+ +
+

Host Boundaries

+
+
+
Host kind
+
What explicit task context means
+
Boundary
+
+
+
Local Mac
+
+ Provider calls, repo discovery, task launch, and workspace creation use the local + desktop environment. +
+
Baseline path.
+
+
+
SSH host
+
+ The Tasks page should know the selected project setup lives on an SSH machine and show + whether provider data is served by the SSH host or temporarily by the desktop client. +
+
+ SSH is first-class host context, but support is capability-based: filesystem, + terminal, git, and provider CLI/token execution may arrive independently. +
+
+
+
Remote Server / runtime
+
+ Provider calls should route through the remote Orca runtime when that runtime owns + the selected project setup and advertises the needed capability. +
+
+ Richer host with runtime RPC, version/capability negotiation, web/mobile handoff, and + more durable background services. +
+
+
+
Future Cloud VM
+
+ Same user-facing host model: a project setup on that VM can serve Tasks and create + Automations once the VM exposes the required host capabilities. +
+
Provisioning lifecycle remains separate from task/provider routing.
+
+
+
+ +
+

Task Source vs Run Host

+
+

+ A logical project can exist on multiple hosts, but provider data still has to be fetched + through one credential context. That source context must be explicit or at least visible. + “Project: orca” is not enough information when Local Mac has GitHub account A and + openclaw 2 has GitHub account B. +

+
+
+
+
Task source context
+

Where issues/PRs are fetched from

+

+ This answers: which host's GitHub/GitLab/Linear/Jira credentials serve the list? It + affects private repo access, rate limits, viewer-specific filters like “assigned to + me,” review requests, notifications, and mutation permissions. +

+
+
+
Run host
+

Where the workspace is created

+

+ This answers: when the user starts work from a task, which project-host setup creates + the workspace? It may default to the task source host, but the user can choose another + ready setup when the project exists there. +

+
+
+
+ Recommendation: default the task source to the active workspace host or the last-used host + for that project/provider, show the serving account beside the picker, and require an + explicit choice when combining hosts would make “me” ambiguous. +
+
+ +
+

What Needs To Change

+
+
+ □ +
+ Unify provider request ownership around Host. +
+ GitHub currently has per-repo runtime routing, while SSH/local are desktop-owned. + Linear/Jira use active runtime. GitLab uses desktop IPC with SSH-aware repo paths. + We need a shared provider request target: { provider, hostId, projectHostSetupId? }. +
+
+ P0 +
+
+ □ +
+ Make Tasks choose an explicit host context. +
+ Applies to Local, SSH, Remote Server, and future Cloud VM hosts. The Tasks page + should show which host/account is serving results. Multi-project and multi-host + selections should either group by host or require a selected host when provider + identity can differ. +
+
+ P0 +
+
+ □ +
+ Store Automations as project + run-host, not repo-id pretending to be project-id. +
+ Existing automations store projectId as a repo id. New automations + should target a logical project and either an existing workspace or a + projectHostSetupId for fresh workspaces. +
+
+ P0 +
+
+ □ +
+ Add host-scoped provider settings. +
+ Settings should answer “which GitHub/GitLab/Linear/Jira account is active on this + host?” without confusing client settings with server/SSH settings. +
+
+ P0 +
+
+ □ +
+ Gate UI by host capabilities and connection state. +
+ Remote Servers can expose runtime RPC capabilities. SSH hosts may support filesystem, + terminal, git, and provider CLI execution through Orca’s SSH bridge. Disconnected or + incompatible hosts should be visible but disabled where action would fail. +
+
+ P1 +
+
+ □ +
+ Extend CLI/API language to match the product model. +
+ Commands should accept --project plus --host or + --project-host-setup for workflows that create workspaces or automations. + Keep repo selectors as compatibility aliases. +
+
+ P1 +
+
+ □ +
+ Audit adjacent surfaces that already have host-specific state. +
+ Source-control AI model choices, task resume state, PR/review caches, setup scripts, + notification actions, direct launch-from-task flows, and run history should include + enough host identity to avoid cross-machine leakage. +
+
+ P1 +
+
+
+ +
+

Model

+
+
+
Host
+

One user-facing bucket

+

+ Local Mac, SSH target, Remote Server/runtime, and future Cloud VM all appear as + hosts. The user chooses where work runs; Orca chooses the transport. +

+
+
+
Project setup
+

Project on a host

+

+ A logical project may have setup rows on multiple hosts. Each row owns path, + readiness, default worktree location, and setup method. +

+
+
+
Provider identity
+

Account where the request runs

+

+ GitHub/GitLab/Linear/Jira identity should be explicit: local desktop account, + SSH-host CLI/token, or Remote Server account. +

+
+
+
+ +
+

Tasks Mock

+
+
+
+
Tasks · provider context is visible
+
⌘K
+
+
+ +
+
+
+ Tasks +
+ + + +
+
+
+ Project: orca + Host: Local Mac + GitHub: @jinwoo +
+
+
+
+
+
#5071 Review multi-host project setup flow
+
+ orca · GitHub + served by Local Mac + account @jinwoo +
+
+ Start on Local Mac +
+
+
+
#5068 Add runtime compatibility copy
+
+ orca · GitHub + also available on openclaw 2 + 2 host contexts +
+
+ Choose host… +
+
+ Host context is a first-class filter. If openclaw 2 has a different GitHub + account, switching Host changes the request owner and the rows can legitimately differ. +
+
+ + +
+
+
+

+ Interaction: click GitHub / Linear / GitLab above. The important UI addition is the + visible Host + provider identity pair, not a separate “remote task” mode. +

+
+ +
+

Automations Mock

+
+
+
+
Automation editor · run location is explicit
+
+
+
+
+
Create Automation
+

Daily issue triage

+
+
+
Project
+
orca
+
+
+
Run on
+
+ openclaw 2 + SSH host +
+
+ Only hosts where this project is set up appear here. Add Project remains the way + to make the project available on another host. +
+
+
+
Workspace
+
+ + +
+
+
+
Create from
+
origin/main · resolved on openclaw 2
+
+
+
Agent
+
Codex · model from openclaw 2 host settings
+
+
+
Provider context
+
GitHub tasks: @deploy-bot on openclaw 2
+
+
+ Stored target should be logical project + host setup. Existing workspace mode can + infer host from the workspace, but the editor should still display it. +
+
+
+
+
Run history
+
+
+
Daily issue triage
+
+ orca + openclaw 2 + auto-daily-20260612 +
+
+ Completed +
+
+
+
Daily issue triage
+
+ orca + build-vm + interactive SSH auth needed +
+
+ Skipped +
+
+ Scheduled runs should preserve host and workspace display names so history remains + understandable after a host disconnects or a workspace is deleted. +
+
+
+
+
+ +
+

Settings Mock

+
+
+
+
Settings · host-scoped integrations
+
+
+
+ +
+
+
+ Host Settings +
Settings that depend on where commands run
+
+ Viewing: openclaw 2 +
+
+
+
+
GitHub
+
+ Account @deploy-bot + Served through SSH host CLI/token +
+
+ Reconnect +
+
+
+
GitLab
+
+ Not connected on this host + Tasks will be disabled for GitLab on openclaw 2 +
+
+ Connect +
+
+
+
Default worktree location
+
+ ~/orca/worktrees + Host filesystem path +
+
+ Change +
+
+ This does not need to become a huge second Settings app. Keep global client + preferences global, and add a host dropdown only on panes whose values truly live + on the selected host. +
+
+
+
+
+
+ +
+

Implementation Breakdown

+
+
+
Area
+
Change
+
Verification
+
+
+
Shared model
+
+ Add a normalized provider runtime target keyed by ExecutionHostId. Keep + existing ProjectHostSetup as the project-on-host record. +
+
Unit tests for local, SSH, runtime, disconnected, old-runtime.
+
+
+
GitHub Tasks
+
+ Route SSH GitHub lookups through the selected SSH host when supported, or clearly + label them as desktop-served until support lands. +
+
Local account vs SSH account fixture must return different rows.
+
+
+
GitLab Tasks
+
+ Move from desktop-only IPC assumptions to explicit host provider target. Preserve + current SSH project-ref safeguards. +
+
GitLab local, SSH, and runtime provider smoke tests.
+
+
+
Linear / Jira
+
+ Replace ambient active-runtime routing in task surfaces with an explicit host context. + Settings can still default to the focused host. +
+
Switch host without changing project; account/status updates predictably.
+
+
+
Automations schema
+
+ Add logical projectId, hostId, and optional + projectHostSetupId. Migrate old repo-id automations by mapping the repo + to its project-host setup. +
+
Migration tests with old repo-backed automations.
+
+
+
Automation dispatch
+
+ Resolve host before precheck, worktree creation, terminal launch, provider session + observation, and usage collection. +
+
Scheduled run on local, SSH, runtime; disconnected SSH skip; auth-needed skip.
+
+
+
CLI
+
+ Add host-aware selectors for automation create/run and worktree creation while keeping + repo selectors compatible. +
+
CLI fixtures for --project, --host, old --repo.
+
+
+
Telemetry / logs
+
+ Track host kind and capability state, not hostnames, paths, repo names, or account + identifiers. +
+
Telemetry product gate review.
+
+
+
+ +
+

Other Explicitness Gaps

+
+
+ □ +
+ Composer “Start from” and linked-work-item picker. +
+ The composer can fetch/link GitHub work items from the selected repo. In the new + model it should inherit or show the task source host/account, then choose a separate + run host when creating the workspace. +
+
+ P0 +
+
+ □ +
+ Task row actions and issue/PR drawers. +
+ Mutations such as assign, comment, review-request, state change, merge, mark viewed, + and create issue need to run under the same explicit provider context that fetched + the row, not whichever host is currently focused later. +
+
+ P0 +
+
+ □ +
+ Linear/Jira issue preview pages. +
+ These currently key heavily off active runtime and provider workspace ids. They need + a durable source host/account context so opening, editing, commenting, and starting a + workspace from an issue stay in the same provider identity. +
+
+ P0 +
+
+ □ +
+ Hosted review / PR helper flows. +
+ Review creation, PR field generation, fix-checks launch, branch-to-PR lookup, and + direct launch from review/task need to carry project-host setup plus provider source + context. Otherwise the action can resolve against the wrong checkout or account. +
+
+ P1 +
+
+ □ +
+ Settings and integrations entry points. +
+ Provider settings should say whether they are client-global, host-scoped, or + project-scoped. Opening settings from Tasks should land on the same host/account + context the user was just looking at. +
+
+ P1 +
+
+ □ +
+ Persisted task UI state and caches. +
+ Recent selections, resume state, work item cache keys, PR detail caches, labels, + assignees, and partial-failure banners should be keyed by provider source context, + not only repo id or active runtime. +
+
+ P1 +
+
+ □ +
+ Automation run history, reuse, and usage attribution. +
+ Runs should store logical project, project-host setup, source provider context, run + host, terminal/session ids, and display labels so history remains clear after host + disconnects, account changes, or workspace deletion. +
+
+ P1 +
+
+ □ +
+ CLI/API selectors. +
+ CLI commands that create workspaces, create automations, start from tasks, or fetch + provider data need explicit --project, --host, and/or + --task-source-host options. Existing --repo selectors can + remain compatibility aliases. +
+
+ P1 +
+
+ □ +
+ Telemetry and diagnostics. +
+ Events/logs should include non-sensitive host kind and capability status so we can + debug routing without recording account names, hostnames, repo paths, or issue text. +
+
+ P2 +
+
+
+ +
+

Subagent Audit Confirmation

+
+

+ A second codebase pass split across Tasks/provider routing, Automations, + Composer/PR launch, and Settings/cache/CLI agreed on the same core gap: + Orca needs an explicit TaskSourceContext separate from the + ProjectHostSetup used to run work. +

+
+
+
+

Tasks/provider routing

+

+ GitHub is partly host-scoped already, but Linear/Jira still route through active + runtime, GitLab is mostly repo-path/local-IPC based, and drawer mutations can lose the + source account. Report: notes/subagent-tasks-provider-hosts-audit.md. +

+
+
+

Automations

+

+ Automations still treat projectId as a repo id and do not snapshot + hostId, projectHostSetupId, provider source context, or + runtime capability/version on runs. Report: + notes/subagent-automations-host-model-audit.md. +

+
+
+

Composer / PR launch

+

+ Start-from flows use one repoId as both source item and run target. + Pending worktree creation stores linked numbers but not source host/account context. + Report: notes/subagent-composer-pr-launch-hosts-audit.md. +

+
+
+

Settings / cache / CLI

+

+ Some host foundations are strong, but GitLab selectors are repo-path-only, provider + diagnostics are still client-global, agent launch overrides are global, and CLI + automation selectors remain repo-first. Report: + notes/subagent-settings-cache-cli-hosts-audit.md. +

+
+
+
+ +
+

Open Product Decisions

+
+
+

Do Tasks allow multi-host aggregation?

+

+ Recommendation: yes for GitHub/GitLab rows when each row can show its serving host, + but default to one selected host for Linear/Jira until cross-host grouping is useful. +

+
+
+

Should SSH provider auth run on the SSH machine?

+

+ Recommendation: eventually yes. In the interim, label desktop-served SSH provider + calls explicitly to avoid misleading users with different accounts. +

+
+
+

Are Remote Servers “better SSH”?

+

+ Recommendation: explain them as richer hosts. SSH gives filesystem/terminal access to + a machine you already manage. Remote Server adds Orca runtime services, durable RPC, + web/mobile handoff, capability/version negotiation, and richer background behavior. +

+
+
+

Do we need host onboarding now?

+

+ Recommendation: no. Existing SSH/server settings can remain the entry point. This work + should make configured hosts behave correctly once present. +

+
+
+
+
+ + + diff --git a/notes/multihost-remote-verification-report.html b/notes/multihost-remote-verification-report.html new file mode 100644 index 00000000000..76635a270ab --- /dev/null +++ b/notes/multihost-remote-verification-report.html @@ -0,0 +1,278 @@ + + + + + Host Context / SSH Verification + + + +
+

Host Context / SSH Verification

+

+ Run on 2026-06-12 against the isolated dev app at CDP 9336, + renderer 127.0.0.1:5176, profile + /tmp/orca-dev-host-context-1781302330, and Docker SSH target + orca-ssh-test on 127.0.0.1:2222. +

+ +
+

Fixes From This Pass

+
    +
  • + Legacy repos whose GitHub upstream 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. +
  • +
  • + 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. +
  • +
  • + Add Project copy now uses host-language for SSH/remote-host create and browse flows: + host folder, host path, and + Browse host filesystem. +
  • +
  • + 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. +
  • +
  • + Switching Run on 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. +
  • +
  • + Automations SSH-source unavailable states no longer ask the user to + Connect SSH when the SSH host is already connected; the UI now says + Source unavailable and offers Retry source. +
  • +
  • + 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. +
  • +
+
+ +
+

Flows Verified

+
    +
  • + Linked local /tmp/orca-host-context-local-project and SSH + /home/orca/orca-host-context-remote-project copies into one logical + github:stablyai/orca project. +
  • +
  • + Created an SSH workspace from the multi-host project: + /home/orca/host-context-ssh-workspace. +
  • +
  • + Created a local workspace from the same multi-host project: + /Users/jinwoohong/orca/workspaces/orca-host-context-local-project/host-context-local-workspace. +
  • +
  • + Disconnected SSH and verified metadata fallback keeps SSH workspaces visible with repaired + logical project ownership, while the UI shows SSH Disconnected and disables + project create controls. +
  • +
  • + Cloned a throwaway Git repo through Add Project on SSH into + /home/orca/add-project-clones/host-context-clone-source; verified remote + filesystem and Orca project-host setup state. +
  • +
  • + Created a new Git project through Add Project on SSH at + /home/orca/add-project-created/host-context-created-project; verified + .git exists and Orca added it as an SSH project-host setup. +
  • +
  • + 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. +
  • +
  • + Started work from GitHub issue #5200, changed Run on from + Local Mac to SSH, confirmed the issue source card stayed selected, then created + /home/orca/task-ssh-source-local-163115 on the SSH host with sidebar + metadata showing Linked issue #5200. +
  • +
  • + Opened Automations with the SSH host connected and Hermes unavailable on the remote + PATH; verified the UI reports Source unavailable with a + Retry source action rather than a misleading SSH connection prompt. +
  • +
  • + 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. +
  • +
+
+ +
+

Validation

+
    +
  • + pnpm vitest run --config config/vitest.config.ts src/main/ipc/worktrees.test.ts + passed: 126 tests. +
  • +
  • + Focused Add Project/sidebar tests passed: 4 files, 35 tests. +
  • +
  • pnpm run typecheck passed.
  • +
  • + pnpm vitest run --config config/vitest.config.ts src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx src/renderer/src/lib/repo-runtime-owner.test.ts + passed: 9 tests. +
  • +
  • + Continued verification added focused Add Project host-selector tests, Automations + branch-routing tests, shared host-model tests, and another full + pnpm run typecheck pass. +
  • +
  • + Docker/SSH verification intentionally used throwaway paths under /home/orca + and an isolated Orca profile; evidence images are uncommitted. +
  • +
+
+ +
+

Screenshots

+
+ Linked local and SSH project +
One logical project after local + SSH setup linking.
+
+
+ Live metadata repair +
Live app after stale SSH worktree metadata repaired to the logical project ID.
+
+
+ Run on host options +
New Workspace: Run on shows exactly Local Mac and the SSH project setup.
+
+
+ Local run host selected +
Selecting Local Mac re-enables local-only sparse checkout controls.
+
+
+ Local workspace created +
Local workspace created from the same multi-host project.
+
+
+ Disconnected SSH fallback +
Disconnected SSH state: visible fallback rows, disabled create control, reconnect dialog.
+
+
+ Add Project SSH selected +
Add Project uses one Host selector for SSH.
+
+
+ Disconnected SSH host disabled in Add Project host picker +
Disconnected SSH hosts stay visible in the Add Project Host picker, but are disabled and muted.
+
+
+ SSH clone screen +
SSH clone screen has editable parent folder and no redundant SSH target field.
+
+
+ SSH clone complete +
SSH clone flow completed and activated the cloned project.
+
+
+ SSH create screen host copy +
Create Project copy now says host folder and Browse host filesystem.
+
+
+ SSH create complete +
SSH create-project flow completed and activated the new project.
+
+
+ Sidebar after Add Project flows +
Sidebar after clone/create SSH projects plus the multi-host project.
+
+
+ Calmer host section headers +
Host grouping view after the host-header visual polish: quiet grouping rows, explicit SSH label, aligned counts.
+
+
+ Tasks source display +
Tasks renders in the multi-host profile after the metadata/render-loop fix.
+
+
+ Task start workspace modal +
Start from task opens New Workspace with source item separate from Run on.
+
+
+ Task start workspace on SSH +
Changing Run on to SSH preserves the GitHub issue source before creation.
+
+
+ Automations SSH source unavailable +
Automations distinguishes a connected SSH host from an unavailable remote automation source.
+
+
+
+ + diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index a2a93b6eec6..83a1bb56f60 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -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 diff --git a/notes/subagent-automations-host-model-audit.md b/notes/subagent-automations-host-model-audit.md new file mode 100644 index 00000000000..fa9d10d76cd --- /dev/null +++ b/notes/subagent-automations-host-model-audit.md @@ -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. + diff --git a/notes/subagent-composer-pr-launch-hosts-audit.md b/notes/subagent-composer-pr-launch-hosts-audit.md new file mode 100644 index 00000000000..9504114a178 --- /dev/null +++ b/notes/subagent-composer-pr-launch-hosts-audit.md @@ -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. diff --git a/notes/subagent-settings-cache-cli-hosts-audit.md b/notes/subagent-settings-cache-cli-hosts-audit.md new file mode 100644 index 00000000000..f464697e8dc --- /dev/null +++ b/notes/subagent-settings-cache-cli-hosts-audit.md @@ -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. diff --git a/notes/subagent-tasks-provider-hosts-audit.md b/notes/subagent-tasks-provider-hosts-audit.md new file mode 100644 index 00000000000..56cc7998b7d --- /dev/null +++ b/notes/subagent-tasks-provider-hosts-audit.md @@ -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. diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index be09b4060e3..077ecc1eb1c 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -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 & { repo?: string @@ -294,10 +299,21 @@ async function resolveDefaultTarget( cwd: string, client: Parameters[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[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 } } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 55ec1e55b61..2325180b61d 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -196,67 +196,6 @@ describe('orca root help', () => { expect(searchHelp).toContain('--query 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 []') - expect(issueHelp).toContain('--comments Include threaded Linear comments') - expect(issueHelp).toContain('--attachments Include attachment metadata and URLs') - expect(issueHelp).toContain('--workspace Connected Linear workspace id') - expect(issueHelp).toContain('--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 ') - expect(searchHelp).toContain('--workspace Connected Linear workspace id, or all') - expect(searchHelp).toContain('--query 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 []') - expect(commentHelp).toContain('--body-file Read Linear body from a file or stdin') - expect(commentHelp).toContain('--write-id 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 ') - 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(() => {}) diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts index ff3071a9372..0143c5cc3bb 100644 --- a/src/cli/specs/automations.ts +++ b/src/cli/specs/automations.ts @@ -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.' diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 553674a9c34..77b3312295a 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -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() diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 58651ceda6e..28ce1e83eb3 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -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() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index d2386b0de99..0b33ca2123a 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -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({ diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index c4b5fb192d8..8982e908fe9 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -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' } diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 84bd75be495..41f601d7835 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -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 { diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index b7fbca39edf..a27b466d90b 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -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 diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index cb92cb73487..ead63c146b2 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index f4a8c55cf8f..acfc0ea2c54 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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 diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 2a5896dc0cc..d64ba8fca4a 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -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: [] }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index e6810e1d147..1598bbe891c 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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 } diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts new file mode 100644 index 00000000000..2b453d36f9e --- /dev/null +++ b/src/main/ipc/gitlab.test.ts @@ -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') + }) +}) diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index c753c1de624..e1106026643 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -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, diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 8a45a89b931..88ddd56502d 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -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() + }) }) diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index fe23909ab5c..69ea3084f39 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -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, diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index ed37fdd2925..154255d25ed 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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.` ) }) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index f3b76844c06..6a0205a061a 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 })}`) + ) } } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 1876dd524f1..8d56eb0dafe 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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([ { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8310e55f629..345fdae04ae 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9f06fe8de99..cefdadbbbf6 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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()) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 43d33579d37..e5b3c6ce3c8 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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: diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 7edacc56fec..acbbf3a9be1 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 77beaa6fe57..1e7a48a905b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index c4143fb32b8..fe918875d22 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -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 }) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index 20824d8f2f8..858e80596d0 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index afd697ecfe7..a9932985a98 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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({ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 273f84e7cf4..a903e947c6c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 — diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index 809545816e5..bc14907d74a 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -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) } diff --git a/src/preload/index.ts b/src/preload/index.ts index f0e5ea7be5d..144e1529462 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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') }, diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 34919342cee..71f87a0c9c4 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -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, @@ -37,6 +38,8 @@ import { Pencil, Plus, RefreshCw, + Send, + Settings, UndoDot, Users, Wrench, @@ -83,11 +86,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 { createGitHubChecksTabState, resolveGitHubChecksTabState, @@ -137,9 +135,6 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' -import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' -import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' -import { GitHubIssueCommentComposer } from '@/components/github/GitHubIssueCommentComposer' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { getGitHubPRReviewerRows, @@ -172,13 +167,12 @@ import type { PRCheckDetail, PRComment } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' const IS_MAC = navigator.userAgent.includes('Mac') -const IS_WINDOWS = !IS_MAC && navigator.userAgent.includes('Windows') -const WINDOWS_WINDOW_CONTROLS_WIDTH = '138px' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -273,6 +267,7 @@ type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null repoId?: string | null + sourceContext?: TaskSourceContext | null initialTab?: ItemDialogTab variant?: 'sheet' | 'page' backLabel?: string @@ -437,11 +432,13 @@ function PRReviewersPanel({ item, loading, repoPath, + sourceContext, onReviewersRequested }: { item: GitHubWorkItem loading: boolean repoPath: string | null + sourceContext?: TaskSourceContext | null onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const [open, setOpen] = useState(false) @@ -460,8 +457,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) @@ -707,6 +704,7 @@ function PRReviewersPanel({ : await window.api.gh.requestPRReviewers({ repoPath: repoPath ?? '', repoId: item.repoId, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -782,6 +780,7 @@ function PRReviewersPanel({ : await window.api.gh.removePRReviewers({ repoPath: repoPath ?? '', repoId: item.repoId, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -1415,6 +1414,7 @@ function getPRFileContentCacheKey(args: { function loadPRFileContents(args: { repoPath: string repoId: string + sourceContext?: TaskSourceContext | null prNumber: number file: GitHubPRFile headSha: string @@ -1430,6 +1430,7 @@ function loadPRFileContents(args: { .prFileContents({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, path: args.file.path, oldPath: args.file.oldPath, @@ -1452,6 +1453,7 @@ function loadPRFileContents(args: { function addIssueCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -1459,6 +1461,7 @@ function addIssueCommentForRepo(args: { return window.api.gh.addIssueComment({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, number: args.number, body: args.body, type: args.type @@ -1468,6 +1471,7 @@ function addIssueCommentForRepo(args: { function addPRReviewCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number commitId: string path: string @@ -1478,6 +1482,7 @@ function addPRReviewCommentForRepo(args: { return window.api.gh.addPRReviewComment({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, commitId: args.commitId, path: args.path, @@ -1490,6 +1495,7 @@ function addPRReviewCommentForRepo(args: { function addPRReviewCommentReplyForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number commentId: number body: string @@ -1500,6 +1506,7 @@ function addPRReviewCommentReplyForRepo(args: { return window.api.gh.addPRReviewCommentReply({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, commentId: args.commentId, body: args.body, @@ -1512,6 +1519,7 @@ function addPRReviewCommentReplyForRepo(args: { function setPRFileViewedForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number pullRequestId: string path: string @@ -1520,6 +1528,7 @@ function setPRFileViewedForRepo(args: { return window.api.gh.setPRFileViewed({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, prNumber: args.prNumber, pullRequestId: args.pullRequestId, path: args.path, @@ -1530,12 +1539,14 @@ function setPRFileViewedForRepo(args: { function getWorkItemDetailsForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null number: number type: 'issue' | 'pr' }): Promise<GitHubWorkItemDetails | null> { return window.api.gh.workItemDetails({ repoPath: args.repoPath, repoId: args.repoId, + sourceContext: args.sourceContext, number: args.number, type: args.type }) @@ -1668,6 +1679,7 @@ type PRFilesCombinedDiffViewerProps = { comments: PRComment[] repoPath: string repoId: string + sourceContext?: TaskSourceContext | null prNumber: number prUrl: string headSha: string | undefined @@ -1682,6 +1694,7 @@ function PRFilesCombinedDiffViewer({ comments, repoPath, repoId, + sourceContext, prNumber, prUrl, headSha, @@ -1848,6 +1861,7 @@ function PRFilesCombinedDiffViewer({ const contents = await loadPRFileContents({ repoPath, repoId, + sourceContext, prNumber, file, headSha, @@ -2028,6 +2042,7 @@ function PRFilesCombinedDiffViewer({ const result = await addPRReviewCommentForRepo({ repoPath, repoId, + sourceContext, prNumber, commitId: headSha, path: section.path, @@ -2051,7 +2066,7 @@ function PRFilesCombinedDiffViewer({ ) return true }, - [headSha, onCommentAdded, prNumber, repoId, repoPath] + [headSha, onCommentAdded, prNumber, repoId, repoPath, sourceContext] ) const renderViewedCheckbox = useCallback( @@ -2197,6 +2212,7 @@ function CommentCodeContext({ comment, repoPath, repoId, + sourceContext, prNumber, files, headSha, @@ -2205,6 +2221,7 @@ function CommentCodeContext({ comment: PRComment repoPath: string | null repoId: string + sourceContext?: TaskSourceContext | null prNumber: number files: GitHubPRFile[] headSha: string | undefined @@ -2229,7 +2246,7 @@ function CommentCodeContext({ return } let cancelled = false - loadPRFileContents({ repoPath, repoId, prNumber, file, headSha, baseSha }) + loadPRFileContents({ repoPath, repoId, sourceContext, prNumber, file, headSha, baseSha }) .then((result) => { if (!cancelled) { setContents(result) @@ -2243,7 +2260,7 @@ function CommentCodeContext({ return () => { cancelled = true } - }, [baseSha, file, headSha, line, prNumber, repoId, repoPath]) + }, [baseSha, file, headSha, line, prNumber, repoId, repoPath, sourceContext]) const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( contextExpansionState, @@ -2497,6 +2514,7 @@ function CommentCodeContext({ function ConversationTab({ item, repoPath, + sourceContext, body, comments, files, @@ -2517,6 +2535,7 @@ function ConversationTab({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null body: string comments: PRComment[] files: GitHubPRFile[] @@ -2580,6 +2599,7 @@ function ConversationTab({ await runWorkItemBodyUpdate({ item, repoPath, + sourceContext, projectOrigin, body: resolvedBodyDraft, parsedSlug: bodySlug @@ -2610,7 +2630,8 @@ function ConversationTab({ item, onBodyUpdated, projectOrigin, - repoPath + repoPath, + sourceContext ]) const handleReply = useCallback( @@ -2629,6 +2650,7 @@ function ConversationTab({ ? await addPRReviewCommentReplyForRepo({ repoPath, repoId: item.repoId, + sourceContext, prNumber: item.number, commentId: comment.id, body: replyBody, @@ -2639,6 +2661,7 @@ function ConversationTab({ : await addIssueCommentForRepo({ repoPath, repoId: item.repoId, + sourceContext, number: item.number, body: `@${comment.author} ${replyBody}`, type: item.type @@ -2656,7 +2679,7 @@ function ConversationTab({ toast.success(translate('auto.components.GitHubItemDialog.10f4ff5be8', 'Reply posted.')) return true }, - [item.number, item.repoId, item.type, onCommentAdded, repoPath] + [item.number, item.repoId, item.type, onCommentAdded, repoPath, sourceContext] ) const rightPanel = @@ -2666,6 +2689,7 @@ function ConversationTab({ item={item} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} onStateChange={onStateChange} @@ -2675,6 +2699,7 @@ function ConversationTab({ item={item} loading={loading} repoPath={repoPath} + sourceContext={sourceContext} onReviewersRequested={onReviewersRequested} /> <aside className="overflow-hidden rounded-lg border border-border/50 bg-card/50 shadow-xs"> @@ -2682,6 +2707,7 @@ function ConversationTab({ item={item} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} headSha={headSha} checks={checks} loading={loading || !detailsLoaded} @@ -2695,12 +2721,12 @@ function ConversationTab({ <div key={comment.id} className={cn( - 'min-w-0 overflow-hidden rounded-md border border-border/60 bg-card shadow-xs', - isReply && 'ml-8 max-w-[calc(100%-2rem)] border-l-2 border-l-border/80', + 'min-w-0 overflow-hidden rounded-lg border border-border/40 bg-card/50 shadow-xs', + isReply && 'ml-6 max-w-[calc(100%-1.5rem)]', comment.isResolved && PR_COMMENT_RESOLVED_CONTAINER_CLASS )} > - <div className="flex min-w-0 items-center gap-2 border-b border-border/50 bg-muted/20 px-3 py-2"> + <div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2"> {comment.authorAvatarUrl ? ( <img src={comment.authorAvatarUrl} @@ -2787,6 +2813,7 @@ function ConversationTab({ comment={comment} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} prNumber={item.number} files={files} headSha={headSha} @@ -2978,9 +3005,9 @@ function ConversationTab({ {detailsLoaded ? ( <> - <div className="flex items-center gap-2 border-b border-border/40 pb-2 pt-1"> + <div className="flex items-center gap-2 pt-1"> <MessageSquare className="size-4 text-muted-foreground" /> - <span className="text-[13px] font-semibold text-foreground"> + <span className="text-[13px] font-medium text-foreground"> {translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} </span> {comments.length > 0 && ( @@ -3014,13 +3041,13 @@ function ConversationTab({ )} {comments.length === 0 ? ( - <p className="px-1 py-2 text-[13px] text-muted-foreground"> + <div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground"> {translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')} - </p> + </div> ) : visibleComments.length === 0 ? ( - <p className="px-1 py-2 text-center text-[13px] text-muted-foreground"> + <div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-center text-[13px] text-muted-foreground"> {getPRCommentAudienceEmptyLabel(commentFilter)} - </p> + </div> ) : ( <div className="flex min-w-0 flex-col gap-3"> {visibleCommentGroups.map(renderCommentGroup)} @@ -3030,19 +3057,14 @@ function ConversationTab({ ) : null} {detailsLoaded && repoPath && ( - <GitHubIssueCommentComposer - className="mt-2" + <GHCommentComposer + className="mt-1" repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} issueNumber={item.number} itemType={item.type} - itemState={localState} - itemId={item.id} - projectOrigin={projectOrigin} - previewGithubRepo={markdownGitHubRepo} onCommentAdded={onCommentAdded} - onStateChange={onStateChange} - onMutated={onMutated} /> )} </div> @@ -3056,6 +3078,7 @@ function PRActionsPanel({ item, repoPath, repoId, + sourceContext, projectOrigin, localState, onStateChange, @@ -3064,6 +3087,7 @@ function PRActionsPanel({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined localState: GitHubWorkItem['state'] onStateChange: (state: GitHubWorkItem['state']) => void @@ -3134,6 +3158,7 @@ function PRActionsPanel({ await runPullRequestStateUpdate({ repoPath, repoId, + sourceContext, projectOrigin, number: item.number, updates: { state: nextState } @@ -3184,6 +3209,7 @@ function PRActionsPanel({ const result = await window.api.gh.mergePR({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, method, prRepo: item.prRepo ?? null @@ -3215,6 +3241,7 @@ function PRActionsPanel({ const result = await window.api.gh.setPRAutoMerge({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, enabled, prRepo: item.prRepo ?? null @@ -3253,7 +3280,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> @@ -3262,8 +3289,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' )} > @@ -3272,13 +3298,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> @@ -3324,8 +3348,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' )} @@ -3339,11 +3362,9 @@ function PRActionsPanel({ ) : ( <CircleDot className="size-3.5" /> )} - <span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}> - {nextState === 'closed' - ? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request') - : translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')} - </span> + {nextState === 'closed' + ? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request') + : translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')} </Button> </div> </aside> @@ -3556,6 +3577,7 @@ function ChecksTab({ item, repoPath, repoId, + sourceContext, headSha, checks, loading, @@ -3565,6 +3587,7 @@ function ChecksTab({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null headSha: string | undefined checks: GitHubWorkItemDetails['checks'] loading: boolean @@ -3626,6 +3649,7 @@ function ChecksTab({ const nextChecks = (await window.api.gh.prChecks({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, noCache: true @@ -3643,7 +3667,7 @@ function ChecksTab({ } finally { setRefreshing(false) } - }, [headSha, item.number, onChecksUpdated, repoId, repoPath]) + }, [headSha, item.number, onChecksUpdated, repoId, repoPath, sourceContext]) const handleRerun = useCallback( async (failedOnly: boolean): Promise<void> => { @@ -3655,6 +3679,7 @@ function ChecksTab({ const result = await window.api.gh.rerunPRChecks({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, failedOnly @@ -3679,7 +3704,7 @@ function ChecksTab({ setRerunning(false) } }, - [handleRefresh, headSha, item.number, rerunning, repoId, repoPath] + [handleRefresh, headSha, item.number, rerunning, repoId, repoPath, sourceContext] ) const handleFixBrokenChecks = useCallback(async (): Promise<void> => { @@ -4303,6 +4328,7 @@ function getGitHubMutationSettings(repoId: string | null | undefined) { async function runIssueUpdate(args: { repoPath: string | null repoId?: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined number: number updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates'] @@ -4335,6 +4361,7 @@ async function runIssueUpdate(args: { const res = await window.api.gh.updateIssue({ repoPath: args.repoPath, repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, number: args.number, updates: args.updates }) @@ -4346,6 +4373,7 @@ async function runIssueUpdate(args: { async function runWorkItemBodyUpdate(args: { item: GitHubWorkItem repoPath: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined body: string parsedSlug: GitHubOwnerRepo | null @@ -4382,6 +4410,7 @@ async function runWorkItemBodyUpdate(args: { await runIssueUpdate({ repoPath: args.repoPath, repoId: args.item.repoId, + sourceContext: args.sourceContext, projectOrigin: args.projectOrigin, number: args.item.number, updates: { body: args.body } @@ -4391,6 +4420,7 @@ async function runWorkItemBodyUpdate(args: { async function runPullRequestStateUpdate(args: { repoPath: string | null repoId?: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined number: number updates: { state: 'open' | 'closed' } @@ -4423,6 +4453,7 @@ async function runPullRequestStateUpdate(args: { const res = await window.api.gh.updatePRState({ repoPath: args.repoPath, repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, prNumber: args.number, updates: args.updates }) @@ -4431,10 +4462,44 @@ async function runPullRequestStateUpdate(args: { } } +function GitHubLabelsSettingsLink({ + url, + separated, + onOpen +}: { + url: string | null + separated?: boolean + onOpen?: () => void +}): React.JSX.Element | null { + if (!url) { + return null + } + + return ( + <div className={cn(separated && 'mt-1 border-t border-border/60 pt-1')}> + <button + type="button" + onClick={() => { + onOpen?.() + void window.api.shell.openUrl(url) + }} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-accent hover:text-accent-foreground" + > + <Settings className="size-3.5 shrink-0" /> + <span className="min-w-0 flex-1 text-left"> + {translate('auto.components.GitHubItemDialog.2aa9acdf34', 'Edit labels on GitHub')} + </span> + <ExternalLink className="size-3 shrink-0 opacity-70" /> + </button> + </div> + ) +} + function GHEditSection({ item, repoPath, repoId, + sourceContext, projectOrigin, localState, localLabels, @@ -4450,6 +4515,7 @@ function GHEditSection({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined localState: GitHubWorkItem['state'] localLabels: string[] @@ -4540,6 +4606,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { state: newState } @@ -4569,6 +4636,7 @@ function GHEditSection({ item.repoId, localState, repoPath, + sourceContext, projectOrigin, patchWorkItem, patchProjectRowIfNeeded, @@ -4590,6 +4658,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { addLabels: [label] } @@ -4616,6 +4685,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { removeLabels: [label] } @@ -4644,6 +4714,7 @@ function GHEditSection({ item.repoId, localLabels, repoPath, + sourceContext, projectOrigin, patchWorkItem, patchProjectRowIfNeeded, @@ -4670,6 +4741,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { removeAssignees: [login] } @@ -4694,6 +4766,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { addAssignees: [login] } @@ -4719,6 +4792,7 @@ function GHEditSection({ item.repoId, assigneesItemKey, repoPath, + sourceContext, projectOrigin, localAssignees, patchProjectRowIfNeeded, @@ -4731,6 +4805,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> + ) + if (layout === 'sidebar') { return ( <aside className="flex flex-col gap-5 text-[13px]"> @@ -4812,14 +4898,41 @@ function GHEditSection({ className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end" > - <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 text-left"> + <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> </div> @@ -4878,15 +4991,39 @@ function GHEditSection({ className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end" > - <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> + ) : null} + {!repoLabels.error ? ( + <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> + ) : null} + <GitHubLabelsSettingsLink + url={repositoryLabelsUrl} + separated={!repoLabels.error && repoLabels.data.length > 0} + onOpen={() => setLabelPopoverOpen(false)} /> </PopoverContent> </Popover> @@ -4921,12 +5058,12 @@ function GHEditSection({ ) : null} {hasAttachedWorkspace ? ( <DropdownMenu modal={false}> - <ButtonGroup className="inline-flex w-auto max-w-full"> + <ButtonGroup className="w-full"> <Button type="button" size="sm" onClick={handleOpenOrUseWorkspace} - className="gap-1.5" + className="flex-1 gap-1.5" aria-label={translate( 'auto.components.GitHubItemDialog.84855fedd0', 'Open workspace attached to issue' @@ -5047,15 +5184,39 @@ 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> + ) : null} + {!repoLabels.error ? ( + <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> + ) : null} + <GitHubLabelsSettingsLink + url={repositoryLabelsUrl} + separated={!repoLabels.error && repoLabels.data.length > 0} + onOpen={() => setLabelPopoverOpen(false)} /> </PopoverContent> </Popover> @@ -5087,14 +5248,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> @@ -5161,6 +5349,99 @@ function GHEditSection({ ) } +function GHCommentComposer({ + className, + repoPath, + repoId, + sourceContext, + issueNumber, + itemType, + onCommentAdded +}: { + className?: string + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + issueNumber: number + itemType: 'issue' | 'pr' + onCommentAdded: (comment: PRComment) => void +}): React.JSX.Element { + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const mountedRef = useMountedRef() + + const handleSubmit = useCallback(async () => { + const trimmed = body.trim() + if (!trimmed) { + return + } + setSubmitting(true) + try { + const result = await addIssueCommentForRepo({ + repoPath, + repoId: repoId ?? undefined, + sourceContext, + number: issueNumber, + body: trimmed, + type: itemType + }) + if (!mountedRef.current) { + return + } + if (result.ok) { + setBody('') + // Why: use the comment returned by GitHub so the optimistic row shows + // the real login/avatar immediately instead of waiting for a reopen. + onCommentAdded(result.comment) + } else { + toast.error( + result.error ?? + translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') + ) + } + } catch (err) { + if (mountedRef.current) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') + ) + } + } finally { + if (mountedRef.current) { + setSubmitting(false) + } + } + }, [body, mountedRef, repoPath, repoId, sourceContext, issueNumber, itemType, onCommentAdded]) + + return ( + <div className={cn('flex flex-col items-start gap-2', className)}> + <GitHubMarkdownComposer + value={body} + onChange={setBody} + placeholder={translate('auto.components.GitHubItemDialog.c5c117270e', 'Add a comment…')} + disabled={submitting} + minHeightClassName="min-h-28" + className="w-full" + onSubmitShortcut={() => void handleSubmit()} + /> + <Button + onClick={handleSubmit} + disabled={!body.trim() || submitting} + className="gap-2" + aria-label={translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} + > + {submitting ? ( + <LoaderCircle className="size-3.5 animate-spin" /> + ) : ( + <Send className="size-3.5" /> + )} + {translate('auto.components.GitHubItemDialog.bf43425540', 'Comment')} + </Button> + </div> + ) +} + // Why: the dialog doesn't carry the resolved PR-source slug the Tasks view's // list cache carries, so we reach into workItemsCache to recover it. We scope // the lookup to the dialog's own `repoPath` via the public @@ -5229,6 +5510,7 @@ export default function GitHubItemDialog({ workItem, repoPath, repoId, + sourceContext, initialTab, variant = 'sheet', backLabel = 'Back', @@ -5465,6 +5747,7 @@ export default function GitHubItemDialog({ getWorkItemDetailsForRepo({ repoPath, repoId: effectiveRepoId ?? undefined, + sourceContext, number: workItem.number, type: workItem.type }) @@ -5525,7 +5808,7 @@ export default function GitHubItemDialog({ error: message }) }) - }, [repoPath, effectiveRepoId, workItem, detailsCacheKey, initialTab, refetchTick]) + }, [repoPath, effectiveRepoId, sourceContext, workItem, detailsCacheKey, initialTab, refetchTick]) const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot const displayWorkItem = useMemo<GitHubWorkItem | null>(() => { @@ -5659,6 +5942,7 @@ export default function GitHubItemDialog({ const ok = await setPRFileViewedForRepo({ repoId: workItem.repoId, repoPath, + sourceContext, prNumber: workItem.number, pullRequestId: details.pullRequestId, path, @@ -5685,20 +5969,13 @@ export default function GitHubItemDialog({ }) } }, - [details?.pullRequestId, detailsCacheKey, repoPath, workItem] + [details?.pullRequestId, detailsCacheKey, repoPath, sourceContext, workItem] ) const isIssuePage = variant === 'page' && workItem?.type === 'issue' const ownerRepo = workItem ? parseOwnerRepoFromItemUrl(workItem.url) : null const issueStateBadgeTone = localState === 'closed' ? 'bg-rose-600 text-white' : 'bg-emerald-600 text-white' - const sheetHeaderStyle = useMemo( - () => - variant === 'sheet' && IS_WINDOWS - ? ({ paddingRight: `calc(1rem + ${WINDOWS_WINDOW_CONTROLS_WIDTH})` } as const) - : undefined, - [variant] - ) const content = workItem ? ( <div className="flex h-full min-h-0 flex-col"> @@ -5899,12 +6176,7 @@ export default function GitHubItemDialog({ </div> </> ) : ( - <div - className="flex-none border-b border-border/60 bg-card/80 px-4 py-3 shadow-xs backdrop-blur supports-[backdrop-filter]:bg-card/70" - // Why: this sheet portals outside the app root, so it cannot inherit - // the Windows titlebar inset variable that keeps header buttons clear. - style={sheetHeaderStyle} - > + <div className="flex-none border-b border-border/60 bg-card/80 px-4 py-3 shadow-xs backdrop-blur supports-[backdrop-filter]:bg-card/70"> <div className="flex items-start gap-3"> {variant === 'page' ? ( <Button @@ -6057,6 +6329,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} localLabels={localLabels} @@ -6095,6 +6368,7 @@ export default function GitHubItemDialog({ item={displayWorkItem ?? workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} body={body} comments={comments} files={files} @@ -6145,6 +6419,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} localLabels={localLabels} @@ -6213,6 +6488,7 @@ export default function GitHubItemDialog({ item={displayWorkItem ?? workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} body={body} comments={comments} files={files} @@ -6264,6 +6540,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} headSha={details?.headSha} checks={checks} loading={loading || !detailsLoaded} @@ -6294,6 +6571,7 @@ export default function GitHubItemDialog({ comments={comments} repoPath={repoPath ?? ''} repoId={effectiveRepoId ?? ''} + sourceContext={sourceContext} prNumber={workItem.number} prUrl={workItem.url} headSha={details?.headSha} diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index efd9848a0df..299623275c1 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -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 diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index a5b68b7395e..a41857ab1ef 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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 } : {}), diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index b3873e7c8c2..15cedfc816d 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -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> diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index ced24286a1d..4f6a4f9eaf4 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -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> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index d2a7520982e..95475f154f3 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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) diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index 3bb37a76061..1ab6f87c4fa 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -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')} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index b6007af94bb..686d8d3f469 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -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)} diff --git a/src/renderer/src/components/automations/CreateFromPicker.test.tsx b/src/renderer/src/components/automations/CreateFromPicker.test.tsx new file mode 100644 index 00000000000..46cb4ded084 --- /dev/null +++ b/src/renderer/src/components/automations/CreateFromPicker.test.tsx @@ -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() + }) +}) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index e116564cbdd..580741eeb54 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -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({ diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts new file mode 100644 index 00000000000..71a575ffa85 --- /dev/null +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts new file mode 100644 index 00000000000..47c1d96b6d9 --- /dev/null +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -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 } +} diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 14b39b71735..238f97b3681 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -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(() => { diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index d1c6dcd9a2e..cf17adfd719 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -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 diff --git a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx index b1703d7e286..61e5ee91c31 100644 --- a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -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 + ) ) } diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index f40bf8b3926..44bab69dc65 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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( diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index fd0ae5857fb..aba828f247e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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> ) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index fcbe66d40fd..4051469e0c0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -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> diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index e1a496c7619..6b8d2fdec1a 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -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 })] diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 650d85b231d..08121326730 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -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"> diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 45aa8d3af78..34fa5eae8ad 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -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> diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 00dca53528e..0a33fba4db6 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -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> diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index 01164d6657b..22e8306262d 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -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} /> diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx index 347ba3d05d3..4405810eca3 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -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( diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index 10f897625dd..2c9f2012d99 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -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} /> diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index acba1735bc0..872fb4469ef 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -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" > diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 3dccec319e6..6f5f771379c 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -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"> diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index 92f2c64baed..60d10e33add 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index aae0fad4017..fd7fe4eae57 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -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" diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index afdcc9a1435..bec514251ab 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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 diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index bd27b1cc8ed..a7e97b1ac05 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -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) => { diff --git a/src/renderer/src/components/settings/provider-account-scope.test.ts b/src/renderer/src/components/settings/provider-account-scope.test.ts new file mode 100644 index 00000000000..c0eeb50c5d9 --- /dev/null +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -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.' + }) + }) +}) diff --git a/src/renderer/src/components/settings/provider-account-scope.ts b/src/renderer/src/components/settings/provider-account-scope.ts new file mode 100644 index 00000000000..147128bf6e4 --- /dev/null +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -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.' + } +} diff --git a/src/renderer/src/components/settings/setting-ownership.test.ts b/src/renderer/src/components/settings/setting-ownership.test.ts new file mode 100644 index 00000000000..49439d414f8 --- /dev/null +++ b/src/renderer/src/components/settings/setting-ownership.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/settings/setting-ownership.ts b/src/renderer/src/components/settings/setting-ownership.ts new file mode 100644 index 00000000000..d18949443ad --- /dev/null +++ b/src/renderer/src/components/settings/setting-ownership.ts @@ -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] +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 10449193110..1d99e344431 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -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') }) }) diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index 187a739fcdb..40b7d2aa583 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -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 -} diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx index e7cbe7ada49..5b7b95c8468 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx @@ -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 }) }) diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx index ae308733345..12a6ae4222b 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx @@ -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 } ) diff --git a/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx index 4d78029c304..19d78a6f445 100644 --- a/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx @@ -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') } diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx index 4b592ad1a46..32fd5c88fba 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx @@ -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't installed, so a plain folder is the default.') }) it('disables create while an auto-filled parent belongs to a previous target', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 9f9b2e2db0c..2c75a2222f9 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -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> </> ) diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 12f8b17ac73..152f5108839 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -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> ) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index a05e2f6c56e..3240130da49 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -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') }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index b02ba98b115..d9e01804e6c 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -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} /> diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx index 6be72485fe2..470e81b0512 100644 --- a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx index 7c61054a1d6..a5fdf9ffc7d 100644 --- a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx @@ -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 ? ( diff --git a/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx b/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx index 45dc41f662f..736cd8cb631 100644 --- a/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx @@ -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> diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index 0cc003efd97..2b544fea65e 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx index bb6f931e685..9baf5373a7e 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx @@ -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)} diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index cb1bb949010..5ab96be88ca 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx index c515e0b881e..adca84a5230 100644 --- a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx +++ b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx @@ -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> diff --git a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx index 7d92e5057cc..eb513c698ee 100644 --- a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx +++ b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx @@ -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' diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index b291a3329b7..254c7490ca6 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -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', { diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx index 2abaebe4365..f05809ce11a 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx @@ -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> diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index bb1dd1878de..18027cbbcae 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -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> diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx index 77a3bf955dc..3ce6db39e76 100644 --- a/src/renderer/src/components/sidebar/SshTargetRow.tsx +++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx @@ -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. diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 3d28042cce2..ea0ebc9cb00 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 83db13782f7..f7e7ed28a7e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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), diff --git a/src/renderer/src/components/sidebar/add-repo-host-availability.ts b/src/renderer/src/components/sidebar/add-repo-host-availability.ts index 27f5156ad2b..63019f298c3 100644 --- a/src/renderer/src/components/sidebar/add-repo-host-availability.ts +++ b/src/renderer/src/components/sidebar/add-repo-host-availability.ts @@ -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' } diff --git a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts index 8dd50fe75c5..b5e7e3edfe9 100644 --- a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts +++ b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts @@ -37,14 +37,26 @@ export function getAddRepoLocalStartActions({ const primaryAction = { kind: 'browse' as const, icon: FolderOpen, - title: translate( - 'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7', - 'Browse folder' - ), - description: translate( - 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', - 'Local project, Git repo, or folder with many repos' - ), + title: + browseHostKind === 'ssh' + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle', + 'Open project on SSH host' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7', + 'Browse folder' + ), + description: + browseHostKind === 'ssh' + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription', + 'Existing Git repository or folder on this SSH host' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', + 'Local project, Git repo, or folder with many repos' + ), onClick: onBrowse } @@ -53,11 +65,11 @@ export function getAddRepoLocalStartActions({ icon: Monitor, title: translate( 'auto.components.sidebar.add.repo.local.start.actions.3d162cc76f', - 'Remote project' + 'Project on SSH host' ), description: translate( 'auto.components.sidebar.add.repo.local.start.actions.a6c20dca96', - 'Open a project from an SSH target' + 'Open a project folder from an SSH host' ), onClick: onOpenRemoteStep } @@ -78,13 +90,19 @@ export function getAddRepoLocalStartActions({ kind: 'create' as const, icon: Plus, title: translate( - 'auto.components.sidebar.add.repo.local.start.actions.createProjectTitle', - 'Create project' - ), - description: translate( - 'auto.components.sidebar.add.repo.local.start.actions.createGitProjectDescription', - 'Create a local Git repository' + 'auto.components.sidebar.add.repo.local.start.actions.c709860596', + 'Create new project' ), + description: canCreateProject + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.d72789705e', + 'Start from an empty folder' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.sshCreateUnavailable', + 'Not available for SSH hosts yet' + ), + disabled: !canCreateProject, onClick: onOpenCreateStep } diff --git a/src/renderer/src/components/sidebar/create-project-defaults.test.ts b/src/renderer/src/components/sidebar/create-project-defaults.test.ts index 4691b605081..6b1a7204fb8 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.test.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.test.ts @@ -85,6 +85,20 @@ describe('create project defaults', () => { defaultParent: '', runtimeEnvironmentId: 'env-1' }) - ).toBe('server folder not selected') + ).toBe('host folder not selected') + expect( + formatCreateProjectParentSummary({ + parent: '/Users/alice/orca/projects', + defaultParent: '/Users/alice/orca/projects', + isRemoteHost: true + }) + ).toBe('/Users/alice/orca/projects') + expect( + formatCreateProjectParentSummary({ + parent: '', + defaultParent: '', + isRemoteHost: true + }) + ).toBe('host folder not selected') }) }) diff --git a/src/renderer/src/components/sidebar/create-project-defaults.ts b/src/renderer/src/components/sidebar/create-project-defaults.ts index e5df269a967..fd5175c8c22 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.ts @@ -68,20 +68,22 @@ export function formatCreateProjectParentSummary({ parent, defaultParent, runtimeEnvironmentId, + isRemoteHost, missingLocationLabel = 'location not selected', - missingServerLocationLabel = 'server folder not selected' + missingServerLocationLabel = 'host folder not selected' }: { parent: string defaultParent: string runtimeEnvironmentId?: string | null + isRemoteHost?: boolean missingLocationLabel?: string missingServerLocationLabel?: string }): string { const trimmedParent = parent.trim() if (!trimmedParent) { - return runtimeEnvironmentId ? missingServerLocationLabel : missingLocationLabel + return runtimeEnvironmentId || isRemoteHost ? missingServerLocationLabel : missingLocationLabel } - if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId) { + if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId && !isRemoteHost) { return '~/orca/projects' } return trimmedParent diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index e1440c4be5a..fa1e1fab963 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -13,9 +13,11 @@ import type { GitLabWorkItem, LinearIssue, ProjectGroup, - Repo + Repo, + TuiAgent } from '../../../../shared/types' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import { translate } from '@/i18n/i18n' const EMPTY_REPOS: Repo[] = [] @@ -114,3 +116,12 @@ export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSumm export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { return buildLinearIssueLinkedWorkItem(issue) } + +export function getFolderWorkspacePrimaryActionLabel(quickAgent: TuiAgent | null): string { + return quickAgent + ? translate( + 'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart', + 'Create & Start Agent' + ) + : translate('auto.components.sidebar.FolderWorkspaceComposerDialog.create', 'Create Workspace') +} diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 40ee26fe44b..d51dd2c2924 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -1,9 +1,8 @@ -import React, { useEffect, useMemo } from 'react' +import React, { useEffect } from 'react' import { useAppStore } from '@/store' import { TooltipProvider } from '@/components/ui/tooltip' import { useSidebarResize } from '@/hooks/useSidebarResize' import SidebarHeader from './SidebarHeader' -import ProjectOrderManualDefaultNotice from './ProjectOrderManualDefaultNotice' import SidebarNav from './SidebarNav' import SetupScriptPromptCard from './SetupScriptPromptCard' import WorktreeList from './WorktreeList' @@ -14,8 +13,6 @@ import { cn } from '@/lib/utils' import { FolderPlus, Loader2 } from 'lucide-react' import { useSidebarProjectDrop } from './useSidebarProjectDrop' import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel' -import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' -import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog')) const NonGitFolderDialog = React.lazy(() => import('./NonGitFolderDialog')) @@ -45,15 +42,9 @@ function Sidebar({ const sidebarWidth = useAppStore((s) => s.sidebarWidth) const setSidebarWidth = useAppStore((s) => s.setSidebarWidth) const repos = useAppStore((s) => s.repos) - const settings = useAppStore((s) => s.settings) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) const activeModal = useAppStore((s) => s.activeModal) const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop() - const systemPrefersDark = useSystemPrefersDark() - const leftSidebarStyle = useMemo( - () => resolveLeftSidebarStyleVariables(settings, systemPrefersDark), - [settings, systemPrefersDark] - ) as React.CSSProperties | undefined const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = React.useState(false) const unmountAddRepoDialogTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null) const { @@ -124,7 +115,6 @@ function Sidebar({ ref={containerRef} data-native-file-drop-target={sidebarOpen ? nativeDropTarget : undefined} className="relative min-h-0 flex-shrink-0 bg-worktree-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent" - style={leftSidebarStyle} {...dropHandlers} > {sidebarOpen && ( @@ -132,7 +122,6 @@ function Sidebar({ {/* Fixed controls */} <SidebarNav /> <SidebarHeader onWorkspaceBoardMenuOpenChange={setWorkspaceBoardMenuOpen} /> - <ProjectOrderManualDefaultNotice /> <WorktreeList scrollOffsetRef={worktreeScrollOffsetRef} @@ -192,7 +181,6 @@ function Sidebar({ </React.Suspense> {sidebarOpen ? ( <WorkspaceKanbanDrawer - leftSidebarStyle={leftSidebarStyle} open={workspaceBoardOpen} preserveOpenForMenu={workspaceBoardMenuOpen} onOpenChange={handleWorkspaceBoardOpenChange} diff --git a/src/renderer/src/components/sidebar/project-header-drag.test.ts b/src/renderer/src/components/sidebar/project-header-drag.test.ts index ff251e7da65..b53a4e868bc 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.test.ts @@ -1,32 +1,7 @@ // @vitest-environment happy-dom -import { act, createElement } from 'react' -import { createRoot } from 'react-dom/client' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' -import { - isProjectHeaderDragHandleTarget, - isRepoHeaderActionTarget, - useRepoHeaderDrag -} from './project-header-drag' -import type { Repo } from '../../../../shared/types' - -function createRepo(id: string, projectGroupId: string | null = null): Repo { - return { - id, - path: `/tmp/${id}`, - displayName: id, - badgeColor: '#000000', - addedAt: 0, - projectGroupId, - projectGroupOrder: 0 - } -} - -function createPointerEvent(type: string, init: MouseEventInit & { pointerId: number }): Event { - const event = new MouseEvent(type, { bubbles: true, ...init }) - Object.defineProperty(event, 'pointerId', { value: init.pointerId }) - return event -} +import { isRepoHeaderActionTarget } from './project-header-drag' function createHeader(markup: string): HTMLElement { const header = document.createElement('div') @@ -60,95 +35,3 @@ describe('repo header action targets', () => { expect(isRepoHeaderActionTarget(header, header)).toBe(false) }) }) - -describe('project header drag handle targets', () => { - it('accepts pointer events on the project name handle', () => { - const header = createHeader(` - <span data-repo-header-drag-handle="" id="handle">Orca</span> - <span id="chevron"></span> - `) - - const handle = header.querySelector('#handle') as HTMLElement - expect(isProjectHeaderDragHandleTarget(handle, handle)).toBe(true) - }) - - it('rejects pointer events outside the project name handle', () => { - const header = createHeader(` - <span data-repo-header-drag-handle="" id="handle">Orca</span> - <span id="chevron"></span> - `) - - expect(isProjectHeaderDragHandleTarget(header.querySelector('#chevron'), header)).toBe(false) - }) -}) - -describe('repo header drag pointer capture', () => { - it('captures the pointer only after crossing the drag threshold', async () => { - const scrollContainer = document.createElement('div') - document.body.appendChild(scrollContainer) - const repoById = new Map<string, Repo>([ - ['repo-a', createRepo('repo-a')], - ['repo-b', createRepo('repo-b')] - ]) - const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]]) - const setPointerCapture = vi.fn() - - function DragHarness(): React.ReactElement { - const repoDrag = useRepoHeaderDrag({ - orderedRepoIds: ['repo-a', 'repo-b'], - sidebarRepoHeaderIdsByBucket, - repoById, - usesProjectGroupOrdering: false, - onCommitRepoOrder: vi.fn(), - onCommitProjectGroupOrder: vi.fn(), - getScrollContainer: () => scrollContainer - }) - - return createElement('div', { - 'data-repo-header-drag-handle': '', - 'data-repo-header-id': 'repo-a', - 'data-repo-header-index': 0, - 'data-repo-header-bucket': 'ungrouped', - onPointerDown: (event: React.PointerEvent<HTMLElement>) => - repoDrag.onHandlePointerDown(event, 'repo-a'), - ref: (element: HTMLDivElement | null) => { - if (element) { - element.setPointerCapture = setPointerCapture - } - } - }) - } - - const root = createRoot(scrollContainer) - await act(async () => { - root.render(createElement(DragHarness)) - }) - const handle = scrollContainer.querySelector<HTMLElement>('[data-repo-header-drag-handle]') - expect(handle).not.toBeNull() - - await act(async () => { - handle!.dispatchEvent( - createPointerEvent('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 7 }) - ) - }) - expect(setPointerCapture).not.toHaveBeenCalled() - - await act(async () => { - window.dispatchEvent( - createPointerEvent('pointermove', { clientX: 12, clientY: 12, pointerId: 7 }) - ) - }) - expect(setPointerCapture).not.toHaveBeenCalled() - - await act(async () => { - window.dispatchEvent( - createPointerEvent('pointermove', { clientX: 20, clientY: 20, pointerId: 7 }) - ) - }) - expect(setPointerCapture).toHaveBeenCalledWith(7) - - await act(async () => { - root.unmount() - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index 0c424ebec08..ac84dc620be 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -1,68 +1,105 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { - computeProjectHeaderDropPreview, - measureProjectHeaderDragRects -} from './project-header-drop' -import { commitProjectHeaderDragDrop } from './project-header-drag-commit' -import { - INITIAL_REPO_DRAG_STATE, - PROJECT_HEADER_DRAG_THRESHOLD_PX, - type ProjectHeaderDragSession, - type RepoDragState, - type RepoHeaderDragController, - type UseRepoHeaderDragArgs -} from './project-header-drag-contract' -import { createProjectHeaderDragSession } from './project-header-drag-start' -import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' - // Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by // react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire // against stale targets. With pointer events we cache the active set of repo // header positions and compute the drop index from the live pointer Y. +export type RepoDragState = { + draggingRepoId: string | null + // Insertion index in the orderedRepoIds array where the dragged repo would + // land if released now. null while not dragging. + dropIndex: number | null + // Y coordinate (in scrollContainer's local space, i.e. relative to its + // top-left content origin including current scrollTop offset) where the + // insertion bar should be drawn. null while not dragging. + dropIndicatorY: number | null +} + +const INITIAL_STATE: RepoDragState = { + draggingRepoId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseRepoHeaderDragArgs = { + orderedRepoIds: string[] + onCommit: (orderedIds: string[]) => void + // Returns the scroll container that hosts the virtualized rows. Bounding + // rects are read from this element so insertion-bar Y values stay correct + // when the sidebar is resized. + getScrollContainer: () => HTMLElement | null +} + +type HeaderRect = { + repoId: string + // top/bottom in scrollContainer-local space (page-coord top minus container + // page-coord top, plus current scrollTop). + top: number + bottom: number +} + +export type RepoHeaderDragController = { + state: RepoDragState + // Call from the repo header's onPointerDown. The drag does NOT start + // immediately — it arms a pending session that promotes to an active drag + // only once the pointer moves past DRAG_THRESHOLD_PX. A pointerup before + // promotion releases without committing, so the surrounding click handler + // still fires and toggles the group's collapsed state. + onHandlePointerDown: (event: React.PointerEvent<HTMLElement>, repoId: string) => void +} + +// Pixels the pointer must travel before we promote a pending press into a +// real drag. Below this we treat the press as a normal click (toggle group). +const DRAG_THRESHOLD_PX = 4 +const REPO_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isRepoHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return currentTarget.contains(target) && target.closest(REPO_HEADER_ACTION_SELECTOR) !== null +} + export function useRepoHeaderDrag({ orderedRepoIds, - sidebarRepoHeaderIdsByBucket, - repoById, - usesProjectGroupOrdering, - onCommitRepoOrder, - onCommitProjectGroupOrder, + onCommit, getScrollContainer }: UseRepoHeaderDragArgs): RepoHeaderDragController { - const [state, setState] = useState<RepoDragState>(INITIAL_REPO_DRAG_STATE) + const [state, setState] = useState<RepoDragState>(INITIAL_STATE) + // Tracks whether a press has begun (armed) regardless of promotion. Used + // only to gate window listeners; visible drag state lives in `state`. const [sessionArmed, setSessionArmed] = useState(false) + // Why: endDrag reads dropIndex on pointerup, but binding the listener with + // dropIndex in deps would re-add window listeners on every pointermove. + // The ref tracks the latest computed value without invalidating the effect. const latestDropIndexRef = useRef<number | null>(null) latestDropIndexRef.current = state.dropIndex + // Keep callbacks stable: they read from refs so we don't re-bind window + // listeners every render. const orderedIdsRef = useRef(orderedRepoIds) orderedIdsRef.current = orderedRepoIds - const sidebarRepoHeaderIdsByBucketRef = useRef(sidebarRepoHeaderIdsByBucket) - sidebarRepoHeaderIdsByBucketRef.current = sidebarRepoHeaderIdsByBucket - const repoByIdRef = useRef(repoById) - repoByIdRef.current = repoById - const usesProjectGroupOrderingRef = useRef(usesProjectGroupOrdering) - usesProjectGroupOrderingRef.current = usesProjectGroupOrdering - const onCommitRepoOrderRef = useRef(onCommitRepoOrder) - onCommitRepoOrderRef.current = onCommitRepoOrder - const onCommitProjectGroupOrderRef = useRef(onCommitProjectGroupOrder) - onCommitProjectGroupOrderRef.current = onCommitProjectGroupOrder + const onCommitRef = useRef(onCommit) + onCommitRef.current = onCommit const getContainerRef = useRef(getScrollContainer) getContainerRef.current = getScrollContainer - const autoscrollLastFrameTimeRef = useRef<number | null>(null) - const autoscrollFrameIdRef = useRef<number | null>(null) - const dragSessionRef = useRef<ProjectHeaderDragSession | null>(null) - - const refreshHeaderRects = useCallback(() => { - const container = getContainerRef.current() - const session = dragSessionRef.current - if (!container || !session) { - return [] - } - const rects = measureProjectHeaderDragRects(container, session.bucketKey) - session.headerRects = rects - return rects - }, []) + const dragSessionRef = useRef<{ + repoId: string + pointerId: number + headerRects: HeaderRect[] + handleEl: HTMLElement + startX: number + startY: number + // false until the pointer moves past DRAG_THRESHOLD_PX. While false the + // session exists but no drop indicator is shown and pointerup is treated + // as a click rather than a drop. + promoted: boolean + } | null>(null) const computeDrop = useCallback( (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { @@ -72,123 +109,107 @@ export function useRepoHeaderDrag({ return null } const containerRect = container.getBoundingClientRect() - return computeProjectHeaderDropPreview({ - pointerY, - containerTop: containerRect.top, - scrollTop: container.scrollTop, - rects: session.headerRects, - sidebarRepoHeaderIds: session.sidebarRepoHeaderIds - }) + // Translate pointer to container-local coords + scroll. + const localY = pointerY - containerRect.top + container.scrollTop + const rects = session.headerRects + if (rects.length === 0) { + return null + } + // Find the first header whose midpoint is below the pointer. + let insertBefore = rects.length + for (let i = 0; i < rects.length; i++) { + const mid = (rects[i].top + rects[i].bottom) / 2 + if (localY < mid) { + insertBefore = i + break + } + } + // Why anchor to the target header (not midpoint between headers): the + // space between two project group headers is filled with worktree cards, + // so the midpoint falls *inside another repo's content*. Sitting the + // indicator just above the target header keeps it at the visual top of + // where the dragged group would land. + const INDICATOR_GAP_PX = 4 + const rawIndicatorY = + insertBefore >= rects.length + ? rects.at(-1)!.bottom + INDICATOR_GAP_PX + : Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX) + // Why: while scrolled, the topmost mounted header is pinned flush at the + // container top, so `top - GAP` lands above the overflow clip region and + // the line is painted invisibly. Floor the indicator at the current + // scroll offset so a top-of-list drop stays visible just below the edge. + const indicatorY = Math.max(container.scrollTop, rawIndicatorY) + return { dropIndex: insertBefore, dropIndicatorY: indicatorY } }, [] ) - const cancelAutoscroll = useCallback(() => { - if (autoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(autoscrollFrameIdRef.current) - autoscrollFrameIdRef.current = null - } - autoscrollLastFrameTimeRef.current = null - }, []) - - const endDrag = useCallback( - (commit: boolean) => { - cancelAutoscroll() - const session = dragSessionRef.current - if (!session) { - setState(INITIAL_REPO_DRAG_STATE) - setSessionArmed(false) - return - } - try { - session.handleEl.releasePointerCapture(session.pointerId) - } catch { - // capture may already be released (pointercancel, element unmounted) - } - if (session.promoted) { - const handleEl = session.handleEl - const swallow = (e: MouseEvent): void => { - const target = e.target as Node | null - if (target && handleEl.contains(target)) { - e.stopPropagation() - e.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - setTimeout(() => window.removeEventListener('click', swallow, true), 0) - } - const sidebarDropIndex = - commit && session.promoted && latestDropIndexRef.current !== null - ? latestDropIndexRef.current - : null - dragSessionRef.current = null - setState(INITIAL_REPO_DRAG_STATE) + const endDrag = useCallback((commit: boolean) => { + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_STATE) setSessionArmed(false) - if (sidebarDropIndex === null) { - return - } - - commitProjectHeaderDragDrop({ - session, - sidebarDropIndex, - orderedRepoIds: orderedIdsRef.current, - repoById: repoByIdRef.current, - usesProjectGroupOrdering: usesProjectGroupOrderingRef.current, - onCommitRepoOrder: onCommitRepoOrderRef.current, - onCommitProjectGroupOrder: onCommitProjectGroupOrderRef.current - }) - }, - [cancelAutoscroll] - ) - - const runAutoscrollFrame = useCallback( - (frameTime: number) => { - autoscrollFrameIdRef.current = null - const session = dragSessionRef.current - const container = getContainerRef.current() - if (!session?.promoted || !container) { - cancelAutoscroll() - return - } - - const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime - autoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point: { clientX: 0, clientY: session.latestPointerY }, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - container.scrollTop = autoscroll.scrollTop - refreshHeaderRects() - } - - const drop = computeDrop(session.latestPointerY) - if (drop) { - setState((prev) => - prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY - ? prev - : { draggingRepoId: session.repoId, ...drop } - ) - } - - autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) - }, - [cancelAutoscroll, computeDrop, refreshHeaderRects] - ) - - const ensureAutoscroll = useCallback(() => { - if (autoscrollFrameIdRef.current !== null) { return } - autoscrollLastFrameTimeRef.current = null - autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) - }, [runAutoscrollFrame]) + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + // After a real drag, the browser still fires a click on the header. + // Swallow exactly one click in capture phase so it doesn't toggle the + // group's collapsed state. Scope to the dragged handle (and ancestors) + // so an unrelated click that races between pointerup and the failsafe + // teardown isn't silently eaten. + const handleEl = session.handleEl + const swallow = (e: MouseEvent): void => { + const target = e.target as Node | null + if (target && handleEl.contains(target)) { + e.stopPropagation() + e.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + // Failsafe: if no click ever arrives (e.g. pointercancel), drop the + // listener after a tick so future clicks aren't silenced. + setTimeout(() => window.removeEventListener('click', swallow, true), 0) + } + // Only commit a reorder if the press was promoted into a real drag — + // otherwise the press was effectively a click, and the surrounding + // header onClick handler will toggle collapse. + const finalIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_STATE) + setSessionArmed(false) + if (finalIndex === null) { + return + } + const ids = orderedIdsRef.current + const fromIndex = ids.indexOf(session.repoId) + if (fromIndex === -1) { + return + } + // Splice fromIndex out, then insert at finalIndex (adjusting if the + // removal shifted indices). + const next = ids.slice() + next.splice(fromIndex, 1) + const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex + if (insertAt === fromIndex) { + return + } + next.splice(insertAt, 0, session.repoId) + onCommitRef.current(next) + }, []) + // Window-level listeners while a session is armed — pointer capture on the + // header element ensures the events still fire even if the pointer leaves + // it. The session may be unpromoted (waiting for a movement past the + // threshold to become a real drag) or promoted (drop indicator visible). useEffect(() => { if (!sessionArmed) { return @@ -198,36 +219,24 @@ export function useRepoHeaderDrag({ if (!session || e.pointerId !== session.pointerId) { return } - session.latestPointerY = e.clientY if (!session.promoted) { const dx = e.clientX - session.startX const dy = e.clientY - session.startY - if ( - dx * dx + dy * dy < - PROJECT_HEADER_DRAG_THRESHOLD_PX * PROJECT_HEADER_DRAG_THRESHOLD_PX - ) { + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) { return } session.promoted = true - try { - session.handleEl.setPointerCapture(session.pointerId) - } catch { - // setPointerCapture can throw if the element is detached; the global - // pointer listeners still fire, so dragging keeps working. - } - refreshHeaderRects() setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null }) } - refreshHeaderRects() const drop = computeDrop(e.clientY) - if (drop) { - setState((prev) => - prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY - ? prev - : { draggingRepoId: session.repoId, ...drop } - ) + if (!drop) { + return } - ensureAutoscroll() + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingRepoId: session.repoId, ...drop } + ) } const onPointerUp = (e: PointerEvent): void => { const session = dragSessionRef.current @@ -261,12 +270,15 @@ export function useRepoHeaderDrag({ window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('blur', onBlur) - cancelAutoscroll() } - }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) + }, [sessionArmed, computeDrop, endDrag]) + // From pointerdown onward (armed session, before/after promotion) force a + // grabbing cursor and disable text selection across the whole window so + // the user gets immediate feedback that the press registered, even before + // they've moved past DRAG_THRESHOLD_PX. useEffect(() => { - if (state.draggingRepoId === null) { + if (!sessionArmed) { return } const body = document.body @@ -278,21 +290,63 @@ export function useRepoHeaderDrag({ body.style.cursor = prevCursor body.style.userSelect = prevUserSelect } - }, [state.draggingRepoId]) + }, [sessionArmed]) const onHandlePointerDown = useCallback( (event: React.PointerEvent<HTMLElement>, repoId: string) => { - const session = createProjectHeaderDragSession({ - event, - repoId, - repoById: repoByIdRef.current, - sidebarRepoHeaderIdsByBucket: sidebarRepoHeaderIdsByBucketRef.current, - getScrollContainer: getContainerRef.current - }) - if (!session) { + // Only react to primary button. Ignore right/middle clicks. + if (event.button !== 0) { return } - dragSessionRef.current = session + // Don't intercept presses from nested action surfaces; Radix triggers + // and disabled wrappers are not always plain button descendants. + if (isRepoHeaderActionTarget(event.target, event.currentTarget)) { + return + } + const container = getContainerRef.current() + if (!container) { + return + } + // Snapshot every repo header's position in scrollContainer-local space. + // Using a snapshot (vs reading the DOM each pointermove) means the drop + // computation does not depend on those rows still being mounted — + // critical because react-virtual will unmount them as the user scrolls. + const containerRect = container.getBoundingClientRect() + const headerEls = container.querySelectorAll<HTMLElement>('[data-repo-header-id]') + const headerRects: HeaderRect[] = [] + headerEls.forEach((el) => { + const id = el.getAttribute('data-repo-header-id') + if (!id) { + return + } + const rect = el.getBoundingClientRect() + headerRects.push({ + repoId: id, + top: rect.top - containerRect.top + container.scrollTop, + bottom: rect.bottom - containerRect.top + container.scrollTop + }) + }) + headerRects.sort((a, b) => a.top - b.top) + + const handleEl = event.currentTarget + try { + handleEl.setPointerCapture(event.pointerId) + } catch { + // setPointerCapture can throw if the element is detached; the global + // pointer listeners still fire, so dragging keeps working. + } + dragSessionRef.current = { + repoId, + pointerId: event.pointerId, + headerRects, + handleEl, + startX: event.clientX, + startY: event.clientY, + promoted: false + } + // Don't show drag UI yet. Wait for movement past DRAG_THRESHOLD_PX so a + // simple click on the header still toggles collapse via the surrounding + // onClick handler. setSessionArmed(true) }, [] @@ -300,8 +354,3 @@ export function useRepoHeaderDrag({ return { state, onHandlePointerDown } } - -export { - isRepoHeaderActionTarget, - isProjectHeaderDragHandleTarget -} from './project-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/sidebar-project-drop.ts b/src/renderer/src/components/sidebar/sidebar-project-drop.ts index 42c29ded37f..fc61c049e38 100644 --- a/src/renderer/src/components/sidebar/sidebar-project-drop.ts +++ b/src/renderer/src/components/sidebar/sidebar-project-drop.ts @@ -61,7 +61,7 @@ export function getSidebarProjectDropAffordance(args: { ), description: translate( 'auto.components.sidebar.sidebar.project.drop.740e8d0d46', - 'Use Add Project for server paths' + 'Use Add Project for host paths' ) } } diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts index 23747c0c00f..3d02f5fe1a9 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -165,10 +165,10 @@ describe('useAddRepoCloneFlow', () => { }) it('strips Electron IPC wrappers from clone errors', async () => { + const cloneError = + 'Clone failed: Destination already exists and is not empty: /srv/orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' mocks.cloneRemote.mockRejectedValue( - new Error( - "Error invoking remote method 'repos:cloneRemote': Error: Clone failed: fatal: destination path 'orca' already exists and is not an empty directory." - ) + new Error(`Error invoking remote method 'repos:cloneRemote': Error: ${cloneError}`) ) const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') @@ -182,9 +182,7 @@ describe('useAddRepoCloneFlow', () => { }) await result.handleClone() - expect(mocks.stateSetters[3]).toHaveBeenCalledWith( - "Clone failed: fatal: destination path 'orca' already exists and is not an empty directory." - ) + expect(mocks.stateSetters[3]).toHaveBeenCalledWith(cloneError) }) it('clones through the selected runtime environment', async () => { diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index 98c09262f08..cd376a014fa 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -89,11 +89,11 @@ export function useAddRepoCloneFlow({ const handlePickDestination = useCallback(async (): Promise<void> => { if (activeRuntimeEnvironmentId?.trim() || sshTargetId?.trim()) { // Why: the native folder picker returns a client-local path. Runtime - // clone destinations must be typed as server paths. + // and SSH clone destinations must be typed as paths on that host. toast.error( translate( 'auto.components.sidebar.useAddRepoCloneFlow.0dc4d1b657', - 'Enter a server path for the clone destination.' + 'Enter a host path for the clone destination.' ) ) return @@ -152,16 +152,7 @@ export function useAddRepoCloneFlow({ translate('auto.components.sidebar.useAddRepoCloneFlow.4d0013cc93', 'Repository cloned'), { description: repo.displayName } ) - // Why: eagerly upsert so step 2 finds the repo before the IPC event. - const state = useAppStore.getState() - const existingIdx = state.repos.findIndex((r) => r.id === repo.id) - if (existingIdx === -1) { - useAppStore.setState({ repos: [...state.repos, repo] }) - } else { - const updated = [...state.repos] - updated[existingIdx] = repo - useAppStore.setState({ repos: updated }) - } + upsertAddedRepoWithProjectHostSetup(repo) // Why: once the repo exists, a transient non-authoritative refresh // should fall through to project reveal instead of leaving the add flow open. await fetchWorktrees(repo.id, { requireAuthoritative: true }) diff --git a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts index 6f3c8eeeb48..f11f85a540a 100644 --- a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts @@ -72,7 +72,7 @@ export function useAddRepoLocalFolderFlow({ toast.error( translate( 'auto.components.sidebar.useAddRepoLocalFolderFlow.7ab10e4974', - 'Use a server path to add projects from a remote runtime.' + 'Use a host path to add projects from a remote host.' ) ) closeModal() diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts index 941362ca5e8..aa81789d7e9 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts @@ -63,14 +63,16 @@ function useHarness(overrides: Partial<Parameters<typeof useCreateProjectDefault mocks.stateIndex = 0 mocks.refIndex = 0 const setCreateParent = vi.fn() + const setCreateKind = vi.fn() const result = useCreateProjectDefaults({ step: 'create', activeRuntimeEnvironmentId: null, createParent: '', setCreateParent, + setCreateKind, ...overrides }) - return { result, setCreateParent } + return { result, setCreateParent, setCreateKind } } describe('useCreateProjectDefaults', () => { @@ -91,15 +93,16 @@ describe('useCreateProjectDefaults', () => { mocks.getDefaultCreateProjectParent.mockResolvedValue('/Users/alice/orca/projects') }) - it('auto-fills the local default parent and records Git availability', async () => { + it('auto-fills the local default parent and defaults to git when available', async () => { mocks.isGitAvailable.mockResolvedValue(true) - const { setCreateParent } = useHarness() + const { setCreateParent, setCreateKind } = useHarness() await flushAsync() expect(setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects') expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('available') + expect(setCreateKind).toHaveBeenCalledWith('git') expect(mocks.getDefaultCreateProjectParent).toHaveBeenCalled() expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) @@ -125,22 +128,24 @@ describe('useCreateProjectDefaults', () => { expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') }) - it('records unavailable Git without changing project kind', async () => { + it('defaults to folder with a visible fallback when Git is unavailable', async () => { mocks.isGitAvailable.mockResolvedValue(false) - useHarness() + const { setCreateKind } = useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unavailable') + expect(setCreateKind).toHaveBeenCalledWith('folder') }) - it('reports unknown availability when the Git probe fails', async () => { + it('reports unknown availability and keeps the kind when the Git probe fails', async () => { mocks.isGitAvailable.mockRejectedValue(new Error('probe failed')) - useHarness() + const { setCreateKind } = useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown') + expect(setCreateKind).not.toHaveBeenCalled() }) it('does not overwrite a parent the user already chose', async () => { @@ -156,7 +161,7 @@ describe('useCreateProjectDefaults', () => { mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' }) mocks.callRuntimeRpc.mockResolvedValue({ available: true }) - const { setCreateParent } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) + const { setCreateParent, setCreateKind } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) await flushAsync() expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~') @@ -171,6 +176,7 @@ describe('useCreateProjectDefaults', () => { { timeoutMs: 3000 } ) expect(mocks.isGitAvailable).not.toHaveBeenCalled() + expect(setCreateKind).toHaveBeenCalledWith('git') }) it('replaces an untouched local default when switching to a runtime target', async () => { @@ -242,11 +248,26 @@ describe('useCreateProjectDefaults', () => { expect(setCreateParent).not.toHaveBeenCalled() }) - it('does nothing outside the create step', async () => { - const { setCreateParent } = useHarness({ step: 'add' }) + it('does not use client defaults or Git probing for SSH targets', async () => { + mocks.isGitAvailable.mockResolvedValue(true) + + const { setCreateParent, setCreateKind } = useHarness({ sshTargetId: 'ssh-1' }) await flushAsync() expect(setCreateParent).not.toHaveBeenCalled() + expect(setCreateKind).not.toHaveBeenCalled() + expect(mocks.getDefaultCreateProjectParent).not.toHaveBeenCalled() + expect(mocks.isGitAvailable).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown') + }) + + it('does nothing outside the create step', async () => { + const { setCreateParent, setCreateKind } = useHarness({ step: 'add' }) + await flushAsync() + + expect(setCreateParent).not.toHaveBeenCalled() + expect(setCreateKind).not.toHaveBeenCalled() expect(mocks.isGitAvailable).not.toHaveBeenCalled() expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts index f93e61ee3d1..e1df52b084b 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts @@ -5,7 +5,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AddRepoDialogStep } from './add-repo-dialog-types' -import { getDefaultCreateProjectParent, type GitAvailability } from './create-project-defaults' +import { + getDefaultCreateProjectParent, + type GitAvailability, + type RepoKind +} from './create-project-defaults' const LOCAL_GIT_AVAILABILITY_TIMEOUT_MS = 1500 const RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS = 3000 @@ -14,12 +18,12 @@ export type CreateRuntimeParentStatus = 'idle' | 'checking' | 'failed' type AutoFilledCreateParent = { parent: string - runtimeEnvironmentId: string | null + targetKey: string } type CreateParentProvenance = { parent: string - runtimeEnvironmentId: string | null + targetKey: string } function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { @@ -46,13 +50,17 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { export function useCreateProjectDefaults({ step, activeRuntimeEnvironmentId, + sshTargetId, createParent, - setCreateParent + setCreateParent, + setCreateKind }: { step: AddRepoDialogStep activeRuntimeEnvironmentId: string | null | undefined + sshTargetId?: string | null | undefined createParent: string setCreateParent: (value: string) => void + setCreateKind: (kind: RepoKind) => void }): { createDefaultParent: string createGitAvailability: GitAvailability @@ -60,6 +68,7 @@ export function useCreateProjectDefaults({ createParentDefaultPending: boolean resetCreateDefaultState: () => void markCreateParentTouched: (value?: string) => void + markCreateKindTouched: () => void } { const [createDefaultParent, setCreateDefaultParent] = useState('') const [createGitAvailability, setCreateGitAvailability] = useState<GitAvailability>('unknown') @@ -69,9 +78,16 @@ export function useCreateProjectDefaults({ const autoFilledCreateParentRef = useRef<AutoFilledCreateParent | null>(null) const createParentProvenanceRef = useRef<CreateParentProvenance | null>(null) const createParentTouchedRef = useRef(false) + const createKindTouchedRef = useRef(false) const createParentDefaultGenRef = useRef(0) const createGitProbeGenRef = useRef(0) const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null + const activeCreateParentSshTargetId = sshTargetId?.trim() || null + const activeCreateParentTargetKey = activeCreateParentRuntimeEnvironmentId + ? `runtime:${activeCreateParentRuntimeEnvironmentId}` + : activeCreateParentSshTargetId + ? `ssh:${activeCreateParentSshTargetId}` + : 'local' const canReplaceCreateParentDefault = useCallback((parent: string): boolean => { if (createParentTouchedRef.current) { @@ -88,6 +104,7 @@ export function useCreateProjectDefaults({ autoFilledCreateParentRef.current = null createParentProvenanceRef.current = null createParentTouchedRef.current = false + createKindTouchedRef.current = false setCreateDefaultParent('') setCreateGitAvailability('unknown') setCreateRuntimeParentStatus('idle') @@ -99,33 +116,34 @@ export function useCreateProjectDefaults({ autoFilledCreateParentRef.current = null createParentProvenanceRef.current = { parent: (value ?? createParent).trim(), - runtimeEnvironmentId: activeCreateParentRuntimeEnvironmentId + targetKey: activeCreateParentTargetKey } createParentTouchedRef.current = true }, - [activeCreateParentRuntimeEnvironmentId, createParent] + [activeCreateParentTargetKey, createParent] ) + const markCreateKindTouched = useCallback(() => { + createKindTouchedRef.current = true + }, []) const createParentDefaultPending = step === 'create' && !createParentTouchedRef.current && Boolean(createParent.trim()) && autoFilledCreateParentRef.current?.parent === createParent.trim() && - autoFilledCreateParentRef.current.runtimeEnvironmentId !== - activeCreateParentRuntimeEnvironmentId + autoFilledCreateParentRef.current.targetKey !== activeCreateParentTargetKey const createParentTargetPending = step === 'create' && Boolean(createParent.trim()) && createParentProvenanceRef.current?.parent === createParent.trim() && - createParentProvenanceRef.current.runtimeEnvironmentId !== - activeCreateParentRuntimeEnvironmentId + createParentProvenanceRef.current.targetKey !== activeCreateParentTargetKey const createParentPending = createParentDefaultPending || createParentTargetPending useEffect(() => { if (step !== 'create') { return } - if (activeCreateParentRuntimeEnvironmentId) { + if (activeCreateParentRuntimeEnvironmentId || activeCreateParentSshTargetId) { return } // Why: invalidate any in-flight runtime parent probe once local mode owns the default. @@ -135,7 +153,7 @@ export function useCreateProjectDefaults({ } if ( createParent.trim() && - autoFilledCreateParentRef.current?.runtimeEnvironmentId !== null && + autoFilledCreateParentRef.current?.targetKey !== 'local' && autoFilledCreateParentRef.current?.parent === createParent.trim() ) { setCreateDefaultParent('') @@ -143,7 +161,7 @@ export function useCreateProjectDefaults({ return } if ( - autoFilledCreateParentRef.current?.runtimeEnvironmentId === null && + autoFilledCreateParentRef.current?.targetKey === 'local' && autoFilledCreateParentRef.current.parent === createParent.trim() ) { return @@ -161,8 +179,8 @@ export function useCreateProjectDefaults({ } setCreateDefaultParent(parent) createStepAutoFilledRef.current = true - autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId: null } - createParentProvenanceRef.current = { parent, runtimeEnvironmentId: null } + autoFilledCreateParentRef.current = { parent, targetKey: 'local' } + createParentProvenanceRef.current = { parent, targetKey: 'local' } setCreateParent(parent) }) .catch(() => { @@ -171,6 +189,7 @@ export function useCreateProjectDefaults({ }, [ activeRuntimeEnvironmentId, activeCreateParentRuntimeEnvironmentId, + activeCreateParentSshTargetId, canReplaceCreateParentDefault, createParent, setCreateParent, @@ -182,7 +201,7 @@ export function useCreateProjectDefaults({ return } const runtimeEnvironmentId = activeCreateParentRuntimeEnvironmentId - if (!runtimeEnvironmentId) { + if (!runtimeEnvironmentId || activeCreateParentSshTargetId) { setCreateRuntimeParentStatus('idle') return } @@ -192,7 +211,7 @@ export function useCreateProjectDefaults({ } if ( createParent.trim() && - autoFilledCreateParentRef.current?.runtimeEnvironmentId !== runtimeEnvironmentId && + autoFilledCreateParentRef.current?.targetKey !== `runtime:${runtimeEnvironmentId}` && autoFilledCreateParentRef.current?.parent === createParent.trim() ) { setCreateDefaultParent('') @@ -201,7 +220,7 @@ export function useCreateProjectDefaults({ return } if ( - autoFilledCreateParentRef.current?.runtimeEnvironmentId === runtimeEnvironmentId && + autoFilledCreateParentRef.current?.targetKey === `runtime:${runtimeEnvironmentId}` && autoFilledCreateParentRef.current.parent === createParent.trim() ) { setCreateRuntimeParentStatus('idle') @@ -224,8 +243,8 @@ export function useCreateProjectDefaults({ } const parent = getDefaultCreateProjectParent(result.resolvedPath) createStepAutoFilledRef.current = true - autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId } - createParentProvenanceRef.current = { parent, runtimeEnvironmentId } + autoFilledCreateParentRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` } + createParentProvenanceRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` } setCreateDefaultParent(parent) setCreateParent(parent) setCreateRuntimeParentStatus('idle') @@ -239,6 +258,7 @@ export function useCreateProjectDefaults({ }, [ activeRuntimeEnvironmentId, activeCreateParentRuntimeEnvironmentId, + activeCreateParentSshTargetId, canReplaceCreateParentDefault, createParent, setCreateParent, @@ -251,6 +271,12 @@ export function useCreateProjectDefaults({ } const runtimeEnvironmentId = activeRuntimeEnvironmentId?.trim() const gen = ++createGitProbeGenRef.current + if (activeCreateParentSshTargetId) { + // Why: SSH creation happens through the relay; probing client Git would + // make the selected host look healthier or less healthy than it is. + setCreateGitAvailability('unknown') + return + } setCreateGitAvailability('checking') const probe = runtimeEnvironmentId ? callRuntimeRpc<{ available: boolean }>( @@ -270,6 +296,10 @@ export function useCreateProjectDefaults({ return } setCreateGitAvailability(available ? 'available' : 'unavailable') + if (createKindTouchedRef.current) { + return + } + setCreateKind(available ? 'git' : 'folder') }) .catch(() => { if (gen !== createGitProbeGenRef.current) { @@ -277,7 +307,7 @@ export function useCreateProjectDefaults({ } setCreateGitAvailability('unknown') }) - }, [activeRuntimeEnvironmentId, step]) + }, [activeRuntimeEnvironmentId, activeCreateParentSshTargetId, setCreateKind, step]) return { createDefaultParent, @@ -285,6 +315,7 @@ export function useCreateProjectDefaults({ createRuntimeParentStatus, createParentDefaultPending: createParentPending, resetCreateDefaultState, - markCreateParentTouched + markCreateParentTouched, + markCreateKindTouched } } diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 2aa2118cffd..116d97ffad7 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -98,11 +98,13 @@ describe('useCreateRepo default-checkout handoff', () => { vi.clearAllMocks() mocks.stateIndex = 0 mocks.stateSetters = [] - mocks.stateValues = ['created', '/projects', null, false] + mocks.stateValues = ['created', '/projects', 'git', null, false] mocks.storeState.repos = [] mocks.storeState.projects = [] mocks.storeState.projectHostSetups = [] mocks.storeState.worktreesByRepo = {} + mocks.createRepo.mockReset() + mocks.createRemoteRepo.mockReset() mocks.storeState.settings.activeRuntimeEnvironmentId = null vi.stubGlobal('window', { api: { @@ -153,10 +155,11 @@ describe('useCreateRepo default-checkout handoff', () => { }) it('does not return a parent path when the runtime target blocks the local picker', async () => { - mocks.storeState.settings.activeRuntimeEnvironmentId = 'env-1' const { useCreateRepo } = await import('./useCreateRepo') - const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady) + const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { + runtimeEnvironmentId: 'env-1' + }) await expect(result.handlePickParent()).resolves.toBeNull() expect(window.api.repos.pickDirectory).not.toHaveBeenCalled() @@ -181,11 +184,11 @@ describe('useCreateRepo default-checkout handoff', () => { ) }) - it('uses the folder completion path if IPC returns a folder project', async () => { + it('marks onboarding folder progress when a created folder project opens', async () => { const repo = makeRepo({ kind: 'folder' }) const worktree = { id: `${repo.id}::/projects/created` } const closeModal = vi.fn() - mocks.stateValues = ['created', '/projects', null, false] + mocks.stateValues = ['created', '/projects', 'folder', null, false] mocks.createRepo.mockResolvedValue({ repo }) mocks.fetchWorktrees.mockImplementation(async (repoId: string) => { mocks.storeState.worktreesByRepo = { [repoId]: [worktree] } @@ -199,7 +202,7 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.createRepo).toHaveBeenCalledWith({ parentPath: '/projects', name: 'created', - kind: 'git' + kind: 'folder' }) expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, { diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index 9d9aa95e418..052c96922a1 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -10,6 +10,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import type { RepoKind } from './create-project-defaults' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' export function useCreateRepo( fetchWorktrees: ( @@ -17,13 +20,22 @@ export function useCreateRepo( options?: { requireAuthoritative?: boolean } ) => Promise<boolean>, closeModal: () => void, - onGitRepoReady?: (repoId: string) => void | Promise<void> + onGitRepoReady?: (repoId: string) => void | Promise<void>, + options: { + hostId?: string | null + runtimeEnvironmentId?: string | null + sshTargetId?: string | null + } = {} ) { const [createName, setCreateName] = useState('') const [createParent, setCreateParent] = useState('') + const [createKind, setCreateKind] = useState<RepoKind>('git') const [createError, setCreateError] = useState<string | null>(null) const [isCreating, setIsCreating] = useState(false) const mountedRef = useMountedRef() + const hostToken = options.hostId ?? options.sshTargetId ?? '' + const hostTokenRef = useRef(hostToken) + hostTokenRef.current = hostToken // Why: monotonic ID so stale create callbacks can detect they were superseded // when the user clicks Back or closes the dialog mid-create. Mirrors the @@ -34,18 +46,30 @@ export function useCreateRepo( createGenRef.current++ setCreateName('') setCreateParent('') + setCreateKind('git') setCreateError(null) setIsCreating(false) }, []) const handlePickParent = useCallback(async (): Promise<string | null> => { - if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) { + if (options.sshTargetId) { + // Why: the native picker can only browse the client machine. SSH create + // uses a host path typed by the user until remote folder picking exists. + toast.error( + translate( + 'auto.components.sidebar.AddRepoCreateStep.ssh_parent_manual', + 'Enter an SSH parent path.' + ) + ) + return null + } + if (options.runtimeEnvironmentId?.trim()) { // Why: the native folder picker returns a client-local path. Runtime - // project creation needs an explicit server parent path. + // project creation needs an explicit host parent path. toast.error( translate( 'auto.components.sidebar.AddRepoCreateStep.875dda0995', - 'Enter a server parent path.' + 'Enter a host parent path.' ) ) return null @@ -58,7 +82,7 @@ export function useCreateRepo( return dir } return null - }, [mountedRef]) + }, [mountedRef, options.runtimeEnvironmentId, options.sshTargetId]) const handleCreate = useCallback(async () => { const name = createName.trim() @@ -66,31 +90,47 @@ export function useCreateRepo( if (!name || !parentPath) { return } + const requestHostToken = hostTokenRef.current const gen = ++createGenRef.current setIsCreating(true) setCreateError(null) try { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) - const result = - target.kind === 'environment' + const target = options.runtimeEnvironmentId?.trim() + ? { kind: 'environment' as const, environmentId: options.runtimeEnvironmentId.trim() } + : getActiveRuntimeTarget({ + ...useAppStore.getState().settings, + activeRuntimeEnvironmentId: null + }) + const result = options.sshTargetId + ? await window.api.repos.createRemote({ + connectionId: options.sshTargetId, + parentPath, + name, + kind: createKind + }) + : target.kind === 'environment' ? await callRuntimeRpc<{ repo: Repo } | { error: string }>( target, 'repo.create', { parentPath, name, - kind: 'git' + kind: createKind }, { timeoutMs: 60_000 } ) : await window.api.repos.create({ parentPath, name, - kind: 'git' + kind: createKind }) // Why: if the user closed the dialog or clicked Back mid-create, // createGenRef was bumped by resetCreateState. Ignore stale results. - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } if ('error' in result) { @@ -98,8 +138,6 @@ export function useCreateRepo( return } const repo = result.repo - // Upsert into the store before the repos:changed event round-trips, - // so the next step can find the repo immediately. const state = useAppStore.getState() const existingIdx = state.repos.findIndex((r) => r.id === repo.id) // Why: the IPC handler dedupes by path (see repos:create) and returns @@ -107,13 +145,7 @@ export function useCreateRepo( // handler took the dedup path — no new project was created, so don't // claim one was. const wasDeduped = existingIdx !== -1 - if (existingIdx === -1) { - useAppStore.setState({ repos: [...state.repos, repo] }) - } else { - const updated = [...state.repos] - updated[existingIdx] = repo - useAppStore.setState({ repos: updated }) - } + upsertAddedRepoWithProjectHostSetup(repo) if (wasDeduped) { toast.info( translate( @@ -137,7 +169,11 @@ export function useCreateRepo( // Why: if refresh is temporarily non-authoritative, the shared opener // still reveals the project so the user is not left in a completed add flow. await fetchWorktrees(repo.id, { requireAuthoritative: true }) - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } await onGitRepoReady?.(repo.id) @@ -145,7 +181,11 @@ export function useCreateRepo( // Why: folder repos skip the Git default-checkout handoff, so activate the synthetic // root workspace before closing. Matches addNonGitFolder's behavior. await fetchWorktrees(repo.id) - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0] @@ -156,26 +196,46 @@ export function useCreateRepo( closeModal() } } catch (err) { - if (gen !== createGenRef.current || !mountedRef.current) { + if ( + gen !== createGenRef.current || + requestHostToken !== hostTokenRef.current || + !mountedRef.current + ) { return } - setCreateError(err instanceof Error ? err.message : String(err)) + setCreateError(extractIpcErrorMessage(err, String(err))) } finally { // Why: only clear the loading state if this invocation is still current; // a superseded create must not flip the flag back off for a new flow. - if (gen === createGenRef.current && mountedRef.current) { + if ( + gen === createGenRef.current && + requestHostToken === hostTokenRef.current && + mountedRef.current + ) { setIsCreating(false) } } - }, [createName, createParent, fetchWorktrees, mountedRef, closeModal, onGitRepoReady]) + }, [ + createName, + createParent, + createKind, + fetchWorktrees, + mountedRef, + closeModal, + onGitRepoReady, + options.runtimeEnvironmentId, + options.sshTargetId + ]) return { createName, createParent, + createKind, createError, isCreating, setCreateName, setCreateParent, + setCreateKind, setCreateError, resetCreateState, handlePickParent, diff --git a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts index 2f0ba7539b2..f44048aea87 100644 --- a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts +++ b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts @@ -70,7 +70,7 @@ export function useSidebarProjectDrop(): { { description: translate( 'auto.components.sidebar.useSidebarProjectDrop.5ccb56c7be', - 'Use Add Project to enter a server path.' + 'Use Add Project to enter a host path.' ) } ) diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index eee28187dac..2d312983d3b 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -30,6 +30,7 @@ export function getWorktreeDragUnitGroups( continue } if ( + row.type === 'host-header' || row.type === 'imported-worktrees-card' || row.type === 'pending-creation' || row.type === 'folder-workspace' diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 03b0a9394ec..01a07d39d1b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -14,6 +14,8 @@ import { } from './worktree-list-groups' import type { DetectedWorktree, + Project, + ProjectHostSetup, FolderWorkspace, Repo, ProjectGroup, @@ -1351,77 +1353,6 @@ describe('project groups', () => { ]) }) - it('keeps missing projectGroupOrder siblings in manual fallback slots', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const repoA: Repo = { - ...repo, - id: 'repo-a', - displayName: 'alpha', - projectGroupId: group.id - } - const repoB: Repo = { - ...repo, - id: 'repo-b', - displayName: 'beta', - projectGroupId: group.id, - projectGroupOrder: 1000 - } - const repoC: Repo = { - ...repo, - id: 'repo-c', - displayName: 'gamma', - projectGroupId: group.id - } - const repoMap = new Map([ - [repoA.id, repoA], - [repoB.id, repoB], - [repoC.id, repoC] - ]) - const repoOrder = new Map([ - [repoA.id, 0], - [repoB.id, 1], - [repoC.id, 2] - ]) - - const rows = buildRows( - 'repo', - [ - { ...worktree, id: 'wt-a', repoId: repoA.id }, - { ...worktree, id: 'wt-b', repoId: repoB.id }, - { ...worktree, id: 'wt-c', repoId: repoC.id } - ], - repoMap, - null, - new Set(), - repoOrder, - undefined, - 'manual', - undefined, - undefined, - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - 'repo:repo-a', - 'repo:repo-b', - 'repo:repo-c' - ]) - }) - it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => { const groupA: ProjectGroup = { id: 'group-a', @@ -1598,6 +1529,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) @@ -1674,6 +1606,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) @@ -1742,6 +1675,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 1615d6c73b1..92faea22f3b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -3,6 +3,8 @@ import { CircleX, FolderTree, List, Pin } from 'lucide-react' import type React from 'react' import type { DetectedWorktree, + Project, + ProjectHostSetup, FolderWorkspace, Repo, ProjectGroup, @@ -27,8 +29,9 @@ import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-stat import type { AppState } from '../../store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups' -import { getRepoDisplayLabelsByPath } from '../../lib/repo-display-labels' -import { translate } from '../../i18n/i18n' +import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import { translate } from '@/i18n/i18n' +import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' export { branchName } @@ -183,30 +186,22 @@ export const PR_GROUP_META: Record< } > = { done: { - get label() { - return translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done') - }, + label: translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done'), icon: ConductorDoneIcon, tone: 'text-[#c7a594]' }, 'in-review': { - get label() { - return translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review') - }, + label: translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review'), icon: ConductorReviewIcon, tone: 'text-[#16a34a]' }, 'in-progress': { - get label() { - return translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress') - }, + label: translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress'), icon: ConductorProgressIcon, tone: 'text-[#d4a300]' }, closed: { - get label() { - return translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed') - }, + label: translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed'), icon: CircleX, tone: 'text-zinc-600 dark:text-zinc-300' } @@ -224,9 +219,7 @@ export function getProjectGroupHeaderKey(groupId: string | null): string { export const PINNED_GROUP_KEY = 'pinned' export const PINNED_GROUP_META = { - get label() { - return translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned') - }, + label: translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned'), tone: 'text-foreground', icon: Pin } as const @@ -234,9 +227,7 @@ export const PINNED_GROUP_META = { export const ALL_GROUP_KEY = 'all' export const ALL_GROUP_META = { - get label() { - return translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All') - }, + label: translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All'), tone: 'text-foreground', icon: List } as const @@ -670,6 +661,7 @@ export function buildRows( placeholderRepoIds: ReadonlySet<string> = new Set(), importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(), pendingCreations: readonly PendingCreationRef[] = [], + projectGrouping?: ProjectGroupingModel, folderWorkspaces: readonly FolderWorkspace[] = [] ): Row[] { const result: Row[] = [] @@ -953,27 +945,20 @@ export function buildRows( compareRecentRank(recentRankForEntry(left), recentRankForEntry(right)) ) } - const manualFallbackRank = new Map( - entries.map((entry) => [entry[0], manualRankForEntry(entry, repoOrder)]) - ) - // Why: legacy grouped projects may not have projectGroupOrder yet. Falling - // back to manual rank keeps one-project drag writes able to land between - // old siblings instead of any finite order jumping ahead of all missing ones. + // Manual: within a Project Group, projects order by their per-group rank + // (projectGroupOrder), not the global repoOrder. return [...entries].sort((left, right) => { const leftOrder = left[1].repo?.projectGroupOrder const rightOrder = right[1].repo?.projectGroupOrder const leftRank = typeof leftOrder === 'number' && Number.isFinite(leftOrder) ? leftOrder - : (manualFallbackRank.get(left[0]) ?? Number.POSITIVE_INFINITY) * 1000 + : Number.POSITIVE_INFINITY const rightRank = typeof rightOrder === 'number' && Number.isFinite(rightOrder) ? rightOrder - : (manualFallbackRank.get(right[0]) ?? Number.POSITIVE_INFINITY) * 1000 - if (leftRank !== rightRank) { - return leftRank - rightRank - } - return left[1].label.localeCompare(right[1].label) + : Number.POSITIVE_INFINITY + return leftRank - rightRank }) } diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index f16e3c76f2e..efcd2cab77c 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -12,6 +12,7 @@ export type TaskPageRepoCacheInput = { id: string path: string executionHostId?: string | null + sourceCacheScope?: string | null } export type TaskPageDialogWorkItemKey = { @@ -53,7 +54,10 @@ export function selectTaskPageWorkItemsCacheEntries( query: string ): (CacheEntry<GitHubWorkItem[]> | undefined)[] { return repos.map( - (repo) => workItemsCache[workItemsCacheKey(repo.id, limit, query, repo.executionHostId)] + (repo) => + workItemsCache[ + workItemsCacheKey(repo.id, limit, query, repo.sourceCacheScope ?? repo.executionHostId) + ] ) } diff --git a/src/renderer/src/components/task-page-default-repo-selection.test.ts b/src/renderer/src/components/task-page-default-repo-selection.test.ts new file mode 100644 index 00000000000..28775cbe5aa --- /dev/null +++ b/src/renderer/src/components/task-page-default-repo-selection.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/types' +import { getDefaultTaskRepoSelection } from './task-page-default-repo-selection' + +function repo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo { + return { + path: `/repos/${overrides.id}`, + displayName: overrides.id, + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...overrides + } +} + +describe('getDefaultTaskRepoSelection', () => { + it('selects one source per logical GitHub project', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ + id: 'local-orca', + upstream: { owner: 'StablyAI', repo: 'Orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'other', + upstream: { owner: 'stablyai', repo: 'other' } + }) + ]) + + expect([...selection].sort()).toEqual(['local-orca', 'other']) + }) + + it('prefers local checkout over a remote checkout for the same project', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ + id: 'ssh-orca', + addedAt: 1, + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'local-orca', + addedAt: 2, + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ]) + + expect([...selection]).toEqual(['local-orca']) + }) + + it('keeps same-named folders separate when provider identity is missing', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ id: 'local-app', displayName: 'app' }), + repo({ id: 'ssh-app', displayName: 'app', connectionId: 'builder' }) + ]) + + expect([...selection].sort()).toEqual(['local-app', 'ssh-app']) + }) +}) diff --git a/src/renderer/src/components/task-page-default-repo-selection.ts b/src/renderer/src/components/task-page-default-repo-selection.ts new file mode 100644 index 00000000000..c2379ed0795 --- /dev/null +++ b/src/renderer/src/components/task-page-default-repo-selection.ts @@ -0,0 +1,31 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/types' + +export function getDefaultTaskRepoSelection(repos: readonly Repo[]): Set<string> { + const selectedByProject = new Map<string, Repo>() + for (const repo of repos) { + const projectKey = getTaskRepoProjectKey(repo) + const current = selectedByProject.get(projectKey) + if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { + selectedByProject.set(projectKey, repo) + } + } + return new Set([...selectedByProject.values()].map((repo) => repo.id)) +} + +function getTaskRepoProjectKey(repo: Repo): string { + const owner = typeof repo.upstream?.owner === 'string' ? repo.upstream.owner.trim() : '' + const name = typeof repo.upstream?.repo === 'string' ? repo.upstream.repo.trim() : '' + return owner && name ? `github:${owner.toLowerCase()}/${name.toLowerCase()}` : `repo:${repo.id}` +} + +function compareDefaultTaskRepoCandidate(a: Repo, b: Repo): number { + // Why: when the same logical project exists on multiple hosts, default to + // the local checkout to avoid surprising remote auth/network work on first load. + const aLocal = getRepoExecutionHostId(a) === LOCAL_EXECUTION_HOST_ID + const bLocal = getRepoExecutionHostId(b) === LOCAL_EXECUTION_HOST_ID + if (aLocal !== bLocal) { + return aLocal ? -1 : 1 + } + return (a.addedAt ?? 0) - (b.addedAt ?? 0) || a.id.localeCompare(b.id) +} diff --git a/src/renderer/src/components/task-page-empty-state.test.ts b/src/renderer/src/components/task-page-empty-state.test.ts new file mode 100644 index 00000000000..25cd9bd6223 --- /dev/null +++ b/src/renderer/src/components/task-page-empty-state.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { getRepoBackedTaskEmptyState } from './task-page-empty-state' + +describe('getRepoBackedTaskEmptyState', () => { + it('explains when no repo-backed task source is selected', () => { + expect( + getRepoBackedTaskEmptyState({ + provider: 'github', + selectedRepoCount: 0 + }) + ).toEqual({ + title: 'No project sources selected', + description: + 'Select at least one project source so Orca knows which host/account to fetch tasks from.' + }) + }) + + it('keeps GitHub no-match copy when sources are selected', () => { + expect( + getRepoBackedTaskEmptyState({ + provider: 'github', + selectedRepoCount: 2 + }) + ).toEqual({ + title: 'No matching GitHub work', + description: 'Change the query or clear it.' + }) + }) + + it('uses GitLab view-specific no-match copy when sources are selected', () => { + expect( + getRepoBackedTaskEmptyState({ + provider: 'gitlab', + selectedRepoCount: 1, + gitlabView: 'mrs' + }) + ).toEqual({ + title: 'No GitLab merge requests', + description: 'No GitLab MRs match this filter.' + }) + }) +}) diff --git a/src/renderer/src/components/task-page-empty-state.ts b/src/renderer/src/components/task-page-empty-state.ts new file mode 100644 index 00000000000..cb874a88d4c --- /dev/null +++ b/src/renderer/src/components/task-page-empty-state.ts @@ -0,0 +1,43 @@ +export type RepoBackedTaskEmptyStateProvider = 'github' | 'gitlab' + +export type RepoBackedTaskEmptyState = { + title: string + description: string +} + +export function getRepoBackedTaskEmptyState(args: { + provider: RepoBackedTaskEmptyStateProvider + selectedRepoCount: number + gitlabView?: 'issues' | 'mrs' | 'todos' +}): RepoBackedTaskEmptyState { + if (args.selectedRepoCount === 0) { + return { + title: 'No project sources selected', + description: + 'Select at least one project source so Orca knows which host/account to fetch tasks from.' + } + } + if (args.provider === 'github') { + return { + title: 'No matching GitHub work', + description: 'Change the query or clear it.' + } + } + switch (args.gitlabView) { + case 'issues': + return { + title: 'No GitLab issues', + description: 'No GitLab issues match this filter.' + } + case 'mrs': + return { + title: 'No GitLab merge requests', + description: 'No GitLab MRs match this filter.' + } + default: + return { + title: 'No GitLab work', + description: 'No GitLab work matches this filter.' + } + } +} diff --git a/src/renderer/src/components/task-source-context-summary.test.ts b/src/renderer/src/components/task-source-context-summary.test.ts new file mode 100644 index 00000000000..d84a4eadbc7 --- /dev/null +++ b/src/renderer/src/components/task-source-context-summary.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { getTaskSourceContextSummary } from './task-source-context-summary' + +describe('task source context summary', () => { + it('shows provider, host, and provider identity for a single repo-backed source', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ] + }) + + expect(summary.label).toBe('GitHub · devbox · stablyai/orca') + expect(summary.title).toBe('GitHub · Host: devbox · Source: stablyai/orca') + }) + + it('shows disconnected source-host availability for a single SSH repo source', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [{ hostId: 'ssh:devbox', status: 'disconnected' }] + }) + + expect(summary.label).toBe('GitHub · devbox · disconnected · stablyai/orca') + expect(summary.title).toBe( + 'GitHub · Host: devbox · Availability: devbox disconnected · Source: stablyai/orca' + ) + }) + + it('summarizes multiple unavailable source hosts without cluttering the label', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 2, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'project-a', + hostId: 'ssh:devbox', + repoId: 'repo-a' + }, + { + kind: 'task-source', + provider: 'github', + projectId: 'project-b', + hostId: 'ssh:buildbox', + repoId: 'repo-b' + } + ], + hostAvailability: [ + { hostId: 'ssh:devbox', status: 'auth-failed' }, + { hostId: 'ssh:buildbox', status: 'reconnecting' } + ] + }) + + expect(summary.label).toBe('GitHub · devbox, buildbox · 2 unavailable · 2 projects') + expect(summary.title).toBe( + 'GitHub · Host: devbox, buildbox · Availability: devbox auth needed, buildbox connecting · 2 selected projects' + ) + }) + + it('summarizes multiple repo-backed hosts without hiding the selected count', () => { + const summary = getTaskSourceContextSummary({ + provider: 'gitlab', + providerLabel: 'GitLab', + selectedRepoCount: 3, + repoContexts: [ + { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-a', + hostId: 'local', + repoId: 'repo-a' + }, + { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-b', + hostId: 'ssh:build', + repoId: 'repo-b' + }, + { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-c', + hostId: 'runtime:linux', + repoId: 'repo-c' + } + ] + }) + + expect(summary.label).toBe('GitLab · Local Mac +2 · 3 projects') + expect(summary.title).toBe('GitLab · Host: Local Mac, build, linux · 3 selected projects') + }) + + it('shows blocked remote-server source-host availability', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'project-a', + hostId: 'runtime:old-server', + repoId: 'repo-a', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [{ hostId: 'runtime:old-server', health: 'blocked' }] + }) + + expect(summary.label).toBe('GitHub · old-server · server update needed · stablyai/orca') + expect(summary.title).toBe( + 'GitHub · Host: old-server · Availability: old-server server update needed · Source: stablyai/orca' + ) + }) + + it('shows account-backed Linear and Jira sources', () => { + expect( + getTaskSourceContextSummary({ + provider: 'linear', + providerLabel: 'Linear', + accountHostId: 'local', + linearWorkspaceName: 'Stably' + }).label + ).toBe('Linear · Local Mac · Stably') + + expect( + getTaskSourceContextSummary({ + provider: 'jira', + providerLabel: 'Jira', + accountHostId: 'runtime:server', + jiraSiteName: 'Stably Jira' + }).label + ).toBe('Jira · server · Stably Jira') + }) + + it('shows account-backed source host availability', () => { + const summary = getTaskSourceContextSummary({ + provider: 'linear', + providerLabel: 'Linear', + accountHostId: 'runtime:old-server', + linearWorkspaceName: 'Stably', + hostAvailability: [{ hostId: 'runtime:old-server', health: 'blocked' }] + }) + + expect(summary.label).toBe('Linear · old-server · server update needed · Stably') + expect(summary.title).toBe( + 'Linear source · Host: old-server · Availability: old-server server update needed · Account: Stably' + ) + }) +}) diff --git a/src/renderer/src/components/task-source-context-summary.ts b/src/renderer/src/components/task-source-context-summary.ts new file mode 100644 index 00000000000..821162bccd2 --- /dev/null +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -0,0 +1,232 @@ +import { getExecutionHostLabel } from '../../../shared/execution-host' +import type { ExecutionHostScope } from '../../../shared/execution-host' +import type { ExecutionHostHealth } from '../../../shared/execution-host-registry' +import type { SshConnectionStatus } from '../../../shared/ssh-types' +import type { TaskProvider } from '../../../shared/types' +import type { TaskProviderIdentity, TaskSourceContext } from '../../../shared/task-source-context' + +export type TaskSourceContextSummary = { + label: string + title: string +} + +export type TaskSourceHostAvailability = { + hostId: ExecutionHostScope + status?: SshConnectionStatus + health?: ExecutionHostHealth +} + +export function getTaskSourceContextSummary(args: { + provider: TaskProvider + providerLabel: string + repoContexts?: readonly TaskSourceContext[] + hostAvailability?: readonly TaskSourceHostAvailability[] + accountHostId?: ExecutionHostScope | null + selectedRepoCount?: number + linearWorkspaceName?: string | null + jiraSiteName?: string | null +}): TaskSourceContextSummary { + switch (args.provider) { + case 'github': + case 'gitlab': + return getRepoBackedTaskSourceSummary(args) + case 'linear': + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: args.linearWorkspaceName, + accountHostId: args.accountHostId, + hostAvailability: args.hostAvailability + }) + case 'jira': + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: args.jiraSiteName, + accountHostId: args.accountHostId, + hostAvailability: args.hostAvailability + }) + } +} + +function getRepoBackedTaskSourceSummary(args: { + providerLabel: string + repoContexts?: readonly TaskSourceContext[] + hostAvailability?: readonly TaskSourceHostAvailability[] + selectedRepoCount?: number +}): TaskSourceContextSummary { + const contexts = args.repoContexts ?? [] + const hostLabels = uniqueLabels(contexts.map((context) => getExecutionHostLabel(context.hostId))) + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? []) + const availabilityLabel = getAvailabilityLabel(unavailableHosts) + const identityLabels = uniqueLabels( + contexts.map((context) => getProviderIdentityLabel(context.providerIdentity)) + ) + const repoCount = args.selectedRepoCount ?? contexts.length + const hostLabel = hostLabels.length === 0 ? 'No host' : formatShortList(hostLabels) + const targetLabel = + repoCount > 1 + ? `${repoCount} projects` + : (identityLabels[0] ?? contexts[0]?.accountLabel ?? 'Selected project') + const titleParts = [ + args.providerLabel, + hostLabels.length > 0 ? `Host: ${formatLongList(hostLabels)}` : null, + unavailableHosts.length > 0 + ? `Availability: ${formatLongList( + unavailableHosts.map((host) => `${host.hostLabel} ${host.statusLabel}`) + )}` + : null, + identityLabels.length > 0 ? `Source: ${formatLongList(identityLabels)}` : null, + repoCount > 1 ? `${repoCount} selected projects` : null + ].filter((part): part is string => Boolean(part)) + + return { + label: [args.providerLabel, hostLabel, availabilityLabel, targetLabel] + .filter((part): part is string => Boolean(part)) + .join(' · '), + title: titleParts.join(' · ') + } +} + +function getAccountBackedTaskSourceSummary( + providerLabel: string, + args: { + accountLabel: string | null | undefined + accountHostId: ExecutionHostScope | null | undefined + hostAvailability?: readonly TaskSourceHostAvailability[] + } +): TaskSourceContextSummary { + const target = args.accountLabel?.trim() || 'Current account' + const hostLabel = getExecutionHostLabel(args.accountHostId ?? 'local') + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? []) + const availabilityLabel = getAvailabilityLabel(unavailableHosts) + const titleParts = [ + `${providerLabel} source`, + `Host: ${hostLabel}`, + availabilityLabel + ? `Availability: ${formatLongList( + unavailableHosts.map((host) => `${host.hostLabel} ${host.statusLabel}`) + )}` + : null, + `Account: ${target}` + ].filter((part): part is string => Boolean(part)) + return { + label: [providerLabel, hostLabel, availabilityLabel, target] + .filter((part): part is string => Boolean(part)) + .join(' · '), + title: titleParts.join(' · ') + } +} + +function getProviderIdentityLabel( + identity: TaskProviderIdentity | null | undefined +): string | null { + if (!identity) { + return null + } + switch (identity.provider) { + case 'github': + return `${identity.owner}/${identity.repo}` + case 'gitlab': + return identity.namespace && identity.project + ? `${identity.namespace}/${identity.project}` + : (identity.projectId ?? null) + case 'linear': + return identity.workspaceName ?? identity.workspaceId ?? null + case 'jira': + return identity.siteUrl ?? identity.siteId ?? null + } +} + +function uniqueLabels(labels: readonly (string | null | undefined)[]): string[] { + const seen = new Set<string>() + const result: string[] = [] + for (const label of labels) { + const trimmed = label?.trim() + if (!trimmed || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + result.push(trimmed) + } + return result +} + +function getUnavailableHosts(hostAvailability: readonly TaskSourceHostAvailability[]): { + hostLabel: string + statusLabel: string +}[] { + const seen = new Set<string>() + const unavailableHosts: { hostLabel: string; statusLabel: string }[] = [] + for (const availability of hostAvailability) { + const statusLabel = getAvailabilityStatusLabel(availability) + if (!statusLabel) { + continue + } + const hostLabel = getExecutionHostLabel(availability.hostId) + const key = `${hostLabel}\u0000${statusLabel}` + if (seen.has(key)) { + continue + } + seen.add(key) + unavailableHosts.push({ hostLabel, statusLabel }) + } + return unavailableHosts +} + +function getAvailabilityStatusLabel(availability: TaskSourceHostAvailability): string | null { + if (availability.status) { + return availability.status === 'connected' ? null : getSshStatusLabel(availability.status) + } + switch (availability.health) { + case 'local': + case 'available': + case undefined: + return null + case 'connecting': + return 'connecting' + case 'blocked': + return 'server update needed' + case 'disconnected': + return 'disconnected' + case 'error': + return 'connection issue' + } +} + +function getAvailabilityLabel( + unavailableHosts: readonly { hostLabel: string; statusLabel: string }[] +): string | null { + if (unavailableHosts.length === 0) { + return null + } + if (unavailableHosts.length === 1) { + return unavailableHosts[0].statusLabel + } + return `${unavailableHosts.length} unavailable` +} + +function getSshStatusLabel(status: SshConnectionStatus): string { + switch (status) { + case 'connected': + return 'connected' + case 'connecting': + case 'deploying-relay': + case 'reconnecting': + return 'connecting' + case 'auth-failed': + return 'auth needed' + case 'reconnection-failed': + case 'error': + return 'connection issue' + case 'disconnected': + return 'disconnected' + } +} + +function formatShortList(labels: readonly string[]): string { + if (labels.length <= 2) { + return labels.join(', ') + } + return `${labels[0]} +${labels.length - 1}` +} + +function formatLongList(labels: readonly string[]): string { + return labels.join(', ') +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index dc56b37f21e..cb4bbfbd2ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -84,6 +84,7 @@ import { cancelScheduledHiddenOutputRestore, scheduleHiddenOutputRestore } from './hidden-output-restore-scheduler' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { @@ -1796,39 +1797,31 @@ export function connectPanePty( if (pendingStartupCommand) { return false } - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - // Why: agentStatusByPaneKey is in-memory only. After an app restart, the - // quit-captured sleeping record is the only surviving source of this - // pane's provider session id (#5232). Live entries win when present — - // they are fresher and setAgentStatus already cleared the record. - const sleepingRecord = entry ? null : state.sleepingAgentSessionsByPaneKey[cacheKey] - const agentType = entry?.agentType ?? sleepingRecord?.agent - const agentState = entry?.state ?? sleepingRecord?.state - const rawProviderSession = entry?.providerSession ?? sleepingRecord?.providerSession - if (!agentType || agentState === 'done' || !isResumableTuiAgent(agentType)) { + const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] + if (!entry || entry.state === 'done' || !isResumableTuiAgent(entry.agentType)) { return false } - const providerSession = normalizeAgentProviderSession(rawProviderSession) + const providerSession = normalizeAgentProviderSession(entry.providerSession) if (!providerSession) { return false } const startupPlan = buildAgentResumeStartupPlan({ - agent: agentType, + agent: entry.agentType, providerSession, - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agentType, state.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agentType, state.settings?.agentDefaultEnv), + cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs( + entry.agentType, + useAppStore.getState().settings?.agentDefaultArgs + ), + agentEnv: resolveTuiAgentLaunchEnv( + entry.agentType, + useAppStore.getState().settings?.agentDefaultEnv + ), platform: getColdRestoreAgentResumePlatform() }) if (!startupPlan) { return false } - if (sleepingRecord) { - // Why: the record is one-shot — consuming it here prevents a later - // worktree activation from launching a duplicate resume tab. - useAppStore.getState().clearSleepingAgentSession(cacheKey) - } // Why: cold restore means the PTY process is gone but the agent provider // session is still resumable, so the replacement shell must launch it. pendingStartupCommand = startupPlan.launchCommand diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index cbc17bbf783..50173412559 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -57,7 +57,8 @@ export function useAutomationDispatchEvents(): void { activeTabId: state.activeTabId, activeTabType: state.activeTabType } - const repo = state.repos.find((entry) => entry.id === automation.projectId) + const runRepoId = automation.runContext?.repoId ?? automation.projectId + const repo = state.repos.find((entry) => entry.id === runRepoId) const automationWorktree = automation.workspaceId ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId) : null @@ -117,6 +118,25 @@ export function useAutomationDispatchEvents(): void { } } + if ( + automation.workspaceMode === 'existing' && + automationWorktree && + automation.runContext?.repoId && + automationWorktree.repoId !== automation.runContext.repoId + ) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) + }) + return + } + if (automation.workspaceMode === 'existing' && !automationWorktree) { await markDispatchResult({ runId: run.id, @@ -156,7 +176,7 @@ export function useAutomationDispatchEvents(): void { await useAppStore .getState() .createWorktree( - automation.projectId, + runRepoId, buildAutomationWorkspaceName(run.title, run.scheduledFor), automation.baseBranch ?? undefined, 'inherit', diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index e416b497e00..29e5b489d3a 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -26,6 +26,10 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' +import { + buildTaskSourceContextFromRepo, + type TaskSourceContext +} from '../../../shared/task-source-context' import type { GitHubWorkItem, GitHubPrStartPoint, @@ -132,6 +136,7 @@ export type UseComposerStateOptions = { initialName?: string initialPrompt?: string initialLinkedWorkItem?: LinkedWorkItemSummary | null + initialTaskSourceContext?: TaskSourceContext | null initialWorkspaceStatus?: WorkspaceStatus /** Seed the Start-from selection when the composer opens. Used by the * Create-from → Quick fallback path so a PR pick that needs a setup @@ -292,6 +297,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS initialName = '', initialPrompt = '', initialLinkedWorkItem = null, + initialTaskSourceContext = null, initialWorkspaceStatus, initialBaseBranch, persistDraft, @@ -479,6 +485,48 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem) : initialLinkedWorkItem ) + const taskSourceContext = useMemo(() => { + if ( + persistDraft && + newWorkspaceDraft?.taskSourceContext && + newWorkspaceDraft.linkedWorkItem?.url === linkedWorkItem?.url + ) { + return newWorkspaceDraft.taskSourceContext + } + if (initialTaskSourceContext && initialLinkedWorkItem?.url === linkedWorkItem?.url) { + return initialTaskSourceContext + } + if ( + !linkedWorkItem || + getLinkedWorkItemProvider(linkedWorkItem) !== 'github' || + !selectedRepo || + selectedWorkspaceTarget.status !== 'ready' + ) { + return null + } + const selectedProject = projects.find( + (project) => project.id === selectedWorkspaceTarget.target.projectId + ) + if (selectedProject?.providerIdentity?.provider !== 'github') { + return null + } + return buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedWorkspaceTarget.target.projectId, + repo: selectedRepo, + projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId, + providerIdentity: selectedProject.providerIdentity + }) + }, [ + initialLinkedWorkItem, + initialTaskSourceContext, + linkedWorkItem, + newWorkspaceDraft?.taskSourceContext, + persistDraft, + projects, + selectedRepo, + selectedWorkspaceTarget + ]) const [linkedIssue, setLinkedIssue] = useState<string>(() => { if (persistDraft && newWorkspaceDraft?.linkedIssue) { return newWorkspaceDraft.linkedIssue @@ -926,6 +974,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note, attachments: attachmentPaths, linkedWorkItem, + taskSourceContext, agent: tuiAgent, linkedIssue, linkedPR, @@ -948,6 +997,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS repoId, selectedWorkspaceTarget, setNewWorkspaceDraft, + taskSourceContext, tuiAgent ]) @@ -1668,7 +1718,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, []) const handleRepoChange = useCallback( - (value: string): void => { + (value: string, options: { preserveStartFrom?: boolean } = {}): void => { setProjectError(null) if (value === repoId) { setRepoId(value) @@ -1678,26 +1728,30 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // the field can render an inline reset (e.g. "was PR #8778") after the // repo changes and the selection is wiped. let hint: string | null = null - if (linkedWorkItem?.type === 'pr' && baseBranch) { - hint = `was PR #${linkedWorkItem.number}` - } else if (linkedWorkItem?.type === 'mr' && baseBranch) { - // Why: GitLab MR convention is `!N`, not `#N` — match the - // upstream UI so the reset hint is recognizable. - hint = `was MR !${linkedWorkItem.number}` - } else if (baseBranch) { - hint = `was ${baseBranch}` + if (!options.preserveStartFrom) { + if (linkedWorkItem?.type === 'pr' && baseBranch) { + hint = `was PR #${linkedWorkItem.number}` + } else if (linkedWorkItem?.type === 'mr' && baseBranch) { + // Why: GitLab MR convention is `!N`, not `#N` — match the + // upstream UI so the reset hint is recognizable. + hint = `was MR !${linkedWorkItem.number}` + } else if (baseBranch) { + hint = `was ${baseBranch}` + } } const preserveLinearLinkedWorkItem = isLinearLinkedWorkItem(linkedWorkItem) setRepoId(value) - setLinkedIssue('') - setLinkedPR(null) - setLinkedGitLabIssue(null) - setLinkedGitLabMR(null) - // Why: repo changes invalidate repo-scoped sources (GitHub/GitLab/branch), - // but a selected Linear issue is workspace-scoped source context and - // must survive choosing the implementation project. - if (!preserveLinearLinkedWorkItem) { - setLinkedWorkItem(null) + if (!options.preserveStartFrom) { + setLinkedIssue('') + setLinkedPR(null) + setLinkedGitLabIssue(null) + setLinkedGitLabMR(null) + // Why: repo changes invalidate repo-scoped sources (GitHub/GitLab/branch), + // but a selected Linear issue is workspace-scoped source context and + // must survive choosing the implementation project. + if (!preserveLinearLinkedWorkItem) { + setLinkedWorkItem(null) + } } setSparseEnabled(false) setSparseDirectories('') @@ -1707,11 +1761,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: the Start-from picker is repo-scoped, so any prior branch/PR // selection is meaningless in the new repo. Resetting to undefined // makes the field fall back to the new repo's effective base ref. - setBaseBranch(undefined) - setPushTarget(undefined) - setBranchNameOverride(undefined) - setForkPushWarning(null) - setStartFromResetHint(hint) + if (!options.preserveStartFrom) { + setBaseBranch(undefined) + setPushTarget(undefined) + setBranchNameOverride(undefined) + setForkPushWarning(null) + setStartFromResetHint(hint) + } }, [baseBranch, linkedWorkItem, repoId, setRepoId] ) @@ -1721,7 +1777,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (!option || option.kind !== 'ready') { return } - handleRepoChange(option.repoId) + // Why: switching the run host for the same logical project must not + // erase the task/PR source the user is starting from. + handleRepoChange(option.repoId, { preserveStartFrom: true }) }, [handleRepoChange, projectHostSetupOptions] ) @@ -2549,6 +2607,19 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS : undefined const request: WorktreeCreationRequest = { repoId, + ...(taskSourceContext ? { taskSourceContext } : {}), + ...(selectedWorkspaceTarget.status === 'ready' + ? { + workspaceRunContext: { + kind: 'workspace-run', + projectId: selectedWorkspaceTarget.target.projectId, + hostId: selectedWorkspaceTarget.target.hostId, + projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId, + repoId: selectedWorkspaceTarget.target.repoId, + path: selectedWorkspaceTarget.target.repo.path + } + } + : {}), name: workspaceName, ...(createDisplayName ? { displayName: createDisplayName } : {}), ...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}), diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx new file mode 100644 index 00000000000..5f0d7bac393 --- /dev/null +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearGitHubSlugMetadataCache, + useRepoAssigneesBySlug, + useRepoLabelsBySlug +} from './useGitHubSlugMetadata' + +const apiMocks = vi.hoisted(() => ({ + listLabelsBySlug: vi.fn(), + listAssignableUsersBySlug: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn(), + getActiveRuntimeTarget: ( + settings?: { activeRuntimeEnvironmentId?: string | null } | null + ) => + settings?.activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } + : { kind: 'local' } +})) + +const roots: Root[] = [] + +function installWindowApi(): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + gh: { + listLabelsBySlug: apiMocks.listLabelsBySlug, + listAssignableUsersBySlug: apiMocks.listAssignableUsersBySlug + } + } + }) +} + +async function flushEffects(): Promise<void> { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function renderProbe(element: React.ReactNode): void { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + act(() => { + root.render(element) + }) +} + +describe('useGitHubSlugMetadata', () => { + beforeEach(() => { + clearGitHubSlugMetadataCache() + apiMocks.listLabelsBySlug.mockReset() + apiMocks.listAssignableUsersBySlug.mockReset() + installWindowApi() + }) + + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + vi.unstubAllGlobals() + }) + + it('does not loop when cached label metadata is read with a fresh settings object', async () => { + let renders = 0 + let labels: string[] = [] + apiMocks.listLabelsBySlug.mockResolvedValue({ ok: true, labels: ['bug'] }) + + function LabelsProbe(): null { + renders += 1 + const metadata = useRepoLabelsBySlug('stablyai', 'orca', { + activeRuntimeEnvironmentId: null + }) + labels = metadata.data + return null + } + + renderProbe(<LabelsProbe />) + await flushEffects() + + expect(labels).toEqual(['bug']) + expect(apiMocks.listLabelsBySlug).toHaveBeenCalledExactlyOnceWith({ + owner: 'stablyai', + repo: 'orca' + }) + expect(renders).toBeLessThanOrEqual(4) + }) + + it('does not loop when cached assignee metadata is read with a fresh settings object', async () => { + let renders = 0 + let assigneeLogins: string[] = [] + apiMocks.listAssignableUsersBySlug.mockResolvedValue({ + ok: true, + users: [{ login: 'jinwoo', name: 'Jinwoo', avatarUrl: 'https://example.test/avatar.png' }] + }) + + function AssigneesProbe(): null { + renders += 1 + const metadata = useRepoAssigneesBySlug( + 'stablyai', + 'orca', + ['jinwoo'], + { activeRuntimeEnvironmentId: null } + ) + assigneeLogins = metadata.data.map((user) => user.login) + return null + } + + renderProbe(<AssigneesProbe />) + await flushEffects() + + expect(assigneeLogins).toEqual(['jinwoo']) + expect(apiMocks.listAssignableUsersBySlug).toHaveBeenCalledExactlyOnceWith({ + owner: 'stablyai', + repo: 'orca', + seedLogins: ['jinwoo'] + }) + expect(renders).toBeLessThanOrEqual(4) + }) +}) diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index bd4a400bf53..26022191fcc 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -57,15 +57,18 @@ export function useRepoLabelsBySlug( const cached = getFreshMetadata(slugLabelStore, key) if (cached) { - // Why: always seed state from cache. A remount with the same key - // resets local state to defaults but `activeKeyRef.current` from the - // new ref instance is null on first run — the previous gate that - // skipped setState when keys matched dropped cached data on remount. - setState({ data: cached.data, loading: false, error: null }) + // Why: parent selectors can pass a fresh settings object each render; + // only the first cached hit for this key should write React state. + if (activeKeyRef.current !== key) { + setState({ data: cached.data, loading: false, error: null }) + } activeKeyRef.current = key return } + if (activeKeyRef.current === key) { + return + } activeKeyRef.current = key const requestKey = key setState((s) => ({ @@ -141,14 +144,18 @@ export function useRepoAssigneesBySlug( const cached = getFreshMetadata(slugAssigneeStore, key) if (cached) { - // Why: see useRepoLabelsBySlug — always seed state from cache so a - // remount with the same key picks up cached data instead of staying - // at the empty default. - setState({ data: cached.data, loading: false, error: null }) + // Why: see useRepoLabelsBySlug — avoid cached no-op writes when only + // the settings object identity changed. + if (activeKeyRef.current !== key) { + setState({ data: cached.data, loading: false, error: null }) + } activeKeyRef.current = key return } + if (activeKeyRef.current === key) { + return + } activeKeyRef.current = key const requestKey = key setState((s) => ({ diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 33830ba845e..86000181fd3 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -402,7 +402,7 @@ export function buildSettingsNavigationMetadata({ title: translate('auto.hooks.useSettingsNavigationMetadata.94a5afe910', 'SSH Hosts'), description: translate( 'auto.hooks.useSettingsNavigationMetadata.31e57d1c70', - 'Remote SSH hosts for files, terminals, and git.' + 'SSH host access for files, terminals, and git.' ), icon: Cable, searchEntries: getSshPaneSearchEntries(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index eac4544f35d..ad097e0310d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -274,8 +274,7 @@ "760bc6883d": "Codex", "a5fc0cb622": "OpenClaude", "bf53f09bf8": "Claude Agent Teams", - "0708ed89f1": "Claude", - "fc80296033": "Devin" + "0708ed89f1": "Claude" }, "skill": { "cli": { @@ -464,35 +463,6 @@ "7d732521ec": "Comment" } } - }, - "folderWorkspacePathStatus": { - "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder" - }, - "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." - }, - "createError": { - "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder", - "generic": "Failed to create folder workspace" - }, - "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." - } - } } }, "hooks": { @@ -540,7 +510,7 @@ "d91ae31fbd": "macOS Permissions", "95a1886d94": "Control terminals and agents from your phone.", "1cd25673df": "Mobile", - "31e57d1c70": "Remote SSH hosts for files, terminals, and git.", + "31e57d1c70": "SSH host access for files, terminals, and git.", "94a5afe910": "SSH Hosts", "40d80bad8a": "Beta", "de0c2907a1": "Remote Orca Servers", @@ -1030,15 +1000,21 @@ "f660aa1454": "Connecting", "7711ad5122": "Local setup command", "e5db1b0419": "Combined setup command", + "runOn": "Run on", "addProjectBeforeWorkspace": "Add a project before creating a workspace.", - "sshNotConnected": "SSH not connected", - "connectingSsh": "Connecting SSH...", - "sshAuthenticationFailed": "SSH authentication failed", - "preparingSshConnection": "Preparing SSH connection...", - "connected": "Connected", - "reconnectingSsh": "Reconnecting SSH...", - "sshReconnectionFailed": "SSH reconnection failed", - "notConnected": "Not connected" + "setupHostExistingFolderTitle": "Set up {{value0}}", + "cloneProjectOnHost": "Clone project", + "cloneUrlPlaceholder": "https://github.com/owner/repo.git", + "cloneDestinationPlaceholder": "/parent/directory/on/host", + "cloningHostSetup": "Cloning...", + "cloneHostSetup": "Clone", + "importExistingFolderOnHost": "Import existing folder", + "setupHostExistingFolderPlaceholder": "/path/to/project/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", + "importingHostSetup": "Importing...", + "importHostSetup": "Import" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace." @@ -1552,9 +1528,7 @@ "a2a279b32a": "Save timed out or failed. Fix errors before closing.", "46e08bc5c8": "This file has unsaved changes.", "61ed600d29": "\"{{value0}}\" has unsaved changes. Do you want to save before closing?", - "cdc9ac4b2d": "editor", - "e57db40c11": "Could not build launch command for {{value0}}.", - "5b2c1a9e44": "No agent CLI detected — install one or pick a default agent in Settings." + "cdc9ac4b2d": "editor" }, "TerminalSearch": { "db234b7519": "Close", @@ -1670,9 +1644,7 @@ "331ebe1170": "Add this pull request to the GitHub merge queue", "b169f943e1": "Merge when ready", "62703b1dc4": "GitHub auto-merge is enabled for this pull request", - "48d75ae118": "Disable auto-merge", - "a5b66afb58": "Checks passed", - "fbd4f57f0a": "Checks passed. Merge eligibility will be checked again before merging." + "48d75ae118": "Disable auto-merge" } } }, @@ -1816,11 +1788,7 @@ "015b4e607d": "Cancel", "e3bd59143c": "Insert", "f24783f470": "https://...", - "ec6310b731": "Use an http:// or https:// image URL.", - "b7e4a1c902": "Paste, drop, or click to add files", - "8f1c2d4e6a": "Nothing to preview", - "c91f0a2b14": "Write", - "d82b1e3f05": "Preview" + "ec6310b731": "Use an http:// or https:// image URL." }, "IssueSourceSelector": { "d6aeb2012b": "Showing issues from", @@ -1894,47 +1862,6 @@ } } } - }, - "CloseReasonDropdown": { - "e1f2a3b4c5": "Choose close reason" - }, - "GitHubIssueCommentComposer": { - "082515176a": "Failed to add comment", - "9f88657c4e": "Issue closed", - "e9b7cb7d17": "Failed to close issue", - "bd3b4492a0": "Issue reopened", - "f2a8c1d903": "Failed to reopen issue", - "a1b2c3d4e5": "Add a comment", - "c5c117270e": "Add your comment here, be kind", - "f6a7b8c9d0": "Close issue", - "b1c2d3e4f5": "Reopen issue", - "0a73f59e85": "Send comment", - "bf43425540": "Comment" - }, - "GitHubWorkItemAssigneePopoverContent": { - "cddd9b04a7": "Loading assignees", - "a00830d3f7": "No users", - "4f8b6f2c1d": "Filter assignees..." - }, - "GitHubWorkItemLabelPopoverContent": { - "2aa9acdf34": "Edit labels on GitHub", - "cddd9b04a7": "Loading labels", - "de26e2eb06": "No labels", - "8b0d52ee3a": "Filter labels..." - }, - "githubIssueCloseReasons": { - "completed": { - "label": "Close as completed", - "description": "Done, closed, fixed, resolved" - }, - "notPlanned": { - "label": "Close as not planned", - "description": "Won't fix, can't repro, stale" - }, - "duplicate": { - "label": "Close as duplicate", - "description": "Duplicate of another issue" - } } }, "linear": { @@ -2210,8 +2137,7 @@ "c44659e09f": "Mobile driving", "7cffad954c": "Collapse", "3eed73394f": "Your keyboard is paused", - "faa367dc74": "This terminal is sized for your mobile app", - "54f7d6f69d": "Resize all terminals" + "faa367dc74": "This terminal is sized for your mobile app" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "Copy context", @@ -2447,9 +2373,7 @@ "77ac113df0": "Start {{value0}}: {{value1}}", "7b1c9d6ae1": "Run", "c781f992e4": "destructive", - "be8f0ff166": "Delete", - "f3a8c2d1e7": "Search quick commands...", - "b4e7f9a2c1": "No commands match" + "be8f0ff166": "Delete" }, "shell": { "icons": { @@ -2839,11 +2763,7 @@ "4f8368c272": "Orca worktrees only", "cfe2282ffa": "Unknown", "7765a4c3e1": "n/a", - "2d41fd45c6": " • Last scan error: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "2d41fd45c6": " • Last scan error: {{value0}}" }, "CodexUsageDailyChart": { "1e6f62d7e3": "Reasoning", @@ -2892,11 +2812,7 @@ "bf6cf2d4dd": "Unknown", "ae255c3dba": "n/a", "247c93ca92": "• inferred pricing", - "8a6655f7a2": " • Last scan error: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "8a6655f7a2": " • Last scan error: {{value0}}" }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", @@ -2935,11 +2851,7 @@ "e04c58327c": "Orca worktrees only", "362231082f": "Unknown", "8095a63426": "n/a", - "6cc7782458": " • Last scan error: {{value0}}", - "rangeLast7Days": "Last 7 days", - "rangeLast30Days": "Last 30 days", - "rangeLast90Days": "Last 90 days", - "rangeAllTime": "All time" + "6cc7782458": " • Last scan error: {{value0}}" }, "ShareUsageButton": { "7d6b25323d": "Share on X", @@ -3058,22 +2970,6 @@ "d9a4b3e2f1c5": "events" } } - }, - "UsageBreakdownSection": { - "7765a4c3e1": "n/a", - "247c93ca92": "• inferred pricing" - }, - "UsageSessionsTable": { - "1afc25eb06": "Turns", - "0f03975d59": "Events", - "21ea00bfa8": "Cache", - "e0b988599d": "Total", - "01476891c7": "Last active", - "c17bed0416": "Project", - "f6a2c8d019": "Model", - "faf3444859": "Input", - "a8b7487ff7": "Output", - "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3158,7 +3054,7 @@ "7d1f51678c": "Add Project", "7726a16374": "Cancel", "046751dbfb": "Add this folder as a separate Orca project.", - "e643b30398": "Remote project added" + "e643b30398": "Project added on SSH host" }, "AddRepoCreateStep": { "0ae45b8238": "my-project", @@ -3171,23 +3067,15 @@ "685b5eefe1": "{{kind}} in {{parent}}", "2a762f3b19": "Checking Git on this host...", "fe1e616c5b": "Git isn't installed, so a plain folder is the default.", - "c234df77f7": "Choose or enter a server parent folder before creating.", + "c234df77f7": "Choose or enter a host parent folder before creating.", "3a13f6e88b": "location not selected", - "6ed14c0281": "server folder not selected", + "6ed14c0281": "host folder not selected", "5e97f0c4b9": "Project created", "2c12db1511": "Project already added", - "875dda0995": "Enter a server parent path.", + "875dda0995": "Enter a host parent path.", "ssh_parent_manual": "Enter an SSH parent path.", "45b7c26034": "Create project", - "85085d74d2": "Creating…", - "createProjectTitle": "Create project", - "createProjectDescription": "Create a local Git repo and first workspace.", - "projectNameLabel": "Project name", - "createsGitRepoHelp": "Git repo:", - "parentFolderLabel": "Parent folder", - "browseParentFolder": "Browse", - "gitRequiredError": "Git is required to create a project.", - "createAction": "Create" + "85085d74d2": "Creating…" }, "AddRepoHostSelector": { "host": "Host", @@ -3201,6 +3089,7 @@ "2f8298f3c3": "Stop scan", "c157f31a95": "Import as group", "40199ef7b3": "Group name", + "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", "787412361a": "What is a group name?", "5f857ba8e6": "in", "4df0d08cc5": "Found", @@ -3209,10 +3098,11 @@ "cf9d382ca1": "Import", "220dd32d83": "Scanning...", "fb33359f69": "Is this a monorepo?", - "d75170194e": "Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder.", - "39d51212cc": "Group name", + "d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.", + "39d51212cc": "Monorepo name", + "e907ec8935": "What is a monorepo name?", "aa0247680d": "No, import separately", - "a0bc4d1f8e": "Import as group", + "a0bc4d1f8e": "Yes, import as monorepo", "8401a7a0d0": "1 repository", "d4f1df62ef": "{{value0}} repositories", "b4263a2ac4": "Found {{value0}} in {{value1}}.", @@ -3221,15 +3111,15 @@ "AddRepoRemoteStep": { "5b205b5281": "Stop scan", "6680289908": "/home/user/project", - "ef410aa881": "Remote path", + "ef410aa881": "Host path", "0416bde073": "Add in Settings", "df6fbcf880": "No SSH targets configured.", "44637f43bd": "SSH target", "80557be85a": "Choose a connected SSH target and enter the path to a Git repository.", - "91b93a90a4": "Open remote project", + "91b93a90a4": "Open project on SSH host", "007651bdf9": "Navigate to a directory and click Select to choose it.", "dd3ff65486": "Browse remote filesystem", - "36d427bb66": "Add remote project", + "36d427bb66": "Add project on SSH host", "35831a7312": "Adding...", "lockedDescription": "Enter the path to a Git repository on {{value0}}." }, @@ -3237,20 +3127,20 @@ "ae990c86a0": "Back to add options", "e1710bf831": "Open as Folder", "8da4d1a5be": "Add Git Project", - "ac66a3ed2d": "Browse server filesystem", + "ac66a3ed2d": "Browse host filesystem", "92d25420a0": "/home/user/project", - "867692f505": "Server path", - "423b5d3d31": "Add a Git repository or folder that already exists on the selected runtime server.", - "3d0c035483": "Open server project", - "438493f214": "Or enter a server path manually", + "867692f505": "Host path", + "423b5d3d31": "Add a Git repository or folder that already exists on the selected host.", + "3d0c035483": "Open host project", + "438493f214": "Or enter a host path manually", "6b9958492a": "Want to import many repos at once? Browse to the parent folder.", "d40d751517": "New repo or folder", - "a81ffa0a99": "Create on server", + "a81ffa0a99": "Create on host", "a2ea37d549": "Remote Git repository", "47759c9491": "Clone from URL", "516187414c": "Existing project or folder", - "0adf083af7": "Browse server", - "8efa930eb5": "Add another project from the selected runtime server.", + "0adf083af7": "Browse host", + "8efa930eb5": "Add another project from the selected host.", "39bd249b3a": "Add a project", "0f8aba944c": "Navigate to a directory and click Select to choose it." }, @@ -3267,7 +3157,7 @@ }, "AddRepoSteps": { "569326d9cc": "Choose folder", - "a93ef169b5": "Browse server filesystem", + "a93ef169b5": "Browse host filesystem", "2ce3f6edf8": "/path/to/destination", "04a4c4e84a": "Clone location", "b698a4a29d": "https://github.com/user/repo.git", @@ -3275,7 +3165,7 @@ "5b2ea674b1": "Enter the Git URL and choose where to clone it.", "c05f88a31f": "Clone from URL", "fe8e629fe3": "Navigate to a directory and click Select to choose it.", - "df8b0e6c22": "Remote project added", + "df8b0e6c22": "Project added on SSH host", "3e64e8a70d": "Connection failed", "32a7256d85": "Clone", "69f5b5380d": "Cloning...", @@ -3295,7 +3185,7 @@ "95548e33bf": "Choose parent folder...", "632b456b1b": "Change", "afaf54f245": "Change parent folder", - "f520f83a97": "Browse server filesystem", + "f520f83a97": "Browse host filesystem", "2a20a603a3": "/home/user/projects", "134e37f711": "Location", "b589b77997": "Navigate to a directory and click Select to choose it." @@ -3588,11 +3478,7 @@ "376bed88e5": "The connection to the remote host encountered an error.", "4afcca1d24": "Reconnect", "11552bf786": "SSH Disconnected", - "cb5938ae79": "Reconnecting...", - "disconnected": "This remote repository is not connected.", - "reconnecting": "Reconnecting to the remote host...", - "reconnectionFailed": "Reconnection to the remote host failed.", - "authFailed": "Authentication to the remote host failed." + "cb5938ae79": "Reconnecting..." }, "SshTargetRow": { "4677394048": "Connecting…", @@ -3651,7 +3537,7 @@ "01f45d3d8a": "sidebar", "93aebe4529": "Folder", "0d224eff10": "Primary worktree", - "ca74db7550": "Remote project via SSH", + "ca74db7550": "Project on SSH host", "021538e1d1": "SSH disconnected" }, "WorktreeCardAgents": { @@ -3818,11 +3704,11 @@ "7edb8ebe24": "Clone from URL", "a6c20dca96": "Open a project from an SSH target", "sshCreateUnavailable": "Not available for SSH hosts yet", - "3d162cc76f": "Remote project", + "3d162cc76f": "Project on SSH host", "fb4fc5380e": "Local project, Git repo, or folder with many repos", "2281fdc8c7": "Browse folder", - "createProjectTitle": "Create project", - "createGitProjectDescription": "Create a local Git repository" + "sshBrowseTitle": "Open project on SSH host", + "sshBrowseDescription": "Existing Git repository or folder on this SSH host" } } } @@ -3873,7 +3759,7 @@ "drop": { "669e12dd97": "Local folders and Git repositories", "ffc769ca29": "Drop folder to add project", - "740e8d0d46": "Use Add Project for server paths", + "740e8d0d46": "Use Add Project for host paths", "e344666fb8": "Server runtime active", "d0f8943f8b": "Preparing the project add flow", "18d3cf40e9": "Checking folder" @@ -3890,10 +3776,10 @@ }, "useAddRepoCloneFlow": { "4d0013cc93": "Repository cloned", - "0dc4d1b657": "Enter a server path for the clone destination." + "0dc4d1b657": "Enter a host path for the clone destination." }, "useAddRepoLocalFolderFlow": { - "7ab10e4974": "Use a server path to add projects from a remote runtime." + "7ab10e4974": "Use a host path to add projects from a remote host." }, "useAddRepoNestedImportFlow": { "680cac2c82": "{{value0}} failed", @@ -3903,7 +3789,7 @@ "useSidebarProjectDrop": { "f34a286c0d": "Could not add dropped folder.", "451a4638db": "Drop a folder to add it as a project.", - "5ccb56c7be": "Use Add Project to enter a server path.", + "5ccb56c7be": "Use Add Project to enter a host path.", "849ef13dc0": "Local folder drops are unavailable for server runtimes.", "c0315153d1": "Drop one folder at a time." }, @@ -3968,43 +3854,47 @@ "index": { "b826a98b6f": "busy" }, - "LinearAgentSkillSetupPrompt": { - "missingCliAndSkill": "Orca CLI and Linear agent skill are missing.", - "modalTitle": "Enable Linear ticket access", - "modalDescription": "Install the Linear skill from a terminal.", - "modalPrompt": "Enable agents to read and edit the attached Linear ticket.", - "dontShowAgain": "Don't show again", - "notNow": "Not now", - "missingBoth": "Orca CLI and Linear agent skill are missing.", - "missingCli": "Orca CLI is missing.", - "missingSkill": "Linear agent skill is missing.", - "title": "Set up Linear agent skill", - "remoteCopy": "This installs host setup; remote agent environments may need separate setup.", - "hostCopy": "Install it for host agent handoffs from linked Linear work.", - "dismiss": "Dismiss Linear agent skill setup", - "setup": "Set up", - "recheck": "Re-check", - "panelTitle": "Linear agent skill", - "panelDescription": "Install the host agent skill for linked Linear task handoffs.", - "terminalTitle": "Install Linear agent skill", - "terminalAria": "Linear agent skill installer terminal", - "install": "Install CLI & Skill", - "wslCopy": "Install it for WSL agent handoffs from linked Linear work.", - "wslLabel": "WSL default" + "HostRemoveDialog": { + "1a2b3c4d5e": "Removed {{value0}}", + "2b3c4d5e6f": "Failed to remove host", + "3c4d5e6f7a": "Remove {{value0}}?", + "4d5e6f7a8b": "This opens the Orca servers settings where you can remove this server.", + "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", + "6f7a8b9c0d": "Cancel", + "7a8b9c0d1e": "Open settings", + "8b9c0d1e2f": "Remove host" + }, + "HostRenameDialog": { + "1a2b3c4d5e": "Rename host", + "2b3c4d5e6f": "This label is shown only on this computer. Leave it blank to use the default name.", + "3c4d5e6f7a": "Display name", + "4d5e6f7a8b": "Reset to default", + "5e6f7a8b9c": "Cancel", + "6f7a8b9c0d": "Save" + }, + "HostSectionHeaderMenu": { + "5b8b4b6a01": "Update server required", + "9b3c1d2e44": "Update client required", + "2c29e2de68": "Connection failed", + "bf07aee59e": "Disconnect failed", + "7f1a2b3c4d": "{{value0}} is reachable", + "4f2c8a9b10": "Host actions for {{value0}}", + "6b7c8d9e10": "Host actions", + "8d1e2f3a4b": "Rename…", + "63f36455cc": "Reconnect", + "59b553e2aa": "Disconnect", + "2d3e4f5a6b": "Check connection", + "3c4d5e6f7a": "Manage host…", + "6e7f8a9b0c": "Remove host…" }, "FolderWorkspaceComposerDialog": { "connectFailed": "Failed to connect to project.", "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.", "title": "Create Folder Workspace", - "create": "Create workspace", + "createStart": "Create & Start Agent", + "create": "Create Workspace", "sourceProject": "Task Source", "chooseSourceProject": "Choose task source" - }, - "ProjectOrderManualDefaultNotice": { - "a1f4c2d8e0": "Manual project order is now the default", - "822ff300ad": "Dismiss", - "b7e3a91c4f": "Drag project headers to reorder, or switch to", - "e8c1f4a2b9": "in workspace options." } }, "shared": { @@ -4236,19 +4126,7 @@ "7d26ccabe8": "Dark", "fb0e0b4453": "System", "932ff1fbff": "Theme", - "0f28e7b30c": "Choose how Orca looks in the app window.", - "leftSidebarAppearance": { - "title": "Left Sidebar Appearance", - "description": "Make the left sidebar match your terminal, stay default, or use a tint.", - "rowDescription": "Make the left sidebar match your terminal, stay default, or use a tint.", - "default": "Default", - "matchTerminal": "Match Terminal", - "tinted": "Tinted", - "tintColor": "Sidebar Tint", - "tintColorDescription": "The color mixed into the left sidebar surface.", - "tintOpacity": "Tint Strength", - "tintOpacityDescription": "Controls how strongly the tint is mixed into the sidebar." - } + "0f28e7b30c": "Choose how Orca looks in the app window." }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -4266,7 +4144,7 @@ "e784ea62dc": "Advanced", "d9b65054ef": ") to a short name summarizing the task. Only branches Orca named itself are renamed, and never after they have been pushed.", "12ea4a408d": "When an agent starts working in a new workspace, Orca renames its auto-generated branch (e.g.", - "ef787db0e3": "Auto-rename branch & worktree", + "ef787db0e3": "Auto-Rename Branch", "6a051586d2": "Rename the auto-generated branch based on the work once an agent starts.", "ec3e0c388e": "Save", "cfd82406dd": "Saving...", @@ -4409,8 +4287,6 @@ "8671e406f0": "Cancel", "a4aafe46e3": "Target path:", "e8012c03a1": "Enables agents to use Orca workspace, terminal, and progress commands.", - "cliSkillTerminalTitle": "CLI skill setup", - "cliSkillTerminalAria": "CLI skill install terminal", "6053cf736c": "CLI skill", "36a6f919ba": "Give agents Orca-aware workspace, terminal, and progress workflows.", "04873eea3e": "Agent skills", @@ -5098,8 +4974,7 @@ "9bedd2a6e5": "Enables agents to hand off context and coordinate work through Orca.", "07641b9768": "Orchestration skill", "2aacdb0517": "Coordinate coding agents across handoffs, worktree handovers, and child-agent work.", - "191ac34567": "Agent Orchestration", - "thisDevice": "This device" + "191ac34567": "Agent Orchestration" }, "OrchestrationSetupCard": { "e7d2a5146c": "Enables agents to hand off context and coordinate work through Orca.", @@ -5492,7 +5367,7 @@ "65660d4548": "macOS Permissions", "c6c01ac209": "Control terminals and agents from your phone.", "c40dadaac8": "Mobile", - "c2ee313198": "Remote SSH hosts for files, terminals, and git.", + "c2ee313198": "SSH host access for files, terminals, and git.", "9b02492d1f": "SSH Hosts", "b5ee17826b": "Switch between local desktop mode and paired remote Orca runtimes.", "7686cb5c36": "Connect this browser to a saved Orca server.", @@ -5550,8 +5425,7 @@ "9abb9be3bc": "Set Up", "23c6874fdf": "AI Capabilities", "2309068a6f": "destructive", - "65358016ea": "Discard", - "thisDevice": "This device" + "65358016ea": "Discard" }, "SettingsFormControls": { "42a4d15a30": "No matching fonts.", @@ -6332,10 +6206,6 @@ "locale": "locale", "i18n": "i18n", "translation": "translation" - }, - "leftSidebarAppearance": { - "title": "Left Sidebar Appearance", - "description": "Make the left sidebar match your terminal, stay default, or use a tint." } } }, @@ -6360,7 +6230,7 @@ "55a1860e47": "rename", "9319bd9827": "branch", "ea94b9da8a": "Rename the auto-generated branch based on the work once an agent starts.", - "427f2cd1eb": "Auto-rename branch & worktree" + "427f2cd1eb": "Auto-Rename Branch" } } } @@ -7596,6 +7466,13 @@ } } }, + "WorkspaceDirectorySetting": { + "1a2b3c4d5e": "Client default", + "2b3c4d5e6f": "Apply to", + "3c4d5e6f7a": "Overrides client default", + "4d5e6f7a8b": "Inherits the client default", + "5e6f7a8b9c": "Reset" + }, "agent-awake-copy": { "e5995ce268": "Keep computer awake while agents are working", "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", @@ -7659,16 +7536,7 @@ "eae4a9f16b": "Add Linear access to browse and link issues.", "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", - "disconnect_all": "Disconnect all", - "linearSkillTitle": "Linear agent skill", - "linearSkillDescription": "Install the host agent skill that agents use for richer linked Linear task handoffs.", - "linearSkillTerminalTitle": "Install Linear agent skill", - "linearSkillTerminalAria": "Linear agent skill installer terminal", - "linearSkillInstall": "Install CLI & Skill", - "linearSkillOptionalHint": "Optional next step: install the Linear agent skill for ticket-aware agent handoffs.", - "linearSkillDismissHint": "Dismiss optional Linear agent skill setup note", - "linearSkillWslDescription": "Install the WSL agent skill that agents use for richer linked Linear task handoffs.", - "linearSkillWslLabel": "WSL default" + "disconnect_all": "Disconnect" } } } @@ -7714,19 +7582,6 @@ } } } - }, - "computerUseSummary": { - "permissionsRequired": "{{value0}} permission{{value1}} required before agents can operate app windows.", - "checkingTitle": "Checking Computer Use access.", - "checkingDescription": "Orca is checking macOS privacy permissions for the Computer Use helper.", - "unavailableTitle": "Computer Use is unavailable.", - "unavailableDescription": "Computer Use permissions are unavailable because {{value0}}.", - "readyTitle": "Computer Use is ready.", - "readyDescription": "Agents can inspect and operate app windows when you ask.", - "permissionsTitle": "Finish setup to use local apps." - }, - "computerUseSkillRuntime": { - "thisDevice": "This device" } }, "right": { @@ -7774,9 +7629,7 @@ "f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.", - "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.", - "b4f3ec62a1": "More {{value0}} actions", - "a9d7c128e4": "unlink {{value0}}" + "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", @@ -8139,8 +7992,7 @@ "31f6d46278": "Unresolved", "2c417432b7": "Resolved locally", "f3a8b2c1d0e5": "Enter a {{value0}} title.", - "e2b7a1c0d9f4": "Failed to create {{value0}}", - "e9e238b260": "Custom command is empty. Add one in Settings -> Git -> Source Control AI." + "e2b7a1c0d9f4": "Failed to create {{value0}}" }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", @@ -8220,7 +8072,7 @@ "74c6885b8a": "More comment actions", "cbcc4ab3db": "Showing first 100 checks", "0dca6bfab5": "Open check details", - "991f50c7e4": "No checks reported yet", + "991f50c7e4": "No checks configured", "9ad98f2a17": "pending", "5e52f4ef7f": "failing", "02ca4f9074": "passing", @@ -8268,7 +8120,7 @@ "ae8a04ef17": "Conflict file details are unavailable", "73d0675356": "Refreshing conflict details…", "5dc3af25c0": "Select comment", - "d7a2f9c401": "Send all unresolved", + "d7a2f9c401": "Send unresolved {{value0}} comments", "d91f2a6c39": "Send {{value0}} queued comments", "a6de3e5a20": "Clear queued comments", "49ea0937e4": "Add comment to resolve list", @@ -8640,10 +8492,7 @@ "c5292c409d": "Agents can inspect app windows and operate local apps when you ask.", "1ecfb490ac": "Computer Use", "01426f3a23": "Agents can navigate sites, inspect pages, and work through browser tasks.", - "ea85d9e628": "Agent Browser Use", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Agents can use linked Linear tasks for richer ticket-aware handoffs.", - "linearTicketsSetupSummary": "Recommended for Linear workspaces; does not affect Linear connection setup." + "ea85d9e628": "Agent Browser Use" }, "FeatureSetupInlineTerminal": { "789b59936e": "Press Enter to run the command and confirm npx if asked. You can also set this up later in Settings.", @@ -8785,7 +8634,7 @@ } }, "AgentFeatureSetupStep": { - "97dcdc010f": "Install CLI & Skills" + "97dcdc010f": "Enable capabilities" } }, "new": { @@ -9062,9 +8911,7 @@ "3a59452a67": "Skill command copied and inserted below for review.", "c605f51f2b": "Capability setup ready", "1aa657d8f4": "Some capability setup needs attention", - "c89534cbe9": "Install CLI & Skills", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Recommended when agents work from linked Linear tasks." + "c89534cbe9": "Install CLI & Skills" }, "AiCommitPrSettingsCard": { "8d4152701a": "e.g. ollama run llama3.1 {{value0}}", @@ -9170,11 +9017,7 @@ "3c4adfd821": "fix login race condition", "56a0271428": "Isolated workspaces", "ef737dcee1": "GitHub & Linear tasks", - "ac51c061e2": "codex", - "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", - "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", - "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", - "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." + "ac51c061e2": "codex" }, "FeatureWallBody": { "25ec5356d6": "Setup" @@ -9247,9 +9090,7 @@ "6e3f5223c5": "Explorer", "ab2901bce6": "Checks", "d7f80060ca": "Source Control", - "8e715588e4": "Search", - "a6c8b9e32f": "Checks passed", - "f4d5e1a7b2": "3 checks" + "8e715588e4": "Search" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Create PR", @@ -10673,44 +10514,6 @@ "8388bdea2b": "Connect Jira site" } } - }, - "link": { - "routing": { - "preference": { - "dialog": { - "badge": "Terminal link", - "preview": "Preview", - "title": "Open terminal links in Orca's browser?", - "description": "Use Orca's browser for terminal links, or keep your system browser.", - "orca": { - "button": "Open in Orca", - "note": "Orca can use imported cookies for logged-in sites." - }, - "settings": { - "note": "Change this later in Settings → Browser." - }, - "system": { - "button": "Use system browser" - }, - "link": { - "label": "Link" - }, - "shortcut": { - "note": { - "prefix": "When links open in Orca,", - "suffix": "click opens system browser once." - } - }, - "keep": { - "title": "Keep terminal links in Orca's browser?", - "description": "Or use your system browser by default.", - "orca": { - "button": "Keep Orca" - } - } - } - } - } } }, "i18n": { diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index fa245b8af84..5baab88f5eb 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -39,6 +39,11 @@ import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' + +// Why: bracketed paste markers and ready-wait grace timing live in +// agent-paste-draft.ts so the new-workspace and "Use" flows share one +// definition of "type into the agent's input as a non-submitted draft". async function getDirectDraftContent( item: LaunchableWorkItem, diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index e4eaecb2f6c..dade5dcc28d 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -9,6 +9,7 @@ import type { } from '../../../shared/types' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { AgentStartedTelemetry } from '@/lib/worktree-activation' +import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' /** Two-phase status reported by the main process while a worktree is created. * `fetching` covers the base-ref git fetch; `creating` covers `git worktree @@ -25,6 +26,13 @@ export type WorktreeCreationPhase = 'fetching' | 'creating' */ export type WorktreeCreationRequest = { repoId: string + /** Source host/account that produced the linked task. Kept separate from the + * run context so Retry does not infer provider ownership from the run host. */ + taskSourceContext?: TaskSourceContext | null + /** Host/setup where the new workspace should run. Duplicates repoId by design: + * repoId keeps old create APIs working, while this records the project-first + * host intent for retry, diagnostics, and future metadata writes. */ + workspaceRunContext?: WorkspaceRunContext | null name: string displayName?: string baseBranch?: string diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 5dc3497a2c7..d1793d0edae 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -38,6 +38,10 @@ import { } from '../../../shared/tui-agent-launch-defaults' import { isTuiAgent } from '../../../shared/tui-agent-config' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { folderWorkspaceActivationBlocked, @@ -578,6 +582,7 @@ setWorktreeNavViewActivator((entry) => { taskPageData: { ...state.taskPageData, openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, openLinearIssue: undefined } @@ -592,6 +597,7 @@ setWorktreeNavViewActivator((entry) => { taskSource: 'github', preselectedRepoId: entry.workItem.repoId, openGitHubWorkItem: entry.workItem, + openGitHubSourceContext: entry.sourceContext, openGitHubInitialTab: entry.initialTab, openLinearIssue: undefined } @@ -605,6 +611,7 @@ setWorktreeNavViewActivator((entry) => { ...state.taskPageData, taskSource: 'linear', openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, openLinearIssue: entry.issue } diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index e2c6d82aaa7..24dcd410388 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -18,17 +18,36 @@ import type { JiraViewer } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../shared/task-source-context' export type RuntimeJiraSettings = | Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> + | TaskSourceContext | null | undefined export type JiraConnectResult = { ok: true; viewer: JiraViewer } | { ok: false; error: string } export type JiraCommentResult = { ok: true; id: string } | { ok: false; error: string } +function isTaskSourceRuntimeSettings(settings: RuntimeJiraSettings): settings is TaskSourceContext { + return settings !== null && settings !== undefined && 'kind' in settings +} + +function getJiraRuntimeTarget( + settings: RuntimeJiraSettings +): ReturnType<typeof getActiveRuntimeTarget> { + // Why: task source context makes provider ownership explicit; legacy callers + // still pass focused runtime settings until Tasks finishes migrating. + return getActiveRuntimeTarget( + isTaskSourceRuntimeSettings(settings) ? getTaskSourceRuntimeSettings(settings) : settings + ) +} + export async function jiraStatus(settings: RuntimeJiraSettings): Promise<JiraConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectionStatus>(target, 'jira.status', undefined, { timeoutMs: 15_000 }) : window.api.jira.status() @@ -38,7 +57,7 @@ export async function jiraConnect( settings: RuntimeJiraSettings, args: { siteUrl: string; email: string; apiToken: string } ): Promise<JiraConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectResult>(target, 'jira.connect', args, { timeoutMs: 30_000 }) : window.api.jira.connect(args) @@ -48,7 +67,7 @@ export async function jiraDisconnect( settings: RuntimeJiraSettings, siteId?: string | null ): Promise<void> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) if (target.kind === 'environment') { await callRuntimeRpc<{ ok: true }>(target, 'jira.disconnect', siteId ? { siteId } : undefined, { timeoutMs: 15_000 @@ -62,7 +81,7 @@ export async function jiraSelectSite( settings: RuntimeJiraSettings, siteId: JiraSiteSelection ): Promise<JiraConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectionStatus>( target, @@ -77,7 +96,7 @@ export async function jiraTestConnection( settings: RuntimeJiraSettings, siteId?: string | null ): Promise<JiraConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraConnectResult>( target, @@ -94,7 +113,7 @@ export async function jiraSearchIssues( limit?: number, siteId?: JiraSiteSelection | null ): Promise<JiraIssue[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { jql, limit, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssue[]>(target, 'jira.searchIssues', args, { timeoutMs: 30_000 }) @@ -107,7 +126,7 @@ export async function jiraListIssues( limit?: number, siteId?: JiraSiteSelection | null ): Promise<JiraIssue[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { filter, limit, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssue[]>(target, 'jira.listIssues', args, { timeoutMs: 30_000 }) @@ -119,7 +138,7 @@ export async function jiraGetIssue( key: string, siteId?: string | null ): Promise<JiraIssue | null> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssue | null>(target, 'jira.getIssue', args, { timeoutMs: 30_000 }) @@ -130,7 +149,7 @@ export async function jiraCreateIssue( settings: RuntimeJiraSettings, args: JiraCreateIssueArgs ): Promise<JiraCreateIssueResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraCreateIssueResult>(target, 'jira.createIssue', args, { timeoutMs: 30_000 }) : window.api.jira.createIssue(args) @@ -142,7 +161,7 @@ export async function jiraUpdateIssue( updates: JiraIssueUpdate, siteId?: string | null ): Promise<JiraMutationResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, updates, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraMutationResult>(target, 'jira.updateIssue', args, { timeoutMs: 30_000 }) @@ -155,7 +174,7 @@ export async function jiraAddIssueComment( body: string, siteId?: string | null ): Promise<JiraCommentResult> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, body, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraCommentResult>(target, 'jira.addIssueComment', args, { @@ -169,7 +188,7 @@ export async function jiraIssueComments( key: string, siteId?: string | null ): Promise<JiraComment[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraComment[]>(target, 'jira.issueComments', args, { timeoutMs: 30_000 }) @@ -180,7 +199,7 @@ export async function jiraListProjects( settings: RuntimeJiraSettings, siteId?: JiraSiteSelection | null ): Promise<JiraProject[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraProject[]>(target, 'jira.listProjects', siteId ? { siteId } : undefined, { timeoutMs: 30_000 @@ -193,7 +212,7 @@ export async function jiraListIssueTypes( projectIdOrKey: string, siteId?: string | null ): Promise<JiraIssueType[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { projectIdOrKey, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraIssueType[]>(target, 'jira.listIssueTypes', args, { timeoutMs: 30_000 }) @@ -206,7 +225,7 @@ export async function jiraListCreateFields( issueTypeId: string, siteId?: string | null ): Promise<JiraCreateField[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { projectIdOrKey, issueTypeId, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraCreateField[]>(target, 'jira.listCreateFields', args, { @@ -219,7 +238,7 @@ export async function jiraListPriorities( settings: RuntimeJiraSettings, siteId?: string | null ): Promise<JiraPriority[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<JiraPriority[]>( target, @@ -236,7 +255,7 @@ export async function jiraListAssignableUsers( query?: string, siteId?: string | null ): Promise<JiraUser[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, query, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraUser[]>(target, 'jira.listAssignableUsers', args, { timeoutMs: 30_000 }) @@ -248,7 +267,7 @@ export async function jiraListTransitions( key: string, siteId?: string | null ): Promise<JiraTransition[]> { - const target = getActiveRuntimeTarget(settings) + const target = getJiraRuntimeTarget(settings) const args = { key, siteId: siteId ?? undefined } return target.kind === 'environment' ? callRuntimeRpc<JiraTransition[]>(target, 'jira.listTransitions', args, { timeoutMs: 30_000 }) diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index 65d909a80ab..706279f1196 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -20,9 +20,14 @@ import type { LinearWorkflowState } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../shared/task-source-context' export type RuntimeLinearSettings = | Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> + | TaskSourceContext | null | undefined @@ -42,6 +47,22 @@ function linearReadForce(options?: LinearReadOptions): { force: true } | {} { return options?.force ? { force: true } : {} } +function isTaskSourceRuntimeSettings( + settings: RuntimeLinearSettings +): settings is TaskSourceContext { + return settings !== null && settings !== undefined && 'kind' in settings +} + +function getLinearRuntimeTarget( + settings: RuntimeLinearSettings +): ReturnType<typeof getActiveRuntimeTarget> { + // Why: task source context makes provider ownership explicit; legacy callers + // still pass focused runtime settings until Tasks finishes migrating. + return getActiveRuntimeTarget( + isTaskSourceRuntimeSettings(settings) ? getTaskSourceRuntimeSettings(settings) : settings + ) +} + function normalizeLinearIssueCollectionResult( result: unknown ): LinearCollectionResult<LinearIssue> { @@ -65,7 +86,7 @@ function normalizeLinearIssueCollectionResult( export async function linearStatus( settings: RuntimeLinearSettings ): Promise<LinearConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectionStatus>(target, 'linear.status', undefined, { timeoutMs: 15_000 @@ -77,7 +98,7 @@ export async function linearTestConnection( settings: RuntimeLinearSettings, workspaceId?: string | null ): Promise<LinearConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectResult>( target, @@ -94,7 +115,7 @@ export async function linearConnect( settings: RuntimeLinearSettings, apiKey: string ): Promise<LinearConnectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectResult>( target, @@ -113,7 +134,7 @@ export async function linearDisconnectWorkspace( settings: RuntimeLinearSettings, workspaceId?: string | null ): Promise<void> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) if (target.kind === 'environment') { await callRuntimeRpc<{ ok: true }>( target, @@ -132,7 +153,7 @@ export async function linearSelectWorkspace( settings: RuntimeLinearSettings, workspaceId: LinearWorkspaceSelection ): Promise<LinearConnectionStatus> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearConnectionStatus>( target, @@ -149,7 +170,7 @@ export async function linearSearchIssues( limit?: number, workspaceId?: LinearWorkspaceSelection | null ): Promise<LinearIssue[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearIssue[]>( target, @@ -166,7 +187,7 @@ export async function linearListIssues( limit?: number, workspaceId?: LinearWorkspaceSelection | null ): Promise<LinearCollectionResult<LinearIssue>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) const result = target.kind === 'environment' ? await callRuntimeRpc<unknown>( @@ -198,7 +219,7 @@ export async function linearCreateIssue( labelIds?: string[] } ): Promise<LinearCreateIssueResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCreateIssueResult>(target, 'linear.createIssue', args, { timeoutMs: 30_000 @@ -225,7 +246,7 @@ export async function linearGetIssue( id: string, workspaceId?: string | null ): Promise<LinearIssue | null> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearIssue | null>( target, @@ -242,7 +263,7 @@ export async function linearUpdateIssue( updates: LinearIssueUpdate, workspaceId?: string | null ): Promise<LinearMutationResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearMutationResult>( target, @@ -259,7 +280,7 @@ export async function linearAddIssueComment( body: string, workspaceId?: string | null ): Promise<LinearCommentResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCommentResult>( target, @@ -275,7 +296,7 @@ export async function linearIssueComments( issueId: string, workspaceId?: string | null ): Promise<LinearComment[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearComment[]>( target, @@ -290,7 +311,7 @@ export async function linearListTeams( settings: RuntimeLinearSettings, workspaceId?: LinearWorkspaceSelection | null ): Promise<LinearTeam[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearTeam[]>( target, @@ -308,7 +329,7 @@ export async function linearListProjects( workspaceId?: LinearWorkspaceSelection | null, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearProjectSummary>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>( target, @@ -342,7 +363,7 @@ export async function linearCreateProject( targetDate?: string } ): Promise<LinearCreateProjectResult> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCreateProjectResult>(target, 'linear.createProject', args, { timeoutMs: 30_000 @@ -356,7 +377,7 @@ export async function linearGetProject( workspaceId: string, options?: LinearReadOptions ): Promise<LinearProjectDetail | null> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearProjectDetail | null>( target, @@ -374,7 +395,7 @@ export async function linearListProjectIssues( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearIssue>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearIssue>>( target, @@ -397,7 +418,7 @@ export async function linearListCustomViews( workspaceId?: LinearWorkspaceSelection | null, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearCustomViewSummary>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearCustomViewSummary>>( target, @@ -420,7 +441,7 @@ export async function linearGetCustomView( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCustomViewSummary | null> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCustomViewSummary | null>( target, @@ -438,7 +459,7 @@ export async function linearListCustomViewIssues( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearIssue>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearIssue>>( target, @@ -461,7 +482,7 @@ export async function linearListCustomViewProjects( workspaceId: string, options?: LinearReadOptions ): Promise<LinearCollectionResult<LinearProjectSummary>> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>( target, @@ -482,7 +503,7 @@ export async function linearTeamStates( teamId: string, workspaceId?: string | null ): Promise<LinearWorkflowState[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearWorkflowState[]>( target, @@ -498,7 +519,7 @@ export async function linearTeamLabels( teamId: string, workspaceId?: string | null ): Promise<LinearLabel[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearLabel[]>( target, @@ -514,7 +535,7 @@ export async function linearTeamMembers( teamId: string, workspaceId?: string | null ): Promise<LinearMember[]> { - const target = getActiveRuntimeTarget(settings) + const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc<LinearMember[]>( target, diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8e430d6e5d1..0e43297c579 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -29,6 +29,7 @@ import { createDetectedAgentsSlice } from './slices/detected-agents' import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' import { createDictationSlice } from './slices/dictation' import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup' +import { createRuntimeStatusSlice } from './slices/runtime-status' import { createPullRequestGenerationSlice } from './slices/pull-request-generation' import { createCommitMessageGenerationSlice } from './slices/commit-message-generation' import { e2eConfig } from '@/lib/e2e-config' @@ -64,6 +65,7 @@ export const useAppStore = create<AppState>()((...a) => ({ ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a) })) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index d9470055294..198ec1bd38c 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -136,6 +136,7 @@ import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' +import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' @@ -170,6 +171,7 @@ function createTestStore() { ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a) })) diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 334cafdd324..bc90b9f582f 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -26,6 +26,7 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -939,6 +940,62 @@ describe('createGitHubSlice.fetchPRComments', () => { vi.useRealTimers() } }) + + it('preserves cached checks when the checks IPC fails', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const checksCacheKey = `${repoPath}::pr-checks::12` + const cachedChecks = [ + { name: 'build', status: 'completed', conclusion: 'failure', url: null } as const + ] + + store.setState({ + checksCache: { + [checksCacheKey]: { + data: cachedChecks, + fetchedAt: 1, + headSha: 'abc123head' + } + } + } as unknown as Partial<AppState>) + mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited')) + + await expect( + store.getState().fetchPRChecks(repoPath, 12, branch, 'abc123head', null, { force: true }) + ).resolves.toEqual(cachedChecks) + + expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(cachedChecks) + expect(store.getState().checksCache[checksCacheKey]?.fetchedAt).toBe(1) + }) + + it('does not return cached checks for a different requested head SHA after IPC failure', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const checksCacheKey = `${repoPath}::pr-checks::12` + const oldHeadChecks = [ + { name: 'build', status: 'completed', conclusion: 'success', url: null } as const + ] + + store.setState({ + checksCache: { + [checksCacheKey]: { + data: oldHeadChecks, + fetchedAt: 1, + headSha: 'old-head' + } + } + } as unknown as Partial<AppState>) + mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited')) + + await expect( + store.getState().fetchPRChecks(repoPath, 12, branch, 'new-head', null, { force: true }) + ).resolves.toEqual([]) + + expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(oldHeadChecks) + expect(store.getState().checksCache[checksCacheKey]?.headSha).toBe('old-head') + }) }) describe('createGitHubSlice.fetchPRCheckDetails', () => { @@ -3647,6 +3704,116 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { ).toMatchObject({ repoId: 'caller-repo-id', number: 17 }) }) + it('routes work item fetches through an explicit GitHub source context', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-work-items-source-context', + ok: true, + result: { + items: [ + { type: 'issue', number: 19, title: 'Source issue', url: 'https://example.test/19' } + ], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }, + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [ + { + id: 'local-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] + } as Partial<AppState>) + + const sourceContext = { + kind: 'task-source' as const, + provider: 'github' as const, + projectId: 'github:stablyai/orca', + hostId: 'runtime:source-runtime' as const, + projectHostSetupId: 'setup-1', + repoId: 'source-runtime-repo-id', + providerIdentity: { provider: 'github' as const, owner: 'stablyai', repo: 'orca' } + } + + await store.getState().fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open', { + sourceContext + }) + + expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.listWorkItems', + params: { + repo: 'source-runtime-repo-id', + limit: 24, + query: 'is:open' + }, + timeoutMs: 30_000 + }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', getTaskSourceCacheScope(sourceContext)) + ]?.data?.[0] + ).toMatchObject({ repoId: 'caller-repo-id', number: 19 }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:focused-runtime') + ] + ).toBeUndefined() + }) + + it('keeps explicit GitHub source identities in separate work-item cache buckets', async () => { + const store = createTestStore() + const firstSourceContext = { + kind: 'task-source' as const, + provider: 'github' as const, + projectId: 'project-1', + hostId: 'local' as const, + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + providerIdentity: { provider: 'github' as const, owner: 'acme', repo: 'orca' } + } + const secondSourceContext = { + ...firstSourceContext, + providerIdentity: { provider: 'github' as const, owner: 'stablyai', repo: 'orca' } + } + mockApi.gh.listWorkItems + .mockResolvedValueOnce({ + items: [{ type: 'issue', number: 1, title: 'Acme', url: 'https://example.test/1' }], + sources: { issues: { owner: 'acme', repo: 'orca' }, prs: { owner: 'acme', repo: 'orca' } } + }) + .mockResolvedValueOnce({ + items: [{ type: 'issue', number: 2, title: 'Stably', url: 'https://example.test/2' }], + sources: { + issues: { owner: 'stablyai', repo: 'orca' }, + prs: { owner: 'stablyai', repo: 'orca' } + } + }) + + await store.getState().fetchWorkItems('repo-1', '/repo', 24, '', { + sourceContext: firstSourceContext + }) + await store.getState().fetchWorkItems('repo-1', '/repo', 24, '', { + sourceContext: secondSourceContext + }) + + expect( + store.getState().workItemsCache[ + workItemsCacheKey('repo-1', 24, '', getTaskSourceCacheScope(firstSourceContext)) + ]?.data?.[0]?.number + ).toBe(1) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('repo-1', 24, '', getTaskSourceCacheScope(secondSourceContext)) + ]?.data?.[0]?.number + ).toBe(2) + }) + it('routes SSH-owned work item fetches through local IPC when a runtime is focused', async () => { const store = createTestStore() mockApi.gh.listWorkItems.mockResolvedValueOnce({ @@ -4202,6 +4369,100 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) + it('keeps GitHub project view caches separate for runtime and local sources', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } + } as Partial<AppState>) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + ok: true, + data: { + project: { + id: 'project-remote', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Remote Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + + store.setState({ + settings: { activeRuntimeEnvironmentId: null } + } as Partial<AppState>) + mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ + ok: true, + data: { + project: { + id: 'project-local', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Local Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } + }) + + const localResult = await store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + + expect(localResult.ok).toBe(true) + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(1) + expect( + store.getState().projectViewCache[ + projectViewCacheKey('organization', 'acme', 1, 'view-1', undefined, 'runtime:env-1') + ]?.data?.project.id + ).toBe('project-remote') + expect( + store.getState().projectViewCache[projectViewCacheKey('organization', 'acme', 1, 'view-1')] + ?.data?.project.id + ).toBe('project-local') + }) + it('bounds project view table cache entries across many projects', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index fcbfdc2ee4d..2dc64ec91db 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -52,8 +52,14 @@ import { getRepoExecutionHostId, getSettingsFocusedExecutionHostId, normalizeExecutionHostId, - parseExecutionHostId + parseExecutionHostId, + type ExecutionHostId } from '../../../../shared/execution-host' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: declared separately from CacheEntry<T> (not a generified E parameter) @@ -201,12 +207,65 @@ function getWorkItemsCacheKeyForOwner( ) } +function getGitHubWorkItemSourceHostId( + state: AppState, + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + sourceContext?: TaskSourceContext | null +): ExecutionHostId | undefined { + if (sourceContext?.provider === 'github') { + return sourceContext.hostId + } + return repo + ? (normalizeExecutionHostId(getGitHubRepoOwnerHostId(state.settings, repo)) ?? undefined) + : undefined +} + +function getGitHubWorkItemSourceCacheScope( + state: AppState, + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + sourceContext?: TaskSourceContext | null +): string | undefined { + if (sourceContext?.provider === 'github') { + return getTaskSourceCacheScope(sourceContext) + } + return getGitHubWorkItemSourceHostId(state, repo, sourceContext) +} + +function getGitHubWorkItemSourceSettings( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + sourceContext?: TaskSourceContext | null +): AppState['settings'] { + if (sourceContext?.provider === 'github') { + return { + ...(settings ?? {}), + ...getTaskSourceRuntimeSettings(sourceContext) + } as AppState['settings'] + } + return settingsForGitHubRepoOwner(settings, repo) +} + function getGitHubWorkItemRequestContext( state: AppState, settings: AppState['settings'], repoId: string, - repoPath: string + repoPath: string, + sourceContext?: TaskSourceContext | null ): GitHubWorkItemRequestContext { + if (sourceContext?.provider === 'github') { + const parsedHost = parseExecutionHostId(sourceContext.hostId) + if (parsedHost?.kind === 'runtime') { + return { + repoId, + repoPath, + target: { + kind: 'environment', + environmentId: parsedHost.environmentId, + runtimeRepoId: sourceContext.repoId ?? repoId + } + } + } + } const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings) return { repoId, @@ -270,12 +329,13 @@ export function projectViewCacheKey( owner: string, projectNumber: number, resolvedViewId: string, - queryOverride?: string + queryOverride?: string, + sourceScope = 'local' ): string { - return `github-project:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` + return `github-project:${sourceScope}:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` } -function projectViewRequestKey(args: GetProjectViewTableArgs): string { +function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string { // Why: callers without `viewId` can't compute the resolved cache key up // front. Use the input-arg signature for inflight dedup; the resolved // cache key is only known after the main-process IPC returns. @@ -286,7 +346,12 @@ function projectViewRequestKey(args: GetProjectViewTableArgs): string { : args.viewName ? `name:${args.viewName}` : 'default' - return `${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}` + return `${sourceScope}:${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}` +} + +function projectViewSourceScope(settings: AppState['settings']): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' } // Why: module-scope inflight map — must mirror `inflightWorkItemsRequests` @@ -478,6 +543,7 @@ export type CacheEntry<T> = { type FetchOptions = { force?: boolean noCache?: boolean + sourceContext?: TaskSourceContext | null } type RepoScopedFetchOptions = FetchOptions & { @@ -498,7 +564,6 @@ function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolea const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed) const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently -const EMPTY_CHECKS_CACHE_TTL = 10_000 // Why: the NewWorkspace page's work-item list is a browse surface, not a // source of truth, so 60s staleness is fine — stale data renders instantly // while a background refresh keeps it current. @@ -513,11 +578,7 @@ const inflightPRRequests = new Map< { promise: Promise<PRInfo | null>; force: boolean; generation: number; lookupHintKey: string } >() const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>() -type InflightChecksRequest = { - promise: Promise<PRCheckDetail[]> - noCache: boolean -} -const inflightChecksRequests = new Map<string, InflightChecksRequest>() +const inflightChecksRequests = new Map<string, Promise<PRCheckDetail[]>>() const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>() type InflightWorkItems = { promise: Promise<GitHubWorkItem[]> @@ -582,9 +643,13 @@ export function workItemsCacheKey( query: string, executionHostId?: string | null ): string { - const hostId = normalizeExecutionHostId(executionHostId) + const scope = executionHostId?.trim() ?? '' + const hostId = normalizeExecutionHostId(scope) const owner = `${repoId}::${limit}::${query}` - return hostId && hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner + if (hostId) { + return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner + } + return scope ? `${scope}::${owner}` : owner } function workItemsInflightRequestKey( @@ -753,10 +818,6 @@ function isFresh<T>(entry: CacheEntry<T> | undefined, ttl = CACHE_TTL): entry is return entry !== undefined && Date.now() - entry.fetchedAt < ttl } -function checksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | undefined): number { - return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL -} - function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { for (const worktrees of Object.values(state.worktreesByRepo)) { const worktree = worktrees.find((w) => w.id === worktreeId) @@ -1376,7 +1437,8 @@ export type GitHubSlice = { repoId: string, limit: number, query: string, - repoPath?: string + repoPath?: string, + sourceContext?: TaskSourceContext | null ) => GitHubWorkItem[] | null /** * Why: the Tasks view header reads sources from the cache to render the @@ -1428,7 +1490,12 @@ export type GitHubSlice = { * the single-repo behavior of quietly serving stale data. */ fetchWorkItemsAcrossRepos: ( - repos: { repoId: string; path: string; executionHostId?: string | null }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], perRepoLimit: number, displayLimit: number, query: string, @@ -1439,7 +1506,12 @@ export type GitHubSlice = { * pagination pages are ephemeral and managed by TaskPage state. */ fetchWorkItemsNextPage: ( - repos: { repoId: string; path: string; executionHostId?: string | null }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], perRepoLimit: number, displayLimit: number, query: string, @@ -1450,14 +1522,25 @@ export type GitHubSlice = { * Returns the sum of per-repo counts for the given query. */ countWorkItemsAcrossRepos: ( - repos: { repoId: string; path: string; executionHostId?: string | null }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], query: string ) => Promise<number> /** * Fire-and-forget prefetch used by UI entry points (hover/focus of the * "new workspace" buttons) to warm the cache before the page mounts. */ - prefetchWorkItems: (repoId: string, repoPath: string, limit?: number, query?: string) => void + prefetchWorkItems: ( + repoId: string, + repoPath: string, + limit?: number, + query?: string, + options?: { sourceContext?: TaskSourceContext | null } + ) => void patchWorkItem: (itemId: string, patch: Partial<GitHubWorkItem>, repoId?: string | null) => void /** * Monotonic counter bumped whenever a repo's issue-source preference is @@ -1533,7 +1616,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s projectViewCache: {}, fetchProjectViewTable: async (args, options) => { - const requestKey = projectViewRequestKey(args) + const target = getActiveRuntimeTarget(get().settings) + const sourceScope = projectViewSourceScope(get().settings) + const requestKey = projectViewRequestKey(args, sourceScope) // Fast path: when the caller supplies `viewId`, we already know the // resolved cache key and can serve a fresh entry directly. @@ -1543,7 +1628,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s args.owner, args.projectNumber, args.viewId, - args.queryOverride + args.queryOverride, + sourceScope ) : null if (!options?.force && maybeKnownKey) { @@ -1568,7 +1654,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const request = (async (): Promise<GetProjectViewTableResult> => { await acquireWorkItemSlot() try { - const target = getActiveRuntimeTarget(get().settings) const envelope = target.kind === 'environment' ? await callRuntimeRpc<GetProjectViewTableResult>( @@ -1585,7 +1670,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s table.project.owner, table.project.number, table.selectedView.id, - args.queryOverride + args.queryOverride, + sourceScope ) set((s) => ({ projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, { @@ -2013,8 +2099,12 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s applyRowPatch(set, cacheKey, rowId, nextRow) }, - getCachedWorkItems: (repoId, limit, query, repoPath) => { - const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) + getCachedWorkItems: (repoId, limit, query, repoPath, sourceContext) => { + const state = get() + const key = + sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(sourceContext)) + : getWorkItemsCacheKeyForOwner(state, repoId, limit, query, repoPath) return get().workItemsCache[key]?.data ?? null }, @@ -2046,23 +2136,26 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s fetchWorkItems: async (repoId, repoPath, limit, query, options): Promise<GitHubWorkItem[]> => { const requestState = get() const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) - const requestSettings = settingsForGitHubRepoOwner(requestState.settings, repo) - const ownerHostId = repo ? getGitHubRepoOwnerHostId(requestState.settings, repo) : undefined - const key = workItemsCacheKey(repoId, limit, query, ownerHostId) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) + const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext) + const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext) + const key = workItemsCacheKey(repoId, limit, query, cacheScope) const cached = get().workItemsCache[key] if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) { return cached.data ?? [] } - const requestState = get() - const requestSettings = requestState.settings - const requestRuntimeEnvironmentId = activeRuntimeEnvironmentId(requestSettings) const requestInvalidationNonce = requestState.workItemsInvalidationNonce const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, repoId, - repoPath + repoPath, + options?.sourceContext ) const inflightKey = workItemsInflightRequestKey(key, requestContext.target) const existing = inflightWorkItemsRequests.get(inflightKey) @@ -2109,9 +2202,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s ? { ...issuesError, source: envelope.sources.issues } : undefined const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath) - const currentHostId = currentRepo - ? getGitHubRepoOwnerHostId(get().settings, currentRepo) - : undefined + const currentHostId = getGitHubWorkItemSourceHostId( + get(), + currentRepo, + options?.sourceContext + ) // Why: host focus changes are allowed, but repo ownership changes mean // this response belongs to an older execution host bucket. if ((currentHostId ?? null) !== (ownerHostId ?? null)) { @@ -2123,12 +2218,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s if (get().workItemsInvalidationNonce !== requestInvalidationNonce) { return items } - // Why: clearing in-flight entries lets the next fetch start, but the - // old promise can still settle. Do not let pre-flip source data - // repopulate the cache after the invalidation nonce changes. - if (get().workItemsInvalidationNonce !== requestInvalidationNonce) { - return items - } set((s) => ({ workItemsCache: withBoundedCacheEntry(s.workItemsCache, key, { data: items, @@ -2166,7 +2255,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const perProjectResults = await Promise.all( repos.map(async (r) => { try { - return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, options) + return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, { + ...options, + sourceContext: r.sourceContext ?? options?.sourceContext + }) } catch (err) { // Why: fall back to any cache entry (stale or not) before declaring // this repo failed. Matches single-repo behavior of silently serving @@ -2177,7 +2269,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s if (isGitHubWorkItemsSshRemoteRequiredError(err)) { return [] as GitHubWorkItem[] } - const key = getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path) + const key = + r.sourceContext?.provider === 'github' + ? workItemsCacheKey( + r.repoId, + perRepoLimit, + query, + getTaskSourceCacheScope(r.sourceContext) + ) + : getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path) const cached = get().workItemsCache[key]?.data if (cached) { console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err) @@ -2199,12 +2299,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s repos.map(async (r) => { const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = settingsForGitHubRepoOwner(requestState.settings, repo) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, r.repoId, - r.path + r.path, + r.sourceContext ) await acquireWorkItemSlot() try { @@ -2249,12 +2354,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s try { const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = settingsForGitHubRepoOwner(requestState.settings, repo) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, r.repoId, - r.path + r.path, + r.sourceContext ) return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined }) } catch { @@ -2265,17 +2375,25 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s return counts.reduce((sum, c) => sum + c, 0) }, - prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '') => { - const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) - const cached = get().workItemsCache[key] + prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { const requestState = get() const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) - const requestSettings = settingsForGitHubRepoOwner(requestState.settings, repo) + const key = + options?.sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext)) + : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) + const cached = get().workItemsCache[key] + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, repoId, - repoPath + repoPath, + options?.sourceContext ) const inflightKey = workItemsInflightRequestKey(key, requestContext.target) // Skip when the cache is fresh or a request is already in flight. @@ -2283,7 +2401,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s return } void get() - .fetchWorkItems(repoId, repoPath, limit, query) + .fetchWorkItems(repoId, repoPath, limit, query, { sourceContext: options?.sourceContext }) .catch(() => {}) }, @@ -2579,11 +2697,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s ) : cacheKey const inflightKey = cacheKey - const requestNoCache = options?.noCache === true || options?.force === true const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] if ( - !requestNoCache && - isFresh(cached, checksCacheTtl(cached)) && + !options?.force && + isFresh(cached, CHECKS_CACHE_TTL) && (!headSha || cached.headSha === headSha) ) { const cachedChecks = cached.data ?? [] @@ -2608,16 +2725,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const inflightRequest = inflightChecksRequests.get(inflightKey) if (inflightRequest) { - if (!requestNoCache || inflightRequest.noCache) { - return inflightRequest.promise - } - // Why: manual refreshes must not inherit an automatic request that may - // still be served from the GitHub CLI cache. Wait, then issue fresh. - await inflightRequest.promise.catch(() => {}) - const latestInflightRequest = inflightChecksRequests.get(inflightKey) - if (latestInflightRequest?.noCache) { - return latestInflightRequest.promise - } + return inflightRequest } const request = (async () => { @@ -2632,7 +2740,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s prNumber, headSha, prRepo: prRepo ?? null, - noCache: requestNoCache + noCache: options?.force }, { timeoutMs: 30_000 } ) @@ -2642,7 +2750,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s prNumber, headSha, prRepo: prRepo ?? null, - noCache: requestNoCache + noCache: options?.force })) as PRCheckDetail[]) set((s) => { const nextState: Partial<AppState> = { @@ -2685,7 +2793,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } })() - inflightChecksRequests.set(inflightKey, { promise: request, noCache: requestNoCache }) + inflightChecksRequests.set(inflightKey, request) return request }, @@ -3103,17 +3211,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const checksCacheKeys = [ ...(alias.repoId ? [ - ...(pr.headSha - ? [ - runtimeScopedRepoCacheKey( - alias.repoPath, - alias.repoId, - prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), - s.settings, - alias.connectionId - ) - ] - : []), runtimeScopedRepoCacheKey( alias.repoPath, alias.repoId, @@ -3124,17 +3221,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s ) ] : []), - ...(pr.headSha - ? [ - runtimeScopedRepoCacheKey( - alias.repoPath, - undefined, - prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), - s.settings, - alias.connectionId - ) - ] - : []), runtimeScopedRepoCacheKey( alias.repoPath, undefined, @@ -3147,15 +3233,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s ] const checksEntry = checksCacheKeys .map((key) => s.checksCache[key]) - .find( - (entry) => - entry?.data && - entry.headSha && - pr.headSha && - entry.headSha === pr.headSha && - event.outcome.fetchedAt - entry.fetchedAt < checksCacheTtl(entry) - ) - if (checksEntry?.data) { + .find((entry) => entry?.data) + if ( + checksEntry?.data && + checksEntry.headSha && + pr.headSha && + checksEntry.headSha === pr.headSha && + event.outcome.fetchedAt - checksEntry.fetchedAt < CHECKS_CACHE_TTL + ) { return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } } return pr diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 79d781abe55..afdc0c65113 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -275,6 +275,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: null, provider: 'github', base: 'main', @@ -307,6 +308,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: 'ssh-1', provider: 'github', base: 'main', @@ -338,6 +340,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.getCreationEligibility).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: 'ssh-1', worktreePath: '/remote/worktree', branch: 'feature/create-pr', diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 6528aa26e99..c535ba617bc 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -23,6 +23,7 @@ export { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-ca type CacheEntry<T> = { data: T | null; fetchedAt: number; linkedReviewHintKey?: string } type FetchOptions = { force?: boolean; repoId?: string; staleWhileRevalidate?: boolean } +type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null } const CACHE_TTL_MS = 60_000 const HOSTED_REVIEW_CACHE_MAX = 500 @@ -52,6 +53,14 @@ function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> { return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS } +function findHostedReviewRepoByPath( + repos: readonly Repo[] | undefined, + repoPath: string, + repoId?: string | null +): Repo | undefined { + return repos?.find((candidate) => (repoId ? candidate.id === repoId : candidate.path === repoPath)) +} + function shouldRefetchForLinkedHint( cached: CacheEntry<HostedReviewInfo> | undefined, hintKey: string @@ -147,7 +156,7 @@ export type HostedReviewSlice = { ) => Promise<HostedReviewCreationEligibility> createHostedReview: ( repoPath: string, - input: CreateHostedReviewInput + input: CreateHostedReviewStoreInput ) => Promise<CreateHostedReviewResult> fetchHostedReviewForBranch: ( repoPath: string, @@ -193,7 +202,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie getHostedReviewCreationEligibility: async (args) => { const settings = get().settings - const repo = get().repos.find((candidate) => candidate.path === args.repoPath) + const repo = findHostedReviewRepoByPath(get().repos, args.repoPath, args.repoId) const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) const target = getActiveRuntimeTarget(ownerSettings) if (target.kind === 'environment') { @@ -212,17 +221,19 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie } return window.api.hostedReview.getCreationEligibility({ ...args, + repoId: repo?.id ?? args.repoId, connectionId: repo?.connectionId ?? null }) }, createHostedReview: async (repoPath, input) => { const settings = get().settings - const repo = get().repos.find((candidate) => candidate.path === repoPath) + const repo = findHostedReviewRepoByPath(get().repos, repoPath, input.repoId) const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) const target = getActiveRuntimeTarget(ownerSettings) + const { repoId: inputRepoId, ...hostedReviewInput } = input if (target.kind === 'environment') { - const { worktreePath, ...runtimeInput } = input + const { worktreePath, ...runtimeInput } = hostedReviewInput return callRuntimeRpc<CreateHostedReviewResult>( target, 'hostedReview.create', @@ -236,8 +247,9 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie } return window.api.hostedReview.create({ repoPath, + repoId: repo?.id ?? inputRepoId ?? undefined, connectionId: repo?.connectionId ?? null, - ...input + ...hostedReviewInput }) }, diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index c3c6dd2a477..a3c19914db9 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -9,6 +9,7 @@ import type { Project, Repo, ProjectGroup, + ProjectHostSetup, FolderWorkspace, ProjectGroupImportResult, NestedRepoScanResult, @@ -22,6 +23,11 @@ import type { ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult } from '../../../../shared/types' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from '../../../../shared/project-host-setup-projection' +import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { FOLDER_WORKSPACE_PATH_STATUS_TTL_MS, type FolderWorkspacePathStatus, @@ -42,10 +48,16 @@ import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' -import { buildDismissedOnboardingFolderAgentStartup } from '../../lib/onboarding-folder-agent-startup' -import { markOnboardingProjectAdded } from '../../lib/onboarding-project-checklist' -import { filterSetupScriptPromptDismissalsToValidRepos } from '../../lib/setup-script-prompt' -import { translate } from '../../i18n/i18n' +import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' +import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' +import { translate } from '@/i18n/i18n' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' @@ -163,6 +175,189 @@ function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { return [...ids] } +function getRuntimeTargetHostId( + target: ReturnType<typeof getActiveRuntimeTarget> +): ReturnType<typeof toRuntimeExecutionHostId> | typeof LOCAL_EXECUTION_HOST_ID { + return target.kind === 'environment' + ? toRuntimeExecutionHostId(target.environmentId) + : LOCAL_EXECUTION_HOST_ID +} + +function getProjectSetupRuntimeTarget( + hostId: ProjectHostSetupExistingFolderArgs['hostId'] +): ReturnType<typeof getActiveRuntimeTarget> { + const parsedHost = parseExecutionHostId(hostId) + return parsedHost?.kind === 'runtime' + ? { kind: 'environment', environmentId: parsedHost.environmentId } + : { kind: 'local' } +} + +function repoWithFetchedOwner(repo: Repo, target: ReturnType<typeof getActiveRuntimeTarget>): Repo { + if (repo.connectionId) { + return { ...repo, executionHostId: getRepoExecutionHostId(repo) } + } + return { ...repo, executionHostId: getRuntimeTargetHostId(target) } +} + +function setupWithFetchedOwner( + setup: ProjectHostSetup, + target: ReturnType<typeof getActiveRuntimeTarget> +): ProjectHostSetup { + const hostId = getRuntimeTargetHostId(target) + if (target.kind !== 'environment' || setup.hostId !== LOCAL_EXECUTION_HOST_ID) { + return setup + } + return { + ...setup, + hostId, + executionHostId: hostId + } +} + +async function fetchProjectHostSetupCompatibility( + target: ReturnType<typeof getActiveRuntimeTarget>, + repos: readonly Repo[] +): Promise<ProjectHostSetupProjection> { + try { + if (target.kind === 'local') { + const projectsApi = ( + window.api as typeof window.api & { + projects?: { + list?: () => Promise<Project[]> + listHostSetups?: () => Promise<ProjectHostSetup[]> + } + } + ).projects + if (!projectsApi?.list || !projectsApi.listHostSetups) { + throw new Error('projects_api_unavailable') + } + return { + projects: await projectsApi.list(), + setups: await projectsApi.listHostSetups() + } + } + await assertProjectHostSetupRuntimeCapability(target) + const [projectResponse, setupResponse] = await Promise.all([ + callRuntimeRpc<{ projects: Project[] }>(target, 'project.list', undefined, { + timeoutMs: 15_000 + }), + callRuntimeRpc<{ setups: ProjectHostSetup[] }>(target, 'projectHostSetup.list', undefined, { + timeoutMs: 15_000 + }) + ]) + return { + projects: projectResponse.projects, + setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) + } + } catch { + // Why: newer clients must still hydrate against older runtimes/preloads + // that only know `repo.list`; derive the transitional model locally. + return projectHostSetupProjectionFromRepos(repos) + } +} + +async function assertProjectHostSetupRuntimeCapability( + target: ReturnType<typeof getActiveRuntimeTarget> +): Promise<void> { + if (target.kind !== 'environment') { + return + } + await assertRuntimeEnvironmentCapability( + target.environmentId, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + 'The selected Orca server does not support project host setup yet. Update Orca on the server and try again.', + 15_000 + ) +} + +function projectCompatibilityFromRepos( + repos: readonly Repo[] +): Pick<RepoSlice, 'projects' | 'projectHostSetups'> { + const projection = projectHostSetupProjectionFromRepos(repos) + return { + projects: projection.projects, + projectHostSetups: projection.setups + } +} + +function mergeProjectHostSetupCompatibility( + derived: Pick<RepoSlice, 'projects' | 'projectHostSetups'>, + fetched: ProjectHostSetupProjection +): Pick<RepoSlice, 'projects' | 'projectHostSetups'> { + return { + projects: mergeById(derived.projects, fetched.projects), + projectHostSetups: mergeById(derived.projectHostSetups, fetched.setups) + } +} + +function mergeById<T extends { id: string }>(base: readonly T[], overlay: readonly T[]): T[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged +} + +function mergeFetchedReposForHost( + previous: readonly Repo[], + fetched: Repo[], + hostId: string +): Repo[] { + const fetchedIds = new Set(fetched.map((repo) => repo.id)) + const preserved = previous.filter((repo) => { + const existingHostId = getRepoExecutionHostId(repo) + return existingHostId !== hostId || fetchedIds.has(repo.id) + }) + const preservedById = new Map(preserved.map((repo) => [repo.id, repo])) + const merged = [...preserved] + for (const repo of fetched) { + const existingIndex = merged.findIndex((entry) => entry.id === repo.id) + if (existingIndex === -1) { + merged.push(repo) + continue + } + merged[existingIndex] = repo + } + return reconcileFetchedRepos( + previous, + merged.filter((repo) => preservedById.has(repo.id) || fetchedIds.has(repo.id)) + ) +} + +function settingsForRepoOwner(state: Pick<AppState, 'repos' | 'settings'>, repoId: string) { + const repo = state.repos.find((entry) => entry.id === repoId) + if (!repo) { + return state.settings + } + if (!repo.executionHostId && !repo.connectionId) { + return state.settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if (parsed?.kind === 'local' && state.settings?.activeRuntimeEnvironmentId) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + if (parsed?.kind !== 'ssh') { + return state.settings + } + // Why: SSH repos are owned through local IPC/SSH plumbing. Existing repo + // mutations must not follow whichever runtime server is currently focused. + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { return request.scope === 'project-group' ? `project-group:${request.projectGroupId}` @@ -416,6 +611,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, ) return { repos: reconciledRepos, + ...projectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), @@ -882,8 +1078,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, if (!moved) { return false } + const ownedMoved = repoWithFetchedOwner(moved, target) set((s) => ({ - repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)), + repos: s.repos.map((repo) => (repo.id === projectId ? ownedMoved : repo)), folderWorkspacePathStatuses: {} })) return true @@ -936,7 +1133,12 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, if (s.repos.some((r) => r.id === repo.id)) { return s } - return { repos: [...s.repos, repo], folderWorkspacePathStatuses: {} } + const nextRepos = [...s.repos, repo] + return { + repos: nextRepos, + ...projectCompatibilityFromRepos(nextRepos), + folderWorkspacePathStatuses: {} + } }) if (alreadyAdded) { toast.info(translate('auto.store.slices.repos.a8e4b3af5b', 'Project already added'), { @@ -1418,9 +1620,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, ...updatesWithoutSourceControlAi, ...(sourceControlAi !== undefined ? { sourceControlAi } : {}) } - }), - folderWorkspacePathStatuses: {} - })) + }) + return { + repos: nextRepos, + ...projectCompatibilityFromRepos(nextRepos), + folderWorkspacePathStatuses: {} + } + }) return true } catch (err) { console.error('Failed to update repo:', err) @@ -1461,7 +1667,11 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, // Caller passed a non-permutation — refuse to apply locally. return } - set({ repos: next, folderWorkspacePathStatuses: {} }) + set({ + repos: next, + ...projectCompatibilityFromRepos(next), + folderWorkspacePathStatuses: {} + }) try { // Why: each host persists only its own repos and rejects non-permutations, // so split the cross-host order into per-host permutations and dispatch one diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 59195ee8a3c..f7b05e003f3 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -42,210 +42,6 @@ function createOpenInApplicationId(): string { ) } -function runtimeScopedStateReset(): Partial<AppState> { - return { - repos: [], - projectGroups: [], - folderWorkspaces: [], - activeRepoId: null, - sparsePresetsByRepo: {}, - sparsePresetsLoadingByRepo: {}, - sparsePresetsLoadStatusByRepo: {}, - sparsePresetsErrorByRepo: {}, - worktreesByRepo: {}, - detectedWorktreesByRepo: {}, - worktreeLineageById: {}, - activeWorktreeId: null, - activeWorkspaceKey: null, - deleteStateByWorktreeId: {}, - baseStatusByWorktreeId: {}, - remoteBranchConflictByWorktreeId: {}, - sortEpoch: 0, - everActivatedWorktreeIds: new Set<string>(), - lastVisitedAtByWorktreeId: {}, - hasHydratedWorktreePurge: false, - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - activeGroupIdByWorktree: {}, - layoutByWorktree: {}, - tabsByWorktree: {}, - activeTabId: null, - activeTabIdByWorktree: {}, - ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {}, - unreadTerminalTabs: {}, - suppressedPtyExitIds: {}, - pendingCodexPaneRestartIds: {}, - codexRestartNoticeByPtyId: {}, - expandedPaneByTabId: {}, - canExpandPaneByTabId: {}, - terminalLayoutsByTabId: {}, - pendingStartupByTabId: {}, - pendingSetupSplitByTabId: {}, - pendingIssueCommandSplitByTabId: {}, - tabBarOrderByWorktree: {}, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {}, - lastKnownRelayPtyIdByTabId: {}, - pendingSnapshotByPtyId: {}, - pendingColdRestoreByPtyId: {}, - deferredSshReconnectTargets: [], - deferredSshSessionIdsByTabId: {}, - cacheTimerByKey: {}, - recentQuickCommandIdByGroup: {}, - showDotfilesByWorktree: {}, - expandedDirs: {}, - pendingExplorerReveal: null, - openFiles: [], - editorDrafts: {}, - markdownViewMode: {}, - editorViewMode: {}, - markdownFrontmatterVisible: {}, - editorCursorLine: {}, - gitIgnoredPathsByWorktree: {}, - activeFileId: null, - activeFileIdByWorktree: {}, - activeTabTypeByWorktree: {}, - activeTabType: 'terminal', - recentlyClosedEditorTabsByWorktree: {}, - browserTabsByWorktree: {}, - browserPagesByWorkspace: {}, - browserAnnotationsByPageId: {}, - remoteBrowserPageHandlesByPageId: {}, - activeBrowserTabId: null, - activeBrowserTabIdByWorktree: {}, - recentlyClosedBrowserTabsByWorktree: {}, - recentlyClosedBrowserPagesByWorkspace: {}, - pendingAddressBarFocusByTabId: {}, - pendingAddressBarFocusByPageId: {}, - browserSessionProfiles: [], - browserSessionImportState: null, - defaultBrowserSessionProfileId: null, - detectedBrowsers: [], - detectedBrowsersLoaded: false, - prCache: {}, - issueCache: {}, - checksCache: {}, - commentsCache: {}, - workItemsCache: {}, - workItemsInvalidationNonce: 0, - projectViewCache: {}, - linearStatus: { connected: false, viewer: null }, - linearStatusChecked: false, - linearStatusContextKey: null, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - jiraStatus: { connected: false, viewer: null }, - jiraStatusChecked: false, - jiraStatusContextKey: null, - jiraIssueCache: {}, - jiraSearchCache: {} - } -} - -function hasUnsavedEditorState(state: AppState): boolean { - return state.openFiles.some((file) => file.isDirty || state.editorDrafts[file.id] !== undefined) -} - -function isPairedWebClient(): boolean { - return Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) -} - -async function closeRemoteBrowserPagesBeforeRuntimeSwitch(state: AppState): Promise<void> { - const worktreeIdByPageId = new Map<string, string>() - for (const pages of Object.values(state.browserPagesByWorkspace)) { - for (const page of pages) { - worktreeIdByPageId.set(page.id, page.worktreeId) - } - } - await Promise.allSettled( - Object.entries(state.remoteBrowserPageHandlesByPageId).map(([pageId, handle]) => { - const worktreeId = worktreeIdByPageId.get(pageId) - if (!worktreeId) { - return Promise.resolve() - } - return callRuntimeRpc( - { kind: 'environment', environmentId: handle.environmentId }, - 'browser.tabClose', - { worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId }, - { timeoutMs: 15_000 } - ) - }) - ) -} - -function collectRemoteTerminalHandlesForRuntimeSwitch( - state: AppState, - fallbackEnvironmentId: string | null -): Map<string, Set<string>> { - const handlesByEnvironmentId = new Map<string, Set<string>>() - const collect = (ptyId: string | null | undefined): void => { - if (!ptyId) { - return - } - const handle = getRemoteRuntimeTerminalHandle(ptyId) - if (!handle) { - return - } - const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId) ?? fallbackEnvironmentId - if (!environmentId) { - return - } - const handles = handlesByEnvironmentId.get(environmentId) ?? new Set<string>() - handles.add(handle) - handlesByEnvironmentId.set(environmentId, handles) - } - - for (const ptyIds of Object.values(state.ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - collect(ptyId) - } - } - for (const tabs of Object.values(state.tabsByWorktree)) { - for (const tab of tabs) { - collect(tab.ptyId) - } - } - for (const layout of Object.values(state.terminalLayoutsByTabId)) { - for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { - collect(ptyId) - } - } - return handlesByEnvironmentId -} - -async function closeRemoteTerminalsBeforeRuntimeSwitch( - state: AppState, - fallbackEnvironmentId: string | null -): Promise<void> { - const handlesByEnvironmentId = collectRemoteTerminalHandlesForRuntimeSwitch( - state, - fallbackEnvironmentId - ) - await Promise.allSettled( - Array.from(handlesByEnvironmentId.entries()).flatMap(([environmentId, handles]) => - Array.from(handles).map((terminal) => - callRuntimeRpc( - { kind: 'environment', environmentId }, - 'terminal.close', - { terminal }, - { timeoutMs: 15_000 } - ) - ) - ) - ) -} - async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise<void> { if (!environmentId) { return @@ -340,15 +136,6 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> if (previousId === nextId) { return true } - if (hasUnsavedEditorState(get())) { - toast.error( - translate( - 'auto.store.slices.settings.faa8fb83dd', - 'Save or close unsaved editor tabs before switching servers.' - ) - ) - return false - } try { clearRuntimeCompatibilityCache(nextId) await verifyRuntimeEnvironmentReachable(nextId) diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index a65cb122a2c..34fac9864db 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -37,6 +37,7 @@ import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' +import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { translate } from '@/i18n/i18n' @@ -80,6 +81,7 @@ export function createTestStore() { ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a) })) diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index f8063313145..6e563f69392 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -19,6 +19,7 @@ import type { FeatureInteractionState } from '../../../../shared/feature-interac import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' const mocks = vi.hoisted(() => ({ sendBracketedPasteToRunningAgent: vi.fn(), @@ -650,6 +651,106 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().rightSidebarExplorerView).toBe('search') }) + it('hydrates a persisted workspace host scope', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI(makePersistedUI({ workspaceHostScope: 'ssh:win%20vm' })) + + expect(store.getState().workspaceHostScope).toBe('ssh:win%20vm') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['ssh:win%20vm']) + }) + + it('hydrates a persisted visible workspace host set', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceHostScope: 'ssh:win%20vm', + visibleWorkspaceHostIds: [ + 'local', + 'ssh:win%20vm', + 'bogus' as NonNullable<PersistedUIState['visibleWorkspaceHostIds']>[number], + 'local' + ] + }) + ) + + expect(store.getState().workspaceHostScope).toBe('ssh:win%20vm') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['local', 'ssh:win%20vm']) + }) + + it('hydrates a persisted workspace host order', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceHostOrder: [ + 'ssh:win%20vm', + 'bogus' as NonNullable<PersistedUIState['workspaceHostOrder']>[number], + 'local', + 'ssh:win%20vm' + ] + }) + ) + + expect(store.getState().workspaceHostOrder).toEqual(['ssh:win%20vm', 'local']) + }) + + it('falls back to all hosts for invalid persisted workspace host scopes', () => { + const store = createUIStore() + + store + .getState() + .hydratePersistedUI( + makePersistedUI({ workspaceHostScope: 'bogus' as PersistedUIState['workspaceHostScope'] }) + ) + + expect(store.getState().workspaceHostScope).toBe('all') + expect(store.getState().visibleWorkspaceHostIds).toBeNull() + }) + + it('persists workspace host scope changes', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setWorkspaceHostScope('runtime:env-1') + + expect(store.getState().workspaceHostScope).toBe('runtime:env-1') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['runtime:env-1']) + expect(setUI).toHaveBeenCalledWith({ + workspaceHostScope: 'runtime:env-1', + visibleWorkspaceHostIds: ['runtime:env-1'] + }) + }) + + it('persists visible workspace host changes independently of focused host', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setWorkspaceHostScope('runtime:env-1') + store.getState().setVisibleWorkspaceHostIds(['local', 'runtime:env-1']) + + expect(store.getState().workspaceHostScope).toBe('runtime:env-1') + expect(store.getState().visibleWorkspaceHostIds).toEqual(['local', 'runtime:env-1']) + expect(setUI).toHaveBeenLastCalledWith({ + workspaceHostScope: 'runtime:env-1', + visibleWorkspaceHostIds: ['local', 'runtime:env-1'] + }) + }) + + it('persists workspace host order changes', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setWorkspaceHostOrder(['ssh:win%20vm', 'bogus' as never, 'local']) + + expect(store.getState().workspaceHostOrder).toEqual(['ssh:win%20vm', 'local']) + expect(setUI).toHaveBeenCalledWith({ workspaceHostOrder: ['ssh:win%20vm', 'local'] }) + }) + it('hydrates persisted per-worktree dotfile visibility', () => { const store = createUIStore() @@ -1273,11 +1374,61 @@ describe('createUISlice settings navigation', () => { 'repo-1', '/repo', expect.any(Number), - 'is:issue is:open' + 'is:issue is:open', + { sourceContext: null } ) expect(prefetchLinearIssues).not.toHaveBeenCalled() }) + it('prefetches direct GitHub task opens with their source context', () => { + const store = createUIStore() + const prefetchWorkItems = vi.fn() + const workItem = makeGitHubWorkItem() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'repo' } + } + + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' + } + ], + settings: { + visibleTaskProviders: ['github'], + defaultTaskSource: 'github', + defaultTaskViewPreset: 'all' + } as unknown as AppState['settings'], + prefetchWorkItems + } as unknown as Partial<AppState>) + + store.getState().openTaskPage({ + taskSource: 'github', + preselectedRepoId: 'repo-1', + openGitHubWorkItem: workItem, + openGitHubSourceContext: sourceContext + }) + + expect(prefetchWorkItems).toHaveBeenCalledWith( + 'repo-1', + '/repo', + expect.any(Number), + 'is:issue is:open', + { sourceContext } + ) + }) + it('returns to the tasks page after visiting settings from an in-progress draft', () => { const store = createUIStore() @@ -1403,7 +1554,13 @@ describe('createUISlice page navigation history', () => { expect(store.getState().worktreeNavHistory).toEqual([ 'a', 'tasks', - { kind: 'task-detail', source: 'github', workItem, initialTab: undefined } + { + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: undefined, + initialTab: undefined + } ]) expect(store.getState().worktreeNavHistoryIndex).toBe(2) @@ -1429,6 +1586,34 @@ describe('createUISlice page navigation history', () => { expect(recordFeatureInteraction).toHaveBeenCalledWith('linear-tasks') }) + it('preserves GitHub task detail source context in navigation history', () => { + const store = createUIStore() + const workItem = makeGitHubWorkItem({ repoId: 'repo-remote' }) + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-remote', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + + store.getState().openTaskPage({ + taskSource: 'github', + openGitHubWorkItem: workItem, + openGitHubSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'github', + workItem, + sourceContext, + initialTab: undefined + }) + }) + it('can suppress the Tasks surface interaction for in-page provider navigation', () => { const store = createUIStore() const recordFeatureInteraction = vi.fn() @@ -1465,7 +1650,13 @@ describe('createUISlice page navigation history', () => { expect(store.getState().worktreeNavHistory).toEqual([ 'a', 'tasks', - { kind: 'task-detail', source: 'github', workItem, initialTab: undefined }, + { + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: undefined, + initialTab: undefined + }, 'tasks' ]) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index a096a5bafc5..25a61e6b6e7 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -28,6 +28,7 @@ import type { VisibleWorkspaceHostIds } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/types' import { @@ -252,6 +253,15 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { + const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) + if (visibleHostIds) { + return visibleHostIds + } + const legacyScope = normalizeExecutionHostScope(ui.workspaceHostScope) + return legacyScope === 'all' ? null : [legacyScope] +} + const MIN_SIDEBAR_WIDTH = 220 const MAX_LEFT_SIDEBAR_WIDTH = 500 // Why: the right sidebar drag-resize is window-relative (see right-sidebar @@ -593,6 +603,7 @@ export type UISlice = { prefilledName?: string taskSource?: TaskProvider openGitHubWorkItem?: GitHubWorkItem + openGitHubSourceContext?: TaskSourceContext | null openGitHubInitialTab?: 'conversation' | 'checks' | 'files' openLinearIssue?: LinearIssue } @@ -618,6 +629,9 @@ export type UISlice = { url: string linearIdentifier?: string } | null + /** Why: starting from a task must preserve where provider data came from + * separately from the host selected to run the workspace. */ + taskSourceContext?: TaskSourceContext | null agent: TuiAgent linkedIssue: string linkedPR: number | null @@ -1087,6 +1101,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) kind: 'task-detail', source: 'github', workItem: data.openGitHubWorkItem, + sourceContext: data.openGitHubSourceContext, initialTab: data.openGitHubInitialTab } as const) : data.openLinearIssue @@ -1164,7 +1179,13 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) ? (resume.githubItemsQuery ?? '').trim() : presetToQuery(resume?.githubItemsPreset ?? defaultPreset) for (const repo of selectedRepos) { - state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query) + state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query, { + sourceContext: + data.openGitHubSourceContext?.provider === 'github' && + data.openGitHubSourceContext.repoId === repo.id + ? data.openGitHubSourceContext + : null + }) } } if (resolvedSource === 'linear' && typeof state.prefetchLinearIssues === 'function') { diff --git a/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts b/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts index c6c6e21a67c..9bee048f17d 100644 --- a/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts +++ b/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts @@ -2,6 +2,7 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import { afterEach, describe, expect, it } from 'vitest' import type { AppState } from '../types' import type { GitHubWorkItem, Worktree } from '../../../../shared/types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import { createWorktreeNavHistorySlice, findPrevLiveWorktreeHistoryIndex, @@ -173,4 +174,38 @@ describe('worktree-nav-history slice: view entries', () => { expect(viewed).toEqual(['tasks', detail]) expect(store.getState().worktreeNavHistoryIndex).toBe(2) }) + + it('keeps same GitHub item details separate when the source host differs', () => { + const store = createHistoryStore(['a']) + const workItem = makeGitHubWorkItem() + const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'repo' } + } + const sshSource: TaskSourceContext = { + ...localSource, + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-ssh' + } + + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: localSource + }) + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'github', + workItem, + sourceContext: sshSource + }) + + expect(store.getState().worktreeNavHistory).toHaveLength(2) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) }) diff --git a/src/renderer/src/store/slices/worktree-nav-history.ts b/src/renderer/src/store/slices/worktree-nav-history.ts index 50509320492..dc4e9a802dc 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { findWorktreeById } from './worktree-helpers' import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types' +import { getTaskSourceCacheScope, type TaskSourceContext } from '../../../../shared/task-source-context' // Why: cap the per-session history so a long-lived workspace with many // worktree jumps cannot grow the array unbounded. 50 is generous enough @@ -20,6 +21,7 @@ export type WorktreeNavHistoryTaskDetailEntry = kind: 'task-detail' source: 'github' workItem: GitHubWorkItem + sourceContext?: TaskSourceContext | null initialTab?: 'conversation' | 'checks' | 'files' } | { kind: 'task-detail'; source: 'linear'; issue: LinearIssue } @@ -82,7 +84,11 @@ function getHistoryEntryKey(entry: WorktreeNavHistoryEntry): string { return entry === 'tasks' || entry === 'automations' ? `view:${entry}` : `worktree:${entry}` } if (entry.source === 'github') { - return `view:task-detail:github:${entry.workItem.repoId}:${entry.workItem.type}:${entry.workItem.number}:${entry.initialTab ?? 'conversation'}` + const sourceScope = + entry.sourceContext?.provider === 'github' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:github:${sourceScope}:${entry.workItem.repoId}:${entry.workItem.type}:${entry.workItem.number}:${entry.initialTab ?? 'conversation'}` } return `view:task-detail:linear:${entry.issue.workspaceId ?? 'selected'}:${entry.issue.id}` } diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index af824632d65..e48b2fad849 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -3278,6 +3278,54 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined() }) + // Why: multi-host regression — once hydration has fired, a mid-session + // fetchAllWorktrees (e.g. triggered by switching focus) must NEVER purge + // terminal state, even if a host transiently reports zero worktrees. The + // hydration-time purge is the only purge path here; it is gated to boot. + it('does not purge another host tab state when hasHydratedWorktreePurge is already true and a host reports zero worktrees', async () => { + const store = createTestStore() + const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) + + // repoB reports zero worktrees this round (host briefly empty), repoA fine. + mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) => + repoId === 'repoA' ? [wtA] : [] + ) + + store.setState({ + hasHydratedWorktreePurge: true, + repos: [repoA, repoB], + tabsByWorktree: { + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + }, + ptyIdsByTabId: { 'tab-B': ['remote:env-b@@terminal-b'] }, + terminalLayoutsByTabId: { + 'tab-B': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-b@@terminal-b' } + } + } + } as unknown as Partial<AppState>) + + await store.getState().fetchAllWorktrees() + + // The zero-worktree host's live tab/terminal state is untouched. + expect(store.getState().tabsByWorktree).toEqual({ + 'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }] + }) + expect(store.getState().ptyIdsByTabId).toEqual({ 'tab-B': ['remote:env-b@@terminal-b'] }) + expect(store.getState().terminalLayoutsByTabId).toEqual({ + 'tab-B': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-b@@terminal-b' } + } + }) + expect(store.getState().hasHydratedWorktreePurge).toBe(true) + }) + it('preserves floating workspace state while purging a real stale worktree', async () => { const store = createTestStore() const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) @@ -3874,6 +3922,56 @@ describe('pending worktree creation state', () => { expect(store.getState().activePendingCreationId).toBe('c1') }) + it('keeps source and run context on the retryable request', () => { + const store = createTestStore() + const entry = makePendingCreation('c1', { + request: { + repoId: 'repo-ssh', + taskSourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'local', + projectHostSetupId: 'setup-local', + repoId: 'repo-local', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }, + workspaceRunContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:ssh-1', + projectHostSetupId: 'setup-ssh', + repoId: 'repo-ssh', + path: '/home/orca/orca' + }, + name: 'feature', + setupDecision: 'inherit', + agent: null, + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null + } + }) + + store.getState().beginPendingWorktreeCreation(entry) + + expect(store.getState().pendingWorktreeCreations.c1.request).toMatchObject({ + repoId: 'repo-ssh', + taskSourceContext: { + provider: 'github', + hostId: 'local', + repoId: 'repo-local' + }, + workspaceRunContext: { + hostId: 'ssh:ssh-1', + projectHostSetupId: 'setup-ssh', + repoId: 'repo-ssh' + } + }) + }) + it('updatePendingWorktreeCreation skips the write when the patch changes nothing', () => { const store = createTestStore() store.getState().beginPendingWorktreeCreation(makePendingCreation('c1')) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index d4ec053d755..8f7a173e900 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -40,6 +40,12 @@ import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { translate } from '@/i18n/i18n' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey, @@ -378,28 +384,23 @@ function applyDetectedWorktreeUpdates( worktreeId: string, updates: Partial<WorktreeMeta> ): AppState['detectedWorktreesByRepo'] { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const result = detectedWorktreesByRepo[repoId] - if (!result) { - return detectedWorktreesByRepo - } - let changed = false - const nextWorktrees = result.worktrees.map((worktree) => { - if (worktree.id !== worktreeId) { - return worktree - } - changed = true - return { ...worktree, ...updates } - }) - if (!changed) { - return detectedWorktreesByRepo + const nextByRepo: AppState['detectedWorktreesByRepo'] = {} + + for (const [repoId, result] of Object.entries(detectedWorktreesByRepo)) { + let repoChanged = false + const nextWorktrees = result.worktrees.map((worktree) => { + if (worktree.id !== worktreeId) { + return worktree + } + repoChanged = true + changed = true + return { ...worktree, ...updates } + }) + nextByRepo[repoId] = repoChanged ? { ...result, worktrees: nextWorktrees } : result } - return { - ...detectedWorktreesByRepo, - [repoId]: { ...result, worktrees: nextWorktrees } - } + return changed ? nextByRepo : detectedWorktreesByRepo } function findKnownWorktreeById( diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index e6152999f36..39d264fb11f 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -27,6 +27,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents' import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history' import type { DictationSlice } from './slices/dictation' import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup' +import type { RuntimeStatusSlice } from './slices/runtime-status' import type { PullRequestGenerationSlice } from './slices/pull-request-generation' import type { CommitMessageGenerationSlice } from './slices/commit-message-generation' @@ -59,5 +60,6 @@ export type AppState = RepoSlice & WorktreeNavHistorySlice & DictationSlice & WorkspaceCleanupSlice & + RuntimeStatusSlice & PullRequestGenerationSlice & CommitMessageGenerationSlice diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index fd63df961ae..643e1372317 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -1227,42 +1227,6 @@ describe('web file preload API', () => { }) }) -describe('web star nag preload API', () => { - beforeEach(() => { - vi.resetModules() - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('keeps the browser-paired star nag API safe and in parity with the preload contract', async () => { - const { api } = await installApi('Linux') - - expect(Object.keys(api.starNag).sort()).toEqual([ - 'complete', - 'disable', - 'dismiss', - 'forceShow', - 'onShow', - 'openWeb', - 'starOrca' - ]) - - const listener = vi.fn() - const unsubscribe = api.starNag.onShow(listener) - unsubscribe() - - await expect(api.starNag.dismiss()).resolves.toBeUndefined() - await expect(api.starNag.complete()).resolves.toBeUndefined() - await expect(api.starNag.disable()).resolves.toBeUndefined() - await expect(api.starNag.openWeb()).resolves.toBeUndefined() - await expect(api.starNag.forceShow()).resolves.toBeUndefined() - await expect(api.starNag.starOrca()).resolves.toBe(false) - expect(listener).not.toHaveBeenCalled() - }) -}) - describe('web GitHub preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 68786868865..00dba98912c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -171,7 +171,6 @@ function invalidateRuntimeWorktreeCaches(): void { type WebSettingsApi = NonNullable<PreloadApi['settings']> type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']> type WebGitHubApi = NonNullable<PreloadApi['gh']> -type WebStarNagApi = NonNullable<PreloadApi['starNag']> type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>> type WebGitHubRouteKey = | 'repoSlug' @@ -560,7 +559,6 @@ function createWebPreloadApi(): Partial<PreloadApi> { browser: createBrowserApi(), emulator: createEmulatorApi(), gh: createGitHubApi(), - starNag: createStarNagApi(), gl: createGitLabApi(), hostedReview: createRuntimeNamespaceApi('hostedReview'), linear: createRuntimeNamespaceApi('linear'), @@ -1589,18 +1587,6 @@ function createEmulatorApi(): NonNullable<Partial<PreloadApi>['emulator']> { } as unknown as NonNullable<Partial<PreloadApi>['emulator']> } -function createStarNagApi(): WebStarNagApi { - return { - onShow: () => noopUnsubscribe, - dismiss: () => Promise.resolve(), - complete: () => Promise.resolve(), - disable: () => Promise.resolve(), - openWeb: () => Promise.resolve(), - starOrca: () => Promise.resolve(false), - forceShow: () => Promise.resolve() - } -} - function createGitHubApi(): WebGitHubApi { const route = <Result>(method: WebGitHubRuntimeMethod, args?: unknown): Promise<Result> => callRuntimeResult<Result>(method, mapRepoPathArg(args)) @@ -2102,9 +2088,9 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> { getInstallStatus: () => Promise.resolve(status), install: () => Promise.resolve(status), remove: () => Promise.resolve(status), - getWslInstallStatus: () => Promise.resolve(status), - installWsl: () => Promise.resolve(status), - removeWsl: () => Promise.resolve(status) + getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), + installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), + removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) } as NonNullable<Partial<PreloadApi>['cli']> } diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 71d7293a701..271710fd92e 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -1,4 +1,5 @@ import type { TuiAgent } from './types' +import type { TaskSourceContext, WorkspaceRunContext } from './task-source-context' export type AutomationWorkspaceMode = 'existing' | 'new_per_run' export type AutomationExecutionTargetType = 'local' | 'ssh' @@ -80,6 +81,12 @@ export type Automation = { prompt: string precheck: AutomationPrecheck | null agentId: TuiAgent + /** Why: projectId is still the legacy repo id during migration; runContext + * carries the logical project + host setup identity for multi-host projects. */ + runContext?: WorkspaceRunContext | null + /** Why: task/provider data can come from a different host/account than the + * workspace run target, so automations persist it separately. */ + sourceContext?: TaskSourceContext | null projectId: string executionTargetType: AutomationExecutionTargetType executionTargetId: string @@ -103,6 +110,8 @@ export type Automation = { export type AutomationRun = { id: string automationId: string + runContext?: WorkspaceRunContext | null + sourceContext?: TaskSourceContext | null title: string scheduledFor: number status: AutomationRunStatus @@ -128,6 +137,8 @@ export type AutomationCreateInput = { prompt: string precheck?: AutomationPrecheck | null agentId: TuiAgent + runContext?: WorkspaceRunContext | null + sourceContext?: TaskSourceContext | null projectId: string workspaceMode: AutomationWorkspaceMode workspaceId?: string | null @@ -147,6 +158,8 @@ export type AutomationUpdateInput = Partial< | 'prompt' | 'precheck' | 'agentId' + | 'runContext' + | 'sourceContext' | 'projectId' | 'workspaceMode' | 'workspaceId' diff --git a/src/shared/git-clone-failure-message.test.ts b/src/shared/git-clone-failure-message.test.ts index 702b40e0c97..c869ef49a1a 100644 --- a/src/shared/git-clone-failure-message.test.ts +++ b/src/shared/git-clone-failure-message.test.ts @@ -2,15 +2,18 @@ import { describe, expect, it } from 'vitest' import { getGitCloneFailureMessage } from './git-clone-failure-message' describe('getGitCloneFailureMessage', () => { - it('keeps the full fatal destination path message after progress output', () => { + it('turns an existing destination into an actionable message after progress output', () => { expect( getGitCloneFailureMessage( [ 'Cloning into \u001b[32morca\u001b[0m...\r', "fatal: destination path 'orca' already exists and is not an empty directory.\n" - ].join('') + ].join(''), + { clonePath: '/work/orca' } ) - ).toBe("fatal: destination path 'orca' already exists and is not an empty directory.") + ).toBe( + 'Destination already exists and is not empty: /work/orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' + ) }) it('prefers the last fatal line over a trailing fragment', () => { @@ -18,7 +21,19 @@ describe('getGitCloneFailureMessage', () => { getGitCloneFailureMessage( "fatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n" ) - ).toBe("fatal: destination path 'orca' already exists and is not an empty directory.") + ).toBe( + 'Destination already exists and is not empty: orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' + ) + }) + + it('uses the known clone path for relay destination fragments', () => { + expect( + getGitCloneFailureMessage('Clone failed: and the repository exists.', { + clonePath: '/srv/orca' + }) + ).toBe( + 'Destination already exists and is not empty: /srv/orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.' + ) }) it('falls back to the last non-empty line', () => { diff --git a/src/shared/git-clone-failure-message.ts b/src/shared/git-clone-failure-message.ts index df4e64940b6..fe19a72f131 100644 --- a/src/shared/git-clone-failure-message.ts +++ b/src/shared/git-clone-failure-message.ts @@ -1,4 +1,7 @@ -export function getGitCloneFailureMessage(stderr: string): string { +export function getGitCloneFailureMessage( + stderr: string, + options: { clonePath?: string | null } = {} +): string { const lines = stderr .replace(/\r/g, '\n') .split('\n') @@ -9,17 +12,29 @@ export function getGitCloneFailureMessage(stderr: string): string { const line = lines[index] const fatalIndex = line.indexOf('fatal:') if (fatalIndex !== -1) { - return line.slice(fatalIndex) + return formatGitCloneFailureLine(line.slice(fatalIndex), options) } const errorIndex = line.indexOf('error:') if (errorIndex !== -1) { - return line.slice(errorIndex) + return formatGitCloneFailureLine(line.slice(errorIndex), options) } } - return lines.at(-1) ?? 'unknown error' + return formatGitCloneFailureLine(lines.at(-1) ?? 'unknown error', options) } function stripAnsi(value: string): string { return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') } + +function formatGitCloneFailureLine(line: string, options: { clonePath?: string | null }): string { + const destinationMatch = line.match( + /^fatal:\s+destination path '([^']+)' already exists and is not an empty directory\.$/ + ) + if (destinationMatch || /repository exists/i.test(line)) { + const destination = options.clonePath?.trim() || destinationMatch?.[1] || null + const target = destination ? `: ${destination}` : '' + return `Destination already exists and is not empty${target}. Choose a different parent folder, delete the existing folder, or add the existing repository instead.` + } + return line +} diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index f7eecd24ce4..bb2c373579e 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -57,6 +57,7 @@ export type CreateHostedReviewInput = { export type CreateHostedReviewArgs = CreateHostedReviewInput & { repoPath: string + repoId?: string connectionId?: string | null } @@ -115,6 +116,7 @@ export type HostedReviewCreationEligibility = { export type HostedReviewCreationEligibilityArgs = { repoPath: string + repoId?: string worktreePath?: string connectionId?: string | null branch: string diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index 465cac1e8bd..57f848afae7 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -112,6 +112,44 @@ describe('project host setup projection', () => { ) }) + it('uses GitHub repo icon metadata as a provider identity fallback', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'local-repo', + path: '/Users/alice/orca', + displayName: 'Orca', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + } + }), + repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'gpu-vm', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'StablyAI/Orca' + } + }) + ]) + + expect(projection.projects).toHaveLength(1) + expect(projection.projects[0]).toMatchObject({ + id: 'github:stablyai/orca', + sourceRepoIds: ['local-repo', 'remote-repo'], + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + expect(getProjectHostSetupsForProject(projection.setups, 'github:stablyai/orca')).toHaveLength( + 2 + ) + }) + it('does not guess that same-named folders are the same project without identity', () => { const projection = projectHostSetupProjectionFromRepos([ repo({ id: 'local-repo', path: '/Users/alice/app', displayName: 'app' }), diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 4e3a1b17705..3f7fec906b4 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -20,13 +20,28 @@ function normalizeIdentityPart(value: string): string { return value.trim().toLowerCase() } -function getProjectProviderIdentity(repo: Pick<Repo, 'upstream'>): ProjectProviderIdentity | null { +function getProjectProviderIdentity( + repo: Pick<Repo, 'upstream' | 'repoIcon'> +): ProjectProviderIdentity | null { const owner = typeof repo.upstream?.owner === 'string' ? repo.upstream.owner.trim() : '' const name = typeof repo.upstream?.repo === 'string' ? repo.upstream.repo.trim() : '' - return owner && name ? { provider: 'github', owner, repo: name } : null + if (owner && name) { + return { provider: 'github', owner, repo: name } + } + if (repo.repoIcon?.type !== 'image' || repo.repoIcon.source !== 'github') { + return null + } + const parts = (repo.repoIcon.label?.trim() ?? '').split('/') + const iconOwner = parts[0]?.trim() + const iconRepo = parts[1]?.trim() + // Why: repo auto-detect can know the GitHub slug through the generated + // avatar icon even when legacy `upstream` has not been backfilled yet. + return iconOwner && iconRepo && parts.length === 2 + ? { provider: 'github', owner: iconOwner, repo: iconRepo } + : null } -function getProjectIdentityKey(repo: Pick<Repo, 'id' | 'upstream'>): string { +function getProjectIdentityKey(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string { const identity = getProjectProviderIdentity(repo) if (!identity) { return `repo:${repo.id}` @@ -34,7 +49,7 @@ function getProjectIdentityKey(repo: Pick<Repo, 'id' | 'upstream'>): string { return `github:${normalizeIdentityPart(identity.owner)}/${normalizeIdentityPart(identity.repo)}` } -function getProjectId(repo: Pick<Repo, 'id' | 'upstream'>): string { +function getProjectId(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string { return getProjectIdentityKey(repo) } diff --git a/src/shared/task-source-context.test.ts b/src/shared/task-source-context.test.ts new file mode 100644 index 00000000000..76b33a52200 --- /dev/null +++ b/src/shared/task-source-context.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from './execution-host' +import { + buildTaskSourceContextFromRepo, + buildWorkspaceRunContext, + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + normalizeTaskSourceContext, + runtimeHostIdFromEnvironmentId +} from './task-source-context' + +describe('task source context', () => { + it('defaults source context to the local host', () => { + expect( + normalizeTaskSourceContext({ + provider: 'github', + projectId: ' project-1 ', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + ).toEqual({ + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: null, + repoId: null, + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: null + }) + }) + + it('uses repo execution ownership when building a source context', () => { + expect( + buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: 'project-1', + repo: { + id: 'repo-1', + connectionId: 'ssh target', + executionHostId: null + } + })?.hostId + ).toBe(toSshExecutionHostId('ssh target')) + + expect( + buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: 'project-1', + repo: { + id: 'repo-1', + connectionId: 'ssh target', + executionHostId: toRuntimeExecutionHostId('remote-runtime') + } + })?.hostId + ).toBe(toRuntimeExecutionHostId('remote-runtime')) + }) + + it('derives runtime settings only for runtime-owned task sources', () => { + expect( + getTaskSourceRuntimeSettings({ + hostId: toRuntimeExecutionHostId('remote-runtime') + }) + ).toEqual({ activeRuntimeEnvironmentId: 'remote-runtime' }) + + expect( + getTaskSourceRuntimeSettings({ + hostId: toSshExecutionHostId('ssh-target') + }) + ).toEqual({ activeRuntimeEnvironmentId: null }) + }) + + it('keeps provider cache scopes separate by host and provider identity', () => { + const local = getTaskSourceCacheScope({ + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + const ssh = getTaskSourceCacheScope({ + provider: 'github', + projectId: 'project-1', + hostId: toSshExecutionHostId('builder'), + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + }) + const differentRepo = getTaskSourceCacheScope({ + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'other', repo: 'orca' } + }) + + expect(local).not.toBe(ssh) + expect(local).not.toBe(differentRepo) + }) + + it('builds workspace run context from an explicit project host setup', () => { + expect( + buildWorkspaceRunContext({ + projectId: 'project-1', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + }) + ).toEqual({ + kind: 'workspace-run', + projectId: 'project-1', + hostId: toSshExecutionHostId('builder'), + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + }) + }) + + it('normalizes focused runtime ids to host ids', () => { + expect(runtimeHostIdFromEnvironmentId(' remote ')).toBe(toRuntimeExecutionHostId('remote')) + expect(runtimeHostIdFromEnvironmentId(' ')).toBe('local') + }) +}) diff --git a/src/shared/task-source-context.ts b/src/shared/task-source-context.ts new file mode 100644 index 00000000000..890e51893ae --- /dev/null +++ b/src/shared/task-source-context.ts @@ -0,0 +1,231 @@ +import { + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from './execution-host' +import type { GlobalSettings, ProjectProviderIdentity, Repo } from './types' + +export type TaskProvider = 'github' | 'gitlab' | 'linear' | 'jira' + +export type GitHubTaskProviderIdentity = ProjectProviderIdentity & { + provider: 'github' +} + +export type GitLabTaskProviderIdentity = { + provider: 'gitlab' + projectId?: string | null + namespace?: string | null + project?: string | null + webUrl?: string | null +} + +export type LinearTaskProviderIdentity = { + provider: 'linear' + workspaceId?: string | null + workspaceName?: string | null + teamId?: string | null + teamKey?: string | null +} + +export type JiraTaskProviderIdentity = { + provider: 'jira' + siteId?: string | null + siteUrl?: string | null + projectKey?: string | null +} + +export type TaskProviderIdentity = + | GitHubTaskProviderIdentity + | GitLabTaskProviderIdentity + | LinearTaskProviderIdentity + | JiraTaskProviderIdentity + +export type TaskSourceContext = { + kind: 'task-source' + provider: TaskProvider + projectId: string + hostId: ExecutionHostId + projectHostSetupId?: string | null + repoId?: string | null + providerIdentity?: TaskProviderIdentity | null + accountLabel?: string | null +} + +export type WorkspaceRunContext = { + kind: 'workspace-run' + projectId: string + hostId: ExecutionHostId + projectHostSetupId: string + repoId: string + path: string +} + +export type TaskSourceContextInput = Omit<TaskSourceContext, 'kind' | 'hostId'> & { + kind?: 'task-source' + hostId?: string | null +} + +export function normalizeTaskSourceContext( + input: TaskSourceContextInput +): TaskSourceContext | null { + const projectId = normalizeNonEmptyString(input.projectId) + if (!projectId) { + return null + } + const provider = normalizeTaskProvider(input.provider) + if (!provider) { + return null + } + return { + kind: 'task-source', + provider, + projectId, + hostId: normalizeExecutionHostId(input.hostId) ?? LOCAL_EXECUTION_HOST_ID, + projectHostSetupId: normalizeNonEmptyString(input.projectHostSetupId), + repoId: normalizeNonEmptyString(input.repoId), + providerIdentity: normalizeTaskProviderIdentity(provider, input.providerIdentity), + accountLabel: normalizeNonEmptyString(input.accountLabel) + } +} + +export function buildTaskSourceContextFromRepo(args: { + provider: TaskProvider + projectId: string + repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'> + projectHostSetupId?: string | null + providerIdentity?: TaskProviderIdentity | null + accountLabel?: string | null +}): TaskSourceContext | null { + return normalizeTaskSourceContext({ + provider: args.provider, + projectId: args.projectId, + hostId: getRepoHostId(args.repo), + repoId: args.repo.id, + projectHostSetupId: args.projectHostSetupId, + providerIdentity: args.providerIdentity, + accountLabel: args.accountLabel + }) +} + +export function getTaskSourceRuntimeSettings( + context: Pick<TaskSourceContext, 'hostId'> | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + const parsed = parseExecutionHostId(context?.hostId) + return { + activeRuntimeEnvironmentId: parsed?.kind === 'runtime' ? parsed.environmentId : null + } +} + +export function getTaskSourceCacheScope( + context: Pick<TaskSourceContext, 'provider' | 'hostId' | 'projectId' | 'projectHostSetupId'> & { + providerIdentity?: TaskProviderIdentity | null + repoId?: string | null + } +): string { + return [ + context.provider, + context.hostId, + context.projectId, + context.projectHostSetupId ?? '', + context.repoId ?? '', + providerIdentityCachePart(context.providerIdentity) + ] + .map(encodeCachePart) + .join(':') +} + +export function buildWorkspaceRunContext(args: { + projectId: string + hostId: string | null | undefined + projectHostSetupId: string + repoId: string + path: string +}): WorkspaceRunContext | null { + const projectId = normalizeNonEmptyString(args.projectId) + const projectHostSetupId = normalizeNonEmptyString(args.projectHostSetupId) + const repoId = normalizeNonEmptyString(args.repoId) + const repoPath = normalizeNonEmptyString(args.path) + if (!projectId || !projectHostSetupId || !repoId || !repoPath) { + return null + } + return { + kind: 'workspace-run', + projectId, + hostId: normalizeExecutionHostId(args.hostId) ?? LOCAL_EXECUTION_HOST_ID, + projectHostSetupId, + repoId, + path: repoPath + } +} + +export function getWorkspaceRunRuntimeSettings( + context: Pick<WorkspaceRunContext, 'hostId'> | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return getTaskSourceRuntimeSettings(context ? { hostId: context.hostId } : null) +} + +function getRepoHostId(repo: Pick<Repo, 'connectionId' | 'executionHostId'>): ExecutionHostId { + const explicit = normalizeExecutionHostId(repo.executionHostId) + if (explicit) { + return explicit + } + const connectionId = normalizeNonEmptyString(repo.connectionId) + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +function normalizeTaskProvider(value: string): TaskProvider | null { + switch (value) { + case 'github': + case 'gitlab': + case 'linear': + case 'jira': + return value + default: + return null + } +} + +function normalizeTaskProviderIdentity( + provider: TaskProvider, + identity: TaskProviderIdentity | null | undefined +): TaskProviderIdentity | null { + if (!identity || identity.provider !== provider) { + return null + } + return identity +} + +function normalizeNonEmptyString(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function providerIdentityCachePart(identity: TaskProviderIdentity | null | undefined): string { + if (!identity) { + return '' + } + switch (identity.provider) { + case 'github': + return [identity.owner, identity.repo].join('/') + case 'gitlab': + return identity.projectId ?? [identity.namespace, identity.project].filter(Boolean).join('/') + case 'linear': + return [identity.workspaceId, identity.teamId ?? identity.teamKey].filter(Boolean).join('/') + case 'jira': + return [identity.siteId ?? identity.siteUrl, identity.projectKey].filter(Boolean).join('/') + } +} + +function encodeCachePart(value: string): string { + return encodeURIComponent(value) +} + +export function runtimeHostIdFromEnvironmentId( + environmentId: string | null | undefined +): ExecutionHostId { + const trimmed = normalizeNonEmptyString(environmentId) + return trimmed ? toRuntimeExecutionHostId(trimmed) : LOCAL_EXECUTION_HOST_ID +}