From 36277801e4b2b50867c6a2a09b453a974abd209d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:53:01 -0700 Subject: [PATCH] Make remote hosts first class: concurrent multi-host workbench (#5071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restore the outlined server card for host headers Feedback: the bordered card with the server glyph made it clearer that a host section is a separate machine, not just another group. Bring that back while keeping the recent quieting: no status dot when healthy (marks only for connecting/blocked/error/disconnected), no 'This computer' detail on the local host, and collapse/menu/count behavior unchanged. Co-authored-by: Orca * Anchor host badge to its label, indent rows under host cards Sidebar polish from review: - The count badge sat in dead space between the label and the hover-only chevron/menu; it now hugs the label like repo headers - Rows under a host card get a left inset so projects and workspaces visibly belong to the machine above them - A host whose only visible row is a collapsed repo group counted 0 while the group badge said 9; host counts now fall back to header counts for groups contributing no visible items Co-authored-by: Orca * Two-tier sticky headers: pinned host card above pinned group header When scrolling inside a host section, the host card now stays pinned at the top (z-30) while project/status group headers hand off beneath it (z-20, offset by the pinned card height). The host is the outer hierarchy level, so it is the most persistent context — previously the first repo header replaced it, losing 'which machine am I on' exactly when it mattered. The pinned card keeps its collapse/menu/warning affordances. Handoff rules: the next host card pushes the previous one out at the viewport top; a group pins only once it reaches the slot beneath the host card, and a previous host's group can never pin under the next host. Without host sections the logic degrades to the original single-tier behavior. Co-authored-by: Orca * Revert host-section row indent The two-tier sticky host card now provides continuous 'inside this machine' context at any scroll depth, making the static indent redundant — and it cost 12px of sidebar width on every row while making multi-host layouts misalign with single-host ones. Host cards bracketing their sections plus the pinned header carry the ownership signal on their own. Co-authored-by: Orca * Checkpoint multi-host sidebar and project-first notes Co-authored-by: Orca * Add project-first compatibility persistence Co-authored-by: Orca * Expose project host setup APIs Co-authored-by: Orca * Group sidebar rows by project setup Co-authored-by: Orca * Document project-first host model discussion Co-authored-by: Orca * Resolve workspace creation through project host setups Co-authored-by: Orca * Stamp workspace ownership with project host setup Co-authored-by: Orca * Add project host setup existing folder API Co-authored-by: Orca * Summarize project-first host model discussion Co-authored-by: Orca * Add project host setup CLI commands Co-authored-by: Orca * Allow CLI worktree creation by project host setup Co-authored-by: Orca * Add workspace host setup picker Co-authored-by: Orca * Add project host setup settings summary Co-authored-by: Orca * Make project host setup settings navigable Co-authored-by: Orca * Stabilize project host setup settings selector Co-authored-by: Orca * Add project host existing-folder setup form Co-authored-by: Orca * Update project host model implementation status Co-authored-by: Orca * Keep projects outermost in default sidebar view Co-authored-by: Orca * Update project-first sidebar status Co-authored-by: Orca * Show host context in project sidebar groups Co-authored-by: Orca * Show unavailable hosts in workspace run target Co-authored-by: Orca * Import missing project host from composer Co-authored-by: Orca * Clone project host setup from composer Co-authored-by: Orca * Persist project host setup method Co-authored-by: Orca * Clone project hosts over SSH Co-authored-by: Orca * Improve SSH clone cancellation cleanup Co-authored-by: Orca * Backfill workspace project host ownership Co-authored-by: Orca * Gate project host setup runtime capability Co-authored-by: Orca * Preserve independent project host setups Co-authored-by: Orca * Add project host setup update API Co-authored-by: Orca * Add project host setup delete API Co-authored-by: Orca * Add project host setup create API Co-authored-by: Orca * Expose project host setup lifecycle in renderer store Co-authored-by: Orca * Handle independent project host setups in settings Co-authored-by: Orca * Add pending host setup action in project settings Co-authored-by: Orca * Show pending project host setup status in composer Co-authored-by: Orca * Report pending setup state in workspace target resolution Co-authored-by: Orca * Use shared host registry for project setup choices Co-authored-by: Orca * Add settings clone flow for project host setups Co-authored-by: Orca * Gate unavailable project host setup options Co-authored-by: Orca * Gate unavailable project setup hosts in settings Co-authored-by: Orca * Stream SSH clone progress to renderer Co-authored-by: Orca * Update project host model status notes Co-authored-by: Orca * Add CLI project host setup clone command Co-authored-by: Orca * Make add project host aware Co-authored-by: Orca * Complete project host setup validation Co-authored-by: Orca * Recover floating workspace terminal WebGL atlas on reopen (#5069) Co-authored-by: Orca * Fix stale terminal daemon spawn health (#5064) Co-authored-by: Orca * Suspend floating workspace terminal WebGL while the panel is closed (#5073) Co-authored-by: Orca * Fix source control branch compare base (#5074) Co-authored-by: Orca * Fix workspace-creation tour panel clipped by the Create Worktree dialog (#5078) * Fix workspace-creation tour panel clipped by the composer dialog The tour panel portals into dialog/sheet content that clips overflow, but its position was clamped against the window viewport. With the Project field spanning nearly the dialog's full width, the panel landed past the dialog's right edge and overflow-hidden cut it down to a sliver. Clamp hosted panels within the host's bounds instead, so the panel flips below the target and stays fully visible. Co-Authored-By: Claude Fable 5 * Add JSDoc docstrings to satisfy CodeRabbit docstring coverage check Co-Authored-By: Claude Sonnet 4.6 * Test hosted contextual tour overlay positioning --------- Co-authored-by: Claude Fable 5 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * release: v1.4.56 * Handle buffer overflows gracefully and truncate diffs fairly (#5083) - Gracefully fall back to file-name summaries when staged diffs exceed node/ssh execution maxBuffer limits, preventing generation failures. - Split oversized diffs by file and allocate budget via water-filling, ensuring single huge files do not starve smaller human changes. - Clip truncated diff sections on line boundaries to avoid half-lines. * Wrap AI generation controls with tooltips and clean i18n dependencies (#5087) - Wrap the AI generation button in a tooltip so users can see the disabled reason or the action description on hover. - Add unit tests verifying tooltip triggers and aria-label safety. - Simplify memo dependencies in settings metadata and worktree palette by using 'useTranslation()' to handle language-change rerenders directly without needing 'i18n.language'. * fix: address review findings (#5088) * Fix localization in repository hooks and base ref suggestion toast (#5089) * Fix localization in base ref toast and custom hook description - Localize the "commit"/"commits" plural nouns in the base ref toast. - Translate missing suggestion toast strings for JA, KO, and ZH locales. - Pass `{{artifact_url}}` as a literal template variable to translate calls to prevent i18next from treating it as a dynamic placeholder. * Fix localization reactivity in RepositoryHooksSection Move static variables containing translation calls into helper functions and subscribe to translation updates using useTranslation. This ensures that localized options, descriptions, and error messages refresh dynamically when the user changes the UI language. * Fix task page labels after language changes (#5086) Co-authored-by: Orca * release: v1.4.57 * Fix automation tabs showing a shell instead of the live agent (#5099) * Fix automation tabs showing a shell instead of the live agent Opening a background automation's terminal tab showed a bare shell while the agent (Claude) kept running headless — the sidebar updated but the pane was attached to the wrong PTY. On first mount the restored ptyId equals the tab ptyId, and isSessionOwnedByWorktree() returns true for it, so connectPanePty routed the still-live eagerly-spawned PTY into the daemon-reattach branch (transport.connect({ sessionId })), which spawns a fresh shell and orphans the live agent PTY instead of adopting it via attach()+replay. Part A: gate the deferred reattach on the absence of a live eager buffer. A live eager buffer means the PTY is a still-running local session to adopt (attach + replay), not a daemon session to re-connect. Daemon reattach and remote PTYs are unaffected (gated on the eager buffer). Part B: publish never-mounted background automation tabs into the runtime graph (gated on a live eager buffer) so the live agent PTY binds to its real tab instead of surfacing as an orphan `pty:` terminal — fixing `orca terminal list`, the CLI, and automation session-reuse. Adds a characterization test (fails on the old code, passes now) and a runtime-graph publish test. Co-Authored-By: Claude Opus 4.8 (1M context) * Harden eager PTY tab adoption Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca * Fix i18n label spacing in menus and settings (#5108) * fix i18n label spacing * Fix localized account runtime labels Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca * Improve localization catalog sync workflow (#5110) Co-authored-by: Orca * Add Warp terminal theme import (#4714) Co-authored-by: Orca * release: v1.4.58 * Tidy README badge layout * Handle integration credential decrypt failures (#4683) Co-authored-by: Orca * Fix git repo telemetry for repo adds (#5121) Co-authored-by: Orca * Add feature interaction usage bucket telemetry (#5119) Co-authored-by: Orca * Reset WebGL glyph atlases globally to stop cross-terminal glyph corruption (#5122) Co-authored-by: Orca * perf(windows): fix 60s startup ACL walk and OpenCode streaming freeze, with benchmark harnesses (#5124) * release: v1.4.59-rc.0 * Fix packaged shell PATH order (#5125) Co-authored-by: Orca * Add Floating Workspace contextual tour (#5062) * Add floating workspace contextual tour Co-authored-by: Orca * Clarify floating workspace tour intro copy Co-authored-by: Orca * Differentiate floating workspace tour steps instead of repeating examples Co-authored-by: Orca * Lead floating workspace tour with the user benefit Co-authored-by: Orca * Pitch floating workspace tour around cross-repo agents Co-authored-by: Orca * Refine floating workspace tour step 1 copy Co-authored-by: Orca * Anchor floating workspace tour step 2 on the minimize control Co-authored-by: Orca * Restore floating workspace tour step 2 Co-authored-by: Orca * Anchor floating workspace tour steps on New Terminal and New Markdown Note Co-authored-by: Orca * Retitle floating workspace tour step 2 as scratchpad Co-authored-by: Orca * Add why-comments for tour selector fallback and placement flipping Co-authored-by: Orca --------- Co-authored-by: Orca * Fix source control compare base ambiguity (#5127) Co-authored-by: Orca * release: v1.4.59-rc.1 [rc-slot:2026-06-10-15] * release: v1.4.59 * Default-driven create-project flow: name-first form with sensible defaults (#5115) Co-authored-by: Orca * Redesign Connect integrations (#4531) Co-authored-by: Orca * Expose E2E store via build mode * File search match counts (#5085) * Add matchCount to SearchFileResult for accurate per-file hit counts Co-authored-by: Orca * Add file search match count design * rm design doc --------- Co-authored-by: Orca * fix: address review findings (#5139) * perf(windows): avoid blocking daemon pid checks (#5137) * release: v1.4.60-rc.0 * release: v1.4.60 * Preserve core workflow terms in English and apply CJK spacing (#5141) * Preserve core workflow and product terms in English across locales Update translation policy to prevent localization of key terms such as "Agent", "Commit", "Markdown", and "Terminal". This ensures consistent jargon and product branding. Introduce CJK-Latin term spacing to keep these Latin terms legible when combined with CJK text, while adjusting Korean particle spacing. Also add overrides to prevent network proxy settings from being mistranslated as "Agent". * Preserve repo terminology in English and localize source control labels Treat "repo" and "repos" (and their capitalized forms) as brand terms that should remain in English/Latin across CJK and Spanish locales. Update translation files and policies to replace translated words like "repositorio" or "リポジトリ" with "repo"/"repos", and fix an issue where latin brand terms could be incorrectly matched as substrings in larger words during cleanup. Additionally, externalize and localize the "Staged Changes", "Changes", and "Untracked Files" section labels in the source control sidebar. * UX (#5143) * UX/copy tweaks (#5142) * UX/copy tweaks * UX/copy tweaks * Fix missed star UI translations (#5148) * fix: make windows ssh relay deploy survive session teardown (#5136) * Add option to remove child projects when deleting repo groups (#4702) Co-authored-by: Orca * fix: remove checks panel response badge (#5147) * Add read-only `orca linear` CLI with trusted launch-prompt pointer (V1) (#5126) Co-authored-by: Orca * Add AI Vault session history ## Summary - add AI Vault session scanning and resume command construction - add the Agents sidebar panel with filtering, grouping, copy/open actions, and local resume launch - support dragging saved sessions onto terminal split panes ## Validation - pnpm run lint - pnpm run typecheck - pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/register-core-handlers.test.ts src/main/ai-vault/session-scanner.test.ts src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts src/renderer/src/lib/ai-vault-session-drag.test.ts src/renderer/src/lib/launch-ai-vault-session.test.ts * Default agent launches to yolo permissions mode (#5145) * Default agent launches to yolo mode * test: update launch default validations * Fix Claude usage refresh error copy (#5155) Co-authored-by: Orca * Move workspace board to sidebar bottom toolbar (#5146) Co-authored-by: Orca * Rebuild contextual tour positioning on floating-ui; fix hosted dialog placement and arrow seam (#5154) Co-authored-by: Orca * Fix missing spaces in cross-repo switch dialog (#5158) * Fix Ctrl+Tab switcher selection on release (#5116) * Fix additional i18n spacing regressions from #4995 (#5159) * Refine add project selection styling (#5160) Co-authored-by: Orca * improve chinese localization (#5162) * Fix floating workspace needing two clicks after app switch (macOS) (#5128) * Autofocus feedback textarea when Send Feedback dialog opens (#5164) * fix: address pr-bug-scan validated finding from #4683 (#5151) Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces Co-authored-by: orca-bug-scan-bot * fix: enable claude agent teams by default (#5168) * Refresh Jira and Linear status after credential errors (#5169) * fix: address pr-bug-scan validated finding from #4683 Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces * Refresh Jira and Linear status to clear stale credential errors Ensure stale credential decryption errors are cleared from the store status once a successful API read completes. By updating the check in shouldRefreshStatusAfterRead to trigger when a credentialError is currently set, successful issue or list fetches will trigger a status check and remove stale error flags. --------- Co-authored-by: orca-bug-scan-bot * Hide internal context from AI Vault titles (#5175) * Fix detached HEAD publish actions (#5173) * Keep freshly split terminal pane mounted if newborn PTY exits early (#5171) Prevent a newly split pane from collapsing immediately if its PTY exits during initial setup before any output is received or input is sent. This ensures a failed startup session remains visible to the user. * Route task PR queries by upstream source (#5176) * Route task PR queries by upstream source Implements the routing described in docs/tasks-pr-upstream-source.md so task PR and issue queries stay scoped to the selected source. * rm design doc * Prevent stale PR refreshes from restoring unlinked review state (#5180) - Pass `worktreeId` to `fetchPRForBranch` to track active worktree context - Ignore inflight or queued PR fetches if the worktree has been unlinked - Include linked PR/MR metadata in the checks panel snapshot key to trigger updates immediately on link/unlink events * Fix Claude agents management status detection (#5179) Co-authored-by: Orca * fix: address review findings (#5177) * Allow resolving selected review comments with AI (#5184) * Allow resolving selected PR/MR review comments with AI Users can now select specific unresolved review comments or threads in the Checks panel sidebar, queue them, and trigger an AI agent to address them, marking resolved threads on the host upon agent launch. - Adds checkboxes and action/send buttons to select and queue comments. - Builds a structured, robust prompt with sanitized comment metadata. - Optimistically marks threads resolved on launch with rollback on error. - Supports both GitHub PRs and GitLab MRs. * Consolidate PR comment selection state and eliminate effects Combine independent selection states and context-tracking into a single state object. Derive active selection data and prune ineligible comments during render using useMemo instead of relying on asynchronous useEffect synchronization hooks. * Improve source control action dialog layout and recipe saving UX (#5153) * Improve source control agent action dialog layout and recipe UX - Constrain dialog and scroll area heights to prevent viewport overflow. - Add variable chips to easily insert the base prompt with tooltip previews. - Keep the recipe save controls visible when a recipe is already saved, showing informational status text instead of hiding them. - Update localized copy across multiple languages and reduce textarea rows. - Add unit tests for the variable chip preview and save target visibility. * Fix recipe-saved check in source control action dialog * Evaluate only the selected save target instead of checking all available targets, as the action only writes to the selected target. * Update daemon PTY adapter test fake PID to prevent collision with real host OS processes during runtime directory lookups. * fix: remove unsupported agent launch defaults (#5185) * Update Chinese and Japanese translations for worktrees and fixes (#5187) - Correct awkward Chinese translation of "fix" ("使固定") to "修复" and "基本的" to "主工作树" (main worktree). - Improve Japanese translation of "fix" from physical repair ("修理") to software correction ("修正"). * Embed hosted review creation composer directly in Checks panel (#5140) * Embed hosted review creation composer directly in the Checks panel - Replaces the modal pull request/merge request creation dialog with an inline composer embedded in the empty state of the Checks sidebar. - Extracts and moves pull request generation state to a dedicated store slice so AI-generated details are persisted across sidebar unmounts. * Fix hosted review composer feedback * Combine file search and file explorer right sidebar tabs (#5182) Unifies file discovery and tree navigation under a single Explorer domain, simplifying the right sidebar activity bar and reducing tab clutter. * Replaces the standalone 'search' activity bar tab with a nested 'search' subview inside the File Explorer tab * Introduces 'rightSidebarExplorerView' ('files' | 'search') state to manage the active subview inside the Explorer * Adds a search button to the File Explorer toolbar and a back button to the search subview for seamless transition * Exposes 'showRightSidebarFiles' and 'showRightSidebarSearch' store actions to route and seed search queries/include patterns * Adapts file explorer keybindings, git status polling, and external workspace watchers to respect the active subview * Maps legacy persisted search tab state to the new explorer search view for backward compatibility * release: v1.4.61-rc.1 * Add multi-repo folder workspaces (v1) (#5172) Co-authored-by: Orca * release: v1.4.61-rc.2 * Hide unavailable project hosts in worktree composer Co-authored-by: Orca * Remove inline project host setup from composer Co-authored-by: Orca * Mark imported project host setup methods Co-authored-by: Orca * Fix rebase merge fallout Co-authored-by: Orca * Disable unavailable Add Project hosts Co-authored-by: Orca * Compact Add Project host selector Co-authored-by: Orca * Hide redundant SSH target chooser Co-authored-by: Orca * Browse SSH clone destinations Co-authored-by: Orca * Avoid local clone defaults for SSH hosts Co-authored-by: Orca * Polish host-aware Add Project flows Co-authored-by: Orca * Polish remote host add project flows Co-authored-by: Orca * Remove redundant host kind chips Co-authored-by: Orca * Fix remote project setup UX gaps Co-authored-by: Orca * Fix multihost workspace composer project identity Co-authored-by: Orca * Finish host context merge repair Co-authored-by: Orca * Continue host context checklist implementation Co-authored-by: Orca * Route Linear and Jira tasks by source context Co-authored-by: Orca * Preserve Linear task source context in history Co-authored-by: Orca * Scope task retry state by source context Co-authored-by: Orca * Route GitHub drawer reads by source context Co-authored-by: Orca * Guard GitLab selectors with repo context Co-authored-by: Orca * Guard GitHub metadata selectors Co-authored-by: Orca * Route GitHub task row actions by source context Co-authored-by: Orca * Update GitHub source-context checklist status Co-authored-by: Orca * Show host ownership for CLI provider accounts Co-authored-by: Orca * Persist GitLab task detail source context Co-authored-by: Orca * Show host scope for provider API budgets Co-authored-by: Orca * Preserve Jira task source context Co-authored-by: Orca * Scope Jira optimistic task patches Co-authored-by: Orca * Resolve task PR bases on run host Co-authored-by: Orca * Record Jira task workspace usage Co-authored-by: Orca * Scope Linear optimistic task patches Co-authored-by: Orca * Scope GitHub optimistic task patches Co-authored-by: Orca * Clean host copy in onboarding flows Co-authored-by: Orca * Preserve automation CLI run context Co-authored-by: Orca * Add automation CLI source context selector Co-authored-by: Orca * Clarify unavailable task source hosts Co-authored-by: Orca * Surface host model runtime capability skew Co-authored-by: Orca * Use SSH host copy in reconnect dialog Co-authored-by: Orca * Show host context in task source picker Co-authored-by: Orca * Mark task source display complete Co-authored-by: Orca * Clarify provider account host selection Co-authored-by: Orca * Guard task source switching boundary Co-authored-by: Orca * Mark task source diagnostics persisted Co-authored-by: Orca * Mark base resolution host boundary Co-authored-by: Orca * Clarify external automation source states Co-authored-by: Orca * Harden project host compatibility projection Co-authored-by: Orca * Finish host copy audit Co-authored-by: Orca * Add provider host scope controls Co-authored-by: Orca * Show task source account labels Co-authored-by: Orca * Show automation run context in CLI Co-authored-by: Orca * Scope Jira task cache lookups by source Co-authored-by: Orca * Seed workspace creation from task source context Co-authored-by: Orca * Explain disabled external automation actions Co-authored-by: Orca * Surface task source runtime capability gaps Co-authored-by: Orca * Persist automation run context from UI saves Co-authored-by: Orca * Require workspace run capability for setup hosts Co-authored-by: Orca * Disable automation runs for stale host setup Co-authored-by: Orca * Route GitHub drawer metadata by source host Co-authored-by: Orca * Guard runtime project setup mutations by host model Co-authored-by: Orca * Route PR page metadata by repo host Co-authored-by: Orca * Route PR mention metadata by repo host Co-authored-by: Orca * Route GitHub Project edits by view source Co-authored-by: Orca * Clarify runtime automation disabled states Co-authored-by: Orca * Guard runtime automation backend dispatch Co-authored-by: Orca * Preserve GitLab task source identity Co-authored-by: Orca * Remove redundant SSH target row in add project Co-authored-by: Orca * Add task source provider availability reasons Co-authored-by: Orca * Surface task provider preflight availability Co-authored-by: Orca * Record local GitHub task source verification Co-authored-by: Orca * Record Linear task source verification Co-authored-by: Orca * Show automation source context in details Co-authored-by: Orca * Record remote capability negotiation coverage Co-authored-by: Orca * Record local add project create verification Co-authored-by: Orca * Scope Linear cached task reads by source Co-authored-by: Orca * Preserve PR generation host ownership Co-authored-by: Orca * Route git operations by owner host Co-authored-by: Orca * Route delete warnings by worktree owner Co-authored-by: Orca * Route editor drops by worktree owner Co-authored-by: Orca * Route agent draft paste by tab owner Co-authored-by: Orca * Route file explorer requests by worktree owner Co-authored-by: Orca * Document remaining host context gaps Co-authored-by: Orca * Check runtime task source provider auth Co-authored-by: Orca * Validate automation source availability Co-authored-by: Orca * Route remaining UI requests by owner host Co-authored-by: Orca * Route quick open file listing by worktree owner Co-authored-by: Orca * Route typed GitHub lookups by source host Co-authored-by: Orca * Centralize automation run identity fallback Co-authored-by: Orca * Surface unsupported task source providers Co-authored-by: Orca * Document automation legacy repo compatibility Co-authored-by: Orca * Record live host model verification Co-authored-by: Orca * Quiet disconnected SSH polling Co-authored-by: Orca * Verify task drawer source boundaries Co-authored-by: Orca * Verify GitLab repo source selectors Co-authored-by: Orca * Route automations through owning host Co-authored-by: Orca * Update host context verification checklist Co-authored-by: Orca * Run remote automations headlessly in serve mode Co-authored-by: Orca * Keep setup guide entry stable during refresh Co-authored-by: Orca * Keep setup script prompt stable during host switches Co-authored-by: Orca * Deduplicate Tasks project picker sources Co-authored-by: Orca * Use project identity for Tasks picker dedupe Co-authored-by: Orca * Add Tasks source host switcher Co-authored-by: Orca * Refine Tasks source picker disclosure Co-authored-by: Orca * Polish Tasks source picker hover Co-authored-by: Orca * Open Tasks source menu on hover Co-authored-by: Orca * Match Tasks source submenu hover behavior Co-authored-by: Orca * Open Tasks source submenu from project row hover Co-authored-by: Orca * Group automation project hosts Co-authored-by: Orca * Tighten automation project picker density Co-authored-by: Orca * Show selected host in Tasks project picker Co-authored-by: Orca * Hide host labels for single-host project pickers Co-authored-by: Orca * Use saved remote server names in host pickers Co-authored-by: Orca * Use standard add project start for remote servers Co-authored-by: Orca * Use saved host labels in workspace surfaces Co-authored-by: Orca * Route remote browser tabs through runtime hosts Co-authored-by: Orca * Keep sidebar project-first across grouping modes Co-authored-by: Orca * Polish multi-host remote runtime UX Co-authored-by: Orca * Fix CI lint and remove design notes Co-authored-by: Orca * Fix CI test failures Co-authored-by: Orca * Fix Windows CLI path expectation Co-authored-by: Orca * Fix CI renderer test expectations Co-authored-by: Orca * Fix remaining verify test failures Co-authored-by: Orca --------- Co-authored-by: Orca Co-authored-by: Bryant Ung Co-authored-by: Claude Fable 5 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Borja <3930245+BorjaLL@users.noreply.github.com> Co-authored-by: Parker Rex Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Trevin Chow Co-authored-by: buf0-bot[bot] <252831055+buf0-bot[bot]@users.noreply.github.com> Co-authored-by: orca-bug-scan-bot --- .gitignore | 4 + src/cli/args.ts | 2 + src/cli/dispatch.ts | 2 + src/cli/format.test.ts | 55 + src/cli/format.ts | 8 + src/cli/handlers/automations.ts | 108 +- src/cli/handlers/project.ts | 204 +++ src/cli/handlers/repo.ts | 29 +- src/cli/handlers/worktree.ts | 22 +- src/cli/help.ts | 20 +- src/cli/index.test.ts | 815 +++++++++- src/cli/project-format.ts | 80 + src/cli/repo-path-arguments.ts | 31 + src/cli/specs/automations.ts | 15 +- src/cli/specs/core.ts | 7 +- src/cli/specs/index.ts | 2 + src/cli/specs/project.ts | 113 ++ src/cli/workspace-format.ts | 14 +- src/cli/worktree-project-target.ts | 85 + src/main/automations/headless-dispatch.ts | 71 + src/main/automations/run-target-resolution.ts | 99 ++ src/main/automations/run-usage-collection.ts | 99 ++ src/main/automations/service-precheck.test.ts | 101 ++ src/main/automations/service.test.ts | 222 +++ src/main/automations/service.ts | 207 +-- .../daemon-health-socket-cleanup.test.ts | 7 +- src/main/daemon/daemon-health.test.ts | 21 + src/main/daemon/daemon-health.ts | 5 +- src/main/daemon/daemon-init.test.ts | 12 +- src/main/daemon/daemon-server.test.ts | 9 +- src/main/daemon/daemon-server.ts | 8 + src/main/daemon/pty-subprocess.ts | 70 + src/main/daemon/types.ts | 6 + src/main/git/repo-clone-path.ts | 27 +- src/main/index.ts | 101 +- src/main/ipc/github-work-item-args.ts | 4 + src/main/ipc/github.test.ts | 61 + src/main/ipc/github.ts | 76 +- src/main/ipc/gitlab.test.ts | 177 ++ src/main/ipc/gitlab.ts | 170 +- src/main/ipc/hosted-review.test.ts | 24 + src/main/ipc/hosted-review.ts | 4 +- src/main/ipc/preflight.test.ts | 25 + src/main/ipc/preflight.ts | 4 +- src/main/ipc/pty.test.ts | 37 + src/main/ipc/pty.ts | 13 + src/main/ipc/repos-remote.test.ts | 537 ++++++- src/main/ipc/repos.ts | 888 +++++++++-- src/main/ipc/runtime-environments.test.ts | 26 + src/main/ipc/runtime-environments.ts | 15 + src/main/ipc/session.ts | 19 +- src/main/ipc/ssh.test.ts | 2 +- src/main/ipc/ssh.ts | 2 +- src/main/ipc/worktree-logic.test.ts | 7 + src/main/ipc/worktree-logic.ts | 5 + src/main/ipc/worktree-remote.ts | 7 + src/main/ipc/worktrees-windows.test.ts | 3 + src/main/ipc/worktrees.test.ts | 305 +++- src/main/ipc/worktrees.ts | 113 +- src/main/persistence.test.ts | 711 ++++++++- src/main/persistence.ts | 633 +++++++- src/main/providers/ssh-git-provider.test.ts | 82 + src/main/providers/ssh-git-provider.ts | 66 +- src/main/providers/types.ts | 6 +- src/main/runtime/orca-runtime.test.ts | 52 + src/main/runtime/orca-runtime.ts | 508 ++++-- .../runtime/rpc/methods/automations.test.ts | 19 + src/main/runtime/rpc/methods/automations.ts | 51 + src/main/runtime/rpc/methods/client-ui.ts | 3 + .../methods/project-runtime-rpc-methods.ts | 122 ++ src/main/runtime/rpc/methods/repo.ts | 2 + src/main/ssh/ssh-relay-session.ts | 6 +- src/preload/api-types.ts | 511 +++--- src/preload/gitlab.ts | 213 +-- src/preload/index.ts | 68 +- src/relay/git-exec-validator.test.ts | 38 +- src/relay/git-exec-validator.ts | 49 +- src/relay/git-handler.test.ts | 1 + src/relay/git-handler.ts | 97 +- src/renderer/src/App.tsx | 27 +- .../src/components/GitHubItemDialog.tsx | 594 +++++-- .../src/components/GitLabItemDialog.tsx | 103 +- .../src/components/JiraIssueWorkspace.tsx | 43 +- .../src/components/LinearIssueTextEditor.tsx | 10 +- .../src/components/LinearIssueWorkspace.tsx | 68 +- .../src/components/LinearItemDrawer.tsx | 119 +- .../components/NewWorkspaceComposerCard.tsx | 51 +- .../components/NewWorkspaceComposerModal.tsx | 3 + .../src/components/PullRequestPage.tsx | 203 ++- src/renderer/src/components/StarNagCard.tsx | 42 +- src/renderer/src/components/TaskPage.tsx | 1419 ++++++++++++++--- src/renderer/src/components/Terminal.tsx | 91 +- .../src/components/WorktreeJumpPalette.tsx | 105 +- .../automations/AutomationDetail.tsx | 59 +- .../automations/AutomationEditorDialog.tsx | 3 + .../AutomationEditorDialogFooter.tsx | 8 +- .../automations/AutomationProjectCombobox.tsx | 403 +++++ .../automations/AutomationsPage.tsx | 484 +++++- .../automations/CreateFromPicker.test.tsx | 116 ++ .../automations/CreateFromPicker.tsx | 5 +- .../ExternalAutomationManagers.tsx | 53 +- .../automation-host-client.test.ts | 129 ++ .../automations/automation-host-client.ts | 156 ++ .../automation-project-groups.test.ts | 75 + .../automations/automation-project-groups.ts | 64 + .../automation-run-context.test.ts | 74 + .../automations/automation-run-context.ts | 29 + .../automation-source-display.test.ts | 80 + .../automations/automation-source-display.ts | 64 + .../automation-target-availability.test.ts | 378 +++++ .../automation-target-availability.ts | 277 ++++ ...nal-automation-source-availability.test.ts | 169 ++ ...external-automation-source-availability.ts | 124 ++ .../components/browser-pane/BrowserPane.tsx | 44 +- .../cmd-j/palette-host-badge.test.ts | 77 + .../components/cmd-j/palette-host-badge.ts | 28 + .../components/cmd-j/palette-results.test.ts | 12 +- .../editor/editor-autosave-controller.ts | 9 +- ...ture-interaction-writer-boundaries.test.ts | 35 +- ...github-item-dialog-source-boundary.test.ts | 43 + .../components/github-project/ProjectCell.tsx | 73 +- .../github-project/ProjectItemSlugDialog.tsx | 11 +- .../github-project/ProjectPicker.tsx | 26 +- .../components/github-project/ProjectRow.tsx | 6 +- .../github-project/ProjectViewList.tsx | 6 +- .../github-project/ProjectViewWrapper.tsx | 38 +- .../slug-dialog/AssigneesEditor.tsx | 7 +- .../github-project/slug-dialog/Comments.tsx | 38 +- .../slug-dialog/LabelsEditor.tsx | 7 +- .../slug-dialog/SlugDialogBody.tsx | 25 +- .../github/GitHubIssueCommentComposer.tsx | 17 +- .../github/github-issue-comment-helpers.ts | 10 +- .../github/github-rate-limit-display.tsx | 12 + .../gitlab/gitlab-rate-limit-display.tsx | 12 + .../new-workspace/ProjectCombobox.test.tsx | 101 ++ .../new-workspace/ProjectCombobox.tsx | 212 +++ .../ProjectHostSetupCombobox.test.tsx | 155 ++ .../ProjectHostSetupCombobox.tsx | 105 ++ .../new-workspace/SmartWorkspaceNameField.tsx | 107 +- .../onboarding/AgentFeatureSetupStep.test.tsx | 2 +- .../src/components/onboarding/RepoStep.tsx | 203 ++- .../onboarding/RepoStepNestedImportPanel.tsx | 145 ++ .../onboarding/use-onboarding-flow.ts | 4 +- .../components/ports/WorkspacePortScanner.tsx | 71 +- .../pull-request-page-host-boundary.test.ts | 51 + .../src/components/quick-open-file-list.ts | 5 +- .../components/right-sidebar/AiVaultPanel.tsx | 2 +- .../ChecksPanel.review-header.test.tsx | 6 +- .../components/right-sidebar/ChecksPanel.tsx | 470 ++---- .../right-sidebar/CommitArea.test.tsx | 8 +- .../right-sidebar/PortsPanel.test.tsx | 102 ++ .../components/right-sidebar/PortsPanel.tsx | 36 +- .../src/components/right-sidebar/Search.tsx | 3 +- ...ourceControl.host-context-boundary.test.ts | 54 + .../right-sidebar/SourceControl.tsx | 743 +++------ .../right-sidebar/active-checks-status.ts | 6 +- .../right-sidebar/checks-panel-content.tsx | 21 +- ...le-explorer-runtime-owner-boundary.test.ts | 38 + .../file-explorer-runtime-owner.ts | 12 + .../right-sidebar/git-status-refresh.test.ts | 8 +- .../right-sidebar/git-status-refresh.ts | 15 +- .../pr-comments-list-selection.test.tsx | 112 +- .../use-hosted-review-actions.ts | 13 +- .../useCreatePullRequestDialogFields.ts | 5 +- .../right-sidebar/useFileDuplicate.ts | 5 +- .../right-sidebar/useFileExplorerDragDrop.ts | 3 +- .../right-sidebar/useFileExplorerImport.ts | 5 +- .../useFileExplorerInlineInput.ts | 3 +- .../right-sidebar/useFileExplorerTree.ts | 6 +- .../useFileExplorerVisibleRowProjection.ts | 3 +- .../useFileExplorerWatch.test.ts | 69 + .../right-sidebar/useFileExplorerWatch.ts | 15 +- .../right-sidebar/useGitStatusPolling.test.ts | 15 +- .../right-sidebar/useGitStatusPolling.ts | 5 +- .../src/components/settings/AgentsPane.tsx | 7 +- .../src/components/settings/BaseRefPicker.tsx | 5 +- .../src/components/settings/BrowserPane.tsx | 67 +- .../settings/BrowserSessionCookiesSection.tsx | 47 + .../src/components/settings/CliSection.tsx | 236 +-- .../settings/CommitMessageAiPane.tsx | 203 +-- .../components/settings/ComputerUsePane.tsx | 161 +- .../GeneralWorkspaceSettingsSection.tsx | 57 +- .../settings/HostedReviewCreationDefaults.tsx | 128 ++ .../settings/ManageSessionsSection.tsx | 47 +- .../components/settings/OrchestrationPane.tsx | 76 +- .../settings/ProviderHostScopeControl.tsx | 49 + .../components/settings/QuickCommandsList.tsx | 163 ++ .../components/settings/QuickCommandsPane.tsx | 260 +-- .../settings/QuickCommandsScopeFilter.tsx | 161 ++ .../settings/RepositoryHostSetupActions.tsx | 259 +++ .../RepositoryHostSetupsSection.test.tsx | 606 +++++++ .../settings/RepositoryHostSetupsSection.tsx | 229 +++ .../settings/RepositoryIconPicker.tsx | 7 +- .../components/settings/RepositoryPane.tsx | 157 +- .../RepositoryPaneDraftInput.test.tsx | 2 +- .../settings/RepositorySettingsDraftInput.tsx | 58 + .../RepositorySourceControlAiSection.tsx | 9 +- .../settings/RuntimeEnvironmentsPane.test.ts | 183 +++ .../settings/RuntimeEnvironmentsPane.tsx | 851 ++++++++-- .../src/components/settings/Settings.tsx | 108 +- .../src/components/settings/SshPane.tsx | 4 +- .../settings/WorkspaceDirectorySetting.tsx | 202 +++ .../settings/appearance-status-bar-search.ts | 6 +- ...-source-control-integration-cards.test.tsx | 126 ++ .../cli-source-control-integration-cards.tsx | 50 +- .../host-scoped-setting-scope.test.ts | 48 + .../settings/host-scoped-setting-scope.ts | 33 + .../settings/jira-integration-card.test.tsx | 119 ++ .../settings/jira-integration-card.tsx | 11 + .../settings/provider-account-scope.test.ts | 33 + .../settings/provider-account-scope.ts | 46 + .../provider-rate-limit-scope-panels.test.tsx | 57 + .../repository-host-setup-options.test.ts | 75 + .../settings/repository-host-setup-options.ts | 103 ++ .../components/settings/repository-search.ts | 35 + .../settings/runtime-environments-search.ts | 6 +- .../settings/setting-ownership.test.ts | 32 + .../components/settings/setting-ownership.ts | 55 + .../src/components/settings/ssh-search.ts | 10 +- .../task-tracker-integration-cards.test.tsx | 242 +-- .../task-tracker-integration-cards.tsx | 209 +-- .../AddProjectFromFolderDialog.test.tsx | 2 +- .../sidebar/AddProjectFromFolderDialog.tsx | 2 +- .../components/sidebar/AddRepoCloneStep.tsx | 217 +++ .../sidebar/AddRepoCreateKindCard.tsx | 72 + .../sidebar/AddRepoCreateStep.test.tsx | 37 +- .../components/sidebar/AddRepoCreateStep.tsx | 362 +++-- .../src/components/sidebar/AddRepoDialog.tsx | 396 ++--- .../sidebar/AddRepoDialogChrome.tsx | 33 + .../sidebar/AddRepoDialogStepContent.test.tsx | 151 +- .../sidebar/AddRepoDialogStepContent.tsx | 72 +- .../sidebar/AddRepoHostSelector.test.tsx | 105 ++ .../sidebar/AddRepoHostSelector.tsx | 118 ++ .../sidebar/AddRepoHostSelectorSlot.tsx | 18 + .../sidebar/AddRepoNestedImportStep.test.tsx | 18 +- .../components/sidebar/AddRepoRemoteStep.tsx | 139 +- .../sidebar/AddRepoServerStartStep.tsx | 32 +- .../sidebar/AddRepoStartSteps.test.tsx | 88 +- .../components/sidebar/AddRepoStartSteps.tsx | 18 +- .../sidebar/AddRepoStepIndicator.tsx | 7 +- .../AddRepoSteps.default-checkout.test.ts | 38 + .../src/components/sidebar/AddRepoSteps.tsx | 246 +-- .../sidebar/CreateProjectLocationField.tsx | 44 +- ...rktreeDialog.host-context-boundary.test.ts | 28 + .../sidebar/DeleteWorktreeDialog.tsx | 95 +- .../DeleteWorktreeDialogDescription.tsx | 33 + .../sidebar/FolderWorkspaceComposerDialog.tsx | 94 +- .../components/sidebar/HostRemoveDialog.tsx | 145 ++ .../components/sidebar/HostRenameDialog.tsx | 104 ++ .../sidebar/HostSectionHeaderMenu.tsx | 301 ++++ .../LinearAgentSkillSetupPrompt.test.tsx | 14 + .../components/sidebar/NonGitFolderDialog.tsx | 2 +- .../components/sidebar/RemoteFileBrowser.tsx | 2 +- .../ScrollToCurrentWorkspaceToolbarButton.tsx | 2 +- .../sidebar/SetupGuideSidebarEntry.test.tsx | 50 +- .../sidebar/SetupGuideSidebarEntry.tsx | 23 +- .../sidebar/SetupScriptPromptCard.test.ts | 52 + .../sidebar/SetupScriptPromptCard.tsx | 234 ++- .../sidebar/SetupScriptPromptCardShell.tsx | 103 ++ .../sidebar/SetupScriptPromptToast.tsx | 38 + .../sidebar/SidebarHostScopeMenuSection.tsx | 155 ++ .../sidebar/SidebarHostScopeStrip.tsx | 75 + .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 45 +- .../sidebar/SshDisconnectedDialog.tsx | 4 +- .../src/components/sidebar/SshTargetRow.tsx | 2 +- .../sidebar/WorktreeCard.pr-display.test.tsx | 32 +- .../src/components/sidebar/WorktreeCard.tsx | 47 +- .../components/sidebar/WorktreeCardPorts.tsx | 13 +- .../WorktreeList.lineage-child-card.test.ts | 7 + ...ktreeList.lineage-child-real-card.test.tsx | 1 + .../src/components/sidebar/WorktreeList.tsx | 614 +++++-- .../components/sidebar/WorktreeMetaDialog.tsx | 224 ++- .../sidebar/add-repo-dialog-types.ts | 2 +- .../sidebar/add-repo-host-availability.ts | 5 + .../sidebar/add-repo-local-start-actions.ts | 71 +- .../sidebar/add-repo-store-upsert.ts | 19 + .../components/sidebar/clone-defaults.test.ts | 13 + .../src/components/sidebar/clone-defaults.ts | 4 +- .../sidebar/create-project-defaults.test.ts | 16 +- .../sidebar/create-project-defaults.ts | 8 +- .../delete-worktree-preference-toast.ts | 50 + .../folder-workspace-composer-helpers.ts | 13 +- .../folder-workspace-composer-keyboard.ts | 60 + .../sidebar/host-header-drag-dom.ts | 40 + .../components/sidebar/host-header-drag.ts | 314 ++++ .../sidebar/host-header-menu-items.test.ts | 92 ++ .../sidebar/host-header-menu-items.ts | 73 + .../sidebar/host-rename-remove.test.ts | 54 + .../components/sidebar/host-rename-remove.ts | 56 + .../sidebar/host-section-order.test.ts | 31 + .../components/sidebar/host-section-order.ts | 32 + .../sidebar/host-section-rows.test.ts | 613 +++++++ .../components/sidebar/host-section-rows.ts | 224 +++ src/renderer/src/components/sidebar/index.tsx | 14 +- .../sidebar/open-setup-script-settings.ts | 19 + .../sidebar/project-header-drag.test.ts | 121 +- .../components/sidebar/project-header-drag.ts | 435 ++--- .../setup-script-prompt-exposure-telemetry.ts | 39 + .../setup-script-prompt-render-state.ts | 61 + .../sidebar/sidebar-host-options.test.ts | 210 +++ .../sidebar/sidebar-host-options.ts | 162 ++ .../sidebar/sidebar-project-drop.ts | 2 +- .../sidebar/sidebar-workspace-option-items.ts | 194 +++ .../sidebar/use-add-repo-host-change-reset.ts | 32 + .../use-add-repo-host-selection.test.ts | 174 ++ .../sidebar/use-add-repo-host-selection.ts | 97 ++ .../use-add-repo-remote-nested-scan.ts | 60 + .../sidebar/use-complete-git-repo-add.ts | 55 + .../sidebar/use-sidebar-host-scope-options.ts | 50 + ...e-visible-workspace-kanban-worktree-ids.ts | 10 + .../sidebar/use-worktree-issue-link.ts | 106 ++ .../sidebar/useAddRepoCloneFlow.test.ts | 220 +++ .../components/sidebar/useAddRepoCloneFlow.ts | 63 +- .../sidebar/useAddRepoLocalFolderFlow.ts | 2 +- .../sidebar/useCreateProjectDefaults.test.ts | 41 +- .../sidebar/useCreateProjectDefaults.ts | 75 +- .../useCreateRepo.default-checkout.test.ts | 87 +- .../src/components/sidebar/useCreateRepo.ts | 112 +- .../sidebar/useSidebarProjectDrop.ts | 2 +- .../sidebar/visible-worktrees.test.ts | 135 +- .../components/sidebar/visible-worktrees.ts | 39 +- .../components/sidebar/worktree-drag-units.ts | 2 + .../sidebar/worktree-list-groups.test.ts | 300 +++- .../sidebar/worktree-list-groups.ts | 256 ++- .../worktree-list-virtual-rows.test.ts | 146 ++ .../sidebar/worktree-list-virtual-rows.ts | 113 +- .../sidebar/worktree-meta-updates.ts | 51 + .../status-bar/PortsStatusSegment.tsx | 14 +- .../status-bar/RuntimeHostStatusRow.tsx | 117 ++ .../status-bar/SshStatusSegment.tsx | 312 ++-- .../status-bar/SshTargetStatusRow.tsx | 152 ++ .../src/components/status-bar/StatusBar.tsx | 2 +- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 25 +- .../status-bar/ports-status-popover-rows.tsx | 16 +- .../src/components/tab-bar/TabBar.tsx | 5 +- .../tab-bar/tab-create-entry-action.test.ts | 27 + .../tab-bar/tab-create-entry-action.ts | 32 +- .../components/tab-group/useTabDragSplit.ts | 3 +- .../tab-group/useTabGroupWorkspaceModel.ts | 31 +- .../task-drawer-source-boundary.test.ts | 101 ++ .../task-page-cache-selectors.test.ts | 35 + .../components/task-page-cache-selectors.ts | 11 +- .../task-page-default-repo-selection.test.ts | 279 ++++ .../task-page-default-repo-selection.ts | 101 ++ .../components/task-page-empty-state.test.ts | 42 + .../src/components/task-page-empty-state.ts | 43 + .../task-page-jira-cache-selectors.test.ts | 94 ++ .../task-page-jira-cache-selectors.ts | 35 +- .../task-page-source-switch-boundary.test.ts | 79 + .../task-project-source-combobox-model.ts | 41 + .../task-project-source-combobox.tsx | 406 +++++ .../task-source-context-summary.test.ts | 369 +++++ .../components/task-source-context-summary.ts | 301 ++++ .../task-source-provider-availability.test.ts | 159 ++ .../task-source-provider-availability.ts | 77 + .../terminal-pane/pty-connection.test.ts | 82 +- .../terminal-pane/pty-connection.ts | 36 +- .../terminal-drop-handler.test.ts | 82 +- .../terminal-pane/terminal-drop-handler.ts | 12 +- .../terminal/terminal-tab-actions.test.ts | 24 + .../terminal/terminal-tab-actions.ts | 12 +- .../components/ui/repo-multi-combobox.test.ts | 28 + .../src/components/ui/repo-multi-combobox.tsx | 18 +- ...mp-palette-source-context-boundary.test.ts | 31 + .../src/hooks/useAutomationDispatchEvents.ts | 25 +- ...poserState-host-context-boundaries.test.ts | 101 ++ src/renderer/src/hooks/useComposerState.ts | 461 +++++- .../useEditorExternalWatch-targets.test.ts | 6 +- .../src/hooks/useEditorExternalWatch.ts | 5 +- .../src/hooks/useGitHubSlugMetadata.test.tsx | 130 ++ .../src/hooks/useGitHubSlugMetadata.ts | 25 +- .../src/hooks/useGlobalFileDrop.test.ts | 72 +- src/renderer/src/hooks/useGlobalFileDrop.ts | 56 +- src/renderer/src/hooks/useIpcEvents.test.ts | 3 + src/renderer/src/hooks/useIpcEvents.ts | 24 +- src/renderer/src/hooks/useIssueMetadata.ts | 28 +- .../hooks/useSettingsNavigationMetadata.ts | 6 +- src/renderer/src/i18n/locales/en.json | 540 +++---- src/renderer/src/i18n/locales/es.json | 31 +- src/renderer/src/i18n/locales/ja.json | 31 +- src/renderer/src/i18n/locales/ko.json | 31 +- src/renderer/src/i18n/locales/zh.json | 31 +- .../src/i18n/no-top-level-translate.test.ts | 2 +- .../src/lib/active-agent-note-send.ts | 7 +- .../src/lib/active-agent-note-target.ts | 12 +- .../src/lib/agent-paste-draft.test.ts | 103 +- src/renderer/src/lib/agent-paste-draft.ts | 36 +- .../src/lib/ensure-hooks-confirmed.test.ts | 79 + .../src/lib/ensure-hooks-confirmed.ts | 17 +- .../src/lib/github-work-item-source-lookup.ts | 76 + .../lib/launch-agent-background-session.ts | 7 +- .../src/lib/launch-agent-in-new-tab.ts | 3 +- .../lib/launch-work-item-direct-preflight.ts | 15 +- .../src/lib/launch-work-item-direct.ts | 12 +- .../lib/new-workspace-composer-repo.test.ts | 42 + .../src/lib/new-workspace-composer-repo.ts | 19 +- .../lib/new-workspace-project-options.test.ts | 84 + .../src/lib/new-workspace-project-options.ts | 85 + .../src/lib/pending-worktree-creation.ts | 8 + .../src/lib/project-host-clone-url.test.ts | 46 + .../src/lib/project-host-clone-url.ts | 14 + .../lib/project-host-setup-options.test.ts | 325 ++++ .../src/lib/project-host-setup-options.ts | 218 +++ .../lib/project-host-workspace-target.test.ts | 203 +++ .../src/lib/project-host-workspace-target.ts | 208 +++ .../src/lib/repo-runtime-owner.test.ts | 99 ++ src/renderer/src/lib/repo-runtime-owner.ts | 50 + src/renderer/src/lib/repo-slug-cache.ts | 51 + src/renderer/src/lib/repo-slug-index.ts | 40 +- src/renderer/src/lib/smart-github-submit.ts | 15 +- .../src/lib/tab-number-shortcuts.test.ts | 10 + src/renderer/src/lib/tab-number-shortcuts.ts | 6 +- .../src/lib/workspace-port-actions.ts | 78 +- .../lib/workspace-session-host-persistence.ts | 138 ++ .../lib/workspace-session-host-split.test.ts | 318 ++++ .../src/lib/workspace-session-host-split.ts | 379 +++++ .../worktree-activation-created-agent.test.ts | 140 ++ .../src/lib/worktree-activation.test.ts | 21 + src/renderer/src/lib/worktree-activation.ts | 97 +- .../src/lib/worktree-palette-search.ts | 12 +- .../src/lib/worktree-runtime-owner.test.ts | 31 + .../src/lib/worktree-runtime-owner.ts | 69 + .../worktree-sort-order-host-split.test.ts | 37 + .../src/lib/worktree-sort-order-host-split.ts | 36 + .../runtime-compatibility-test-fixture.ts | 4 +- .../src/runtime/runtime-jira-client.ts | 55 +- .../src/runtime/runtime-linear-client.ts | 69 +- .../src/runtime/runtime-rpc-client.test.ts | 47 + .../src/runtime/runtime-rpc-client.ts | 30 + .../src/runtime/web-runtime-session.test.ts | 3 +- .../src/runtime/web-runtime-session.ts | 1 + .../src/runtime/web-session-tabs-sync.ts | 2 + src/renderer/src/store/index.ts | 2 + src/renderer/src/store/selectors.test.ts | 156 +- src/renderer/src/store/selectors.ts | 126 +- src/renderer/src/store/slices/browser.test.ts | 215 +++ src/renderer/src/store/slices/browser.ts | 167 +- .../store/slices/cmd-j-create-actions.test.ts | 39 +- .../src/store/slices/diffComments.test.ts | 31 + src/renderer/src/store/slices/diffComments.ts | 10 +- src/renderer/src/store/slices/editor.test.ts | 22 + src/renderer/src/store/slices/editor.ts | 90 +- .../src/store/slices/github-cache-key.ts | 45 +- .../src/store/slices/github-checks.ts | 12 +- .../slices/github-project-row-owner.test.ts | 42 + .../store/slices/github-project-row-owner.ts | 25 + src/renderer/src/store/slices/github.test.ts | 949 ++++++++++- src/renderer/src/store/slices/github.ts | 983 +++++++++--- .../slices/hosted-review-cache-identity.ts | 32 +- .../src/store/slices/hosted-review.test.ts | 69 + .../src/store/slices/hosted-review.ts | 76 +- src/renderer/src/store/slices/jira.test.ts | 90 ++ src/renderer/src/store/slices/jira.ts | 184 ++- src/renderer/src/store/slices/linear.test.ts | 173 ++ src/renderer/src/store/slices/linear.ts | 379 +++-- .../slices/repo-reorder-host-split.test.ts | 46 + .../store/slices/repo-reorder-host-split.ts | 47 + .../store/slices/repos-project-groups.test.ts | 8 +- .../repos-project-host-capability.test.ts | 147 ++ .../repos-project-host-lifecycle.test.ts | 226 +++ src/renderer/src/store/slices/repos.test.ts | 520 +++++- src/renderer/src/store/slices/repos.ts | 577 ++++++- .../src/store/slices/runtime-status.test.ts | 126 ++ .../src/store/slices/runtime-status.ts | 120 ++ .../src/store/slices/settings.test.ts | 347 ++-- src/renderer/src/store/slices/settings.ts | 240 +-- .../src/store/slices/store-cascades.test.ts | 99 ++ .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/terminals.ts | 18 +- src/renderer/src/store/slices/ui.test.ts | 364 ++++- src/renderer/src/store/slices/ui.ts | 170 +- .../worktree-nav-history-view-entries.test.ts | 138 +- .../src/store/slices/worktree-nav-history.ts | 53 +- .../src/store/slices/worktrees.test.ts | 196 +++ src/renderer/src/store/slices/worktrees.ts | 174 +- src/renderer/src/store/types.ts | 2 + src/renderer/src/web/web-preload-api.test.ts | 144 +- src/renderer/src/web/web-preload-api.ts | 74 +- src/shared/automation-run-identity.test.ts | 64 + src/shared/automation-run-identity.ts | 15 + src/shared/automations-types.ts | 20 + src/shared/constants.ts | 6 + src/shared/execution-host-registry.test.ts | 204 +++ src/shared/execution-host-registry.ts | 227 +++ src/shared/execution-host.test.ts | 66 + src/shared/execution-host.ts | 138 ++ src/shared/feature-interaction-catalog.ts | 2 + src/shared/feature-interaction-categories.ts | 1 + src/shared/feature-interactions.test.ts | 2 + src/shared/git-clone-failure-message.test.ts | 44 + src/shared/git-clone-failure-message.ts | 40 + src/shared/host-setting-overrides.test.ts | 144 ++ src/shared/host-setting-overrides.ts | 89 ++ src/shared/hosted-review.ts | 2 + .../project-host-setup-projection.test.ts | 200 +++ src/shared/project-host-setup-projection.ts | 161 ++ src/shared/protocol-version.ts | 11 +- src/shared/runtime-types.ts | 1 + src/shared/task-source-context.test.ts | 170 ++ src/shared/task-source-context.ts | 231 +++ src/shared/types.ts | 186 +++ src/shared/workspace-session-schema.ts | 3 + 502 files changed, 43248 insertions(+), 8051 deletions(-) create mode 100644 src/cli/handlers/project.ts create mode 100644 src/cli/project-format.ts create mode 100644 src/cli/repo-path-arguments.ts create mode 100644 src/cli/specs/project.ts create mode 100644 src/cli/worktree-project-target.ts create mode 100644 src/main/automations/headless-dispatch.ts create mode 100644 src/main/automations/run-target-resolution.ts create mode 100644 src/main/automations/run-usage-collection.ts create mode 100644 src/main/ipc/gitlab.test.ts create mode 100644 src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts create mode 100644 src/renderer/src/components/automations/AutomationProjectCombobox.tsx create mode 100644 src/renderer/src/components/automations/CreateFromPicker.test.tsx create mode 100644 src/renderer/src/components/automations/automation-host-client.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-client.ts create mode 100644 src/renderer/src/components/automations/automation-project-groups.test.ts create mode 100644 src/renderer/src/components/automations/automation-project-groups.ts create mode 100644 src/renderer/src/components/automations/automation-run-context.test.ts create mode 100644 src/renderer/src/components/automations/automation-run-context.ts create mode 100644 src/renderer/src/components/automations/automation-source-display.test.ts create mode 100644 src/renderer/src/components/automations/automation-source-display.ts 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/automations/external-automation-source-availability.test.ts create mode 100644 src/renderer/src/components/automations/external-automation-source-availability.ts create mode 100644 src/renderer/src/components/cmd-j/palette-host-badge.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-host-badge.ts create mode 100644 src/renderer/src/components/github-item-dialog-source-boundary.test.ts create mode 100644 src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx create mode 100644 src/renderer/src/components/new-workspace/ProjectCombobox.tsx create mode 100644 src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx create mode 100644 src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx create mode 100644 src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx create mode 100644 src/renderer/src/components/pull-request-page-host-boundary.test.ts create mode 100644 src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts create mode 100644 src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx create mode 100644 src/renderer/src/components/settings/ProviderHostScopeControl.tsx create mode 100644 src/renderer/src/components/settings/QuickCommandsList.tsx create mode 100644 src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx create mode 100644 src/renderer/src/components/settings/RepositoryHostSetupActions.tsx create mode 100644 src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx create mode 100644 src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx create mode 100644 src/renderer/src/components/settings/RepositorySettingsDraftInput.tsx create mode 100644 src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts create mode 100644 src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx create mode 100644 src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx create mode 100644 src/renderer/src/components/settings/host-scoped-setting-scope.test.ts create mode 100644 src/renderer/src/components/settings/host-scoped-setting-scope.ts create mode 100644 src/renderer/src/components/settings/jira-integration-card.test.tsx 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/provider-rate-limit-scope-panels.test.tsx create mode 100644 src/renderer/src/components/settings/repository-host-setup-options.test.ts create mode 100644 src/renderer/src/components/settings/repository-host-setup-options.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/sidebar/AddRepoCloneStep.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoCreateKindCard.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoHostSelector.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx create mode 100644 src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts create mode 100644 src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx create mode 100644 src/renderer/src/components/sidebar/HostRemoveDialog.tsx create mode 100644 src/renderer/src/components/sidebar/HostRenameDialog.tsx create mode 100644 src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx create mode 100644 src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts create mode 100644 src/renderer/src/components/sidebar/SetupScriptPromptCardShell.tsx create mode 100644 src/renderer/src/components/sidebar/SetupScriptPromptToast.tsx create mode 100644 src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx create mode 100644 src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx create mode 100644 src/renderer/src/components/sidebar/add-repo-host-availability.ts create mode 100644 src/renderer/src/components/sidebar/add-repo-store-upsert.ts create mode 100644 src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts create mode 100644 src/renderer/src/components/sidebar/host-header-drag-dom.ts create mode 100644 src/renderer/src/components/sidebar/host-header-drag.ts create mode 100644 src/renderer/src/components/sidebar/host-header-menu-items.test.ts create mode 100644 src/renderer/src/components/sidebar/host-header-menu-items.ts create mode 100644 src/renderer/src/components/sidebar/host-rename-remove.test.ts create mode 100644 src/renderer/src/components/sidebar/host-rename-remove.ts create mode 100644 src/renderer/src/components/sidebar/host-section-order.test.ts create mode 100644 src/renderer/src/components/sidebar/host-section-order.ts create mode 100644 src/renderer/src/components/sidebar/host-section-rows.test.ts create mode 100644 src/renderer/src/components/sidebar/host-section-rows.ts create mode 100644 src/renderer/src/components/sidebar/open-setup-script-settings.ts create mode 100644 src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts create mode 100644 src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-host-options.test.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-host-options.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts create mode 100644 src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts create mode 100644 src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts create mode 100644 src/renderer/src/components/sidebar/use-add-repo-host-selection.ts create mode 100644 src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts create mode 100644 src/renderer/src/components/sidebar/use-complete-git-repo-add.ts create mode 100644 src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts create mode 100644 src/renderer/src/components/sidebar/use-worktree-issue-link.ts create mode 100644 src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-meta-updates.ts create mode 100644 src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx create mode 100644 src/renderer/src/components/status-bar/SshTargetStatusRow.tsx create mode 100644 src/renderer/src/components/task-drawer-source-boundary.test.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-page-jira-cache-selectors.test.ts create mode 100644 src/renderer/src/components/task-page-source-switch-boundary.test.ts create mode 100644 src/renderer/src/components/task-project-source-combobox-model.ts create mode 100644 src/renderer/src/components/task-project-source-combobox.tsx 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/components/task-source-provider-availability.test.ts create mode 100644 src/renderer/src/components/task-source-provider-availability.ts create mode 100644 src/renderer/src/components/ui/repo-multi-combobox.test.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts create mode 100644 src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts create mode 100644 src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx create mode 100644 src/renderer/src/lib/github-work-item-source-lookup.ts create mode 100644 src/renderer/src/lib/new-workspace-project-options.test.ts create mode 100644 src/renderer/src/lib/new-workspace-project-options.ts create mode 100644 src/renderer/src/lib/project-host-clone-url.test.ts create mode 100644 src/renderer/src/lib/project-host-clone-url.ts create mode 100644 src/renderer/src/lib/project-host-setup-options.test.ts create mode 100644 src/renderer/src/lib/project-host-setup-options.ts create mode 100644 src/renderer/src/lib/project-host-workspace-target.test.ts create mode 100644 src/renderer/src/lib/project-host-workspace-target.ts create mode 100644 src/renderer/src/lib/repo-runtime-owner.test.ts create mode 100644 src/renderer/src/lib/repo-runtime-owner.ts create mode 100644 src/renderer/src/lib/repo-slug-cache.ts create mode 100644 src/renderer/src/lib/workspace-session-host-persistence.ts create mode 100644 src/renderer/src/lib/workspace-session-host-split.test.ts create mode 100644 src/renderer/src/lib/workspace-session-host-split.ts create mode 100644 src/renderer/src/lib/worktree-runtime-owner.test.ts create mode 100644 src/renderer/src/lib/worktree-runtime-owner.ts create mode 100644 src/renderer/src/lib/worktree-sort-order-host-split.test.ts create mode 100644 src/renderer/src/lib/worktree-sort-order-host-split.ts create mode 100644 src/renderer/src/store/slices/github-project-row-owner.test.ts create mode 100644 src/renderer/src/store/slices/github-project-row-owner.ts create mode 100644 src/renderer/src/store/slices/repo-reorder-host-split.test.ts create mode 100644 src/renderer/src/store/slices/repo-reorder-host-split.ts create mode 100644 src/renderer/src/store/slices/repos-project-host-capability.test.ts create mode 100644 src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts create mode 100644 src/renderer/src/store/slices/runtime-status.test.ts create mode 100644 src/renderer/src/store/slices/runtime-status.ts create mode 100644 src/shared/automation-run-identity.test.ts create mode 100644 src/shared/automation-run-identity.ts create mode 100644 src/shared/execution-host-registry.test.ts create mode 100644 src/shared/execution-host-registry.ts create mode 100644 src/shared/execution-host.test.ts create mode 100644 src/shared/execution-host.ts create mode 100644 src/shared/git-clone-failure-message.test.ts create mode 100644 src/shared/git-clone-failure-message.ts create mode 100644 src/shared/host-setting-overrides.test.ts create mode 100644 src/shared/host-setting-overrides.ts create mode 100644 src/shared/project-host-setup-projection.test.ts create mode 100644 src/shared/project-host-setup-projection.ts 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/src/cli/args.ts b/src/cli/args.ts index a114e5e1121..64a604ae46e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -127,6 +127,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if ( [ 'automations', + 'project', 'repo', 'worktree', 'terminal', @@ -156,6 +157,7 @@ export function isCommandGroup(commandPath: string[]): boolean { (commandPath.length === 1 && [ 'automations', + 'project', 'repo', 'worktree', 'terminal', diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index ab517e126d6..8186872d2f8 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -2,6 +2,7 @@ import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' import { CORE_HANDLERS } from './handlers/core' import { AUTOMATION_HANDLERS } from './handlers/automations' +import { PROJECT_HANDLERS } from './handlers/project' import { REPO_HANDLERS } from './handlers/repo' import { WORKTREE_HANDLERS } from './handlers/worktree' import { FILE_HANDLERS } from './handlers/file' @@ -37,6 +38,7 @@ function buildHandlers(): Map { const groups = [ CORE_HANDLERS, AUTOMATION_HANDLERS, + PROJECT_HANDLERS, REPO_HANDLERS, WORKTREE_HANDLERS, FILE_HANDLERS, diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts index 3d6c9df9aea..cd614976015 100644 --- a/src/cli/format.test.ts +++ b/src/cli/format.test.ts @@ -6,6 +6,7 @@ import { quoteCliCommandArgument } from './shell-command-quote' import { RuntimeRpcFailureError } from './runtime-client' import { formatCliError, + formatAutomationShow, formatComputerAction, formatGetAppState, formatTerminalRead, @@ -13,6 +14,7 @@ import { printResult } from './format' import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { Automation } from '../shared/automations-types' let testScreenshotDir: string | null = null @@ -140,6 +142,59 @@ describe('formatWorktreeList', () => { }) }) +describe('formatAutomationShow', () => { + function automation(overrides: Partial = {}): Automation { + return { + id: 'auto-1', + name: 'Nightly', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-legacy', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } + } + + it('shows explicit run context before the legacy repo id', () => { + const output = formatAutomationShow({ + automation: automation({ + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + path: '/srv/orca' + } + }) + }) + + expect(output).toContain('runProjectId: github:stablyai/orca') + expect(output).toContain('runHostId: runtime:gpu') + expect(output).toContain('projectHostSetupId: setup-gpu') + expect(output).toContain('runRepoId: repo-gpu') + expect(output).toContain('runPath: /srv/orca') + expect(output).toContain('legacyRepoId: repo-legacy') + expect(output).not.toContain('projectId: repo-legacy') + }) +}) + describe('formatTerminalRead', () => { it('warns limited cursor reads to continue with the next cursor', () => { const output = formatTerminalRead({ diff --git a/src/cli/format.ts b/src/cli/format.ts index 500c5ca55d0..e6b751d42eb 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -22,6 +22,14 @@ export { formatListWindows } from './computer-format' export type { ComputerActionFollowUpTarget } from './computer-format' +export { + formatProjectHostSetupCreateResult, + formatProjectHostSetupDeleteResult, + formatProjectHostSetupList, + formatProjectHostSetupResult, + formatProjectHostSetupUpdateResult, + formatProjectList +} from './project-format' export { formatTerminalClose, formatTerminalCreate, diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index be09b4060e3..cbbe7517a06 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -7,7 +7,13 @@ import type { AutomationSchedulePreset, AutomationUpdateInput } from '../../shared/automations-types' -import type { TuiAgent } from '../../shared/types' +import { + buildWorkspaceRunContext, + normalizeTaskSourceContext, + type TaskSourceContext, + type WorkspaceRunContext +} from '../../shared/task-source-context' +import type { ProjectHostSetup, TuiAgent } from '../../shared/types' import { DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS @@ -30,6 +36,11 @@ import { } from '../flags' import { RuntimeClientError } from '../runtime-client' import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' +import { + assertWorkspaceTargetFlagsCompatible, + hasWorkspaceProjectTarget, + resolveProjectCreateTarget +} from '../worktree-project-target' type AutomationCreateParams = Omit & { repo?: string @@ -270,6 +281,46 @@ function getPrecheckFlag( } } +function getSourceContextFlag( + flags: Map +): TaskSourceContext | null | undefined { + if (!flags.has('source-context')) { + return undefined + } + const value = flags.get('source-context') + if (typeof value !== 'string') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context requires a JSON TaskSourceContext or null' + ) + } + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON') + } + if (parsed === null) { + return null + } + if (!parsed || typeof parsed !== 'object') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context must be a JSON TaskSourceContext or null' + ) + } + const sourceContext = normalizeTaskSourceContext( + parsed as Parameters[0] + ) + if (!sourceContext) { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context is not a valid TaskSourceContext' + ) + } + return sourceContext +} + function getWorkspaceModeFlag( flags: Map ): 'existing' | 'new_per_run' | undefined { @@ -293,11 +344,25 @@ async function resolveDefaultTarget( flags: Map, cwd: string, client: Parameters[0]['client'] -): Promise<{ repo?: string; workspace?: string }> { +): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> { + 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 projectTarget = await resolveProjectCreateTarget(flags, client) + if (projectTarget) { + return { + repo: projectTarget.repoSelector, + runContext: buildAutomationRunContextFromSetup(projectTarget.setup) + } + } const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) if (repo || workspace) { return { repo, workspace } @@ -316,15 +381,46 @@ async function getExplicitTarget( flags: Map, cwd: string, client: Parameters[0]['client'] -): Promise<{ repo?: string; workspace?: string }> { +): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> { + 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 projectTarget = await resolveProjectCreateTarget(flags, client) + if (projectTarget) { + return { + repo: projectTarget.repoSelector, + runContext: buildAutomationRunContextFromSetup(projectTarget.setup) + } + } const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) return { repo, workspace } } +function buildAutomationRunContextFromSetup(setup: ProjectHostSetup): WorkspaceRunContext { + const runContext = buildWorkspaceRunContext({ + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }) + if (!runContext) { + throw new RuntimeClientError( + 'invalid_argument', + `Project host setup is missing automation run context fields: ${setup.id}` + ) + } + return runContext +} + export const AUTOMATION_HANDLERS: Record = { 'automations list': async ({ client, json }) => { const result = await client.call<{ automations: Automation[] }>('automation.list') @@ -342,6 +438,7 @@ export const AUTOMATION_HANDLERS: Record = { throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') } const target = await resolveDefaultTarget(flags, cwd, client) + const sourceContext = getSourceContextFlag(flags) const workspaceMode = getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run') const result = await client.call<{ automation: Automation }>('automation.create', { @@ -349,6 +446,8 @@ export const AUTOMATION_HANDLERS: Record = { prompt: getRequiredStringFlag(flags, 'prompt'), precheck: getPrecheckFlag(flags), agentId: getProviderFlag(flags), + ...(target.runContext ? { runContext: target.runContext } : {}), + ...(sourceContext !== undefined ? { sourceContext } : {}), repo: target.repo, workspace: target.workspace, workspaceMode, @@ -364,6 +463,7 @@ export const AUTOMATION_HANDLERS: Record = { 'automations edit': async ({ flags, client, cwd, json }) => { const target = await getExplicitTarget(flags, cwd, client) const schedule = getScheduleFlag(flags, false) + const sourceContext = getSourceContextFlag(flags) const result = await client.call<{ automation: Automation }>('automation.update', { id: getRequiredStringFlag(flags, 'id'), updates: { @@ -371,6 +471,8 @@ export const AUTOMATION_HANDLERS: Record = { prompt: getOptionalStringFlag(flags, 'prompt'), precheck: getPrecheckFlag(flags), agentId: getOptionalProviderFlag(flags), + ...(target.runContext ? { runContext: target.runContext } : {}), + ...(sourceContext !== undefined ? { sourceContext } : {}), repo: target.repo, workspace: target.workspace, workspaceMode: getWorkspaceModeFlag(flags), diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts new file mode 100644 index 00000000000..cc5f32e2cd2 --- /dev/null +++ b/src/cli/handlers/project.ts @@ -0,0 +1,204 @@ +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCloneArgs, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + RepoKind +} from '../../shared/types' +import type { CommandHandler } from '../dispatch' +import { + formatProjectHostSetupCreateResult, + formatProjectHostSetupDeleteResult, + formatProjectHostSetupList, + formatProjectHostSetupResult, + formatProjectHostSetupUpdateResult, + formatProjectList, + printResult +} from '../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { resolveRepoPathArgument } from '../repo-path-arguments' +import { RuntimeClientError } from '../runtime-client' + +function getOptionalRepoKind(flags: Map): RepoKind | undefined { + const kind = getOptionalStringFlag(flags, 'kind') + if (kind === undefined) { + return undefined + } + if (kind === 'git' || kind === 'folder') { + return kind + } + throw new RuntimeClientError('invalid_argument', '--kind must be git or folder') +} + +export const PROJECT_HANDLERS: Record = { + 'project list': async ({ client, json }) => { + const result = await client.call<{ projects: Project[] }>('project.list') + printResult(result, json, formatProjectList) + }, + 'project setups': async ({ flags, client, json }) => { + const projectFilter = getOptionalStringFlag(flags, 'project') + const hostFilter = getOptionalStringFlag(flags, 'host') + const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') + const setups = result.result.setups.filter( + (setup) => + (projectFilter === undefined || setup.projectId === projectFilter) && + (hostFilter === undefined || setup.hostId === hostFilter) + ) + printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList) + }, + 'project setup-existing-folder': async ({ flags, client, cwd, json }) => { + const rawPath = getRequiredStringFlag(flags, 'path') + const args: ProjectHostSetupExistingFolderArgs = { + projectId: getRequiredStringFlag(flags, 'project'), + hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'], + path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'), + kind: getOptionalRepoKind(flags), + displayName: getOptionalStringFlag(flags, 'display-name') + } + const result = await client.call<{ result: ProjectHostSetupResult }>( + 'projectHostSetup.setupExistingFolder', + args + ) + printResult(result, json, formatProjectHostSetupResult) + }, + 'project setup-clone': async ({ flags, client, cwd, json }) => { + const rawDestination = getRequiredStringFlag(flags, 'destination') + const args: ProjectHostSetupCloneArgs = { + projectId: getRequiredStringFlag(flags, 'project'), + hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCloneArgs['hostId'], + url: getRequiredStringFlag(flags, 'url'), + destination: resolveRepoPathArgument( + rawDestination, + cwd, + client.isRemote, + 'Project setup clone' + ), + displayName: getOptionalStringFlag(flags, 'display-name') + } + const result = await client.call<{ result: ProjectHostSetupResult }>( + 'projectHostSetup.clone', + args + ) + printResult(result, json, formatProjectHostSetupResult) + }, + 'project setup-create': async ({ flags, client, cwd, json }) => { + const path = getOptionalStringFlag(flags, 'path') + const args: ProjectHostSetupCreateArgs = { + projectId: getRequiredStringFlag(flags, 'project'), + hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCreateArgs['hostId'], + setupId: getOptionalStringFlag(flags, 'setup-id'), + path: + path === undefined + ? undefined + : resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup create'), + kind: getOptionalRepoKind(flags), + displayName: getOptionalStringFlag(flags, 'display-name'), + worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'), + gitUsername: getOptionalStringFlag(flags, 'git-username'), + setupState: getOptionalSetupState(flags), + setupMethod: getOptionalIndependentSetupMethod(flags) + } + const result = await client.call<{ result: ProjectHostSetupCreateResult }>( + 'projectHostSetup.create', + args + ) + printResult(result, json, formatProjectHostSetupCreateResult) + }, + 'project setup-update': async ({ flags, client, cwd, json }) => { + const path = getOptionalStringFlag(flags, 'path') + const args: ProjectHostSetupUpdateArgs = { + setupId: getRequiredStringFlag(flags, 'setup'), + updates: { + displayName: getOptionalStringFlag(flags, 'display-name'), + path: + path === undefined + ? undefined + : resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup update'), + worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'), + gitUsername: getOptionalStringFlag(flags, 'git-username'), + kind: getOptionalRepoKind(flags), + setupState: getOptionalSetupState(flags), + setupMethod: getOptionalSetupMethod(flags) + } + } + const result = await client.call<{ result: ProjectHostSetupUpdateResult }>( + 'projectHostSetup.update', + args + ) + printResult(result, json, formatProjectHostSetupUpdateResult) + }, + 'project setup-delete': async ({ flags, client, json }) => { + const result = await client.call<{ result: ProjectHostSetupDeleteResult }>( + 'projectHostSetup.delete', + { + setupId: getRequiredStringFlag(flags, 'setup') + } + ) + printResult(result, json, formatProjectHostSetupDeleteResult) + } +} + +function getOptionalSetupState( + flags: Map +): ProjectHostSetupUpdateArgs['updates']['setupState'] { + const state = getOptionalStringFlag(flags, 'state') + if (state === undefined) { + return undefined + } + if ( + state === 'ready' || + state === 'not-set-up' || + state === 'setting-up' || + state === 'error' || + state === 'unsupported' + ) { + return state + } + throw new RuntimeClientError( + 'invalid_argument', + '--state must be ready, not-set-up, setting-up, error, or unsupported' + ) +} + +function getOptionalIndependentSetupMethod( + flags: Map +): ProjectHostSetupCreateArgs['setupMethod'] { + const method = getOptionalStringFlag(flags, 'method') + if (method === undefined) { + return undefined + } + if (method === 'imported-existing-folder' || method === 'cloned' || method === 'provisioned') { + return method + } + throw new RuntimeClientError( + 'invalid_argument', + '--method must be imported-existing-folder, cloned, or provisioned' + ) +} + +function getOptionalSetupMethod( + flags: Map +): ProjectHostSetupUpdateArgs['updates']['setupMethod'] { + const method = getOptionalStringFlag(flags, 'method') + if (method === undefined) { + return undefined + } + if ( + method === 'legacy-repo' || + method === 'imported-existing-folder' || + method === 'cloned' || + method === 'provisioned' + ) { + return method + } + throw new RuntimeClientError( + 'invalid_argument', + '--method must be legacy-repo, imported-existing-folder, cloned, or provisioned' + ) +} diff --git a/src/cli/handlers/repo.ts b/src/cli/handlers/repo.ts index fc4c9f22809..e0432cc9bc6 100644 --- a/src/cli/handlers/repo.ts +++ b/src/cli/handlers/repo.ts @@ -1,33 +1,8 @@ -import { resolve as resolvePath } from 'path' import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format' import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags' -import { RuntimeClientError } from '../runtime-client' - -function isAbsoluteServerPath(value: string): boolean { - return ( - value.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(value) || - value.startsWith('\\\\') || - value.startsWith('//') - ) -} - -function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string { - if (!isRemote) { - return resolvePath(cwd, inputPath) - } - // Why: the local CLI cwd is unrelated to a paired runtime's filesystem. - // Relative remote paths would silently target the wrong machine. - if (!isAbsoluteServerPath(inputPath)) { - throw new RuntimeClientError( - 'invalid_argument', - 'Remote repo add requires --path to be an absolute path on the remote server.' - ) - } - return inputPath -} +import { resolveRepoPathArgument } from '../repo-path-arguments' export const REPO_HANDLERS: Record = { 'repo list': async ({ client, json }) => { @@ -37,7 +12,7 @@ export const REPO_HANDLERS: Record = { 'repo add': async ({ flags, client, cwd, json }) => { const repoPath = getRequiredStringFlag(flags, 'path') const result = await client.call<{ repo: Record }>('repo.add', { - path: resolveRepoAddPath(repoPath, cwd, client.isRemote) + path: resolveRepoPathArgument(repoPath, cwd, client.isRemote, 'Remote repo add') }) printResult(result, json, formatRepoShow) }, diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 1ee5a3b37d4..009ce6c7cc4 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -21,6 +21,11 @@ import { resolveCurrentWorktreeSelector } from '../selectors' import { isTuiAgent } from '../../shared/tui-agent-config' +import { + assertWorkspaceTargetFlagsCompatible, + hasWorkspaceProjectTarget, + resolveProjectCreateRepoSelector +} from '../worktree-project-target' import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link' type HookWarningResult = { @@ -149,10 +154,15 @@ function getRepoSelectorFromWorktreeSelector(selector: string | undefined): stri return `id:${worktreeId.slice(0, separatorIndex)}` } -function getCreateRepoSelector( +async function getCreateRepoSelector( flags: Map, - cwdParentWorktree: string | undefined -): string { + cwdParentWorktree: string | undefined, + client: Parameters[0]['client'] +): Promise { + const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client) + if (projectRepoSelector) { + return projectRepoSelector + } const explicitRepo = getPresentStringFlag(flags, 'repo') if (explicitRepo) { return explicitRepo @@ -195,6 +205,7 @@ export const WORKTREE_HANDLERS: Record = { }, 'worktree create': async ({ flags, client, cwd, json }) => { assertParentFlagsCompatible(flags) + assertWorkspaceTargetFlagsCompatible(flags) const callerTerminalHandle = typeof process.env.ORCA_TERMINAL_HANDLE === 'string' && process.env.ORCA_TERMINAL_HANDLE.length > 0 @@ -210,7 +221,8 @@ export const WORKTREE_HANDLERS: Record = { const setupDecision = getOptionalSetupDecision(flags) const noParent = flags.get('no-parent') === true let cwdParentWorktree: string | undefined - if ((!explicitParentWorktree && !noParent) || !flags.has('repo')) { + const needsCwdRepoInference = !flags.has('repo') && !hasWorkspaceProjectTarget(flags) + if ((!explicitParentWorktree && !noParent) || needsCwdRepoInference) { try { // Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running // inside an Orca worktree. Cwd keeps CLI-created children nestable and @@ -222,7 +234,7 @@ export const WORKTREE_HANDLERS: Record = { } const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue') const result = await client.call('worktree.create', { - repo: getCreateRepoSelector(flags, cwdParentWorktree), + repo: await getCreateRepoSelector(flags, cwdParentWorktree, client), name: getRequiredStringFlag(flags, 'name'), baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), diff --git a/src/cli/help.ts b/src/cli/help.ts index 6445ac2458e..a11293db900 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -29,6 +29,15 @@ Automations: automations run Run an Orca automation now automations runs List automation run history +Projects: + project list List durable projects known to Orca + project setups List project host setups + project setup-existing-folder Make a project available on a host by importing an existing folder + project setup-clone Make a project available on a host by cloning a repository + project setup-create Create independent project host setup metadata + project setup-update Update project host setup metadata + project setup-delete Remove a project host setup + Repos: repo list List repos registered in Orca repo add Add a project to Orca by filesystem path @@ -185,7 +194,7 @@ Common Commands: orca environment show --environment [--json] orca environment rm --environment [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --name [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] + orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] @@ -204,6 +213,13 @@ Common Commands: orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] orca terminal close [--terminal ] [--json] + orca project list [--json] + orca project setups [--project ] [--host ] [--json] + orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] + orca project setup-clone --project --host --url --destination [--display-name ] [--json] + orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json] + orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json] + orca project setup-delete --setup [--json] orca repo list [--json] orca repo add --path [--json] orca repo show --repo [--json] @@ -486,6 +502,8 @@ export function formatFlagHelp(flag: string): string { '--workspace-status Board status id (defaults: todo, in-progress, in-review, completed)', staged: '--staged Open staged source-control changes', provider: '--provider Agent id such as codex, claude, or gemini', + 'source-context': + '--source-context Explicit TaskSourceContext for automation task/provider data', trigger: '--trigger Automation schedule preset, cron, or RRULE', schedule: '--schedule Alias for --trigger', time: '--time Time used with daily/weekdays/weekly presets', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 002fe513005..1f9d798f135 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -141,6 +141,18 @@ describe('orca root help', () => { expect(logSpy.mock.calls[0][0]).toContain( 'computer press-key Press a single key such as Return or Escape' ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-existing-folder Make a project available on a host by importing an existing folder' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-create Create independent project host setup metadata' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-update Update project host setup metadata' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'project setup-delete Remove a project host setup' + ) expect(callMock).not.toHaveBeenCalled() }) @@ -154,7 +166,6 @@ describe('orca root help', () => { 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') @@ -163,10 +174,6 @@ describe('orca root help', () => { 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') @@ -187,21 +194,6 @@ describe('orca root help', () => { 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() }) @@ -325,7 +317,9 @@ describe('orca cli worktree awareness', () => { await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src') - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { worktree: 'id:repo::/tmp/repo/feature' }) @@ -1005,6 +999,153 @@ describe('orca cli worktree awareness', () => { }) }) + it('resolves project and host flags to the matching repo for worktree.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_create', { + worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:gpu', + '--name', + 'feature', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { + repo: 'id:repo-gpu', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: undefined, + noParent: true, + callerTerminalHandle: undefined + }) + }) + + it('resolves project-host-setup directly for worktree.create', async () => { + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + 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_create', { + worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--project-host-setup', + 'setup-gpu', + '--name', + 'feature', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'worktree.create', + expect.objectContaining({ repo: 'id:repo-gpu' }) + ) + }) + + it('rejects mixing repo and project target flags on worktree.create', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-local', + '--project', + 'github:stablyai/orca', + '--name', + 'feature', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Choose either --repo or project target flags, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('passes an explicit parent through worktree.create without cwd inference', async () => { queueFixtures( callMock, @@ -1368,6 +1509,174 @@ describe('orca cli worktree awareness', () => { }) }) + it('lists projects through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_list', { + projects: [ + { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + providerIdentity: { + provider: 'github', + owner: 'stablyai', + repo: 'orca' + }, + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['project', 'list', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('project.list') + }) + + it('filters project host setups locally after fetching setup compatibility state', 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-remote', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: 'repo-remote', + path: '/srv/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['project', 'setups', '--project', 'github:stablyai/orca', '--host', 'runtime:gpu'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.list') + expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote') + expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local') + }) + + it('sets up an existing project folder with a path resolved against the local cli cwd', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-local', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-1', + path: path.resolve('/tmp/orca'), + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + }, + repo: { + id: 'repo-1', + path: path.resolve('/tmp/orca'), + displayName: 'Orca', + badgeColor: '#7c3aed', + addedAt: 1 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-existing-folder', + '--project', + 'github:stablyai/orca', + '--host', + 'local', + '--path', + '..', + '--kind', + 'git', + '--display-name', + 'Orca', + '--json' + ], + '/tmp/orca/worktrees/feature' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.setupExistingFolder', { + projectId: 'github:stablyai/orca', + hostId: 'local', + path: path.resolve('/tmp/orca/worktrees'), + kind: 'git', + displayName: 'Orca' + }) + }) + + it('rejects remote project setup relative paths instead of resolving against client cwd', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'project', + 'setup-existing-folder', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:gpu', + '--path', + './orca', + '--pairing-code', + 'remote-runtime', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Remote project setup requires --path to be an absolute path on the remote server.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -2490,7 +2799,9 @@ describe('orca cli worktree awareness', () => { await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src') expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) + expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { + worktree: undefined + }) }) it('passes emulator gesture points through to the runtime', async () => { @@ -2626,7 +2937,9 @@ describe('orca cli worktree awareness', () => { '/tmp/repo/feature/src' ) - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { name: 'Daily review', prompt: 'Review open changes', @@ -2644,6 +2957,243 @@ 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', + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + path: '/srv/orca' + }, + workspace: undefined, + workspaceMode: 'new_per_run' + }) + ) + }) + + it('resolves project-host-setup flags for automation edit with explicit run context', async () => { + queueFixtures( + callMock, + okFixture('req_project_setups', { + setups: [ + { + 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_edit', { + automation: { id: 'auto-1', name: 'GPU review' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['automations', 'edit', 'auto-1', '--project-host-setup', 'setup-gpu', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'automation.update', + expect.objectContaining({ + id: 'auto-1', + updates: expect.objectContaining({ + repo: 'id:repo-gpu', + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + path: '/srv/orca' + } + }) + }) + ) + }) + + it('passes automation source context JSON through create', async () => { + const sourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-gpu', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: 'gpu-bot' + } + queueFixtures( + callMock, + okFixture('req_automation_create', { + automation: { id: 'auto-1', name: 'GPU task review' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'automations', + 'create', + '--name', + 'GPU task review', + '--trigger', + 'daily', + '--prompt', + 'Review open work', + '--provider', + 'codex', + '--repo', + 'id:repo-gpu', + '--source-context', + JSON.stringify(sourceContext), + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith( + 1, + 'automation.create', + expect.objectContaining({ + repo: 'id:repo-gpu', + sourceContext + }) + ) + }) + + it('clears automation source context on edit with null', async () => { + queueFixtures( + callMock, + okFixture('req_edit', { + automation: { id: 'auto-1', name: 'GPU task review' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenNthCalledWith( + 1, + 'automation.update', + expect.objectContaining({ + id: 'auto-1', + updates: expect.objectContaining({ + sourceContext: null + }) + }) + ) + }) + + it('rejects invalid automation source context JSON before calling the runtime', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'GPU task review', + '--trigger', + 'daily', + '--prompt', + 'Review open work', + '--provider', + 'codex', + '--repo', + 'id:repo-gpu', + '--source-context', + '{nope', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--source-context must be valid JSON' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + 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(() => {}) @@ -2814,8 +3364,12 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), - okFixture('req_create', { automation: { id: 'auto-1', name: 'Daily review' } }), - okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) + okFixture('req_create', { + automation: { id: 'auto-1', name: 'Daily review' } + }), + okFixture('req_edit', { + automation: { id: 'auto-1', name: 'Daily review' } + }) ) vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -2840,7 +3394,9 @@ describe('orca cli worktree awareness', () => { ) await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo') - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith( 2, 'automation.create', @@ -2904,8 +3460,16 @@ describe('orca cli worktree awareness', () => { }) it.each([ - { flag: 'enabled', value: 'false', message: '--enabled does not take a value' }, - { flag: 'disabled', value: 'false', message: '--disabled does not take a value' } + { + flag: 'enabled', + value: 'false', + message: '--enabled does not take a value' + }, + { + flag: 'disabled', + value: 'false', + message: '--disabled does not take a value' + } ])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -2943,7 +3507,9 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), - okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } }) + okFixture('req_automation_create', { + automation: { id: 'auto-1', name: 'Daily review' } + }) ) vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -2966,7 +3532,9 @@ describe('orca cli worktree awareness', () => { '/tmp/repo/feature/src' ) - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { name: 'Daily review', prompt: 'Review open changes', @@ -2987,7 +3555,9 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), - okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) + okFixture('req_edit', { + automation: { id: 'auto-1', name: 'Daily review' } + }) ) vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -2996,7 +3566,9 @@ describe('orca cli worktree awareness', () => { '/tmp/repo/feature/src' ) - expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', { id: 'auto-1', updates: { @@ -3064,9 +3636,15 @@ describe('orca cli worktree awareness', () => { missedRunGraceMinutes: undefined } }) - expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' }) - expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' }) - expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { + id: 'auto-1' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { + id: 'auto-1' + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { + id: 'auto-1' + }) }) it('rejects ambiguous positional and flag automation ids before dispatch', async () => { @@ -3088,4 +3666,171 @@ describe('orca cli worktree awareness', () => { process.exitCode = priorExitCode }) + + it('updates project host setup metadata through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup_update', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: '', + path: '/srv/orca', + displayName: 'GPU VM', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 2 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-update', + '--setup', + 'setup-gpu', + '--display-name', + 'GPU VM', + '--path', + '/srv/orca', + '--worktree-base-path', + '../worktrees', + '--state', + 'ready', + '--method', + 'imported-existing-folder', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.update', { + setupId: 'setup-gpu', + updates: { + displayName: 'GPU VM', + path: path.resolve('/tmp/repo', '/srv/orca'), + worktreeBasePath: '../worktrees', + gitUsername: undefined, + kind: undefined, + setupState: 'ready', + setupMethod: 'imported-existing-folder' + } + }) + }) + + it('creates independent project host setup metadata through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup_create', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: '', + path: '', + displayName: 'GPU VM', + setupState: 'setting-up', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 2 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-create', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:gpu', + '--setup-id', + 'setup-gpu', + '--display-name', + 'GPU VM', + '--state', + 'setting-up', + '--method', + 'provisioned', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.create', { + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + setupId: 'setup-gpu', + path: undefined, + kind: undefined, + displayName: 'GPU VM', + worktreeBasePath: undefined, + gitUsername: undefined, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + }) + + it('deletes project host setup metadata through the project-first runtime API', async () => { + queueFixtures( + callMock, + okFixture('req_project_setup_delete', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-gpu', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + repoId: '', + path: '/srv/orca', + displayName: 'GPU VM', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 2 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', { + setupId: 'setup-gpu' + }) + }) }) diff --git a/src/cli/project-format.ts b/src/cli/project-format.ts new file mode 100644 index 00000000000..d5569a0306b --- /dev/null +++ b/src/cli/project-format.ts @@ -0,0 +1,80 @@ +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteResult, + ProjectHostSetupResult, + ProjectHostSetupUpdateResult +} from '../shared/types' + +export function formatProjectList(result: { projects: Project[] }): string { + if (result.projects.length === 0) { + return 'No projects found.' + } + return result.projects + .map((project) => { + const identity = project.providerIdentity + ? `${project.providerIdentity.provider}:${project.providerIdentity.owner}/${project.providerIdentity.repo}` + : 'no-provider' + return `${project.id} ${project.displayName} ${identity}` + }) + .join('\n') +} + +export function formatProjectHostSetupList(result: { setups: ProjectHostSetup[] }): string { + if (result.setups.length === 0) { + return 'No project host setups found.' + } + return result.setups + .map( + (setup) => + `${setup.id} project:${setup.projectId} host:${setup.hostId} ${setup.setupState} ${setup.path}` + ) + .join('\n') +} + +export function formatProjectHostSetupResult(result: { result: ProjectHostSetupResult }): string { + const { project, setup, repo } = result.result + return formatProjectHostSetupResultFields(project, setup, repo.id) +} + +export function formatProjectHostSetupCreateResult(result: { + result: ProjectHostSetupCreateResult +}): string { + const { project, setup } = result.result + return formatProjectHostSetupResultFields(project, setup, undefined) +} + +export function formatProjectHostSetupUpdateResult(result: { + result: ProjectHostSetupUpdateResult +}): string { + const { project, setup, repo } = result.result + return formatProjectHostSetupResultFields(project, setup, repo?.id) +} + +export function formatProjectHostSetupDeleteResult(result: { + result: ProjectHostSetupDeleteResult +}): string { + const { project, setup, repo } = result.result + return [ + `deleted: ${setup.id}`, + formatProjectHostSetupResultFields(project, setup, repo?.id) + ].join('\n') +} + +function formatProjectHostSetupResultFields( + project: Project, + setup: ProjectHostSetup, + repoId: string | undefined +): string { + return [ + `projectId: ${project.id}`, + `project: ${project.displayName}`, + `setupId: ${setup.id}`, + `hostId: ${setup.hostId}`, + `path: ${setup.path}`, + `state: ${setup.setupState}`, + `method: ${setup.setupMethod}`, + `repoId: ${repoId ?? 'none'}` + ].join('\n') +} diff --git a/src/cli/repo-path-arguments.ts b/src/cli/repo-path-arguments.ts new file mode 100644 index 00000000000..efc0148eff0 --- /dev/null +++ b/src/cli/repo-path-arguments.ts @@ -0,0 +1,31 @@ +import { resolve as resolvePath } from 'path' +import { RuntimeClientError } from './runtime-client' + +function isAbsoluteServerPath(value: string): boolean { + return ( + value.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith('\\\\') || + value.startsWith('//') + ) +} + +export function resolveRepoPathArgument( + inputPath: string, + cwd: string, + isRemote: boolean, + remotePathSubject = 'Remote repo path' +): string { + if (!isRemote) { + return resolvePath(cwd, inputPath) + } + // Why: the local CLI cwd is unrelated to a paired runtime's filesystem. + // Relative remote paths would silently target the wrong machine. + if (!isAbsoluteServerPath(inputPath)) { + throw new RuntimeClientError( + 'invalid_argument', + `${remotePathSubject} requires --path to be an absolute path on the remote server.` + ) + } + return inputPath +} diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts index ff3071a9372..e4d0b9640cc 100644 --- a/src/cli/specs/automations.ts +++ b/src/cli/specs/automations.ts @@ -1,7 +1,16 @@ 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', + 'source-context', + '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 +41,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 +55,8 @@ 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 --source-context with a JSON TaskSourceContext when task/provider data should come from a specific host/account; pass null on edit to clear it.', '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/cli/specs/core.ts b/src/cli/specs/core.ts index e47c5512fa6..12484a38f4c 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -102,10 +102,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]', + 'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', + 'project', + 'host', + 'project-host-setup', 'name', 'agent', 'prompt', @@ -122,6 +125,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.', 'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.', + 'Use --project with --host to create on a ready project host setup without spelling the backing repo id.', 'For related work, use the inferred parent or pass --parent-worktree active to make the current workspace relationship explicit.', 'Use --no-parent when the new worktree should be independent of the current workspace.', 'By default this creates the worktree and its first terminal without switching the active Orca workspace.', @@ -133,6 +137,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ examples: [ 'orca worktree create --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id:<repoId> --name related-task --json', + 'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json', 'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json', 'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json', diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index cf80d837bb5..8fb4e2fbd11 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { AUTOMATION_COMMAND_SPECS } from './automations' import { CORE_COMMAND_SPECS } from './core' import { FILE_COMMAND_SPECS } from './file' +import { PROJECT_COMMAND_SPECS } from './project' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' import { ENVIRONMENT_COMMAND_SPECS } from './environment' @@ -14,6 +15,7 @@ import { LINEAR_COMMAND_SPECS } from './linear' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, + ...PROJECT_COMMAND_SPECS, ...FILE_COMMAND_SPECS, ...AUTOMATION_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts new file mode 100644 index 00000000000..f9070da9f40 --- /dev/null +++ b/src/cli/specs/project.ts @@ -0,0 +1,113 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['project', 'list'], + summary: 'List durable projects known to Orca', + usage: 'orca project list [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca project list', 'orca project list --json'] + }, + { + path: ['project', 'setups'], + summary: 'List project host setups', + usage: 'orca project setups [--project <id>] [--host <host-id>] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'], + notes: ['A setup means a project is available on a host at a concrete filesystem path.'], + examples: [ + 'orca project setups', + 'orca project setups --project github:stablyai/orca', + 'orca project setups --host local' + ] + }, + { + path: ['project', 'setup-existing-folder'], + summary: 'Make a project available on a host by importing an existing folder', + usage: + 'orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'], + notes: ['For remote runtimes, --path must be an absolute path on the remote server.'], + examples: [ + 'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca', + 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json' + ] + }, + { + path: ['project', 'setup-clone'], + summary: 'Make a project available on a host by cloning a repository', + usage: + 'orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'url', 'destination', 'display-name'], + notes: [ + 'For remote runtimes, --destination must be an absolute parent directory on the remote server.', + 'SSH targets are cloned through the desktop UI because the desktop client owns SSH connections.' + ], + examples: [ + 'orca project setup-clone --project github:stablyai/orca --host local --url https://github.com/stablyai/orca.git --destination ~/src', + 'orca project setup-clone --project github:stablyai/orca --host runtime:gpu --url https://github.com/stablyai/orca.git --destination /srv --json' + ] + }, + { + path: ['project', 'setup-create'], + summary: 'Create independent project host setup metadata', + usage: + 'orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'project', + 'host', + 'setup-id', + 'path', + 'kind', + 'display-name', + 'worktree-base-path', + 'git-username', + 'state', + 'method' + ], + notes: [ + 'Creates setup metadata without registering a repo compatibility record.', + 'Use setup-existing-folder when Orca should import and manage an actual checkout path now.' + ], + examples: [ + 'orca project setup-create --project github:stablyai/orca --host runtime:gpu --state setting-up --method provisioned --json' + ] + }, + { + path: ['project', 'setup-update'], + summary: 'Update project host setup metadata', + usage: + 'orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'setup', + 'display-name', + 'path', + 'worktree-base-path', + 'git-username', + 'kind', + 'state', + 'method' + ], + notes: [ + 'Repo-backed setups mirror safe fields onto the repo record.', + 'Path and availability state changes are only supported for independent setup records.' + ], + examples: [ + 'orca project setup-update --setup github:stablyai/orca::gpu --display-name "GPU VM"', + 'orca project setup-update --setup github:stablyai/orca::gpu --path /srv/orca --state ready --json' + ] + }, + { + path: ['project', 'setup-delete'], + summary: 'Remove a project host setup', + usage: 'orca project setup-delete --setup <setup-id> [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'setup'], + notes: [ + 'Independent setups are removed directly.', + 'Repo-backed setups remove the registered repo compatibility record.' + ], + examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json'] + } +] diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 27d02d82891..7be9ba302f0 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -1,4 +1,5 @@ import type { Automation, AutomationRun } from '../shared/automations-types' +import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck' import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -178,6 +179,17 @@ export function formatAutomationList(result: { automations: Automation[] }): str export function formatAutomationShow(result: { automation: Automation }): string { const automation = result.automation + const runContext = automation.runContext ?? null + const projectLines = runContext + ? [ + `runProjectId: ${runContext.projectId}`, + `runHostId: ${runContext.hostId}`, + `projectHostSetupId: ${runContext.projectHostSetupId}`, + `runRepoId: ${runContext.repoId}`, + `runPath: ${runContext.path}`, + `legacyRepoId: ${getAutomationLegacyRepoId(automation)}` + ] + : [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`] return [ `id: ${automation.id}`, `name: ${automation.name}`, @@ -193,7 +205,7 @@ export function formatAutomationShow(result: { automation: Automation }): string : 'none' }`, `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, - `projectId: ${automation.projectId}`, + ...projectLines, `workspaceMode: ${automation.workspaceMode}`, `workspaceId: ${automation.workspaceId ?? 'null'}`, `baseBranch: ${automation.baseBranch ?? 'null'}`, diff --git a/src/cli/worktree-project-target.ts b/src/cli/worktree-project-target.ts new file mode 100644 index 00000000000..6626270655a --- /dev/null +++ b/src/cli/worktree-project-target.ts @@ -0,0 +1,85 @@ +import type { ProjectHostSetup } from '../shared/types' +import type { RuntimeClient } from './runtime-client' +import { RuntimeClientError } from './runtime-client' + +export type ProjectCreateTarget = { + repoSelector: string + setup: ProjectHostSetup +} + +function getPresentStringFlag( + flags: Map<string, string | boolean>, + name: string +): string | undefined { + if (!flags.has(name)) { + return undefined + } + const value = flags.get(name) + if (typeof value === 'string' && value.length > 0) { + return value + } + throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`) +} + +export function hasWorkspaceProjectTarget(flags: Map<string, string | boolean>): boolean { + return flags.has('project') || flags.has('host') || flags.has('project-host-setup') +} + +export function assertWorkspaceTargetFlagsCompatible(flags: Map<string, string | boolean>): void { + const hasProjectTarget = hasWorkspaceProjectTarget(flags) + if (flags.has('repo') && hasProjectTarget) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose either --repo or project target flags, not both.' + ) + } + if (flags.has('host') && !flags.has('project') && !flags.has('project-host-setup')) { + throw new RuntimeClientError( + 'invalid_argument', + '--host requires --project unless --project-host-setup is provided.' + ) + } +} + +export async function resolveProjectCreateRepoSelector( + flags: Map<string, string | boolean>, + client: RuntimeClient +): Promise<string | undefined> { + return (await resolveProjectCreateTarget(flags, client))?.repoSelector +} + +export async function resolveProjectCreateTarget( + flags: Map<string, string | boolean>, + client: RuntimeClient +): Promise<ProjectCreateTarget | undefined> { + const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup') + const projectId = getPresentStringFlag(flags, 'project') + const hostId = getPresentStringFlag(flags, 'host') + if (!projectHostSetupId && !projectId && !hostId) { + return undefined + } + const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list') + const setup = result.result.setups.find((candidate) => { + if (candidate.setupState !== 'ready') { + return false + } + if (projectHostSetupId) { + return candidate.id === projectHostSetupId + } + return ( + candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId) + ) + }) + if (!setup) { + throw new RuntimeClientError( + 'invalid_argument', + projectHostSetupId + ? `Project host setup is not ready or was not found: ${projectHostSetupId}` + : `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}` + ) + } + return { + repoSelector: `id:${setup.repoId}`, + setup + } +} diff --git a/src/main/automations/headless-dispatch.ts b/src/main/automations/headless-dispatch.ts new file mode 100644 index 00000000000..1280ca5d0ae --- /dev/null +++ b/src/main/automations/headless-dispatch.ts @@ -0,0 +1,71 @@ +import type { + Automation, + AutomationRun, + AutomationRunOutputSnapshot +} from '../../shared/automations-types' +import type { AutomationRunTargetResult } from './run-target-resolution' + +const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 + +export type HeadlessAutomationDispatchLaunch = { + workspaceId: string + workspaceDisplayName?: string | null + terminalSessionId: string | null + completion?: Promise<{ + status: 'completed' | 'dispatch_failed' + outputSnapshot?: AutomationRunOutputSnapshot | null + error?: string | null + }> +} + +export type HeadlessAutomationDispatcher = (request: { + automation: Automation + run: AutomationRun + target: Extract<AutomationRunTargetResult, { ok: true }> +}) => Promise<HeadlessAutomationDispatchLaunch> + +export function createHeadlessAutomationOutputSnapshotBuffer(): { + append: (chunk: string) => void + snapshot: () => AutomationRunOutputSnapshot | null +} { + const chunks: string[] = [] + let totalChars = 0 + let truncated = false + + return { + append(chunk): void { + if (!chunk) { + return + } + chunks.push(chunk) + totalChars += chunk.length + let overflowChars = totalChars - MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS + while (overflowChars > 0 && chunks.length > 0) { + const firstChunk = chunks[0]! + if (firstChunk.length <= overflowChars) { + chunks.shift() + totalChars -= firstChunk.length + overflowChars -= firstChunk.length + truncated = true + continue + } + chunks[0] = firstChunk.slice(overflowChars) + totalChars -= overflowChars + truncated = true + overflowChars = 0 + } + }, + snapshot(): AutomationRunOutputSnapshot | null { + const content = chunks.join('').trim() + if (!content) { + return null + } + return { + format: 'plain_text', + content, + capturedAt: Date.now(), + truncated + } + } + } +} diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts new file mode 100644 index 00000000000..417737a9960 --- /dev/null +++ b/src/main/automations/run-target-resolution.ts @@ -0,0 +1,99 @@ +import type { Store } from '../persistence' +import type { Automation } from '../../shared/automations-types' +import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import type { ProjectHostSetup, Repo } from '../../shared/types' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' + +export type AutomationRunTargetResult = + | { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup } + | { ok: false; error: string } + +type AutomationRunTargetOptions = { + allowRemoteHostScheduling?: boolean +} + +function getLegacyPrecheckCwd(store: Store, automation: Automation): string | null { + if (automation.workspaceMode === 'existing') { + const parsed = automation.workspaceId + ? splitWorktreeIdForFilesystem(automation.workspaceId) + : null + return parsed?.worktreePath ?? null + } + return store.getRepo(getAutomationLegacyRepoId(automation))?.path ?? null +} + +export function resolveAutomationRunTarget( + store: Store, + automation: Automation, + options: AutomationRunTargetOptions = {} +): AutomationRunTargetResult { + const context = automation.runContext ?? null + if (!context) { + const repo = store.getRepo(getAutomationLegacyRepoId(automation)) + const cwd = getLegacyPrecheckCwd(store, automation) + if (!repo || !cwd) { + return { ok: false, error: 'Automation run target is no longer available.' } + } + return { ok: true, cwd, repo } + } + const parsedHost = parseExecutionHostId(context.hostId) + if ( + parsedHost?.kind === 'runtime' && + (!options.allowRemoteHostScheduling || automation.schedulerOwner !== 'remote_host_service') + ) { + return { + ok: false, + error: + 'Remote-server automation scheduling is not available from this Orca client yet. Run this automation on the remote server or update Orca when durable remote scheduling is available.' + } + } + + const setup = 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 = 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 } +} diff --git a/src/main/automations/run-usage-collection.ts b/src/main/automations/run-usage-collection.ts new file mode 100644 index 00000000000..ab1233e5765 --- /dev/null +++ b/src/main/automations/run-usage-collection.ts @@ -0,0 +1,99 @@ +import type { Automation, AutomationRun, AutomationRunUsage } from '../../shared/automations-types' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' + +function createUnavailableAutomationUsage( + collectedAt: number, + provider: AutomationRunUsage['provider'], + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string +): AutomationRunUsage { + return { + status: 'unavailable', + provider, + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + } +} + +function getAutomationUsageProvider( + automation: Automation | undefined +): AutomationRunUsage['provider'] { + if (automation?.agentId === 'codex') { + return 'codex' + } + if (automation?.agentId === 'claude') { + return 'claude' + } + return null +} + +export async function collectAutomationRunUsage({ + automation, + run, + claudeUsage, + codexUsage +}: { + automation: Automation | undefined + run: AutomationRun + claudeUsage: ClaudeUsageStore | null + codexUsage: CodexUsageStore | null +}): Promise<AutomationRunUsage> { + const collectedAt = Date.now() + const unavailable = ( + provider: AutomationRunUsage['provider'], + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => + createUnavailableAutomationUsage(collectedAt, provider, unavailableReason, unavailableMessage) + + if (!automation || run.status !== 'completed') { + return unavailable( + getAutomationUsageProvider(automation), + 'run_not_finished', + 'Usage is only collected for completed automation runs.' + ) + } + if (automation.executionTargetType === 'ssh') { + return unavailable( + getAutomationUsageProvider(automation), + 'remote_usage_unavailable', + 'Remote automation usage is not available from local usage logs.' + ) + } + if (automation.agentId === 'claude') { + if (!claudeUsage) { + return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.') + } + return claudeUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + if (automation.agentId === 'codex') { + if (!codexUsage) { + return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.') + } + return codexUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.') +} diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 553674a9c34..37d0ee7fe7e 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() @@ -129,4 +169,65 @@ describe('AutomationService prechecks', () => { await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull() expect(runAutomationPrecheckMock).not.toHaveBeenCalled() }) + + it('honors scheduled prechecks before headless dispatch', async () => { + vi.setSystemTime(new Date('2026-05-12T08:59:00Z')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Conditional remote check', + prompt: 'Check the repo', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00Z').getTime() + }) + runAutomationPrecheckMock.mockResolvedValue({ + command: 'test -f ready', + exitCode: 1, + timedOut: false, + durationMs: 5, + stdout: '', + stderr: 'missing', + stdoutTruncated: false, + stderrTruncated: false, + error: null, + startedAt: Date.now(), + completedAt: Date.now() + }) + const headlessDispatcher = vi.fn() + const service = new AutomationService(store, { + tickMs: 60_000, + allowRemoteHostScheduling: true, + headlessDispatcher + }) + const run = store.createAutomationRun(automation, Date.now(), 'scheduled') + const requestHeadlessDispatch = ( + service as unknown as { + requestHeadlessDispatch: ( + automationArg: typeof automation, + runArg: typeof run, + targetArg: { ok: true; cwd: string; repo: Repo } + ) => Promise<unknown> + } + ).requestHeadlessDispatch.bind(service) + + await requestHeadlessDispatch(automation, run, { + ok: true, + cwd: '/repo', + repo: store.getRepo('r1')! + }) + + expect(headlessDispatcher).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + status: 'skipped_precheck', + error: 'Precheck exited with code 1.' + }) + }) }) diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 58651ceda6e..237ee7b91c0 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import type { Repo } from '../../shared/types' +import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { AutomationService } from './service' const testState = { dir: '' } @@ -124,6 +125,227 @@ 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('skips runtime-owned automations before desktop renderer dispatch', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + const runtimeHostId = toRuntimeExecutionHostId('gpu-server') + store.addRepo(makeRepo({ executionHostId: runtimeHostId })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Remote check', + prompt: 'Check the remote repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: runtimeHostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }, + 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).toContain('Remote-server automation scheduling is not available') + expect(send).not.toHaveBeenCalled() + }) + + it('dispatches remote-host scheduled automations when service runs in serve mode', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + const runtimeHostId = toRuntimeExecutionHostId('gpu-server') + store.addRepo(makeRepo({ executionHostId: runtimeHostId })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Remote check', + prompt: 'Check the remote repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: runtimeHostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }, + 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, + allowRemoteHostScheduling: true + }) + service.setWebContents({ + isDestroyed: () => false, + send + } as never) + service.setRendererReady() + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('dispatching') + expect(send).toHaveBeenCalledWith( + 'automations:dispatchRequested', + expect.objectContaining({ + automation: expect.objectContaining({ schedulerOwner: 'remote_host_service' }), + run: expect.objectContaining({ id: run.id, status: 'dispatching' }) + }) + ) + }) + + it('dispatches remote-host automations headlessly when no renderer is available', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + const runtimeHostId = toRuntimeExecutionHostId('gpu-server') + store.addRepo(makeRepo({ executionHostId: runtimeHostId })) + const setup = store.getProjectHostSetups()[0]! + const automation = store.createAutomation({ + name: 'Remote check', + prompt: 'Check the remote repo', + agentId: 'claude', + projectId: 'r1', + runContext: { + kind: 'workspace-run', + projectId: setup.projectId, + hostId: runtimeHostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path + }, + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const service = new AutomationService(store, { + tickMs: 60_000, + allowRemoteHostScheduling: true, + headlessDispatcher: vi.fn().mockResolvedValue({ + workspaceId: 'remote-wt-1', + workspaceDisplayName: 'Remote automation', + terminalSessionId: 'remote-tab-1', + completion: Promise.resolve({ + status: 'completed', + outputSnapshot: { + format: 'plain_text', + content: 'Done.', + capturedAt: Date.now(), + truncated: false + }, + error: null + }) + }) + }) + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('dispatched') + expect(run.workspaceId).toBe('remote-wt-1') + expect(run.workspaceDisplayName).toBe('Remote automation') + expect(run.terminalSessionId).toBe('remote-tab-1') + await vi.waitFor(() => + expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ + status: 'completed', + workspaceId: 'remote-wt-1', + terminalSessionId: 'remote-tab-1', + outputSnapshot: expect.objectContaining({ content: 'Done.' }) + }) + ) + }) + 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..bd6e7fd534e 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -6,13 +6,18 @@ import type { AutomationDispatchResult, AutomationPrecheckResult, AutomationRun, - AutomationRunStatus, - AutomationRunUsage + AutomationRunStatus } from '../../shared/automations-types' 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 { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' +import { collectAutomationRunUsage } from './run-usage-collection' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import { + didAutomationPrecheckPass, + formatAutomationPrecheckFailure +} from '../../shared/automation-precheck' const DEFAULT_TICK_MS = 60 * 1000 @@ -25,15 +30,25 @@ export class AutomationService { private evaluating = false private readonly claudeUsage: ClaudeUsageStore | null private readonly codexUsage: CodexUsageStore | null + private readonly allowRemoteHostScheduling: boolean + private readonly headlessDispatcher: HeadlessAutomationDispatcher | null constructor( store: Store, - opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {} + opts: { + tickMs?: number + claudeUsage?: ClaudeUsageStore + codexUsage?: CodexUsageStore + allowRemoteHostScheduling?: boolean + headlessDispatcher?: HeadlessAutomationDispatcher + } = {} ) { this.store = store this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS this.claudeUsage = opts.claudeUsage ?? null this.codexUsage = opts.codexUsage ?? null + this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false + this.headlessDispatcher = opts.headlessDispatcher ?? null } setWebContents(webContents: WebContents | null): void { @@ -87,8 +102,10 @@ export class AutomationService { if (run.trigger !== 'scheduled' || !automation.precheck) { return null } - const cwd = this.getPrecheckCwd(automation) - if (!cwd) { + const target = resolveAutomationRunTarget(this.store, automation, { + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) + if (!target.ok) { return { command: automation.precheck.command, exitCode: null, @@ -98,7 +115,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 +124,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 } }) } @@ -124,7 +141,12 @@ export class AutomationService { if (run.usage) { return run } - const usage = await this.collectRunUsage(run) + const usage = await collectAutomationRunUsage({ + automation: this.store.listAutomations().find((entry) => entry.id === run.automationId), + run, + claudeUsage: this.claudeUsage, + codexUsage: this.codexUsage + }) return this.store.updateAutomationRun({ runId: run.id, status: run.status, @@ -135,83 +157,6 @@ export class AutomationService { }) } - private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> { - const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId) - const collectedAt = Date.now() - const unavailable = ( - provider: AutomationRunUsage['provider'], - unavailableReason: AutomationRunUsage['unavailableReason'], - unavailableMessage: string - ): AutomationRunUsage => ({ - status: 'unavailable', - provider, - model: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - reasoningOutputTokens: null, - totalTokens: null, - estimatedCostUsd: null, - estimatedCostSource: null, - providerSessionId: null, - attribution: null, - collectedAt, - unavailableReason, - unavailableMessage - }) - - if (!automation || run.status !== 'completed') { - return unavailable( - automation?.agentId === 'codex' - ? 'codex' - : automation?.agentId === 'claude' - ? 'claude' - : null, - 'run_not_finished', - 'Usage is only collected for completed automation runs.' - ) - } - if (automation.executionTargetType === 'ssh') { - return unavailable( - automation.agentId === 'codex' - ? 'codex' - : automation.agentId === 'claude' - ? 'claude' - : null, - 'remote_usage_unavailable', - 'Remote automation usage is not available from local usage logs.' - ) - } - if (automation.agentId === 'claude') { - if (!this.claudeUsage) { - return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.') - } - return this.claudeUsage.getAutomationRunUsage({ - worktreeId: run.workspaceId, - terminalSessionId: run.terminalSessionId, - startedAt: run.startedAt, - completedAt: collectedAt - }) - } - if (automation.agentId === 'codex') { - if (!this.codexUsage) { - return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.') - } - return this.codexUsage.getAutomationRunUsage({ - worktreeId: run.workspaceId, - terminalSessionId: run.terminalSessionId, - startedAt: run.startedAt, - completedAt: collectedAt - }) - } - return unavailable( - null, - 'provider_unsupported', - 'This agent does not report usage to Orca yet.' - ) - } - private async evaluateDueRuns(): Promise<void> { if (this.evaluating) { return @@ -230,16 +175,6 @@ export class AutomationService { } } - private getPrecheckCwd(automation: Automation): string | null { - if (automation.workspaceMode === 'existing') { - const parsed = automation.workspaceId - ? splitWorktreeIdForFilesystem(automation.workspaceId) - : null - return parsed?.worktreePath ?? null - } - return this.store.getRepo(automation.projectId)?.path ?? null - } - private async evaluateAutomation(automation: Automation, now: number): Promise<void> { const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now) if (scheduledFor === null) { @@ -267,8 +202,22 @@ export class AutomationService { automation: Automation, run: AutomationRun ): Promise<AutomationRun> { + const target = resolveAutomationRunTarget(this.store, automation, { + allowRemoteHostScheduling: this.allowRemoteHostScheduling + }) + 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) { + if (this.headlessDispatcher) { + return await this.requestHeadlessDispatch(automation, run, target) + } return this.store.updateAutomationRun({ runId: run.id, status: 'skipped_unavailable', @@ -286,6 +235,70 @@ export class AutomationService { webContents.send('automations:dispatchRequested', payload) return updated } + + private async requestHeadlessDispatch( + automation: Automation, + run: AutomationRun, + target: Extract<AutomationRunTargetResult, { ok: true }> + ): Promise<AutomationRun> { + const precheckResult = + run.trigger === 'scheduled' && automation.precheck + ? await this.runPrecheck(automation.id, run.id) + : null + if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { + return this.store.updateAutomationRun({ + runId: run.id, + status: 'skipped_precheck', + workspaceId: automation.workspaceId, + precheckResult, + error: formatAutomationPrecheckFailure(precheckResult) + }) + } + try { + const launch = await this.headlessDispatcher!({ automation, run, target }) + const updated = this.store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + error: null + }) + if (launch.completion) { + void launch.completion + .then((completion) => + this.markDispatchResult({ + runId: run.id, + status: completion.status, + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + precheckResult, + outputSnapshot: completion.outputSnapshot ?? null, + error: completion.error ?? null + }) + ) + .catch((error) => + this.markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + error: error instanceof Error ? error.message : String(error) + }) + ) + } + return updated + } catch (error) { + return this.store.updateAutomationRun({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: automation.workspaceId, + error: error instanceof Error ? error.message : String(error) + }) + } + } } function isFinalRunStatus(status: AutomationRunStatus): boolean { diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index a6404ed0220..8d2f8e9ad96 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -42,7 +42,12 @@ describe('daemon health socket listener cleanup', () => { const result = healthCheckDaemon(socketPath, tokenPath) socket.emit('connect') - socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n')) + socket.emit( + 'data', + Buffer.from( + '{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n' + ) + ) await expect(result).resolves.toBe(true) expect(socket.listenerCount('connect')).toBe(0) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 92220f8d548..6bf71a37f98 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -74,15 +74,36 @@ describe('daemon health', () => { }) it('passes when a daemon answers ping', async () => { + const ptySpawnHealthCheck = vi.fn(async () => {}) const server = new DaemonServer({ socketPath, tokenPath, + ptySpawnHealthCheck, spawnSubprocess: () => createMockSubprocess() }) await server.start() try { await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) + expect(ptySpawnHealthCheck).toHaveBeenCalledOnce() + } finally { + await server.shutdown() + } + }) + + it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => { + const server = new DaemonServer({ + socketPath, + tokenPath, + ptySpawnHealthCheck: vi.fn(async () => { + throw new Error('stale node-pty helper') + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + try { + await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) } finally { await server.shutdown() } diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index a4e95ce72bf..8982e908fe9 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -137,7 +137,10 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis 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 } diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 14df83d33c5..4608fa71475 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -20,6 +20,7 @@ const { writeFileSyncMock, netConnectMock, forkMock, + checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -66,6 +67,7 @@ const { } }) + const checkDaemonHealthMock = vi.fn(async () => 'healthy') const healthCheckDaemonMock = vi.fn(async () => true) const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') @@ -101,6 +103,7 @@ const { writeFileSyncMock, netConnectMock, forkMock, + checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -172,6 +175,7 @@ vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ + checkDaemonHealth: checkDaemonHealthMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, @@ -268,6 +272,8 @@ async function importFresh() { setLocalPtyProviderMock.mockClear() unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() + checkDaemonHealthMock.mockClear() + checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() healthCheckDaemonMock.mockResolvedValue(true) getMacDaemonSystemResolverHealthMock.mockReset() @@ -1026,9 +1032,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { probeSocketExistsMock.mockImplementation( (p?: string) => p === '/fake/app/out/main/daemon-entry.js' ) - healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() getAppPathMock.mockReturnValue('/fake/app/out/main') + healthCheckDaemonMock.mockResolvedValue(false) await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( @@ -1069,8 +1075,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after readiness', async () => { - healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() + healthCheckDaemonMock.mockResolvedValue(false) await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( @@ -1124,8 +1130,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after startup error', async () => { - healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() + healthCheckDaemonMock.mockResolvedValue(false) await mod.initDaemonPtyProvider() const launcher = spawnerInstances[0].launcher as ( 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/daemon-server.ts b/src/main/daemon/daemon-server.ts index c41b9cee1db..8f76b0e391e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -11,6 +11,7 @@ import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' +import { checkPtySpawnHealth } from './pty-subprocess' import { PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -22,6 +23,7 @@ import { export type DaemonServerOptions = { socketPath: string tokenPath: string + ptySpawnHealthCheck?: () => Promise<void> spawnSubprocess: (opts: { sessionId: string cols: number @@ -45,6 +47,7 @@ export class DaemonServer { private host: TerminalHost private socketPath: string private tokenPath: string + private ptySpawnHealthCheck: () => Promise<void> private clients = new Map<string, ConnectedClient>() private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) @@ -62,6 +65,7 @@ export class DaemonServer { this.tokenPath = opts.tokenPath this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) + this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth } async start(): Promise<void> { @@ -385,6 +389,10 @@ export class DaemonServer { case 'systemResolverHealth': return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'ptySpawnHealth': + await this.ptySpawnHealthCheck() + return { healthy: true } + case 'shutdown': if (request.payload.killSessions) { this.host.dispose() diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index f589e21c13f..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 @@ -235,6 +236,75 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } +export async function checkPtySpawnHealth(): Promise<void> { + if (process.platform !== 'darwin') { + return + } + + ensureNodePtySpawnHelperExecutable() + preflightMacNodePtySpawnEnvironment() + + const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) + ? process.env.ORCA_USER_DATA_PATH + : getDefaultCwd() + + let proc: pty.IPty + try { + proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env: { + ...process.env, + TERM: 'xterm-256color' + } + }) + } catch (err) { + throw formatPtySpawnError(err, '/bin/sh', cwd) + } + + await new Promise<void>((resolve, reject) => { + let settled = false + let exitDisposable: { dispose(): void } | undefined + const finish = (error?: Error, opts?: { kill?: boolean }): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + exitDisposable?.dispose() + if (opts?.kill) { + try { + proc.kill() + } catch { + // Best-effort cleanup for a short-lived health probe. + } + } + if (error) { + reject(error) + return + } + resolve() + } + const timer = setTimeout(() => { + finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { + kill: true + }) + }, PTY_SPAWN_HEALTH_TIMEOUT_MS) + + // Why: ping only proves the daemon protocol is alive. A real short-lived + // PTY spawn catches stale node-pty helper paths captured by this process. + exitDisposable = proc.onExit(({ exitCode }) => { + if (exitCode === 0) { + finish() + return + } + finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) + }) + }) +} + function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 1b6883ef837..a64e7cf2c37 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -201,6 +201,11 @@ export type SystemResolverHealthRequest = { type: 'systemResolverHealth' } +export type PtySpawnHealthRequest = { + id: string + type: 'ptySpawnHealth' +} + export type GetSnapshotRequest = { id: string type: 'getSnapshot' @@ -260,6 +265,7 @@ export type DaemonRequest = | ShutdownRequest | PingRequest | SystemResolverHealthRequest + | PtySpawnHealthRequest | GetSnapshotRequest | TakePendingOutputRequest diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts index 8c0c7df7576..579b7e1f3e7 100644 --- a/src/main/git/repo-clone-path.ts +++ b/src/main/git/repo-clone-path.ts @@ -14,6 +14,21 @@ export type ClaimedCloneTarget = { type CloneDirectoryIdentity = Pick<Stats, 'dev' | 'ino' | 'birthtimeMs'> +export function deriveCloneRepoNameFromUrl(url: string): string { + // Why: direct callers can supply URLs whose default git clone folder would + // be "." or ".."; rejecting them prevents parent/destination deletion. + const source = url.replace(/\.git\/?$/, '') + const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') + const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) + if (!repoName || repoName === '.' || repoName === '..') { + throw new Error('Invalid repository name derived from URL') + } + if (repoName.includes('/') || repoName.includes('\\')) { + throw new Error('Invalid repository name derived from URL') + } + return repoName +} + export function deriveValidatedClonePath(args: { url: string; destination: string }): string { if ( !args.destination || @@ -23,17 +38,7 @@ export function deriveValidatedClonePath(args: { url: string; destination: strin throw new Error('Clone destination must be an absolute path') } - // Why: direct callers can supply URLs whose default git clone folder would - // be "." or ".."; rejecting them prevents parent/destination deletion. - const source = args.url.replace(/\.git\/?$/, '') - const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') - const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) - if (!repoName || repoName === '.' || repoName === '..') { - throw new Error('Invalid repository name derived from URL') - } - if (repoName.includes('/') || repoName.includes('\\')) { - throw new Error('Invalid repository name derived from URL') - } + const repoName = deriveCloneRepoNameFromUrl(args.url) const clonePath = join(args.destination, repoName) const resolvedDestination = resolve(args.destination) diff --git a/src/main/index.ts b/src/main/index.ts index 943a60d5aae..4d2fe6804df 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -109,6 +109,7 @@ import { browserManager } from './browser/browser-manager' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' import { setUnreadDockBadgeCount } from './dock/unread-badge' import { AutomationService } from './automations/service' +import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { AgentAwakeService } from './agent-awake-service' import { getCrashBreadcrumbSnapshot, @@ -158,6 +159,16 @@ let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null + +function buildHeadlessAutomationWorkspaceName(runTitle: string, scheduledFor: number): string { + const slug = runTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) + const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13) + return `auto-${slug || 'run'}-${stamp}` +} let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null let crashReports: CrashReportStore | null = null @@ -1306,7 +1317,95 @@ app.whenReady().then(async () => { } }) runtime = runtimeService - automations = new AutomationService(store, { claudeUsage, codexUsage }) + automations = new AutomationService(store, { + claudeUsage, + codexUsage, + // Why: desktop clients may mirror remote-host automations, but only a + // server process should execute schedules owned by `remote_host_service`. + allowRemoteHostScheduling: isServeMode, + headlessDispatcher: isServeMode + ? async ({ automation, run, target }) => { + const terminalSnapshotLimit = 2_000 + let terminalHandle: string + let terminalSessionId: string | null = null + let workspaceId: string + let workspaceDisplayName: string | null = null + + if (automation.workspaceMode === 'new_per_run') { + const created = await runtimeService.createManagedWorktree({ + repoSelector: target.repo.id, + name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor), + baseBranch: automation.baseBranch ?? undefined, + setupDecision: 'inherit', + activate: false, + createdWithAgent: automation.agentId, + startupAgent: automation.agentId, + startupPrompt: automation.prompt, + telemetrySource: 'unknown' + }) + terminalHandle = created.startupTerminal?.handle ?? '' + terminalSessionId = created.startupTerminal?.tabId ?? null + workspaceId = created.worktree.id + workspaceDisplayName = created.worktree.displayName ?? null + if (!terminalHandle) { + throw new Error( + created.warning || + 'Automation workspace was created, but no agent terminal started.' + ) + } + } else { + if (!automation.workspaceId) { + throw new Error('The target workspace is no longer available.') + } + const terminal = await runtimeService.launchAgentTerminal( + `id:${automation.workspaceId}`, + { + agent: automation.agentId, + prompt: automation.prompt, + title: run.title + } + ) + terminalHandle = terminal.handle + terminalSessionId = terminal.tabId ?? null + workspaceId = terminal.worktreeId + const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`) + workspaceDisplayName = worktree.displayName ?? null + } + + const completion = (async () => { + const wait = await runtimeService.waitForTerminal(terminalHandle, { + condition: 'tui-idle' + }) + const read = await runtimeService.readTerminal(terminalHandle, { + limit: terminalSnapshotLimit + }) + const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() + snapshotBuffer.append(read.tail.join('\n')) + if (wait.satisfied) { + return { + status: 'completed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: null + } + } + return { + status: 'dispatch_failed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: wait.blockedReason + ? `Automation agent is blocked: ${wait.blockedReason}.` + : 'Automation agent did not report completion.' + } + })() + + return { + workspaceId, + workspaceDisplayName, + terminalSessionId, + completion + } + } + : undefined + }) runtimeService.setAutomationService(automations) runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) runtimeService.setCommitMessageAgentEnvironmentResolvers({ diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts index c0c641f262e..44c689b3aef 100644 --- a/src/main/ipc/github-work-item-args.ts +++ b/src/main/ipc/github-work-item-args.ts @@ -1,5 +1,9 @@ +import type { TaskSourceContext } from '../../shared/task-source-context' + export type WorkItemArgs = { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 2a5896dc0cc..9846bd5f3cc 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -8,6 +8,8 @@ const { getIssueMock, listIssuesMock, listWorkItemsMock, + listLabelsMock, + listAssignableUsersMock, getAuthenticatedViewerMock, mergePRMock, setPRAutoMergeMock, @@ -22,6 +24,8 @@ const { getIssueMock: vi.fn(), listIssuesMock: vi.fn(), listWorkItemsMock: vi.fn(), + listLabelsMock: vi.fn(), + listAssignableUsersMock: vi.fn(), getAuthenticatedViewerMock: vi.fn(), mergePRMock: vi.fn(), setPRAutoMergeMock: vi.fn(), @@ -46,6 +50,8 @@ vi.mock('../github/client', () => ({ getIssue: getIssueMock, listIssues: listIssuesMock, listWorkItems: listWorkItemsMock, + listLabels: listLabelsMock, + listAssignableUsers: listAssignableUsersMock, getAuthenticatedViewer: getAuthenticatedViewerMock, mergePR: mergePRMock, setPRAutoMerge: setPRAutoMergeMock, @@ -74,6 +80,7 @@ describe('registerGitHubHandlers', () => { badgeColor: string addedAt: number connectionId?: string | null + executionHostId?: string | null issueSourcePreference?: 'origin' | 'upstream' } let repos: FixtureRepo[] = [] @@ -91,6 +98,8 @@ describe('registerGitHubHandlers', () => { getIssueMock.mockReset() listIssuesMock.mockReset() listWorkItemsMock.mockReset() + listLabelsMock.mockReset() + listAssignableUsersMock.mockReset() getAuthenticatedViewerMock.mockReset() mergePRMock.mockReset() setPRAutoMergeMock.mockReset() @@ -156,6 +165,58 @@ 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('guards label metadata lookups with source host context', async () => { + listLabelsMock.mockResolvedValue(['bug']) + repos = [ + ...repos, + { + id: 'repo-ssh', + path: '/workspace/remote-repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'openclaw-2', + executionHostId: 'ssh:openclaw-2' + } + ] + registerGitHubHandlers(store as never, stats as never) + + await expect( + handlers['gh:listLabels'](null, { + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'ssh:openclaw-2', + repoId: 'repo-ssh' + } + }) + ).resolves.toEqual(['bug']) + + expect(listLabelsMock).toHaveBeenCalledWith('/workspace/remote-repo', undefined, 'openclaw-2') + }) + 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..87acce471ef 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 } @@ -275,10 +288,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) - ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => { - const repo = assertRegisteredRepo(args, store) - return getIssue(repo.path, args.number, repoConnectionId(repo)) - }) + ipcMain.handle( + 'gh:issue', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + } + ) => { + const repo = assertRegisteredRepo(args, store) + return getIssue(repo.path, args.number, repoConnectionId(repo)) + } + ) ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { const repo = assertRegisteredRepo(args, store) @@ -295,7 +319,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:createIssue', - (_event, args: { repoPath: string; title: string; body: string } & GitHubCreateIssueFields) => { + (_event, args: RepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields) => { const repo = assertRegisteredRepo(args, store) const fields = args.labels !== undefined || args.assignees !== undefined @@ -416,6 +440,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number headSha?: string prRepo?: GitHubOwnerRepo | null @@ -442,6 +468,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string @@ -470,6 +498,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi _event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean @@ -487,7 +517,16 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:resolveReviewThread', - async (_event, args: { repoPath: string; threadId: string; resolve: boolean }) => { + async ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + } + ) => { const repo = assertRegisteredRepo(args, store) // Why: thread resolve doesn't carry the PR number, so we cannot target // a specific cache entry. The renderer cache stores per-(repo, type, number) @@ -546,6 +585,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number commentId: number body: string @@ -687,6 +727,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null @@ -716,6 +758,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi event, args: { repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null prNumber: number enabled: boolean prRepo?: GitHubOwnerRepo | null @@ -743,7 +787,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:updatePRState', async ( event, - args: { repoPath: string; prNumber: number; updates: GitHubPullRequestStateUpdate } + args: RepoScopedArgs & { prNumber: number; updates: GitHubPullRequestStateUpdate } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -773,7 +817,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:rerunPRChecks', async ( _event, - args: { repoPath: string; prNumber: number; headSha?: string; failedOnly?: boolean } + args: RepoScopedArgs & { prNumber: number; headSha?: string; failedOnly?: boolean } ) => { const repo = assertRegisteredRepo(args, store) if ( @@ -794,7 +838,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:requestPRReviewers', - async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => { + async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { const repo = assertRegisteredRepo(args, store) const result = await requestPRReviewers( repo.path, @@ -814,7 +858,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:removePRReviewers', - async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => { + async (event, args: RepoScopedArgs & { prNumber: number; reviewers: string[] }) => { const repo = assertRegisteredRepo(args, store) const result = await removePRReviewers( repo.path, @@ -834,10 +878,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:updateIssue', - async ( - event, - args: { repoPath: string; repoId?: string; number: number; updates: GitHubIssueUpdate } - ) => { + async (event, args: RepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { const repo = assertRegisteredRepo(args, store) if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { return { ok: false, error: 'Invalid issue number' } @@ -863,6 +904,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -898,12 +940,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) - ipcMain.handle('gh:listLabels', (_event, args: { repoPath: string }) => { + ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => { const repo = assertRegisteredRepo(args, store) return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) - ipcMain.handle('gh:listAssignableUsers', (_event, args: { repoPath: string }) => { + ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => { const repo = assertRegisteredRepo(args, store) return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts new file mode 100644 index 00000000000..ace50f94068 --- /dev/null +++ b/src/main/ipc/gitlab.test.ts @@ -0,0 +1,177 @@ +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, getWorkItemByProjectRefMock } = vi.hoisted(() => ({ + ipcHandlers: new Map<string, (...args: unknown[]) => unknown>(), + listWorkItemsMock: vi.fn(), + getWorkItemByProjectRefMock: 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: getWorkItemByProjectRefMock, + 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') + }) + + it('resolves pasted URL lookups by repoId and source host context', async () => { + const remoteRepo = repo({ + id: 'repo-ssh', + path: '/ssh/orca', + connectionId: 'builder', + executionHostId: toSshExecutionHostId('builder') + }) + getWorkItemByProjectRefMock.mockResolvedValueOnce({ + type: 'issue', + number: 42, + title: 'Remote issue' + }) + registerGitLabHandlers(storeWithRepos([repo(), remoteRepo]) as Store) + + const handler = ipcHandlers.get('gitlab:workItemByPath') + await expect( + handler?.(null, { + repoPath: '/local/orca', + repoId: 'repo-ssh', + sourceContext: { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:stablyai/orca', + hostId: toSshExecutionHostId('builder'), + repoId: 'repo-ssh' + }, + host: 'gitlab.com', + path: 'stablyai/orca', + iid: 42, + type: 'issue' + }) + ).resolves.toMatchObject({ number: 42 }) + + expect(getWorkItemByProjectRefMock).toHaveBeenCalledWith( + '/ssh/orca', + { host: 'gitlab.com', path: 'stablyai/orca' }, + 42, + 'issue', + 'builder' + ) + }) +}) diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index c753c1de624..04fe0354ee8 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)) }) @@ -434,15 +493,14 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:workItemByPath', async ( _event, - args: { - repoPath: string + args: GitLabRepoSelectorArgs & { host: string path: string iid: number 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/preflight.test.ts b/src/main/ipc/preflight.test.ts index 1f845f01603..311affa5987 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -589,6 +589,31 @@ describe('preflight', () => { }) }) + it('returns no remote agents when the SSH connection is unavailable', async () => { + getActiveMultiplexerMock.mockReturnValue(null) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual([]) + }) + + it('returns no remote agents when the SSH connection is disposed', async () => { + const request = vi.fn() + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => true, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual([]) + expect(request).not.toHaveBeenCalled() + }) + it('detects agents from the selected WSL distro for a WSL workspace', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 15591d81750..82981552c5f 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -248,7 +248,9 @@ export async function refreshShellPathAndDetectAgents( export async function detectRemoteAgents(args: { connectionId: string }): Promise<string[]> { const mux = getActiveMultiplexer(args.connectionId) if (!mux || mux.isDisposed()) { - throw new Error(`No active SSH connection for "${args.connectionId}"`) + // Why: remote agent detection is passive UI polling. A disconnected host has + // no detectable agents until reconnect, but should not spam IPC errors. + return [] } const result = (await mux.request('preflight.detectAgents', { commands: KNOWN_AGENT_COMMANDS diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a165d4d3c45..94c41a23be7 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2831,6 +2831,43 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated') }) + it('returns idle process inspection results for detached SSH PTYs without a provider', async () => { + const provider = { + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + registerSshPtyProvider('ssh-1', provider as never) + registerPtyHandlers(mainWindow as never) + setPtyOwnership('remote-pty', 'ssh-1') + unregisterSshPtyProvider('ssh-1') + + await expect(handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })).resolves.toBe( + false + ) + await expect( + handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' }) + ).resolves.toBeNull() + expect(provider.hasChildProcesses).not.toHaveBeenCalled() + expect(provider.getForegroundProcess).not.toHaveBeenCalled() + }) + it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { const spawn = vi.fn(async () => ({ id: 'remote-pty' })) registerSshPtyProvider('ssh-1', { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9636c46215d..1e0afbda7ea 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -226,6 +226,13 @@ function getProviderForPty(ptyId: string): IPtyProvider { return getProvider(connectionId) } +function hasPtyProviderForInspection(ptyId: string): boolean { + // Why: process inspection is background polling; disconnected SSH hosts should + // read as idle instead of surfacing repeated IPC errors. + const connectionId = ptyOwnership.get(ptyId) + return connectionId == null || sshProviders.has(connectionId) +} + function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId } @@ -2801,6 +2808,9 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:hasChildProcesses', async (_event, args: { id: string }): Promise<boolean> => { + if (!hasPtyProviderForInspection(args.id)) { + return false + } return getProviderForPty(args.id).hasChildProcesses(args.id) } ) @@ -2808,6 +2818,9 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getForegroundProcess', async (_event, args: { id: string }): Promise<string | null> => { + if (!hasPtyProviderForInspection(args.id)) { + return null + } return getProviderForPty(args.id).getForegroundProcess(args.id) } ) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 6992d21939c..154255d25ed 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -38,12 +38,25 @@ const { mockGitProvider: { isGitRepo: vi.fn().mockReturnValue(true), isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + clone: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + getHostPlatform: vi.fn().mockReturnValue({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix', + commandDialect: 'posix', + pathSeparator: '/', + pathDelimiter: ':' + }) }, mockFilesystemProvider: { readDir: vi.fn().mockResolvedValue([]), readFile: vi.fn().mockRejectedValue(new Error('not found')), - stat: vi.fn().mockRejectedValue(new Error('not found')) + stat: vi.fn().mockRejectedValue(new Error('not found')), + createDir: vi.fn().mockResolvedValue(undefined), + createDirNoClobber: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) }, mockMultiplexer: { request: vi.fn(), @@ -89,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 @@ -98,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 @@ -107,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 @@ -809,6 +822,26 @@ describe('repos:addRemote', () => { mockStore.updateRepo.mockReset() mockGitProvider.isGitRepoAsync.mockReset() mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) + mockGitProvider.exec.mockReset() + mockGitProvider.exec.mockResolvedValue({ stdout: '', stderr: '' }) + mockGitProvider.clone.mockReset() + mockGitProvider.clone.mockResolvedValue({ stdout: '', stderr: '' }) + mockGitProvider.getHostPlatform.mockReset() + mockGitProvider.getHostPlatform.mockReturnValue({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix', + commandDialect: 'posix', + pathSeparator: '/', + pathDelimiter: ':' + }) + mockFilesystemProvider.stat.mockReset() + mockFilesystemProvider.stat.mockRejectedValue(new Error('not found')) + mockFilesystemProvider.createDirNoClobber.mockReset() + mockFilesystemProvider.createDirNoClobber.mockResolvedValue(undefined) + mockFilesystemProvider.deletePath.mockReset() + mockFilesystemProvider.deletePath.mockResolvedValue(undefined) mockMultiplexer.request.mockReset() mockMultiplexer.notify.mockReset() gitSpawnMock.mockReset() @@ -827,6 +860,14 @@ describe('repos:addRemote', () => { expect(handlers.has('repos:addRemote')).toBe(true) }) + it('registers the repos:cloneRemote handler', () => { + expect(handlers.has('repos:cloneRemote')).toBe(true) + }) + + it('registers the repos:createRemote handler', () => { + expect(handlers.has('repos:createRemote')).toBe(true) + }) + it('creates a remote repo with connectionId', async () => { const result = await handlers.get('repos:addRemote')!(null, { connectionId: 'conn-1', @@ -841,7 +882,8 @@ describe('repos:addRemote', () => { displayName: 'project', badgeColor: DEFAULT_REPO_BADGE_COLOR, externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder' }) ) expect(result).toHaveProperty('repo.id') @@ -865,6 +907,376 @@ describe('repos:addRemote', () => { expect(result).toHaveProperty('repo.displayName', 'My Server Repo') }) + it('clones a repo on an SSH target and registers the cloned path', async () => { + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(mockFilesystemProvider.createDir).toHaveBeenCalledWith('/home/user') + expect(mockGitProvider.clone).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/user', + expect.objectContaining({ + signal: expect.any(AbortSignal), + timeoutMs: 10 * 60_000, + onProgress: expect.any(Function) + }) + ) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/orca', + connectionId: 'conn-1', + kind: 'git', + displayName: 'orca', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: false + }) + ) + expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/home/user/orca' + }) + expect(result).toHaveProperty('path', '/home/user/orca') + expect(result).toHaveProperty('connectionId', 'conn-1') + }) + + it('forwards SSH clone progress through the existing clone progress event', async () => { + mockGitProvider.clone.mockImplementationOnce( + async ( + _args: string[], + _cwd: string, + options?: { onProgress?: (progress: { phase: string; percent: number }) => void } + ) => { + options?.onProgress?.({ phase: 'Receiving objects', percent: 42 }) + return { stdout: '', stderr: '' } + } + ) + + await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:clone-progress', { + phase: 'Receiving objects', + percent: 42 + }) + }) + + it('returns an existing SSH repo instead of cloning the same target again', async () => { + const existing = { + id: 'existing-id', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(result).toBe(existing) + expect(mockGitProvider.clone).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('upgrades an existing SSH folder repo after cloning into that path', async () => { + const existing = { + id: 'existing-folder', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'folder' + } + const updated = { ...existing, kind: 'git' } + mockStore.getRepos.mockReturnValue([existing]) + mockStore.updateRepo.mockReturnValue(updated) + + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(mockGitProvider.clone).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/user', + expect.objectContaining({ + signal: expect.any(AbortSignal), + timeoutMs: 10 * 60_000, + onProgress: expect.any(Function) + }) + ) + expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toBe(updated) + }) + + it('does not delete a fresh SSH clone target after git clone fails', async () => { + mockGitProvider.clone.mockRejectedValueOnce(new Error('repository not found')) + mockFilesystemProvider.stat.mockRejectedValueOnce(new Error('not found')) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('repository not found') + + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() + }) + + it('rejects concurrent SSH clones to the same destination', async () => { + let releaseClone!: () => void + mockGitProvider.clone.mockImplementationOnce( + async () => + new Promise<{ stdout: string; stderr: string }>((resolve) => { + releaseClone = () => resolve({ stdout: '', stderr: '' }) + }) + ) + + const firstClone = handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('A clone is already in progress for this SSH destination') + + releaseClone() + await firstClone + }) + + it('resolves SSH clone destinations under home before validating the path', async () => { + mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) + + await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '~/projects' + }) + + expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { + path: '~/projects' + }) + expect(mockGitProvider.clone).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/ubuntu/projects', + expect.any(Object) + ) + }) + + it('does not clean up a pre-existing SSH clone target after git clone fails', async () => { + mockGitProvider.clone.mockRejectedValueOnce(new Error('destination already exists')) + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('destination already exists') + + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() + }) + + it('aborts an active SSH clone and reports the abort without deleting pre-existing targets', async () => { + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + mockGitProvider.clone.mockImplementationOnce( + async (_args: string[], _cwd: string, options?: { signal?: AbortSignal }) => + new Promise<{ stdout: string; stderr: string }>((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted by test'))) + }) + ) + + const clonePromise = handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + + await expect(clonePromise).rejects.toThrow('Clone aborted') + const options = mockGitProvider.clone.mock.calls[0][2] as { signal: AbortSignal } + expect(options.signal.aborted).toBe(true) + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() + }) + + it('rejects SSH clone destinations that are not absolute host paths', async () => { + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: 'relative/path' + }) + ).rejects.toThrow('Clone destination must be an absolute path on the SSH host') + + expect(mockGitProvider.clone).not.toHaveBeenCalled() + }) + + it('creates a new git project on an SSH target', async () => { + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/created') + expect(mockGitProvider.exec).toHaveBeenCalledWith(['init'], '/home/user/created') + expect(mockGitProvider.exec).toHaveBeenCalledWith( + ['commit', '--allow-empty', '-m', 'Initial commit'], + '/home/user/created' + ) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/created', + connectionId: 'conn-1', + kind: 'git', + displayName: 'created', + externalWorktreeVisibility: 'hide' + }) + ) + expect(result).toHaveProperty('repo.path', '/home/user/created') + expect(result).toHaveProperty('repo.connectionId', 'conn-1') + }) + + it('resolves SSH create parents under home before validating the path', async () => { + mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '~/projects', + name: 'created', + kind: 'folder' + }) + + expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { + path: '~/projects' + }) + expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith( + '/home/ubuntu/projects/created' + ) + expect(result).toHaveProperty('repo.path', '/home/ubuntu/projects/created') + }) + + it('creates a new folder project on an SSH target without git init', async () => { + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'notes', + kind: 'folder' + }) + + expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/notes') + expect(mockGitProvider.exec).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/notes', + connectionId: 'conn-1', + kind: 'folder', + displayName: 'notes' + }) + ) + expect(result).toHaveProperty('repo.kind', 'folder') + }) + + it('rejects SSH create parent paths that are not absolute host paths', async () => { + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: 'relative/path', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ error: 'Parent directory must be an absolute path on the SSH host' }) + expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled() + expect(mockGitProvider.exec).not.toHaveBeenCalled() + }) + + it('rejects non-empty existing SSH create targets', async () => { + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + mockFilesystemProvider.readDir.mockResolvedValueOnce([ + { name: 'package.json', isDirectory: false, isSymlink: false } + ]) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ + error: '"created" already exists at this location and is not empty.' + }) + expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled() + expect(mockGitProvider.exec).not.toHaveBeenCalled() + }) + + it('removes a newly created SSH directory when git init fails', async () => { + mockGitProvider.exec.mockRejectedValueOnce(new Error('git init failed')) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ error: 'Failed to initialize git repository: git init failed' }) + expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created', true) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('preserves an existing empty SSH directory and removes only .git when commit fails', async () => { + mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) + mockFilesystemProvider.readDir.mockResolvedValueOnce([]) + mockGitProvider.exec + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('Please tell me who you are')) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '/home/user', + name: 'created', + kind: 'git' + }) + + expect(result).toEqual({ + error: + 'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.' + }) + expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created/.git', true) + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalledWith('/home/user/created', true) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('returns existing repo if same connectionId and path already added', async () => { const existing = { id: 'existing-id', @@ -886,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', @@ -1083,6 +1442,31 @@ describe('repos:addRemote', () => { expect(result).toHaveProperty('repo.path', '/home/ubuntu/subdir') }) + it('returns an existing SSH repo when a selected subdirectory resolves to the repo root', async () => { + const existing = { + id: 'existing-id', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([existing]) + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ + isRepo: true, + rootPath: '/home/user/orca' + }) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/orca/src' + }) + + expect(result).toEqual({ repo: existing }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('ignores SSH target label when custom displayName is provided', async () => { mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' }) mockStore.getSshTarget.mockReturnValueOnce({ @@ -1191,7 +1575,8 @@ describe('repos:add + repos:clone', () => { path: '/tmp/from-add', kind: 'git', externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder' }) ) expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide') @@ -1260,7 +1645,10 @@ describe('repos:add + repos:clone', () => { destination }) - expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' }) + expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) expect(result).toEqual(upgraded) expect(result).toHaveProperty('badgeColor', '#8b5cf6') expect(mockStore.addRepo).not.toHaveBeenCalled() @@ -1425,6 +1813,33 @@ describe('repos:add + repos:clone', () => { expect(existsSync(clonePath)).toBe(false) }) + it('reports the full fatal clone error when stderr includes progress fragments', async () => { + const destination = await createTempRoot() + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + proc.stderr.emit( + 'data', + Buffer.from( + "Cloning into 'orca'...\rfatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n" + ) + ) + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow( + `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.` + ) + }) + it('removes an owned fresh clone target when git spawn emits an error', async () => { const destination = await createTempRoot() const clonePath = join(destination, 'orca') diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 10f7da3815f..6a0205a061a 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -13,6 +13,14 @@ import type { ProjectGroup, FolderWorkspace, ProjectGroupImportResult, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, NestedRepoScanResult, BaseRefDefaultResult, SparsePreset @@ -23,16 +31,21 @@ 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' import { access, mkdir, readdir, rm } from 'fs/promises' import { gitExecFileAsync, gitSpawn } from '../git/runner' import { isAbsolute, join, posix } from 'path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { cleanupClaimedCloneTarget, claimCloneTarget, + deriveCloneRepoNameFromUrl, deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' @@ -65,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 @@ -104,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) { @@ -112,6 +316,278 @@ function getRemoteRepoFolderName(remotePath: string): string { return trimmed.split(/[\\/]/).at(-1) || remotePath } +async function cloneRemoteRepo( + store: Store, + mainWindow: BrowserWindow, + args: { + connectionId: string + url: string + destination: string + } +): Promise<Repo> { + const gitProvider = getSshGitProvider(args.connectionId) + if (!gitProvider) { + throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) + } + const fsProvider = getSshFilesystemProvider(args.connectionId) + if (!fsProvider) { + throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) + } + const host = gitProvider.getHostPlatform?.() + if (!host) { + throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.') + } + const trimmedDestination = await resolveRemoteHomePath(args.connectionId, args.destination.trim()) + if (!isRuntimePathAbsolute(trimmedDestination, host.pathFlavor)) { + throw new Error('Clone destination must be an absolute path on the SSH host') + } + const repoName = deriveCloneRepoNameFromUrl(args.url.trim()) + const clonePath = joinRemotePath(host, trimmedDestination, repoName) + if (relativePathInsideRoot(trimmedDestination, clonePath) === null) { + throw new Error('Clone path must be inside the destination directory') + } + const clonePathKey = normalizeRuntimePathForComparison(clonePath) + const existing = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === clonePathKey + ) + }) + if (existing && !isFolderRepo(existing)) { + emitRepoAdded('clone_url', true) + return existing + } + + const remoteCloneKey = `${args.connectionId}:${clonePathKey}` + if (remoteCloneInFlightByPath.has(remoteCloneKey)) { + throw new Error('A clone is already in progress for this SSH destination') + } + const controller = new AbortController() + const metadata: ActiveRemoteCloneMetadata = { + connectionId: args.connectionId, + clonePath, + controller + } + activeRemoteClone = metadata + remoteCloneInFlightByPath.add(remoteCloneKey) + try { + // Why: local clone creates the typed parent before spawning git. SSH clone + // must match that behavior or a fresh remote parent surfaces as spawn ENOENT. + await fsProvider.createDir(trimmedDestination) + // Why: the SSH relay exposes argv-based git execution, not a shell. Use + // the repo folder name as the target so git creates it inside the chosen + // parent, and keep the same flag separator safety as local clone. + await gitProvider.clone( + ['clone', '--progress', '--', args.url.trim(), repoName], + trimmedDestination, + { + signal: controller.signal, + timeoutMs: 10 * 60_000, + onProgress: (progress) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('repos:clone-progress', progress) + } + } + } + ) + } catch (err) { + 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) { + activeRemoteClone = null + } + remoteCloneInFlightByPath.delete(remoteCloneKey) + } + if (existing && isFolderRepo(existing)) { + const updated = store.updateRepo(existing.id, { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) + if (updated) { + emitRepoAdded('clone_url', false) + getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { + rootPath: clonePath + }) + return updated + } + } + const result = await addRemoteRepoFromPath(store, { + connectionId: args.connectionId, + remotePath: clonePath, + kind: 'git', + setupMethod: 'cloned' + }) + if ('error' in result) { + throw new Error(result.error) + } + emitRepoAdded('clone_url', result.alreadyExisted) + return result.repo +} + +async function createRemoteRepo( + store: Store, + args: { + connectionId: string + parentPath: string + name: string + kind: 'git' | 'folder' + } +): Promise<{ repo: Repo } | { error: string }> { + const name = args.name?.trim() ?? '' + const parentPath = await resolveRemoteHomePath(args.connectionId, args.parentPath?.trim() ?? '') + const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' + if (!name) { + return { error: 'Name cannot be empty' } + } + if (/[\\/]/.test(name) || name === '.' || name === '..') { + return { error: 'Name cannot contain slashes or be "." / ".."' } + } + if (!parentPath) { + return { error: 'Parent directory is required' } + } + const gitProvider = getSshGitProvider(args.connectionId) + const fsProvider = getSshFilesystemProvider(args.connectionId) + if (!gitProvider || !fsProvider) { + return { error: `SSH connection "${args.connectionId}" not found or not connected` } + } + const host = gitProvider.getHostPlatform?.() + if (!host) { + return { error: 'SSH host platform is unavailable. Reconnect the SSH target before creating.' } + } + if (!isRuntimePathAbsolute(parentPath, host.pathFlavor)) { + return { error: 'Parent directory must be an absolute path on the SSH host' } + } + + const targetPath = joinRemotePath(host, parentPath, name) + if (relativePathInsideRoot(parentPath, targetPath) === null) { + return { error: 'Project path must be inside the parent directory' } + } + const targetPathKey = normalizeRuntimePathForComparison(targetPath) + const existing = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === targetPathKey + ) + }) + if (existing) { + emitRepoAdded('folder_picker', true) + return { repo: existing } + } + + let createdDir = false + let targetExists = false + try { + await fsProvider.stat(targetPath) + targetExists = true + } catch { + targetExists = false + } + + if (targetExists) { + try { + const entries = await fsProvider.readDir(targetPath) + if (entries.length > 0) { + return { error: `"${name}" already exists at this location and is not empty.` } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to read directory: ${message}` } + } + } else { + try { + await fsProvider.createDirNoClobber(targetPath) + createdDir = true + } catch (err) { + const raceWinner = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === targetPathKey + ) + }) + if (raceWinner) { + return { repo: raceWinner } + } + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to create directory: ${message}` } + } + } + + if (repoKind === 'git') { + let step: 'init' | 'commit' = 'init' + try { + await gitProvider.exec(['init'], targetPath) + step = 'commit' + await gitProvider.exec(['commit', '--allow-empty', '-m', 'Initial commit'], targetPath) + } catch (err) { + if (createdDir) { + await fsProvider.deletePath(targetPath, true).catch(() => undefined) + } else if (step === 'commit') { + await fsProvider + .deletePath(joinRemotePath(host, targetPath, '.git'), true) + .catch(() => undefined) + } + const message = err instanceof Error ? err.message : String(err) + if (step === 'commit' && /Please tell me who you are|user\.name|user\.email/i.test(message)) { + return { + error: + 'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.' + } + } + const stepLabel = + step === 'init' ? 'Failed to initialize git repository' : 'Failed to create initial commit' + return { error: `${stepLabel}: ${message}` } + } + } + + const raceWinner = store.getRepos().find((repo) => { + return ( + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === targetPathKey + ) + }) + if (raceWinner) { + emitRepoAdded('folder_picker', true) + return { repo: raceWinner } + } + + const result = await addRemoteRepoFromPath(store, { + connectionId: args.connectionId, + remotePath: targetPath, + kind: repoKind, + displayName: name + }) + if ('error' in result) { + return result + } + emitRepoAdded('folder_picker', result.alreadyExisted) + return { repo: result.repo } +} + +async function resolveRemoteHomePath(connectionId: string, path: string): Promise<string> { + if (path !== '~' && path !== '~/' && !path.startsWith('~/')) { + return path + } + const mux = getActiveMultiplexer(connectionId) + if (!mux) { + return path + } + try { + const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string } + return result.resolvedPath + } catch { + // Why: older relays may not support this yet; callers will surface the + // original path validation error instead of failing during resolution. + return path + } +} + type ActiveCloneMetadata = { path: string pathKey: string @@ -123,14 +599,22 @@ type ActiveCloneMetadata = { resolvePendingAbortCleanup: (() => void) | null } +type ActiveRemoteCloneMetadata = { + connectionId: string + clonePath: string + controller: AbortController +} + // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, // and a function-scoped variable would lose the reference to an in-flight clone. let activeClone: ActiveCloneMetadata | null = null +let activeRemoteClone: ActiveRemoteCloneMetadata | null = null let nextCloneGeneration = 1 const latestCloneGenerationByPath = new Map<string, number>() const pendingAbortCleanupByPath = new Map<string, Promise<void>>() const cloneInFlightByPath = new Map<string, Promise<void>>() +const remoteCloneInFlightByPath = new Set<string>() const activeNestedRepoScans = new Map<string, AbortController>() type CompletedNestedRepoScan = { scan: NestedRepoScanResult @@ -141,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(), @@ -169,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']), @@ -529,6 +1076,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') + ipcMain.removeHandler('projects:list') + ipcMain.removeHandler('projectHostSetups:list') + ipcMain.removeHandler('projectHostSetups:create') + ipcMain.removeHandler('projectHostSetups:setupExistingFolder') + ipcMain.removeHandler('projectHostSetups:update') + ipcMain.removeHandler('projectHostSetups:delete') ipcMain.removeHandler('projectGroups:list') ipcMain.removeHandler('projectGroups:create') ipcMain.removeHandler('projectGroups:update') @@ -546,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') @@ -554,6 +1108,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:searchBaseRefDetails') ipcMain.removeHandler('repos:addRemote') ipcMain.removeHandler('repos:create') + ipcMain.removeHandler('repos:createRemote') ipcMain.removeHandler('sparsePresets:list') ipcMain.removeHandler('sparsePresets:save') ipcMain.removeHandler('sparsePresets:remove') @@ -562,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()) @@ -829,6 +1483,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ...(args.connectionId ? { connectionId: args.connectionId } : {}), externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder', ...(group ? { projectGroupId: group.id, @@ -882,42 +1537,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v _event, args: { path: string; kind?: 'git' | 'folder' } ): Promise<{ repo: Repo } | { error: string }> => { - const repoKind = args.kind === 'folder' ? 'folder' : 'git' - if (repoKind === 'git' && !isGitRepo(args.path)) { - return { error: `Not a valid git repository: ${args.path}` } + const result = await addLocalRepoFromPath(store, args.path, args.kind) + 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 } } ) @@ -932,132 +1559,33 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v kind?: 'git' | 'folder' } ): Promise<{ repo: Repo } | { error: string }> => { - const gitProvider = getSshGitProvider(args.connectionId) - if (!gitProvider) { - return { error: `SSH connection "${args.connectionId}" not found or not connected` } + const result = await addRemoteRepoFromPath(store, args) + 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 } + } + ) - // Why: register the workspace root with the relay so mutating FS operations - // are scoped to this repo's path. Without this, the relay's path ACL would - // reject writes to the workspace after the first root is registered. - const mux = getActiveMultiplexer(args.connectionId) - if (mux) { - mux.notify('session.registerRoot', { rootPath: resolvedPath }) + ipcMain.handle( + 'repos:createRemote', + async ( + _event, + args: { + connectionId: string + parentPath: string + 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 } ) @@ -1249,7 +1777,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ...(repoKind === 'git' ? { externalWorktreeVisibility: 'hide' as const, - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'imported-existing-folder' as const } : {}) } @@ -1486,6 +2015,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v clone.process.kill() activeClone = null } + if (activeRemoteClone) { + activeRemoteClone.controller.abort() + activeRemoteClone = null + } }) ipcMain.handle( @@ -1560,19 +2093,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v const text = chunk.toString() stderrTail = (stderrTail + text).slice(-4096) - // Why: git progress lines use \r to overwrite in-place. Split on - // both \r and \n to find the latest progress fragment, then extract - // the phase name and percentage for the renderer. - const lines = text.split(/[\r\n]+/) - for (const line of lines) { - 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) - }) - } - } + // Why: git progress lines use \r to overwrite in-place; parse + // fragments the same way for local and SSH clone flows. + emitCloneProgressFromText(mainWindow, text) }) const finishClone = async ( @@ -1612,8 +2135,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } else if (code === 0) { resolve() } else { - const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' - reject(new Error(`Clone failed: ${lastLine}`)) + reject( + new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`) + ) } } @@ -1636,7 +2160,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) if (existing) { if (isFolderRepo(existing)) { - const updated = store.updateRepo(existing.id, { kind: 'git' }) + const updated = store.updateRepo(existing.id, { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) if (updated) { notifyReposChanged(mainWindow) // Why: folder→git upgrade is a real new git repo provisioning event. @@ -1658,7 +2185,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v addedAt: Date.now(), kind: 'git', externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false + externalWorktreeVisibilityLegacy: false, + projectHostSetupMethod: 'cloned' } store.addRepo(repo) @@ -1676,6 +2204,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + ipcMain.handle( + 'repos:cloneRemote', + async ( + _event, + args: { connectionId: string; url: string; destination: string } + ): Promise<Repo> => { + const repo = await cloneRemoteRepo(store, mainWindow, args) + notifyReposChanged(mainWindow) + return repo + } + ) + ipcMain.handle('repos:getGitUsername', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 993ed146d61..46dc429e13d 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -97,6 +97,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', @@ -115,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', @@ -159,6 +161,30 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(await list(null, undefined)).toEqual([]) }) + it('disconnects a saved runtime without removing it', async () => { + registerRuntimeEnvironmentHandlers() + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const disconnect = handler< + { selector: string }, + { disconnected: { id: string; name: string } } + >('runtimeEnvironments:disconnect') + expect(await disconnect(null, { selector: 'desk' })).toMatchObject({ + disconnected: { id: added.environment.id, name: 'desk' } + }) + + expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) + expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith('desk') + + const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list') + expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }]) + }) + it('checks a saved remote runtime and records the runtime id on success', async () => { registerRuntimeEnvironmentHandlers() sendRemoteRuntimeRequestMock.mockResolvedValue({ diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 3d206a149fb..9c11276407c 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -33,6 +33,7 @@ const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', @@ -103,6 +104,20 @@ export function registerRuntimeEnvironmentHandlers(): void { return { removed: redactRuntimeEnvironment(removed) } } ) + ipcMain.handle( + 'runtimeEnvironments:disconnect', + (_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => { + const environment = resolveEnvironment(getUserDataPath(), args.selector) + // Why: disconnect is intentionally non-destructive; it drops live + // transport state while keeping the paired server available for later. + closeRemoteRuntimeRequestConnection(environment.id) + if (args.selector !== environment.id) { + closeRemoteRuntimeRequestConnection(args.selector) + } + closeSubscriptionsForEnvironment(environment.id) + return { disconnected: redactRuntimeEnvironment(environment) } + } + ) ipcMain.handle( 'runtimeEnvironments:getStatus', async ( diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 5f9518f1737..87f2f8b63d4 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -3,24 +3,27 @@ import type { Store } from '../persistence' import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/types' export function registerSessionHandlers(store: Store): void { - ipcMain.handle('session:get', () => { - return store.getWorkspaceSession() + // Why: hostId is an optional second arg so an older renderer that invokes + // these channels without it keeps reading/writing the 'local' partition + // exactly as before. Channel names stay stable. + ipcMain.handle('session:get', (_event, hostId?: string | null) => { + return store.getWorkspaceSession(hostId) }) - ipcMain.handle('session:set', (_event, args: WorkspaceSessionState) => { - store.setWorkspaceSession(args) + ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => { + store.setWorkspaceSession(args, hostId) }) - ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch) => { - store.patchWorkspaceSession(args) + ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => { + store.patchWorkspaceSession(args, hostId) }) // Synchronous variant for the renderer's beforeunload handler. // sendSync blocks the renderer until this returns, guaranteeing the // data (including terminal scrollback buffers) is persisted to disk // before the window closes — regardless of before-quit ordering. - ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState) => { - store.setWorkspaceSession(args) + ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => { + store.setWorkspaceSession(args, hostId) store.flush() event.returnValue = true }) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index df0ff73f016..a221b941151 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -879,7 +879,7 @@ describe('SSH IPC handlers', () => { await expect( handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow('Failed to terminate remote SSH sessions') + ).rejects.toThrow('Failed to terminate SSH host sessions') expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', 'pty-1', 'terminated') expect(mockConnectionManager.disconnect).not.toHaveBeenCalledWith('ssh-1') }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 3497fd9f99f..e2ea19d1f74 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -850,7 +850,7 @@ export function registerSshHandlers( if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the // grace window. Keep the lease/session intact so the user can retry. - throw new Error(`Failed to terminate remote SSH sessions: ${shutdownFailures.join('; ')}`) + throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) } if (session) { await portForwardManager!.removeAllForwards(args.targetId) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 4923512806f..f33e7476b17 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -308,6 +308,9 @@ describe('mergeWorktree', () => { linkedIssue: 42, linkedPR: 10, linkedLinearIssue: null, + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw-2' as const, + projectHostSetupId: 'remote-repo', linkedGitLabMR: null, linkedGitLabIssue: null, isArchived: true, @@ -336,6 +339,10 @@ describe('mergeWorktree', () => { linkedLinearIssueOrganizationUrlKey: null, linkedGitLabMR: null, linkedGitLabIssue: null, + mobileDiffReview: undefined, + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw-2', + projectHostSetupId: 'remote-repo', isArchived: true, isUnread: true, isPinned: true, diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 9330ee715cc..885d32fec05 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -279,6 +279,11 @@ export function mergeWorktree( id: `${repoId}::${git.path}`, ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId, + ...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta?.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), path: git.path, head: git.head, branch: git.branch, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index c38a54e457c..f18693e6beb 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -33,6 +33,7 @@ import { gitExecFileAsync } from '../git/runner' import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' +import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { buildPosixRunnerScript, buildWindowsRunnerScript, @@ -1419,6 +1420,9 @@ export async function createRemoteWorktree( // Fresh creations must rotate instance identity so stale lineage cannot // attach to the new occupant of the same path. instanceId: randomUUID(), + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), lastActivityAt: now, // Why: grants the new worktree a short grace window at the top of the // Recent sort. During worktree creation (git fetch + add can take several @@ -1922,6 +1926,9 @@ export async function createLocalWorktree( // Fresh creations must rotate instance identity so stale lineage cannot // attach to the new occupant of the same path. instanceId: randomUUID(), + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 4dfa7f8d761..4e867b31c36 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -111,6 +111,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), + getProjectHostSetups: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), @@ -141,6 +142,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { mainWindow.webContents.send.mockReset() store.getRepos.mockReset() store.getRepo.mockReset() + store.getProjectHostSetups.mockReset() store.getSettings.mockReset() store.getWorktreeMeta.mockReset() store.setWorktreeMeta.mockReset() @@ -171,6 +173,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { addedAt: 0, worktreeBaseRef: null }) + store.getProjectHostSetups.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', nestWorkspaces: false, diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index f143db087f1..c428ba6daac 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -228,6 +228,7 @@ describe('registerWorktreeHandlers', () => { getWorktreeMeta: vi.fn(), getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), + getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), getAllWorktreeLineage: vi.fn(), removeWorktreeLineage: vi.fn() @@ -292,6 +293,7 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta, store.getAllWorktreeMeta, store.setWorktreeMeta, + store.getProjectHostSetups, store.removeWorktreeMeta, store.getAllWorktreeLineage, store.removeWorktreeLineage, @@ -338,6 +340,20 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(undefined) store.getAllWorktreeMeta.mockReturnValue({}) store.setWorktreeMeta.mockReturnValue({}) + store.getProjectHostSetups.mockReturnValue([ + { + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ]) store.getAllWorktreeLineage.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') @@ -3213,7 +3229,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 () => { @@ -3432,7 +3452,12 @@ describe('registerWorktreeHandlers', () => { } ]) store.getWorktreeMeta.mockReturnValue(undefined) - const stampedMeta = { lastActivityAt: 1_700_000_000_000 } + const stampedMeta = { + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 1_700_000_000_000 + } store.setWorktreeMeta.mockReturnValue(stampedMeta) const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { @@ -3442,7 +3467,12 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/discovered-wt', - expect.objectContaining({ lastActivityAt: expect.any(Number) }) + expect.objectContaining({ + lastActivityAt: expect.any(Number), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) expect(listed[0]).toMatchObject({ id: 'repo-1::/workspace/discovered-wt', @@ -3450,9 +3480,10 @@ describe('registerWorktreeHandlers', () => { }) }) - it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => { + it('backfills project-host ownership without re-stamping lastActivityAt for existing meta', async () => { // Why: only the *first* discovery should stamp. Re-stamping on every list - // would overwrite real activity and reshuffle the sidebar on refresh. + // would overwrite real activity and reshuffle the sidebar on refresh. Host + // ownership can still be filled because it is derived from the repo setup. listWorktreesMock.mockResolvedValue([ { path: '/workspace/existing-wt', @@ -3474,14 +3505,245 @@ describe('registerWorktreeHandlers', () => { sortOrder: 0, lastActivityAt: 42 }) + store.setWorktreeMeta.mockReturnValue({ + instanceId: 'existing-instance', + projectId: 'repo:repo-1', + 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).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/existing-wt', { + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) expect(listed[0].lastActivityAt).toBe(42) + expect(listed[0]).toMatchObject({ + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) + }) + + 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([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + instanceId: 'existing-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + + await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() }) it('backfills instanceId on discovery for persisted metadata from older profiles', async () => { @@ -3507,6 +3769,9 @@ describe('registerWorktreeHandlers', () => { }) store.setWorktreeMeta.mockReturnValue({ instanceId: 'new-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', lastActivityAt: 42 }) @@ -3517,9 +3782,19 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/existing-wt', - expect.objectContaining({ instanceId: expect.any(String) }) + expect.objectContaining({ + instanceId: expect.any(String), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) - expect(listed[0].instanceId).toBe('new-instance') + expect(listed[0]).toMatchObject({ + instanceId: 'new-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) }) it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { @@ -3545,13 +3820,23 @@ describe('registerWorktreeHandlers', () => { kind: 'folder' }) store.getWorktreeMeta.mockReturnValue(undefined) - store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + store.setWorktreeMeta.mockReturnValue({ + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1', + lastActivityAt: 1_700_000_000_000 + }) await handlers['worktrees:list'](null, { repoId: 'repo-1' }) expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/folder', - expect.objectContaining({ lastActivityAt: expect.any(Number) }) + expect.objectContaining({ + lastActivityAt: expect.any(Number), + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) ) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index b500e18021d..25742c21438 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' +import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { CreateWorktreeArgs, @@ -130,24 +131,57 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin deleteWorktreeHistoryDir(worktreeId) } +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 { + // 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 } + : {}) + } +} + // Why: worktrees discovered on disk (not created via Orca's UI) have no // persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. // That makes them sort to the bottom of "Recent" even though the user just -// added the repo / folder. Stamp discovery time the first time we see a -// worktree so its very existence counts as a recency signal. Subsequent -// list calls find the persisted meta and skip the stamp. -function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { +// added the repo / folder. The same authoritative discovery pass is also the +// safest time to backfill project-host setup ownership for upgraded profiles. +function resolveWorktreeMetaWithDiscoveryBackfill( + store: Store, + repo: Repo, + worktreeId: string +): WorktreeMeta { const existing = store.getWorktreeMeta(worktreeId) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) if (existing) { - if (!existing.instanceId) { + const updates = { + ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), + ...ownershipUpdates + } + if (Object.keys(updates).length > 0) { // Why: profiles created before lineage shipped already have WorktreeMeta // rows. Backfill on authoritative discovery so upgraded workspaces can - // immediately participate in instance-validated lineage. - return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() }) + // immediately participate in instance-validated lineage and host routing. + return store.setWorktreeMeta(worktreeId, updates) } return existing } - return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) + return store.setWorktreeMeta(worktreeId, { + lastActivityAt: Date.now(), + ...ownershipUpdates + }) } async function isAlreadyRemovedWorktreePath(repo: Repo, worktreePath: string): Promise<boolean> { @@ -366,6 +400,7 @@ function pruneLineageForMissingRepoWorktrees( } type SshWorktreeMetaCandidate = { + id: string path: string meta: WorktreeMeta } @@ -389,7 +424,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 @@ -411,15 +446,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) @@ -451,7 +495,7 @@ function buildDetectedGitWorktrees( return detected } - meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) + meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) return toDetectedWorktree({ repo, worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), @@ -468,7 +512,7 @@ function stampAndMergeVisibleDetectedWorktree( repo: Repo, detected: DetectedWorktree ) { - const meta = resolveWorktreeMetaWithDiscoveryStamp(store, detected.id) + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) return mergeWorktree(repo.id, detected, meta, repo.displayName) } @@ -498,6 +542,11 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta id: worktreeId, ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), path: repo.path, head: '', branch: '', @@ -539,12 +588,16 @@ function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { return ids .map((worktreeId) => { const existing = allMeta[worktreeId] - const meta = existing?.instanceId - ? existing - : store.setWorktreeMeta(worktreeId, { - instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + const meta = + existing?.instanceId && Object.keys(ownershipUpdates).length === 0 + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: + existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...ownershipUpdates, + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) return mergeFolderWorkspace(repo, worktreeId, meta) }) .sort((a, b) => { @@ -577,7 +630,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, {})) + 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) }) } @@ -591,6 +649,9 @@ function createFolderWorkspace( const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) const meta = store.setWorktreeMeta(worktreeId, { instanceId, + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), displayName: args.displayName || args.name, lastActivityAt: now, createdAt: now, @@ -685,7 +746,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 { @@ -697,7 +758,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) @@ -750,7 +811,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 { @@ -762,7 +823,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) @@ -814,7 +875,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, @@ -843,7 +904,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 9d569f46375..06764f66a21 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -16,7 +16,9 @@ import { join } from 'path' import { tmpdir } from 'os' import type { PersistedState, + Project, ProjectGroup, + ProjectHostSetup, Repo, TerminalPaneLayoutNode, TerminalTab, @@ -27,11 +29,13 @@ import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' import { + getDefaultPersistedState, getDefaultWorkspaceSession, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/constants' import { folderWorkspaceKey } from '../shared/workspace-scope' +import { toRuntimeExecutionHostId, 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 @@ -175,6 +179,30 @@ const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({ ...overrides }) +const makeProject = (overrides: Partial<Project> = {}): Project => ({ + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides +}) + +const makeProjectHostSetup = (overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup => ({ + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: '', + path: '/repo', + displayName: 'Project', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1, + ...overrides +}) + const makeTerminalTab = (overrides: Partial<TerminalTab> = {}): TerminalTab => ({ id: 'tab1', ptyId: 'pty1', @@ -287,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() @@ -671,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 () => { @@ -1070,6 +1159,155 @@ 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('marks runtime-owned automations as remote-host scheduled', async () => { + const store = await createStore() + store.addRepo( + makeRepo({ + executionHostId: toRuntimeExecutionHostId('gpu-server'), + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ) + + 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.schedulerOwner).toBe('remote_host_service') + expect(automation.runContext).toMatchObject({ + hostId: toRuntimeExecutionHostId('gpu-server') + }) + }) + + 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()) @@ -2284,6 +2522,17 @@ describe('Store', () => { expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) + it('removeProject removes the derived project host setup compatibility record', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) + + store.removeProject('r1') + + expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r2']) + expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual(['r2']) + }) + it('removeProject deletes child and parent lineage for the repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) @@ -2330,6 +2579,278 @@ describe('Store', () => { expect(store.getRepo('r1')!.displayName).toBe('renamed') }) + it('updateRepo keeps project host setup compatibility records in sync', async () => { + const store = await createStore() + store.addRepo(makeRepo({ worktreeBasePath: '../worktrees' })) + + store.updateRepo('r1', { + displayName: 'renamed', + worktreeBasePath: '../new-worktrees', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + + expect(store.getProjects()).toEqual([ + expect.objectContaining({ + id: 'github:stablyai/orca', + displayName: 'renamed', + sourceRepoIds: ['r1'] + }) + ]) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ + id: 'r1', + projectId: 'github:stablyai/orca', + displayName: 'renamed', + worktreeBasePath: '../new-worktrees' + }) + ]) + }) + + it('repo mutations preserve independent project host setup records', 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' })], + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + const store = await createStore() + + store.updateRepo('r1', { displayName: 'renamed' }) + store.reorderRepos(['r1']) + + expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ id: 'r1', displayName: 'renamed' }), + independentSetup + ]) + }) + + it('updates independent project host setup records directly', 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), + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + const store = await createStore() + + const result = store.updateProjectHostSetup({ + setupId: independentSetup.id, + updates: { + displayName: 'GPU VM renamed', + path: '/srv/renamed', + worktreeBasePath: '../worktrees', + setupState: 'ready', + setupMethod: 'cloned', + gitUsername: 'alice' + } + }) + + expect(result).toEqual({ + project: independentProject, + setup: expect.objectContaining({ + id: independentSetup.id, + displayName: 'GPU VM renamed', + path: '/srv/renamed', + worktreeBasePath: '../worktrees', + setupState: 'ready', + setupMethod: 'cloned', + gitUsername: 'alice' + }) + }) + expect(store.getProjectHostSetups()[0]).toMatchObject({ + displayName: 'GPU VM renamed', + path: '/srv/renamed' + }) + }) + + it('creates independent project host setup records for provisioning flows', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + + const result = store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm', + setupId: 'cloud-project::gpu-vm', + displayName: 'GPU VM', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + + expect(result?.project).toMatchObject({ + id: 'github:stablyai/cloud-project', + displayName: 'Cloud Project' + }) + expect(result?.setup).toMatchObject({ + id: 'cloud-project::gpu-vm', + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm', + repoId: '', + path: '', + displayName: 'GPU VM', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + expect(store.getRepos()).toHaveLength(1) + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ id: 'r1', repoId: 'r1' }), + result?.setup + ]) + }) + + it('rejects duplicate project host setup creation for the same host', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + const independentSetup = makeProjectHostSetup({ + id: 'cloud-project::gpu-vm', + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm' + }) + store.createProjectHostSetup({ + projectId: independentSetup.projectId, + hostId: independentSetup.hostId, + setupId: independentSetup.id + }) + + expect(() => + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'runtime:gpu-vm', + setupId: 'duplicate' + }) + ).toThrow('Project host setup already exists: cloud-project::gpu-vm') + }) + + it('updates repo-backed project host setup metadata through the repo record', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', displayName: 'Repo', worktreeBasePath: '../old' })) + + const result = store.updateProjectHostSetup({ + setupId: 'r1', + updates: { + displayName: 'Repo renamed', + worktreeBasePath: '../new', + setupMethod: 'cloned' + } + }) + + expect(result?.repo).toMatchObject({ + id: 'r1', + displayName: 'Repo renamed', + worktreeBasePath: '../new', + projectHostSetupMethod: 'cloned' + }) + expect(result?.project).toMatchObject({ + id: 'repo:r1', + displayName: 'Repo renamed' + }) + expect(result?.setup).toMatchObject({ + id: 'r1', + displayName: 'Repo renamed', + worktreeBasePath: '../new', + setupMethod: 'cloned' + }) + }) + + it('rejects repo-backed project host setup path changes', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + + expect(() => + store.updateProjectHostSetup({ + setupId: 'r1', + updates: { path: '/other' } + }) + ).toThrow('Repo-backed project host setup paths must be changed by re-importing the project.') + }) + + it('deletes independent project host setup records without deleting the project', 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), + projects: [independentProject], + projectHostSetups: [independentSetup] + }) + const store = await createStore() + + const result = store.deleteProjectHostSetup({ setupId: independentSetup.id }) + + expect(result).toEqual({ project: independentProject, setup: independentSetup }) + expect(store.getProjects()).toEqual([independentProject]) + expect(store.getProjectHostSetups()).toEqual([]) + }) + + it('deletes repo-backed project host setups by removing the compatibility repo', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) + + const result = store.deleteProjectHostSetup({ setupId: 'r1' }) + + expect(result?.project).toMatchObject({ id: 'repo:r1' }) + expect(result?.setup).toMatchObject({ id: 'r1', repoId: 'r1' }) + expect(result?.repo).toMatchObject({ id: 'r1' }) + expect(store.getRepo('r1')).toBeUndefined() + expect(store.getProjects()).toEqual([]) + expect(store.getProjectHostSetups()).toEqual([]) + expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined() + }) + + it('updateRepo preserves repo-backed project host setup method', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + store.updateRepo('r1', { projectHostSetupMethod: 'cloned' }) + + expect(store.getRepo('r1')?.projectHostSetupMethod).toBe('cloned') + expect(store.getProjectHostSetups()).toEqual([ + expect.objectContaining({ + id: 'r1', + setupMethod: 'cloned' + }) + ]) + }) + it('updateRepo drops repo icons that fail shared sanitization', async () => { const store = await createStore() store.addRepo(makeRepo()) @@ -7279,3 +7800,153 @@ describe('Store', () => { }) }) }) + +describe('Store host-partitioned workspace sessions', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + const makeHostSession = (activeRepoId: string): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId + }) + + it('migrates a legacy workspaceSession blob into the local partition', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('legacy-repo') + }) + + const store = await createStore() + + // The legacy blob is the 'local' partition; an explicit/default hostId reads it. + expect(store.getWorkspaceSession().activeRepoId).toBe('legacy-repo') + expect(store.getWorkspaceSession('local').activeRepoId).toBe('legacy-repo') + // No data was moved, so a downgrade still finds the legacy field intact. + store.flush() + const persisted = readDataFile() as { workspaceSession?: { activeRepoId?: string } } + expect(persisted.workspaceSession?.activeRepoId).toBe('legacy-repo') + }) + + it('is idempotent: re-loading already-partitioned state preserves all hosts', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('local-repo'), + workspaceSessionsByHostId: { + 'runtime:env-a': makeHostSession('runtime-repo'), + 'ssh:host-b': makeHostSession('ssh-repo') + } + }) + + const readSessionPartitions = (): unknown => { + const data = readDataFile() as { + workspaceSession?: unknown + workspaceSessionsByHostId?: unknown + } + return { + workspaceSession: data.workspaceSession, + workspaceSessionsByHostId: data.workspaceSessionsByHostId + } + } + + const first = await createStore() + first.flush() + const afterFirst = readSessionPartitions() + + const second = await createStore() + second.flush() + const afterSecond = readSessionPartitions() + + // Re-running the partition migration must not move or reshape any host. + expect(afterSecond).toEqual(afterFirst) + expect(second.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') + expect(second.getWorkspaceSession('ssh:host-b').activeRepoId).toBe('ssh-repo') + expect(second.getWorkspaceSession('local').activeRepoId).toBe('local-repo') + }) + + it('drops a stray "local" key in workspaceSessionsByHostId in favor of the legacy blob', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSession: makeHostSession('canonical-local'), + workspaceSessionsByHostId: { + local: makeHostSession('shadow-local') + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('local').activeRepoId).toBe('canonical-local') + }) + + it('isolates writes: setting host A does not mutate host B or local', async () => { + const store = await createStore() + + store.setWorkspaceSession(makeHostSession('repo-local'), 'local') + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + store.setWorkspaceSession(makeHostSession('repo-b'), 'runtime:env-b') + + expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b') + + // Overwriting host A leaves host B and local untouched. + store.setWorkspaceSession(makeHostSession('repo-a2'), 'runtime:env-a') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a2') + expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b') + expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') + }) + + it('patches a single host partition without touching the others', async () => { + const store = await createStore() + store.setWorkspaceSession(makeHostSession('repo-local'), 'local') + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + + store.patchWorkspaceSession({ activeTabId: 'tab-a' }, 'runtime:env-a') + + expect(store.getWorkspaceSession('runtime:env-a').activeTabId).toBe('tab-a') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + // Local was never given that tab id. + expect(store.getWorkspaceSession('local').activeTabId).toBeNull() + expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') + }) + + it('defaults an omitted hostId to the local partition', async () => { + const store = await createStore() + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + + // No hostId → local, which is still empty/default and unaffected by host A. + store.setWorkspaceSession(makeHostSession('repo-local')) + expect(store.getWorkspaceSession().activeRepoId).toBe('repo-local') + expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + }) + + it('round-trips host partitions through disk', async () => { + const store = await createStore() + store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') + store.flush() + + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') + }) + + it('drops a corrupt host partition to defaults without failing the others', async () => { + writeDataFile({ + schemaVersion: 1, + workspaceSessionsByHostId: { + 'runtime:good': makeHostSession('good-repo'), + // activeRepoId must be string|null; a number fails the zod parse. + 'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 } + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo') + // Bad partition collapses to defaults rather than poisoning the map. + expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b10da074e9d..550a38891a3 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -24,6 +24,7 @@ import type { AutomationPrecheckResult, AutomationRunOutputSnapshot, AutomationRun, + AutomationSchedulerOwner, AutomationRunTrigger, AutomationUpdateInput } from '../shared/automations-types' @@ -31,9 +32,19 @@ import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../shared/automation-schedules' +import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import type { PersistedState, + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + RepoProjectHostSetupMethod, Repo, ProjectGroup, FolderWorkspace, @@ -53,10 +64,16 @@ import type { WorkspaceSessionPatch, 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' import { getGitUsername } from './git/repo' +import { getRepoExecutionHostId, parseExecutionHostId } from '../shared/execution-host' import { getDefaultPersistedState, getDefaultNotificationSettings, @@ -71,6 +88,13 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostOrder, + normalizeExecutionHostId, + normalizeVisibleExecutionHostIds, + type ExecutionHostId +} from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' import { isTerminalLeafId, @@ -97,11 +121,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, @@ -243,6 +262,39 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc ) } +/** Normalize the persisted non-'local' host partitions. 'local' is intentionally + * dropped here — it is the legacy workspaceSession blob — so the two surfaces + * never diverge. Each partition is zod-validated independently: a corrupt host + * drops to defaults without taking out the others. Idempotent: re-running on an + * already-normalized map yields the same shape. */ +function parseWorkspaceSessionsByHostId( + raw: unknown, + defaults: WorkspaceSessionState +): Partial<Record<ExecutionHostId, WorkspaceSessionState>> { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {} + } + const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {} + for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { + const hostId = normalizeExecutionHostId(key) + // Why: 'local' belongs in workspaceSession; an invalid/local key here is + // legacy noise and must not shadow the canonical local partition. + if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { + continue + } + const result = parseWorkspaceSession(value) + if (!result.ok) { + console.error( + `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, + result.error + ) + continue + } + partitions[hostId] = { ...defaults, ...result.value } + } + return partitions +} + function backupPath(dataFile: string, index: number): string { return `${dataFile}.bak.${index}` } @@ -434,11 +486,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' || @@ -572,6 +619,116 @@ 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 getAutomationSchedulerOwner(repo: Repo | undefined): AutomationSchedulerOwner { + if (!repo) { + return 'local_host_service' + } + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (host?.kind === 'ssh') { + return 'ssh_bridge' + } + if (host?.kind === 'runtime') { + return 'remote_host_service' + } + return 'local_host_service' +} + +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 === getAutomationLegacyRepoId(automation)), + 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 @@ -809,8 +966,19 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { return owner && repo ? { owner, repo } : undefined } +function sanitizeRepoProjectHostSetupMethod( + value: unknown +): RepoProjectHostSetupMethod | undefined { + return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined +} + function sanitizeRepoUpdatesForPersistence< - T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath'>> + T extends Partial< + Pick< + Repo, + 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath' | 'projectHostSetupMethod' + > + > >(updates: T): T { const sanitized = { ...updates } if ('badgeColor' in sanitized) { @@ -845,6 +1013,14 @@ function sanitizeRepoUpdatesForPersistence< delete sanitized.worktreeBasePath } } + if ('projectHostSetupMethod' in sanitized) { + const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod) + if (setupMethod === undefined) { + delete sanitized.projectHostSetupMethod + } else { + sanitized.projectHostSetupMethod = setupMethod + } + } return sanitized } @@ -1636,6 +1812,74 @@ function migrationUnsupportedEntriesEqual( }) } +function projectHostSetupCompatibilityStateEqual( + state: Pick<PersistedState, 'projects' | 'projectHostSetups'>, + nextState: Pick<PersistedState, 'projects' | 'projectHostSetups'> +): boolean { + return ( + JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) && + JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups) + ) +} + +function isRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + currentRepoIds: ReadonlySet<string> +): boolean { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) +} + +function mergeProjectHostSetupCompatibilityState( + state: Pick<PersistedState, 'projects' | 'projectHostSetups'>, + repos: readonly Repo[] +): Pick<PersistedState, 'projects' | 'projectHostSetups'> { + const projection = projectHostSetupProjectionFromRepos(repos) + const currentRepoIds = new Set(repos.map((repo) => repo.id)) + const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) + const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + // Why: legacy/repo-backed setup rows use the repo id as the setup id. Keep + // only independent setup rows here so repo deletion does not leave ghosts. + const independentSetups = (state.projectHostSetups ?? []).filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) + const independentProjects = (state.projects ?? []) + .filter( + (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) + ) + .map((project) => ({ + ...project, + sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) + })) + return { + projects: [...projection.projects, ...independentProjects], + projectHostSetups: [...projection.setups, ...independentSetups] + } +} + +function makeProjectHostSetupId( + projectId: string, + hostId: ExecutionHostId, + existingIds: ReadonlySet<string>, + requestedId?: string +): string { + const baseId = requestedId?.trim() || `${projectId}::${hostId}` + if (!existingIds.has(baseId)) { + return baseId + } + let suffix = 2 + let candidate = `${baseId}::${suffix}` + while (existingIds.has(candidate)) { + suffix++ + candidate = `${baseId}::${suffix}` + } + return candidate +} + function createMinimalPersistedTerminalTab(args: { worktreeId: string tabId: string @@ -2234,14 +2478,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) || @@ -2258,12 +2494,6 @@ export class Store { if (!autoRenameBranchFromWorkDefaultedOn) { this.loadNeedsSave = true } - if ( - parsed.settings?.openLinksInAppPreferencePrompted !== - migratedOpenLinksInAppPreferencePrompted - ) { - this.loadNeedsSave = true - } const normalizedOnboarding = normalizeLoadedOnboardingState( parsed.onboarding, defaults.onboarding @@ -2327,15 +2557,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, @@ -2350,7 +2571,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 @@ -2491,28 +2711,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), @@ -2521,7 +2725,6 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, - projectOrderManualDefaultNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated @@ -2569,6 +2772,15 @@ export class Store { } return { ...defaults.workspaceSession, ...result.value } })(), + // Why: per-host session partitions for non-'local' hosts. 'local' + // stays in workspaceSession (legacy field) so a downgrade still + // reads the user's workspace. Each entry is zod-validated the same + // way as the legacy blob — a corrupt partition drops to that host's + // defaults without poisoning the others. + workspaceSessionsByHostId: parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) @@ -2621,9 +2833,30 @@ export class Store { this.loadNeedsSave = true } + const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) + if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { + this.loadNeedsSave = true + } + + 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, - repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []), + repos, + ...projectHostSetupCompatibility, workspaceSession: migratedScrollback.session }) if (folderScopeConnectionMigration.changed) { @@ -2843,6 +3076,101 @@ export class Store { return this.state.repos.map((repo) => this.hydrateRepo(repo)) } + getProjects(): Project[] { + return [...this.state.projects] + } + + getProjectHostSetups(): ProjectHostSetup[] { + return [...this.state.projectHostSetups] + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + const project = this.state.projects.find((entry) => entry.id === args.projectId) + if (!project) { + return null + } + const hostId = normalizeExecutionHostId(args.hostId) + if (!hostId) { + throw new Error(`Invalid host ID: ${args.hostId}`) + } + const duplicateSetup = this.state.projectHostSetups.find( + (entry) => entry.projectId === project.id && entry.hostId === hostId + ) + if (duplicateSetup) { + throw new Error(`Project host setup already exists: ${duplicateSetup.id}`) + } + const now = Date.now() + const existingIds = new Set(this.state.projectHostSetups.map((entry) => entry.id)) + const setup: ProjectHostSetup = { + id: makeProjectHostSetupId(project.id, hostId, existingIds, args.setupId), + projectId: project.id, + hostId, + repoId: '', + path: args.path?.trim() ?? '', + displayName: args.displayName?.trim() || project.displayName, + ...(args.kind ? { kind: args.kind } : {}), + ...(args.worktreeBasePath?.trim() ? { worktreeBasePath: args.worktreeBasePath.trim() } : {}), + ...(args.gitUsername?.trim() ? { gitUsername: args.gitUsername.trim() } : {}), + setupState: args.setupState ?? 'not-set-up', + setupMethod: args.setupMethod ?? 'provisioned', + createdAt: now, + updatedAt: now + } + // Why: this is the first non-repo-backed setup creation path; it must + // persist independently so future repo projection sync does not erase it. + this.state.projectHostSetups.push(setup) + this.scheduleSave() + return { project, setup } + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + if (!setup) { + return null + } + const project = this.state.projects.find((entry) => entry.id === setup.projectId) + if (!project) { + return null + } + const repo = setup.repoId + ? this.state.repos.find((entry) => entry.id === setup.repoId) + : undefined + if (repo) { + const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates) + const updatedProject = updated + ? this.state.projects.find((entry) => entry.id === updated.setup.projectId) + : undefined + return updated && updatedProject + ? { project: updatedProject, setup: updated.setup, repo: updated.repo } + : null + } + const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates) + return { project, setup: updatedSetup } + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + if (!setup) { + return null + } + const project = this.state.projects.find((entry) => entry.id === setup.projectId) + if (!project) { + return null + } + const repo = setup.repoId + ? this.state.repos.find((entry) => entry.id === setup.repoId) + : undefined + if (repo) { + this.removeProject(repo.id) + return { project, setup, repo: this.hydrateRepo(repo) } + } + this.state.projectHostSetups = this.state.projectHostSetups.filter( + (entry) => entry.id !== setup.id + ) + this.scheduleSave() + return { project, setup } + } + /** * O(1) read of the persisted repo count. Use this when you only need the * count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and @@ -3110,6 +3438,7 @@ export class Store { addRepo(repo: Repo): void { this.state.repos.push(repo) + this.syncProjectHostSetupCompatibilityState() this.scheduleSave() } @@ -3141,12 +3470,14 @@ export class Store { next.push(repo) } this.state.repos = next + this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return true } removeProject(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) + this.syncProjectHostSetupCompatibilityState() // Why: presets are repo-scoped, so removing the repo means the presets // can never be referenced again — drop them with the parent. delete this.state.sparsePresetsByRepo[id] @@ -3184,6 +3515,7 @@ export class Store { | 'externalWorktreeVisibilityPromptDismissedAt' | 'projectGroupId' | 'projectGroupOrder' + | 'projectHostSetupMethod' > > & { sourceControlAi?: Repo['sourceControlAi'] | null } ): Repo | null { @@ -3252,20 +3584,109 @@ export class Store { } } Object.assign(repo, sanitizedUpdates) + this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return this.hydrateRepo(repo) } + private syncProjectHostSetupCompatibilityState(): void { + const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) + this.state.projects = compatibilityState.projects + this.state.projectHostSetups = compatibilityState.projectHostSetups + } + + private updateRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] + ): { setup: ProjectHostSetup; repo: Repo } | null { + if (updates.path !== undefined && updates.path !== repo.path) { + throw new Error( + 'Repo-backed project host setup paths must be changed by re-importing the project.' + ) + } + if (updates.setupState !== undefined && updates.setupState !== 'ready') { + throw new Error('Repo-backed project host setups cannot be marked unavailable.') + } + const repoUpdates: Parameters<Store['updateRepo']>[1] = {} + if (updates.displayName !== undefined) { + repoUpdates.displayName = updates.displayName + } + if (updates.worktreeBasePath !== undefined) { + repoUpdates.worktreeBasePath = updates.worktreeBasePath + } + if (updates.kind !== undefined) { + repoUpdates.kind = updates.kind + } + if (updates.setupMethod === 'provisioned') { + throw new Error('Repo-backed project host setups cannot be marked provisioned.') + } + if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') { + repoUpdates.projectHostSetupMethod = updates.setupMethod + } + const updatedRepo = + Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo + if (!updatedRepo) { + return null + } + return { + setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup, + repo: updatedRepo + } + } + + private updateIndependentProjectHostSetup( + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] + ): ProjectHostSetup { + if (updates.displayName !== undefined) { + setup.displayName = updates.displayName.trim() || setup.displayName + } + if (updates.path !== undefined) { + setup.path = updates.path.trim() || setup.path + } + if (updates.worktreeBasePath !== undefined) { + const worktreeBasePath = updates.worktreeBasePath.trim() + if (worktreeBasePath) { + setup.worktreeBasePath = worktreeBasePath + } else { + delete setup.worktreeBasePath + } + } + if (updates.kind !== undefined) { + setup.kind = updates.kind + } + if (updates.gitUsername !== undefined) { + const gitUsername = updates.gitUsername.trim() + if (gitUsername) { + setup.gitUsername = gitUsername + } else { + delete setup.gitUsername + } + } + if (updates.setupState !== undefined) { + setup.setupState = updates.setupState + } + if (updates.setupMethod !== undefined) { + setup.setupMethod = updates.setupMethod + } + setup.updatedAt = Date.now() + this.scheduleSave() + return setup + } + private hydrateRepo(repo: Repo): Repo { const { repoIcon: rawRepoIcon, upstream: rawUpstream, sourceControlAi: rawSourceControlAi, + projectHostSetupMethod: rawProjectHostSetupMethod, ...repoWithoutIcon } = repo const repoIcon = sanitizeRepoIcon(rawRepoIcon) const upstream = sanitizeRepoUpstream(rawUpstream) const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi) + const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod) const gitUsername = isFolderRepo(repo) ? '' : (this.gitUsernameCache.get(repo.path) ?? @@ -3280,6 +3701,7 @@ export class Store { ...(repoIcon !== undefined ? { repoIcon } : {}), ...(upstream !== undefined ? { upstream } : {}), ...(sourceControlAi !== undefined ? { sourceControlAi } : {}), + ...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}), kind: isFolderRepo(repo) ? 'folder' : 'git', gitUsername, hookSettings: { @@ -3340,16 +3762,20 @@ 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 schedulerOwner = getAutomationSchedulerOwner(repo) + 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', - schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service', + schedulerOwner, workspaceMode: input.workspaceMode, workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, @@ -3379,6 +3805,8 @@ 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 schedulerOwner = getAutomationSchedulerOwner(repo) + 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 @@ -3392,9 +3820,19 @@ 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', + schedulerOwner, workspaceMode, workspaceId: workspaceMode === 'existing' @@ -3450,6 +3888,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', @@ -3664,21 +4104,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: @@ -3795,6 +4220,10 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth ), + visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( + this.state.ui?.visibleWorkspaceHostIds + ), + workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( this.state.ui?.browserDefaultZoomLevel ), @@ -3861,6 +4290,14 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), + visibleWorkspaceHostIds: + updates.visibleWorkspaceHostIds !== undefined + ? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds) + : normalizeVisibleExecutionHostIds(this.state.ui?.visibleWorkspaceHostIds), + workspaceHostOrder: + updates.workspaceHostOrder !== undefined + ? normalizeExecutionHostOrder(updates.workspaceHostOrder) + : normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel ), @@ -3985,8 +4422,19 @@ export class Store { // ── Workspace Session ───────────────────────────────────────────── - getWorkspaceSession(): PersistedState['workspaceSession'] { - return this.state.workspaceSession ?? getDefaultWorkspaceSession() + /** Resolve an execution host argument to a canonical id. Unknown/empty + * values fall back to 'local' so legacy callers without a hostId keep + * reading and writing the local partition exactly as before. */ + private resolveHostId(hostId?: string | null): ExecutionHostId { + return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + } + + getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + return this.state.workspaceSession ?? getDefaultWorkspaceSession() + } + return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() } readTerminalScrollbackSnapshot(ref: string): string | null { @@ -3999,7 +4447,30 @@ export class Store { return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) } - setWorkspaceSession(session: PersistedState['workspaceSession']): void { + setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.setLocalWorkspaceSession(session) + return + } + this.setHostWorkspaceSession(resolved, session) + } + + /** Persist a non-'local' host partition. The PTY-binding race protections in + * setLocalWorkspaceSession only apply to the local daemon, so remote hosts + * take the lighter prune-and-store path. */ + private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { + const pruned = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, this.state.repos) + ) + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [hostId]: pruned + } + this.scheduleSave() + } + + private setLocalWorkspaceSession(session: PersistedState['workspaceSession']): void { session = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, this.state.repos) ) @@ -4148,22 +4619,30 @@ export class Store { this.scheduleSave() } - patchWorkspaceSession(patch: WorkspaceSessionPatch): void { + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { + const resolved = this.resolveHostId(hostId) // Why: the renderer's debounced hot path sends only changed top-level // session slices. Scalar/UI patches avoid the terminal normalization path; // terminal topology/layout patches still reuse the stale-PTY protections. let next: WorkspaceSessionState = { - ...this.getWorkspaceSession(), + ...this.getWorkspaceSession(resolved), ...patch } if (workspaceSessionPatchNeedsFullNormalization(patch)) { - this.setWorkspaceSession(next) + this.setWorkspaceSession(next, resolved) return } if (Object.hasOwn(patch, 'browserUrlHistory')) { next = pruneWorkspaceSessionBrowserHistory(next) } - this.state.workspaceSession = next + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSession = next + } else { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolved]: next + } + } this.scheduleSave() } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 35a0bc60b2c..289513ffc3f 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -6,6 +6,7 @@ type MockMultiplexer = { request: ReturnType<typeof vi.fn> notify: ReturnType<typeof vi.fn> onNotification: ReturnType<typeof vi.fn> + onNotificationByMethod: ReturnType<typeof vi.fn> dispose: ReturnType<typeof vi.fn> isDisposed: ReturnType<typeof vi.fn> } @@ -15,6 +16,7 @@ function createMockMux(): MockMultiplexer { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn(), onNotification: vi.fn(), + onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()), dispose: vi.fn(), isDisposed: vi.fn().mockReturnValue(false) } @@ -79,6 +81,60 @@ describe('SshGitProvider', () => { expect(result).toEqual(['dist/bundle.js']) }) + it('clone sends git.clone request and forwards matching progress notifications', async () => { + const unsubscribe = vi.fn() + const onProgress = vi.fn() + mux.onNotificationByMethod.mockReturnValue(unsubscribe) + mux.request.mockImplementationOnce(async (_method, params) => { + const progressHandler = mux.onNotificationByMethod.mock.calls[0][1] + progressHandler({ + progressId: params.progressId, + phase: 'Receiving objects', + percent: 42 + }) + progressHandler({ + progressId: 'other-clone', + phase: 'Receiving objects', + percent: 99 + }) + return { stdout: '', stderr: '' } + }) + + await provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user', { + timeoutMs: 1000, + onProgress + }) + + expect(mux.request).toHaveBeenCalledWith( + 'git.clone', + expect.objectContaining({ + args: ['clone', '--progress', '--', 'url', 'repo'], + cwd: '/home/user', + progressId: expect.stringMatching(/^clone-/) + }), + { signal: undefined, timeoutMs: 1000 } + ) + expect(mux.onNotificationByMethod).toHaveBeenCalledWith( + 'git.cloneProgress', + expect.any(Function) + ) + expect(onProgress).toHaveBeenCalledWith({ phase: 'Receiving objects', percent: 42 }) + expect(onProgress).toHaveBeenCalledTimes(1) + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('reports an actionable reconnect message when the relay does not support cloning', async () => { + const methodNotFound = new Error('Method not found: git.clone') as Error & { code?: number } + methodNotFound.code = -32601 + mux.request.mockRejectedValueOnce(methodNotFound) + + await expect( + provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user') + ).rejects.toThrow( + 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + }) + it('getHistory sends git.history request', async () => { const historyResult = { items: [], @@ -176,6 +232,32 @@ describe('SshGitProvider', () => { expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) }) + it('exec forwards abort and timeout options to the relay request', async () => { + const controller = new AbortController() + mux.request.mockResolvedValue({ stdout: '', stderr: '' }) + + await provider.exec( + ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], + '/home/user', + { + signal: controller.signal, + timeoutMs: 60_000 + } + ) + + expect(mux.request).toHaveBeenCalledWith( + 'git.exec', + { + args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], + cwd: '/home/user' + }, + { + signal: controller.signal, + timeoutMs: 60_000 + } + ) + }) + it('getStagedCommitContext reads branch, staged summary, and staged patch remotely', async () => { mux.request.mockImplementation(async (method, payload) => { expect(method).toBe('git.exec') diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 477555df9e6..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 @@ -60,7 +61,11 @@ export class SshGitProvider implements IGitProvider { private nonInteractiveExecQueues = new Map<string, NonInteractiveExecQueueEntry[]>() private loggedWorktreeIsCleanFallback = false - constructor(connectionId: string, mux: SshChannelMultiplexer) { + constructor( + connectionId: string, + mux: SshChannelMultiplexer, + private readonly hostPlatform: RemoteHostPlatform | null = null + ) { this.connectionId = connectionId this.mux = mux } @@ -69,6 +74,10 @@ export class SshGitProvider implements IGitProvider { return this.connectionId } + getHostPlatform(): RemoteHostPlatform | null { + return this.hostPlatform + } + async getStatus( worktreePath: string, options?: { includeIgnored?: boolean } @@ -540,13 +549,64 @@ export class SshGitProvider implements IGitProvider { await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) } - async exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { - return (await this.mux.request('git.exec', { args, cwd })) as { + async exec( + args: string[], + cwd: string, + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise<{ stdout: string; stderr: string }> { + const result = options + ? await this.mux.request('git.exec', { args, cwd }, options) + : await this.mux.request('git.exec', { args, cwd }) + return result as { stdout: string stderr: string } } + async clone( + args: string[], + cwd: string, + options?: { + signal?: AbortSignal + timeoutMs?: number + onProgress?: (progress: { phase: string; percent: number }) => void + } + ): Promise<{ stdout: string; stderr: string }> { + const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}` + const unsubscribe = options?.onProgress + ? this.mux.onNotificationByMethod('git.cloneProgress', (params) => { + if (params.progressId !== progressId) { + return + } + const phase = params.phase + const percent = params.percent + if (typeof phase === 'string' && typeof percent === 'number') { + options.onProgress?.({ phase, percent }) + } + }) + : undefined + try { + const result = await this.mux.request( + 'git.clone', + { args, cwd, progressId }, + { signal: options?.signal, timeoutMs: options?.timeoutMs } + ) + return result as { + stdout: string + stderr: string + } + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + } + throw error + } finally { + unsubscribe?.() + } + } + async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> { return (await this.mux.request('git.isGitRepo', { dirPath })) as { isRepo: boolean diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index d699600350c..5be9472f60f 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -217,7 +217,11 @@ export type IGitProvider = { renameCurrentBranch?(worktreePath: string, newBranch: string): Promise<void> isGitRepo(path: string): boolean isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> - exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + exec( + args: string[], + cwd: string, + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise<{ stdout: string; stderr: string }> getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise<string | null> worktreeIsClean( worktreePath: string, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5460a02efe9..29e038ad544 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -950,12 +950,23 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(status.capabilities).toContain('workspace-ports.v1') expect(status.capabilities).toContain('mobile.tasks.v1') + expect(status.capabilities).toContain('project-host-setup.v1') + expect(status.capabilities).not.toContain('browser.screencast.v1') expect(typeof status.protocolVersion).toBe('number') expect(typeof status.minCompatibleMobileVersion).toBe('number') expect(status.protocolVersion).toBeGreaterThanOrEqual(1) expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0) }) + it('advertises browser screencast only when a renderer window is available', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1') + }) + it('claims the first window as authoritative and ignores later windows', () => { const runtime = createRuntime() @@ -9848,6 +9859,47 @@ describe('OrcaRuntimeService', () => { ) }) + it('publishes headless mobile session agent identity with synthesized PTY status', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: {} + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'claude' + }) + runtime.onPtyData('pty-agent', '\x1b]0;✳ Claude Code\x07', Date.now()) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(created.tab).toMatchObject({ + type: 'terminal', + launchAgent: 'claude' + }) + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'claude' + }) + }) + ]) + }) + it('rejects disabled mobile session agent launches before spawning', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) const runtime = new OrcaRuntimeService({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fe0722d6fda..774e1c567e7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25,6 +25,7 @@ import { deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' +import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { createHash, randomUUID } from 'crypto' import { homedir } from 'os' import { isAbsolute, join, resolve } from 'path' @@ -51,6 +52,17 @@ import type { GitHubOwnerRepo, GlobalSettings, PersistedUIState, + Project, + ProjectHostSetup, + ProjectHostSetupCloneArgs, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, Repo, RemoveWorktreeResult, StatsSummary, @@ -106,6 +118,10 @@ import { LINEAR_WRITE_BODY_CAP } from '../../shared/linear-agent-access' import type { FeatureInteractionId } from '../../shared/feature-interactions' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id' +import { + getProjectHostSetupForRepo, + getProjectHostSetupWorktreeMeta +} from '../../shared/project-host-setup-projection' import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' @@ -586,6 +602,11 @@ type RuntimeStore = { getRepo: Store['getRepo'] addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] + getProjects?: Store['getProjects'] + getProjectHostSetups?: Store['getProjectHostSetups'] + createProjectHostSetup?: Store['createProjectHostSetup'] + updateProjectHostSetup?: Store['updateProjectHostSetup'] + deleteProjectHostSetup?: Store['deleteProjectHostSetup'] getProjectGroups?: Store['getProjectGroups'] createProjectGroup?: Store['createProjectGroup'] updateProjectGroup?: Store['updateProjectGroup'] @@ -1041,6 +1062,11 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt id: worktreeId, ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), path: repo.path, head: '', branch: '', @@ -1898,6 +1924,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, @@ -1929,6 +1957,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 } @@ -2069,6 +2103,9 @@ export class OrcaRuntimeService { } getStatus(): RuntimeStatus { + const capabilities = this.getAvailableAuthoritativeWindow() + ? [...RUNTIME_CAPABILITIES] + : RUNTIME_CAPABILITIES.filter((capability) => capability !== 'browser.screencast.v1') return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -2078,7 +2115,9 @@ export class OrcaRuntimeService { liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: [...RUNTIME_CAPABILITIES], + // Why: headless orca serve cannot create/stream BrowserViews, so clients + // must not treat browser panes as supported just because runtime RPC is up. + capabilities, hostPlatform: process.platform, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION @@ -2846,8 +2885,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 @@ -2862,11 +2899,18 @@ export class OrcaRuntimeService { if (shouldMaterializePendingTerminal) { const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined try { - await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, { - tabId: tab.parentTabId, - leafId: tab.leafId, - sessionId - }) + await this.createHeadlessMobileSessionTerminal( + worktreeId, + true, + undefined, + undefined, + { + tabId: tab.parentTabId, + leafId: tab.leafId, + sessionId + }, + tab.launchAgent + ) } catch (err) { if (sessionId && parseAppSshPtyId(sessionId)) { // Why: an expired SSH reattach clears durable bindings in the store, @@ -2887,8 +2931,16 @@ export class OrcaRuntimeService { candidate.isActive ) const targetTab = activeSibling ?? tab + if (!this.notifier?.focusTerminal) { + if ( + !targetTab.isActive && + this.shouldPersistHeadlessMobileSessionActivation(snapshot!, targetTab) + ) { + this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot!, targetTab) + } + return this.getMobileSessionTabsForWorktree(worktreeId) + } 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. @@ -2896,56 +2948,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 @@ -2956,6 +2961,79 @@ export class OrcaRuntimeService { ) } + private shouldPersistHeadlessMobileSessionActivation( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + if (snapshot.publicationEpoch.includes(':headless-merge:')) { + return false + } + if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') { + return false + } + return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab) + } + + private activateHeadlessMobileSessionTerminalTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + activeTab: RuntimeMobileSessionTerminalTab + ): void { + const tabs = snapshot.tabs.map((candidate) => ({ + ...candidate, + isActive: candidate.id === activeTab.id + })) + const terminalTabs = tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' + ) + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: activeTab.id, + activeTabType: 'terminal', + tabGroups: this.buildHeadlessMobileSessionTabGroups( + worktreeId, + terminalTabs, + activeTab, + snapshot.tabGroups + ), + tabs + } + this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + private persistHeadlessTerminalActiveLeaf( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId] + const nextLayouts = existingLayout + ? { + ...session.terminalLayoutsByTabId, + [tab.parentTabId]: { + ...this.cloneTerminalLayoutSnapshot(existingLayout), + activeLeafId: tab.leafId + } + } + : session.terminalLayoutsByTabId + this.store.setWorkspaceSession({ + ...session, + activeTabId: tab.parentTabId, + activeTabIdByWorktree: { + ...session.activeTabIdByWorktree, + [worktreeId]: tab.parentTabId + }, + terminalLayoutsByTabId: nextLayouts + }) + } + async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -6632,6 +6710,106 @@ export class OrcaRuntimeService { return this.store?.getRepos() ?? [] } + listProjects(): Project[] { + return this.store?.getProjects?.() ?? [] + } + + listProjectHostSetups(): ProjectHostSetup[] { + return this.store?.getProjectHostSetups?.() ?? [] + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult { + if (!this.store?.createProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.createProjectHostSetup(args) + if (!result) { + throw new Error(`Project not found: ${args.projectId}`) + } + return result + } + + async setupProjectExistingFolder( + args: ProjectHostSetupExistingFolderArgs + ): Promise<ProjectHostSetupResult> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const existingProject = this.listProjects().find((project) => project.id === args.projectId) + if (!existingProject) { + throw new Error(`Project not found: ${args.projectId}`) + } + let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git') + let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + if (setup.projectId !== args.projectId) { + if ( + !existingProject.providerIdentity || + existingProject.providerIdentity.provider !== 'github' + ) { + throw new Error('Imported folder does not match the selected project identity.') + } + const updated = this.store.updateRepo(repo.id, { + upstream: { + owner: existingProject.providerIdentity.owner, + repo: existingProject.providerIdentity.repo + } + }) + if (!updated) { + throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`) + } + repo = updated + setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + } + const setupMethod = args.setupMethod ?? 'imported-existing-folder' + const updated = this.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 + setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + const project = this.listProjects().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 } + } + + async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> { + const repo = await this.cloneRepo(args.url, args.destination) + return await this.setupProjectExistingFolder({ + projectId: args.projectId, + hostId: args.hostId, + path: repo.path, + kind: 'git', + displayName: args.displayName, + setupMethod: 'cloned' + }) + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult { + if (!this.store?.updateProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.updateProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + return result + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult { + if (!this.store?.deleteProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.deleteProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + return result + } + listProjectGroups(): ProjectGroup[] { return this.store?.getProjectGroups?.() ?? [] } @@ -7237,8 +7415,7 @@ export class OrcaRuntimeService { } else if (code === 0) { resolve() } else { - const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' - reject(new Error(`Clone failed: ${lastLine}`)) + reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`)) } } proc.on('error', (error) => { @@ -9314,6 +9491,7 @@ export class OrcaRuntimeService { const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) const meta = this.store.setWorktreeMeta(worktreeId, { instanceId, + ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), displayName: args.displayName?.trim() || args.name, lastActivityAt: now, createdAt: now, @@ -9696,6 +9874,7 @@ export class OrcaRuntimeService { // and later recreated, creation must mint a fresh instance identity so // stale lineage records tied to the old occupant fail validation. instanceId: randomUUID(), + ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace // window in Recent sort so ambient PTY bumps in OTHER worktrees can't @@ -9815,6 +9994,7 @@ export class OrcaRuntimeService { let didSpawnStartup = false let didSpawnSetup = false let startupTerminalHandle: string | null = null + let startupTerminalTabId: string | null = null if (effectiveStartup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane @@ -9837,6 +10017,7 @@ export class OrcaRuntimeService { } didSpawnStartup = true startupTerminalHandle = terminal.handle + startupTerminalTabId = terminal.tabId ?? null } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -9956,6 +10137,16 @@ export class OrcaRuntimeService { : {}), ...(addResult.localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } + : {}), + ...(didSpawnStartup && startupTerminalHandle + ? { + startupTerminal: { + spawned: true, + handle: startupTerminalHandle, + ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), + surface: 'background' as const + } + } : {}) } } @@ -10047,6 +10238,7 @@ export class OrcaRuntimeService { let didSpawnStartup = false let didSpawnSetup = false let startupTerminalHandle: string | null = null + let startupTerminalTabId: string | null = null if (args.startup && this.ptyController?.spawn) { try { const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent @@ -10070,6 +10262,7 @@ export class OrcaRuntimeService { } didSpawnStartup = true startupTerminalHandle = terminal.handle + startupTerminalTabId = terminal.tabId ?? null } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -10171,7 +10364,20 @@ export class OrcaRuntimeService { } } - return warning ? { ...result, warning } : result + const resultWithStartupTerminal = + didSpawnStartup && startupTerminalHandle + ? { + ...result, + startupTerminal: { + spawned: true, + handle: startupTerminalHandle, + ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), + surface: 'background' as const + } + } + : result + + return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal } /** @@ -11525,7 +11731,7 @@ export class OrcaRuntimeService { console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) } } - return { handle, worktreeId: workspace.id, title: opts.title ?? null, surface } + return { handle, tabId, worktreeId: workspace.id, title: opts.title ?? null, surface } } this.assertGraphReady() @@ -11575,7 +11781,36 @@ export class OrcaRuntimeService { // populates this.leaves may not have arrived yet. Wait for the leaf to // appear so we can return a valid handle the caller can use right away. const handle = await this.waitForTerminalHandle(reply.tabId) - return { handle, worktreeId: worktreeId ?? '', title: reply.title, surface: 'visible' } + return { + handle, + tabId: reply.tabId, + worktreeId: worktreeId ?? '', + title: reply.title, + surface: 'visible' + } + } + + async launchAgentTerminal( + worktreeSelector: string, + opts: { agent: TuiAgent; prompt: string; title?: string } + ): Promise<RuntimeTerminalCreate> { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('Repository for the selected workspace is no longer available.') + } + const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) + if (repo.connectionId) { + await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) + } else { + this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path) + } + return await this.createTerminal(`id:${worktree.id}`, { + command: startup.startup.command, + env: startup.startup.env, + telemetry: startup.startup.telemetry, + title: opts.title + }) } async createMobileSessionTerminal( @@ -11609,7 +11844,9 @@ export class OrcaRuntimeService { worktreeId, opts.activate !== false, opts.afterTabId, - command + command, + undefined, + opts.agent ) } const requestId = randomUUID() @@ -11694,7 +11931,8 @@ export class OrcaRuntimeService { activate: boolean, afterTabId?: string, command?: string, - identity?: { tabId: string; leafId: string; sessionId?: string } + identity?: { tabId: string; leafId: string; sessionId?: string }, + launchAgent?: TuiAgent ): Promise<RuntimeMobileSessionCreateTerminalResult> { const worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`) const repo = this.store?.getRepo(worktree.repoId) @@ -11742,6 +11980,7 @@ export class OrcaRuntimeService { leafId, ptyId: livePty.pty.ptyId, title: terminal.title ?? livePty.pty.title ?? 'Terminal', + ...(launchAgent ? { launchAgent } : {}), parentLayout, isActive: activate } @@ -13445,15 +13684,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:') ) } @@ -13718,6 +13952,7 @@ export class OrcaRuntimeService { title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), + ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), ...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), isActive: tab.isActive, @@ -13795,6 +14030,7 @@ export class OrcaRuntimeService { stateStartedAt: now, paneKey: this.getMobileTerminalPaneKey(tab), ...(terminalHandle ? { terminalHandle } : {}), + ...(tab.launchAgent ? { agentType: tab.launchAgent } : {}), worktreeId: pty.worktreeId, tabId: tab.parentTabId, terminalTitle: getLatestPtyTitle(pty) ?? tab.title, @@ -15103,6 +15339,72 @@ 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, + teamId?: string + ): ReturnType<typeof listLinearIssues> { + return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId) + } + + linearCreateIssue( + teamId: string, + title: string, + description?: string, + workspaceId?: string, + parentIssueId?: string, + projectId?: string | null, + options?: { + stateId?: string + priority?: number + estimate?: number | null + dueDate?: string | null + 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 @@ -16473,72 +16775,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, - teamId?: string - ): ReturnType<typeof listLinearIssues> { - return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId) - } - - linearCreateIssue( - teamId: string, - title: string, - description?: string, - workspaceId?: string, - parentIssueId?: string, - projectId?: string | null, - options?: { - stateId?: string - priority?: number - estimate?: number | null - dueDate?: string | null - 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/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index f607f801cf3..c65eed036de 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -158,6 +158,9 @@ const UiUpdate = z hideSleepingWorkspaces: z.boolean().optional(), showSleepingWorkspaces: z.boolean().optional(), showInactiveWorkspaces: z.boolean().optional(), + workspaceHostScope: z.string().optional(), + visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), + workspaceHostOrder: z.array(z.string()).optional(), hideDefaultBranchWorkspace: z.boolean().optional(), filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts new file mode 100644 index 00000000000..ba874fda2da --- /dev/null +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -0,0 +1,122 @@ +import { z } from 'zod' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalString, requiredString } from '../schemas' + +const ProjectHostSetupExistingFolder = z.object({ + projectId: requiredString('Missing project ID'), + hostId: 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 + }), + path: requiredString('Missing project path'), + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() +}) + +const ProjectHostSetupClone = z.object({ + projectId: requiredString('Missing project ID'), + hostId: 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 + }), + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination'), + displayName: OptionalString +}) + +const ProjectHostSetupCreate = z.object({ + projectId: requiredString('Missing project ID'), + hostId: 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 + }), + setupId: OptionalString, + path: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + worktreeBasePath: OptionalString, + gitUsername: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() +}) + +const ProjectHostSetupUpdate = z.object({ + setupId: requiredString('Missing setup ID'), + updates: z.object({ + displayName: OptionalString, + path: OptionalString, + worktreeBasePath: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z + .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned']) + .optional(), + gitUsername: OptionalString, + kind: z.enum(['git', 'folder']).optional() + }) +}) + +const ProjectHostSetupDelete = z.object({ + setupId: requiredString('Missing setup ID') +}) + +export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'project.list', + params: null, + handler: (_params, { runtime }) => ({ projects: runtime.listProjects() }) + }), + defineMethod({ + name: 'projectHostSetup.list', + params: null, + handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() }) + }), + defineMethod({ + name: 'projectHostSetup.create', + params: ProjectHostSetupCreate, + handler: (params, { runtime }) => ({ + result: runtime.createProjectHostSetup(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.setupExistingFolder', + params: ProjectHostSetupExistingFolder, + handler: async (params, { runtime }) => ({ + result: await runtime.setupProjectExistingFolder(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.clone', + params: ProjectHostSetupClone, + handler: async (params, { runtime }) => ({ + result: await runtime.setupProjectClone(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.update', + params: ProjectHostSetupUpdate, + handler: (params, { runtime }) => ({ + result: runtime.updateProjectHostSetup(params) + }) + }), + defineMethod({ + name: 'projectHostSetup.delete', + params: ProjectHostSetupDelete, + handler: (params, { runtime }) => ({ + result: runtime.deleteProjectHostSetup(params) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 531e0b8589b..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({ @@ -158,6 +159,7 @@ export const REPO_METHODS: RpcMethod[] = [ params: null, handler: (_params, { runtime }) => ({ repos: runtime.listRepos() }) }), + ...PROJECT_RUNTIME_METHODS, defineMethod({ name: 'projectGroup.list', params: null, diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 1a1eab5f084..31ee86e7b9c 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -529,7 +529,11 @@ export class SshRelaySession { ) registerSshFilesystemProvider(this.targetId, fsProvider) - const gitProvider = new SshGitProvider(this.targetId, mux) + const gitProvider = new SshGitProvider( + this.targetId, + mux, + this.remoteCliBridgeEnv?.hostPlatform ?? null + ) registerSshGitProvider(this.targetId, gitProvider) this.wireUpPtyEvents(ptyProvider) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c0816720ce0..82c873366cf 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 @@ -124,8 +125,18 @@ import type { PRComment, PRInfo, PRRefreshOutcome, + Project, Repo, ProjectGroup, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, FolderWorkspace, ProjectGroupImportResult, ProjectGroupImportMode, @@ -150,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 @@ -159,6 +182,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeAccessGrant } from '../shared/runtime-access-grants' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { ExecutionHostId } from '../shared/execution-host' import type { FeatureInteractionId } from '../shared/feature-interactions' import type { AddIssueCommentBySlugArgs, @@ -760,6 +784,13 @@ export type PreloadApi = { pickFolder: () => Promise<string | null> pickDirectory: () => Promise<string | null> clone: (args: { url: string; destination: string }) => Promise<Repo> + cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise<Repo> + createRemote: (args: { + connectionId: string + parentPath: string + name: string + kind: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> cloneAbort: () => Promise<void> // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. addRemote: (args: { @@ -787,6 +818,16 @@ export type PreloadApi = { }) => Promise<BaseRefSearchResult[]> onChanged: (callback: () => void) => () => void } + projects: { + list: () => Promise<Project[]> + listHostSetups: () => Promise<ProjectHostSetup[]> + createHostSetup: (args: ProjectHostSetupCreateArgs) => Promise<ProjectHostSetupCreateResult> + setupExistingFolder: ( + args: ProjectHostSetupExistingFolderArgs + ) => Promise<ProjectHostSetupResult> + updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise<ProjectHostSetupUpdateResult> + deleteHostSetup: (args: ProjectHostSetupDeleteArgs) => Promise<ProjectHostSetupDeleteResult> + } projectGroups: { list: () => Promise<ProjectGroup[]> create: (args: { @@ -1095,11 +1136,13 @@ export type PreloadApi = { issue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number }) => Promise<IssueInfo | null> workItem: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' }) => Promise<Omit<GitHubWorkItem, 'repoId'> | null> @@ -1111,22 +1154,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 @@ -1135,6 +1178,7 @@ export type PreloadApi = { createIssue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null title: string body: string labels?: string[] @@ -1149,33 +1193,35 @@ 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 + sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string 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 + sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean @@ -1183,17 +1229,18 @@ export type PreloadApi = { resolveReviewThread: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null 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 @@ -1201,75 +1248,83 @@ 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> - addPRReviewComment: ( - args: GitHubPRReviewCommentInput & { repoId?: string } + 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> - listLabels: (args: { repoPath: string; repoId?: string }) => Promise<string[]> + 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 + sourceContext?: TaskSourceContext | null + } + ) => Promise<GitHubCommentResult> + listLabels: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }) => Promise<string[]> listAssignableUsers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null }) => Promise<GitHubAssignableUser[]> /** * Subscribe to local-mutation broadcasts. Used by the work-item-drawer @@ -1350,117 +1405,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: { @@ -1627,8 +1701,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 — @@ -1841,11 +1913,13 @@ export type PreloadApi = { }) => Promise<void> } session: { - get: () => Promise<WorkspaceSessionState> - set: (args: WorkspaceSessionState) => Promise<void> - patch: (args: WorkspaceSessionPatch) => Promise<void> + // hostId is optional and defaults to the 'local' partition on the main + // side, so existing callers that omit it behave exactly as before. + get: (hostId?: ExecutionHostId) => Promise<WorkspaceSessionState> + set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise<void> + patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise<void> readTerminalScrollback: (args: { ref: string }) => string | null - setSync: (args: WorkspaceSessionState) => void + setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } remoteWorkspace: { get: (args: { targetId: string }) => Promise<RemoteWorkspaceSnapshot | null> @@ -2402,6 +2476,9 @@ export type PreloadApi = { }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment> remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> + disconnect: (args: { + selector: string + }) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }> getStatus: (args: { selector: string timeoutMs?: number 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 3c181eed8d0..3bb66ac0f73 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, @@ -478,6 +479,10 @@ const api = { clone: (args) => ipcRenderer.invoke('repos:clone', args), + cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), + + createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), + cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), onCloneProgress: ( @@ -513,6 +518,16 @@ const api = { } } satisfies PreloadApi['repos'], + projects: { + list: () => ipcRenderer.invoke('projects:list'), + listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), + createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), + setupExistingFolder: (args) => + ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), + updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), + deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) + } satisfies PreloadApi['projects'], + projectGroups: { list: () => ipcRenderer.invoke('projectGroups:list'), create: (args) => ipcRenderer.invoke('projectGroups:create', args), @@ -925,12 +940,17 @@ const api = { return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) }, - issue: (args: { repoPath: string; repoId?: string; number: number }): Promise<unknown> => - ipcRenderer.invoke('gh:issue', args), + issue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + }): Promise<unknown> => ipcRenderer.invoke('gh:issue', args), workItem: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' }): Promise<unknown> => ipcRenderer.invoke('gh:workItem', args), @@ -947,6 +967,7 @@ const api = { workItemDetails: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' }): Promise<unknown> => ipcRenderer.invoke('gh:workItemDetails', args), @@ -954,6 +975,7 @@ const api = { prFileContents: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number path: string oldPath?: string @@ -968,6 +990,7 @@ const api = { createIssue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null title: string body: string labels?: string[] @@ -994,6 +1017,7 @@ const api = { prChecks: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number headSha?: string prRepo?: { owner: string; repo: string } | null @@ -1003,6 +1027,7 @@ const api = { prCheckDetails: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string @@ -1013,6 +1038,7 @@ const api = { rerunPRChecks: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number headSha?: string failedOnly?: boolean @@ -1022,6 +1048,7 @@ const api = { prComments: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number prRepo?: { owner: string; repo: string } | null noCache?: boolean @@ -1030,6 +1057,7 @@ const api = { resolveReviewThread: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null threadId: string resolve: boolean }): Promise<boolean> => ipcRenderer.invoke('gh:resolveReviewThread', args), @@ -1037,6 +1065,7 @@ const api = { setPRFileViewed: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number pullRequestId: string path: string @@ -1054,6 +1083,7 @@ const api = { mergePR: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: { owner: string; repo: string } | null @@ -1063,6 +1093,7 @@ const api = { setPRAutoMerge: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number enabled: boolean prRepo?: { owner: string; repo: string } | null @@ -1072,6 +1103,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 }> => @@ -1080,6 +1112,7 @@ const api = { requestPRReviewers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] }): Promise<{ ok: true } | { ok: false; error: string }> => @@ -1088,6 +1121,7 @@ const api = { removePRReviewers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] }): Promise<{ ok: true } | { ok: false; error: string }> => @@ -1096,6 +1130,7 @@ const api = { updateIssue: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number updates: unknown }): Promise<{ ok: true } | { ok: false; error: string }> => @@ -1104,6 +1139,7 @@ const api = { addIssueComment: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -1113,6 +1149,7 @@ const api = { addPRReviewCommentReply: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number commentId: number body: string @@ -1125,6 +1162,7 @@ const api = { addPRReviewComment: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null prNumber: number commitId: string path: string @@ -1133,12 +1171,16 @@ const api = { body: string }): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addPRReviewComment', args), - listLabels: (args: { repoPath: string; repoId?: string }): Promise<string[]> => - ipcRenderer.invoke('gh:listLabels', args), + listLabels: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }): Promise<string[]> => ipcRenderer.invoke('gh:listLabels', args), listAssignableUsers: (args: { repoPath: string repoId?: string + sourceContext?: TaskSourceContext | null }): Promise<GitHubAssignableUser[]> => ipcRenderer.invoke('gh:listAssignableUsers', args), // Why: every renderer subscribes to local mutation broadcasts so each @@ -1475,8 +1517,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') }, @@ -2222,14 +2262,16 @@ const api = { } satisfies PreloadApi['cache'], session: { - get: () => ipcRenderer.invoke('session:get'), - set: (args) => ipcRenderer.invoke('session:set', args), - patch: (args) => ipcRenderer.invoke('session:patch', args), + // hostId is optional and defaults to 'local' on the main side, so existing + // call sites that omit it keep targeting the local session partition. + get: (hostId) => ipcRenderer.invoke('session:get', hostId), + set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), + patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ - setSync: (args) => { - ipcRenderer.sendSync('session:set-sync', args) + setSync: (args, hostId) => { + ipcRenderer.sendSync('session:set-sync', args, hostId) } } satisfies PreloadApi['session'], @@ -3333,6 +3375,10 @@ const api = { ipcRenderer.invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => ipcRenderer.invoke('runtimeEnvironments:remove', args), + disconnect: (args: { + selector: string + }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:disconnect', args), getStatus: (args: { selector: string timeoutMs?: number diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts index 9f12c83d1f4..529371c72a4 100644 --- a/src/relay/git-exec-validator.test.ts +++ b/src/relay/git-exec-validator.test.ts @@ -54,7 +54,6 @@ describe('validateGitExecArgs', () => { it.each([ 'push', 'pull', - 'commit', 'checkout', 'reset', 'rebase', @@ -207,4 +206,41 @@ describe('validateGitExecArgs', () => { expectBlocked(['diff', '--cached', '--no-index', '/etc/passwd'], 'git diff flag not allowed') }) }) + + describe('git clone', () => { + it('allows only the project setup clone shape', () => { + expectAllowed(['clone', '--', 'https://github.com/stablyai/orca.git', 'orca']) + expectAllowed(['clone', '--progress', '--', 'git@github.com:stablyai/orca.git', 'orca']) + }) + + it.each([ + [['clone', 'https://github.com/stablyai/orca.git']], + [['clone', 'https://github.com/stablyai/orca.git', 'orca']], + [['clone', '--depth=1', '--', 'https://github.com/stablyai/orca.git', 'orca']], + [['clone', '--', 'https://github.com/stablyai/orca.git', '.']], + [['clone', '--', 'https://github.com/stablyai/orca.git', '..']], + [['clone', '--', 'https://github.com/stablyai/orca.git', 'nested/orca']], + [['clone', '--', 'https://github.com/stablyai/orca.git', 'nested\\orca']] + ])('rejects unsafe clone args %j', (args) => { + expectBlocked(args, 'git clone') + }) + }) + + describe('git init and empty commit', () => { + it('allows only the SSH create-project init and empty commit shapes', () => { + expectAllowed(['init']) + expectAllowed(['commit', '--allow-empty', '-m', 'Initial commit']) + }) + + it.each([ + [['init', '--bare']], + [['init', '/tmp/other']], + [['commit']], + [['commit', '-am', 'message']], + [['commit', '--allow-empty']], + [['commit', '--allow-empty', '-m', '']] + ])('rejects unsafe create-project write args %j', (args) => { + expectBlocked(args, 'via exec is restricted') + }) + }) }) diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts index aa373957081..546f92afc93 100644 --- a/src/relay/git-exec-validator.ts +++ b/src/relay/git-exec-validator.ts @@ -5,8 +5,9 @@ * Extracted from git-handler-ops.ts to keep both files under the limit. */ -// Why: only read-only git subcommands are allowed via exec. config is restricted -// to read-only flags; branch rejects destructive flags; fetch/worktree removed. +// Why: only read-only git subcommands are allowed via exec, except for the +// exact init/empty-commit shapes used by SSH Create Project after the parent +// directory has already been validated by main. const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'rev-parse', 'branch', @@ -18,6 +19,9 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'merge-base', 'diff', 'ls-files', + 'clone', + 'init', + 'commit', 'for-each-ref', 'check-ref-format', 'config' @@ -81,6 +85,38 @@ const DIFF_ALLOWED_FLAGS = new Set([ '--no-ext-diff' ]) +function validateCloneArgs(args: string[]): void { + // Why: project-host setup needs remote clone, but git.exec must not become a + // general write surface. Permit only `git clone [--progress] -- <url> <dir>`. + const allowed = args[1] === '--progress' ? args.slice(2) : args.slice(1) + if (allowed.length !== 3 || allowed[0] !== '--') { + throw new Error('git clone via exec is restricted to clone [--progress] -- <url> <dir>') + } + const targetDir = allowed[2] + if ( + !targetDir || + targetDir === '.' || + targetDir === '..' || + targetDir.includes('/') || + targetDir.includes('\\') || + targetDir.includes('\0') + ) { + throw new Error('git clone target directory must be a single safe path segment') + } +} + +function validateInitArgs(args: string[]): void { + if (args.length !== 1) { + throw new Error('git init via exec is restricted to init with no arguments') + } +} + +function validateCommitArgs(args: string[]): void { + if (args.length !== 4 || args[1] !== '--allow-empty' || args[2] !== '-m' || !args[3]) { + throw new Error('git commit via exec is restricted to commit --allow-empty -m <message>') + } +} + // Why: git accepts --flag=value compound syntax (e.g. --file=/etc/passwd), // which bypasses exact-match Set.has() checks. This helper catches both forms. function matchesDeniedFlag(arg: string, denySet: Set<string>): boolean { @@ -124,6 +160,12 @@ export function validateGitExecArgs(args: string[]): void { throw new Error('git config write operations are not allowed via exec') } } + if (subcommand === 'init') { + validateInitArgs(args) + } + if (subcommand === 'commit') { + validateCommitArgs(args) + } if (subcommand === 'branch') { if (restArgs.some((a) => matchesDeniedFlag(a, BRANCH_DESTRUCTIVE_FLAGS))) { throw new Error('Destructive git branch flags are not allowed via exec') @@ -156,4 +198,7 @@ export function validateGitExecArgs(args: string[]): void { throw new Error(`git diff flag not allowed via exec: ${unsupportedArg}`) } } + if (subcommand === 'clone') { + validateCloneArgs(args) + } } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 79d3d763a2b..db443ec89a2 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -90,6 +90,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.exec') + expect(methods).toContain('git.clone') expect(methods).toContain('git.isGitRepo') }) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 1ae336b81e7..74883a6aa34 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,9 +1,9 @@ /* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC protocol surface so local and SSH git behavior stay in one dispatch table. */ -import { execFile } from 'child_process' +import { execFile, spawn } from 'child_process' import { promisify } from 'util' import * as path from 'path' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' import { @@ -44,6 +44,7 @@ import { removeSafeUntrackedDiscardTarget, removeSafeUntrackedDiscardTargets } from '../shared/git-discard-path-safety' +import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -93,14 +94,15 @@ export class GitHandler { this.refreshLocalBaseRefForWorktreeCreate(p) ) this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p)) - this.dispatcher.onRequest('git.exec', (p) => this.exec(p)) + this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context)) + this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context)) this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) } private async git( args: string[], cwd: string, - opts?: { maxBuffer?: number; disableOptionalLocks?: boolean } + opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; signal?: AbortSignal } ): Promise<{ stdout: string; stderr: string }> { const env = buildRelayCommandEnv() if (opts?.disableOptionalLocks) { @@ -110,7 +112,8 @@ export class GitHandler { cwd: expandTilde(cwd), env, encoding: 'utf-8', - maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER + maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, + signal: opts?.signal }) } @@ -585,15 +588,95 @@ export class GitHandler { }) } - private async exec(params: Record<string, unknown>) { + private async exec(params: Record<string, unknown>, context?: RequestContext) { const args = params.args as string[] const cwd = params.cwd as string validateGitExecArgs(args) - const { stdout, stderr } = await this.git(args, cwd) + const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal }) return { stdout, stderr } } + private async clone(params: Record<string, unknown>, context?: RequestContext) { + const args = params.args as string[] + const cwd = params.cwd as string + const progressId = params.progressId + validateGitExecArgs(args) + if (typeof progressId !== 'string' || progressId.length === 0) { + throw new Error('Missing clone progress id.') + } + if (args[0] !== 'clone') { + throw new Error('git.clone only supports clone commands.') + } + return await this.spawnClone(args, cwd, progressId, context) + } + + private async spawnClone( + args: string[], + cwd: string, + progressId: string, + context?: RequestContext + ): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolve, reject) => { + const child = spawn('git', args, { + cwd: expandTilde(cwd), + env: buildRelayCommandEnv(), + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + let settled = false + const cleanup = (): void => { + context?.signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + child.kill() + } + context?.signal?.addEventListener('abort', onAbort, { once: true }) + child.stdout?.on('data', (chunk: Buffer) => { + stdout = (stdout + chunk.toString('utf-8')).slice(-4096) + }) + child.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf-8') + stderr = (stderr + text).slice(-4096) + for (const line of text.split(/[\r\n]+/)) { + const match = line.match(/^([\w\s]+):\s+(\d+)%/) + if (match) { + this.dispatcher.notify('git.cloneProgress', { + progressId, + phase: match[1].trim(), + percent: parseInt(match[2], 10) + }) + } + } + }) + child.on('error', (error) => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + }) + child.on('close', (code, signal) => { + if (settled) { + return + } + settled = true + cleanup() + if (context?.signal?.aborted) { + reject(new Error('Clone aborted')) + return + } + if (code === 0 && !signal) { + resolve({ stdout, stderr }) + return + } + reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderr)}`)) + }) + }) + } + private async renameCurrentBranch(params: Record<string, unknown>) { const worktreePath = params.worktreePath const newBranch = params.newBranch diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ea9d0ea2194..e19001d5c8d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -92,6 +92,11 @@ import { shouldPersistWorkspaceSession } from './lib/workspace-session' import { createSessionWriteSubscriber } from './lib/session-write-subscriber' +import { + fetchWorkspaceSessionFromHosts, + patchWorkspaceSessionByHost, + persistWorkspaceSessionByHostSync +} from './lib/workspace-session-host-persistence' import { getStartupErrorFallbackUI, hydratePersistedUIAfterStartupRead @@ -759,7 +764,14 @@ function App(): React.JSX.Element { cancelled, hydratePersistedUI: actions.hydratePersistedUI }) - const session = await window.api.session.get() + // Why: runtime-owned worktree slices live in per-host partitions. + // Repos were fetched above, so the known runtime hosts are derivable + // here; merge their slices into the unified session the hydrators + // expect. An unreadable host partition is skipped (fail-soft). + const session = await fetchWorkspaceSessionFromHosts( + window.api.session, + useAppStore.getState().repos + ) await actions.fetchKeybindings() if (!cancelled) { actions.hydrateWorkspaceSession(session) @@ -1051,9 +1063,12 @@ function App(): React.JSX.Element { store: useAppStore, shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), persist: ({ patch }) => { - const localWrite = window.api.session.patch(patch) - void localWrite const state = useAppStore.getState() + // Why: route each runtime host's worktree-scoped slice to its own + // partition; the returned promise is the local write so the + // remote-workspace upload chain below keeps its ordering. + const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) + void localWrite const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter( (targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict' ) @@ -1113,7 +1128,11 @@ function App(): React.JSX.Element { // into the store via Zustand setters. The earlier read is only for the // gating flags and would miss those updates. const freshState = useAppStore.getState() - window.api.session.setSync(buildWorkspaceSessionPayload(freshState)) + persistWorkspaceSessionByHostSync( + window.api.session, + buildWorkspaceSessionPayload(freshState), + freshState + ) shutdownBuffersCaptured = true } window.addEventListener('beforeunload', captureAndFlush) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c4735589d49..c5773669e44 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,12 +167,15 @@ import type { PRCheckDetail, PRComment } from '../../../shared/types' +import { + getTaskSourceRuntimeSettings, + 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. @@ -272,6 +270,7 @@ type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null repoId?: string | null + sourceContext?: TaskSourceContext | null initialTab?: ItemDialogTab variant?: 'sheet' | 'page' backLabel?: string @@ -436,11 +435,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) @@ -458,7 +459,19 @@ function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const reviewerInputRef = useRef<HTMLInputElement | null>(null) const reviewerInputFocusFrameRef = useRef<number | null>(null) const reviewerPanelMountedRef = useRef(true) @@ -533,11 +546,12 @@ function PRReviewersPanel({ open && reviewSlug ? reviewSlug.owner : null, open && reviewSlug ? reviewSlug.repo : null, reviewerSeedUsers.map((user) => user.login), - settings + sourceSettings ) const reviewerMetadataByPath = useRepoAssignees( open && !reviewSlug ? repoPath : null, - open && !reviewSlug ? item.repoId : null + open && !reviewSlug ? item.repoId : null, + sourceSettings ) const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } @@ -636,7 +650,8 @@ function PRReviewersPanel({ localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment' + const canRequestReview = + !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' const measureReviewerPickerPlacement = useCallback(() => { const rect = reviewerInputRef.current?.getBoundingClientRect() @@ -679,7 +694,7 @@ function PRReviewersPanel({ ) return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -702,6 +717,7 @@ function PRReviewersPanel({ : await window.api.gh.requestPRReviewers({ repoPath: repoPath ?? '', repoId: item.repoId, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -721,7 +737,9 @@ function PRReviewersPanel({ localReviewRequests ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) onReviewersRequested(nextReviewRequests) setReviewerInput('') useAppStore.getState().recordFeatureInteraction('github-tasks') @@ -754,7 +772,7 @@ function PRReviewersPanel({ if (logins.length === 0) { return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -777,6 +795,7 @@ function PRReviewersPanel({ : await window.api.gh.removePRReviewers({ repoPath: repoPath ?? '', repoId: item.repoId, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -795,7 +814,9 @@ function PRReviewersPanel({ (reviewer) => !removed.has(reviewer.login.toLowerCase()) ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) onReviewersRequested(nextReviewRequests) setReviewerInput('') useAppStore.getState().recordFeatureInteraction('github-tasks') @@ -1410,6 +1431,7 @@ function getPRFileContentCacheKey(args: { function loadPRFileContents(args: { repoPath: string repoId: string + sourceContext?: TaskSourceContext | null prNumber: number file: GitHubPRFile headSha: string @@ -1425,6 +1447,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, @@ -1447,6 +1470,7 @@ function loadPRFileContents(args: { function addIssueCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -1454,6 +1478,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 @@ -1463,6 +1488,7 @@ function addIssueCommentForRepo(args: { function addPRReviewCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number commitId: string path: string @@ -1473,6 +1499,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, @@ -1485,6 +1512,7 @@ function addPRReviewCommentForRepo(args: { function addPRReviewCommentReplyForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number commentId: number body: string @@ -1495,6 +1523,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, @@ -1507,6 +1536,7 @@ function addPRReviewCommentReplyForRepo(args: { function setPRFileViewedForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null prNumber: number pullRequestId: string path: string @@ -1515,6 +1545,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, @@ -1525,12 +1556,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 }) @@ -1663,6 +1696,7 @@ type PRFilesCombinedDiffViewerProps = { comments: PRComment[] repoPath: string repoId: string + sourceContext?: TaskSourceContext | null prNumber: number prUrl: string headSha: string | undefined @@ -1677,6 +1711,7 @@ function PRFilesCombinedDiffViewer({ comments, repoPath, repoId, + sourceContext, prNumber, prUrl, headSha, @@ -1843,6 +1878,7 @@ function PRFilesCombinedDiffViewer({ const contents = await loadPRFileContents({ repoPath, repoId, + sourceContext, prNumber, file, headSha, @@ -1884,7 +1920,7 @@ function PRFilesCombinedDiffViewer({ ) }) }, - [baseSha, fileByPath, headSha, prNumber, repoId, repoPath] + [baseSha, fileByPath, headSha, prNumber, repoId, repoPath, sourceContext] ) const retrySection = useCallback( @@ -2023,6 +2059,7 @@ function PRFilesCombinedDiffViewer({ const result = await addPRReviewCommentForRepo({ repoPath, repoId, + sourceContext, prNumber, commitId: headSha, path: section.path, @@ -2046,7 +2083,7 @@ function PRFilesCombinedDiffViewer({ ) return true }, - [headSha, onCommentAdded, prNumber, repoId, repoPath] + [headSha, onCommentAdded, prNumber, repoId, repoPath, sourceContext] ) const renderViewedCheckbox = useCallback( @@ -2192,6 +2229,7 @@ function CommentCodeContext({ comment, repoPath, repoId, + sourceContext, prNumber, files, headSha, @@ -2200,6 +2238,7 @@ function CommentCodeContext({ comment: PRComment repoPath: string | null repoId: string + sourceContext?: TaskSourceContext | null prNumber: number files: GitHubPRFile[] headSha: string | undefined @@ -2224,7 +2263,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) @@ -2238,7 +2277,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, @@ -2492,6 +2531,7 @@ function CommentCodeContext({ function ConversationTab({ item, repoPath, + sourceContext, body, comments, files, @@ -2512,6 +2552,7 @@ function ConversationTab({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null body: string comments: PRComment[] files: GitHubPRFile[] @@ -2575,6 +2616,7 @@ function ConversationTab({ await runWorkItemBodyUpdate({ item, repoPath, + sourceContext, projectOrigin, body: resolvedBodyDraft, parsedSlug: bodySlug @@ -2605,7 +2647,8 @@ function ConversationTab({ item, onBodyUpdated, projectOrigin, - repoPath + repoPath, + sourceContext ]) const handleReply = useCallback( @@ -2624,6 +2667,7 @@ function ConversationTab({ ? await addPRReviewCommentReplyForRepo({ repoPath, repoId: item.repoId, + sourceContext, prNumber: item.number, commentId: comment.id, body: replyBody, @@ -2634,6 +2678,7 @@ function ConversationTab({ : await addIssueCommentForRepo({ repoPath, repoId: item.repoId, + sourceContext, number: item.number, body: `@${comment.author} ${replyBody}`, type: item.type @@ -2651,7 +2696,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 = @@ -2661,6 +2706,7 @@ function ConversationTab({ item={item} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} onStateChange={onStateChange} @@ -2670,6 +2716,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"> @@ -2677,6 +2724,7 @@ function ConversationTab({ item={item} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} headSha={headSha} checks={checks} loading={loading || !detailsLoaded} @@ -2690,12 +2738,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} @@ -2782,6 +2830,7 @@ function ConversationTab({ comment={comment} repoPath={repoPath} repoId={item.repoId} + sourceContext={sourceContext} prNumber={item.number} files={files} headSha={headSha} @@ -2973,9 +3022,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 && ( @@ -3009,13 +3058,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)} @@ -3025,19 +3074,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> @@ -3051,6 +3095,7 @@ function PRActionsPanel({ item, repoPath, repoId, + sourceContext, projectOrigin, localState, onStateChange, @@ -3059,6 +3104,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 @@ -3089,10 +3135,10 @@ function PRActionsPanel({ const applyStatePatch = useCallback( (state: GitHubWorkItem['state']) => { onStateChange(state) - patchWorkItem(item.id, { state }, item.repoId) + patchWorkItem(item.id, { state }, item.repoId, { sourceContext }) patchProjectRowIfNeeded(state) }, - [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem] + [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext] ) const handleStateChange = async (): Promise<void> => { @@ -3129,6 +3175,7 @@ function PRActionsPanel({ await runPullRequestStateUpdate({ repoPath, repoId, + sourceContext, projectOrigin, number: item.number, updates: { state: nextState } @@ -3179,6 +3226,7 @@ function PRActionsPanel({ const result = await window.api.gh.mergePR({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, method, prRepo: item.prRepo ?? null @@ -3210,6 +3258,7 @@ function PRActionsPanel({ const result = await window.api.gh.setPRAutoMerge({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, enabled, prRepo: item.prRepo ?? null @@ -3248,7 +3297,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> @@ -3257,8 +3306,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' )} > @@ -3267,13 +3315,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> @@ -3319,8 +3365,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' )} @@ -3334,11 +3379,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> @@ -3551,6 +3594,7 @@ function ChecksTab({ item, repoPath, repoId, + sourceContext, headSha, checks, loading, @@ -3560,6 +3604,7 @@ function ChecksTab({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null headSha: string | undefined checks: GitHubWorkItemDetails['checks'] loading: boolean @@ -3621,6 +3666,7 @@ function ChecksTab({ const nextChecks = (await window.api.gh.prChecks({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, noCache: true @@ -3638,7 +3684,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> => { @@ -3650,6 +3696,7 @@ function ChecksTab({ const result = await window.api.gh.rerunPRChecks({ repoPath, repoId: repoId ?? undefined, + sourceContext, prNumber: item.number, headSha, failedOnly @@ -3674,7 +3721,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> => { @@ -4288,15 +4335,23 @@ function ChecksTab({ // repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt // to a thrown rejection so the existing `useImmediateMutation` flow // (which expects throws on failure) continues to work unchanged. +function getGitHubMutationSettings(repoId: string | null | undefined) { + const state = useAppStore.getState() + // Why: project-origin mutations are slug-addressed, but when we know the + // backing repo id they must still execute on that repo's owner host. + return getSettingsForRepoRuntimeOwner(state, repoId ?? null) +} + 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'] }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4323,6 +4378,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 }) @@ -4334,6 +4390,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 @@ -4345,7 +4402,7 @@ async function runWorkItemBodyUpdate(args: { if (!targetSlug) { throw new Error('No GitHub repository context available for this pull request.') } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.item.repoId)) const updateArgs = { owner: targetSlug.owner, repo: targetSlug.repo, @@ -4370,6 +4427,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 } @@ -4379,12 +4437,13 @@ 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' } }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4411,6 +4470,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 }) @@ -4419,10 +4479,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, @@ -4438,6 +4532,7 @@ function GHEditSection({ item: GitHubWorkItem repoPath: string | null repoId: string | null + sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined localState: GitHubWorkItem['state'] localLabels: string[] @@ -4463,6 +4558,19 @@ function GHEditSection({ const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const { isPending, run } = useImmediateMutation() // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside @@ -4486,16 +4594,18 @@ function GHEditSection({ const slugRepo = projectOrigin?.repo ?? null const repoLabelsByPath = useRepoLabels( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + sourceSettings ) - const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) const repoAssigneesByPath = useRepoAssignees( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + sourceSettings ) - const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) + const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, sourceSettings) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath const hasAttachedWorkspace = attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined @@ -4528,23 +4638,24 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { state: newState } }), onOptimistic: () => { onStateChange(newState) - patchWorkItem(item.id, { state: newState }, item.repoId) + patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { onStateChange(prevState) - patchWorkItem(item.id, { state: prevState }, item.repoId) + patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: prevState }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('github-tasks') - patchWorkItem(item.id, { state: newState }, item.repoId) + patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: newState }) onMutated() }, @@ -4557,6 +4668,7 @@ function GHEditSection({ item.repoId, localState, repoPath, + sourceContext, projectOrigin, patchWorkItem, patchProjectRowIfNeeded, @@ -4578,13 +4690,14 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { addLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId) + patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: newLabels }) }, onSuccess: () => { @@ -4593,7 +4706,7 @@ function GHEditSection({ }, onRevert: () => { onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId) + patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: prevLabels }) }, onError: (err) => toast.error(err) @@ -4604,18 +4717,19 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { removeLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId) + patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: newLabels }) }, onRevert: () => { onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId) + patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ labels: prevLabels }) }, onSuccess: () => { @@ -4632,6 +4746,7 @@ function GHEditSection({ item.repoId, localLabels, repoPath, + sourceContext, projectOrigin, patchWorkItem, patchProjectRowIfNeeded, @@ -4658,6 +4773,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { removeAssignees: [login] } @@ -4682,6 +4798,7 @@ function GHEditSection({ runIssueUpdate({ repoId: item.repoId, repoPath, + sourceContext, projectOrigin, number: item.number, updates: { addAssignees: [login] } @@ -4707,6 +4824,7 @@ function GHEditSection({ item.repoId, assigneesItemKey, repoPath, + sourceContext, projectOrigin, localAssignees, patchProjectRowIfNeeded, @@ -4719,6 +4837,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]"> @@ -4800,14 +4930,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> @@ -4866,15 +5023,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> @@ -4909,12 +5090,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' @@ -5035,15 +5216,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> @@ -5075,14 +5280,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> @@ -5149,6 +5381,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 @@ -5166,10 +5491,12 @@ function GHEditSection({ // doc §1 rule: hide when either side is unknown rather than guessing. function WorkItemIssueSourceIndicator({ url, - repoId + repoId, + repoPath }: { url: string repoId: string | null + repoPath?: string | null }): React.JSX.Element | null { // Why: subscribe to a single store-side selector that returns the resolved // sources for this repo — either the primary `(repoPath, PER_REPO_FETCH_LIMIT, '')` @@ -5183,7 +5510,7 @@ function WorkItemIssueSourceIndicator({ // indicator is small and the cache rewrite rate is bounded by user-initiated // refresh/search actions. const sources = useAppStore((s) => - s.getWorkItemsAnySourcesForRepo(repoId ?? '', PER_REPO_FETCH_LIMIT) + s.getWorkItemsAnySourcesForRepo(repoId ?? '', PER_REPO_FETCH_LIMIT, repoPath ?? undefined) ) const issues = useMemo<GitHubOwnerRepo | null>(() => { const fromUrl = parseOwnerRepoFromItemUrl(url) @@ -5215,6 +5542,7 @@ export default function GitHubItemDialog({ workItem, repoPath, repoId, + sourceContext, initialTab, variant = 'sheet', backLabel = 'Back', @@ -5451,6 +5779,7 @@ export default function GitHubItemDialog({ getWorkItemDetailsForRepo({ repoPath, repoId: effectiveRepoId ?? undefined, + sourceContext, number: workItem.number, type: workItem.type }) @@ -5511,7 +5840,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>(() => { @@ -5645,6 +5974,7 @@ export default function GitHubItemDialog({ const ok = await setPRFileViewedForRepo({ repoId: workItem.repoId, repoPath, + sourceContext, prNumber: workItem.number, pullRequestId: details.pullRequestId, path, @@ -5671,20 +6001,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"> @@ -5870,7 +6193,11 @@ export default function GitHubItemDialog({ {formatRelativeTime(workItem.updatedAt)} </span> </span> - <WorkItemIssueSourceIndicator url={workItem.url} repoId={effectiveRepoId} /> + <WorkItemIssueSourceIndicator + url={workItem.url} + repoId={effectiveRepoId} + repoPath={repoPath} + /> {issueAttachedWorkspaceLabel ? ( <span className="inline-flex min-w-0 items-center gap-1.5"> <FolderKanban className="size-3.5 shrink-0" /> @@ -5881,12 +6208,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 @@ -5939,7 +6261,11 @@ export default function GitHubItemDialog({ ) : null} </div> {workItem.type === 'issue' && ( - <WorkItemIssueSourceIndicator url={workItem.url} repoId={effectiveRepoId} /> + <WorkItemIssueSourceIndicator + url={workItem.url} + repoId={effectiveRepoId} + repoPath={repoPath} + /> )} </div> <div className="flex shrink-0 items-center justify-end gap-1"> @@ -6035,6 +6361,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} localLabels={localLabels} @@ -6073,6 +6400,7 @@ export default function GitHubItemDialog({ item={displayWorkItem ?? workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} body={body} comments={comments} files={files} @@ -6123,6 +6451,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} projectOrigin={projectOrigin} localState={localState} localLabels={localLabels} @@ -6191,6 +6520,7 @@ export default function GitHubItemDialog({ item={displayWorkItem ?? workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} body={body} comments={comments} files={files} @@ -6242,6 +6572,7 @@ export default function GitHubItemDialog({ item={workItem} repoPath={repoPath} repoId={effectiveRepoId} + sourceContext={sourceContext} headSha={details?.headSha} checks={checks} loading={loading || !detailsLoaded} @@ -6272,6 +6603,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..9d425d9cc54 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,24 @@ import type { GitLabWorkItemDetails, MRComment } from '../../../shared/types' +import 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 +320,8 @@ function PipelineJobRow({ export default function GitLabItemDialog({ item, repoPath, + repoId, + sourceContext, onClose, onCreateWorkspace }: Props): React.JSX.Element { @@ -351,6 +362,16 @@ export default function GitLabItemDialog({ const [retryingJobId, setRetryingJobId] = useState<number | null>(null) const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null) const mountedRef = useMountedRef() + const repoSelector = useMemo<GitLabDialogRepoSelector | null>(() => { + if (!repoPath) { + return null + } + return { + repoPath, + ...(repoId ? { repoId } : {}), + ...(sourceContext ? { sourceContext } : {}) + } + }, [repoId, repoPath, sourceContext]) const updateCommentDraft = useCallback( (value: string): void => { setCommentDraftState({ itemId, value }) @@ -359,7 +380,7 @@ export default function GitLabItemDialog({ ) useEffect(() => { - if (!item || !repoPath) { + if (!item || !repoSelector) { setDetails(null) setLoading(false) setError(null) @@ -370,7 +391,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 +415,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 +444,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 +462,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 +483,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 +504,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 +542,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 +578,7 @@ export default function GitLabItemDialog({ item, labelDraft, mountedRef, - repoPath, + repoSelector, titleDraft ]) @@ -568,7 +589,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 +598,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 +623,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 +669,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 +692,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 +718,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 +749,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 +789,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 +819,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 +848,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 +877,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 +890,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 +910,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 +948,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/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 4be68a77b56..56ffbc802fb 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -42,12 +42,14 @@ import type { JiraTransition, JiraUser } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type JiraIssueWorkspaceProps = { issue: JiraIssue | null onUse: (issue: JiraIssue) => void onClose: () => void + sourceContext?: TaskSourceContext | null } const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) @@ -111,9 +113,11 @@ async function copyTextToClipboard(text: string, label: string): Promise<void> { export default function JiraIssueWorkspace({ issue, onUse, - onClose + onClose, + sourceContext }: JiraIssueWorkspaceProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchJiraIssue = useAppStore((s) => s.patchJiraIssue) const [fullIssue, setFullIssue] = useState<JiraIssue | null>(null) const [issueLoading, setIssueLoading] = useState(false) @@ -139,7 +143,7 @@ export default function JiraIssueWorkspace({ setCommentsLoading(true) setCommentsError(null) try { - let fetched = await jiraIssueComments(settings, targetIssue.key, targetIssue.siteId) + let fetched = await jiraIssueComments(providerSettings, targetIssue.key, targetIssue.siteId) if (requestId !== requestIdRef.current) { return } @@ -159,7 +163,7 @@ export default function JiraIssueWorkspace({ } } }, - [settings] + [providerSettings] ) useEffect(() => { @@ -186,7 +190,7 @@ export default function JiraIssueWorkspace({ setCommentsError(null) setIssueLoading(true) - void jiraGetIssue(settings, issue.key, issue.siteId) + void jiraGetIssue(providerSettings, issue.key, issue.siteId) .then((result) => { if (requestId !== requestIdRef.current) { return @@ -205,9 +209,9 @@ export default function JiraIssueWorkspace({ }) void Promise.all([ - jiraListTransitions(settings, issue.key, issue.siteId), - jiraListPriorities(settings, issue.siteId), - jiraListAssignableUsers(settings, issue.key, undefined, issue.siteId) + jiraListTransitions(providerSettings, issue.key, issue.siteId), + jiraListPriorities(providerSettings, issue.siteId), + jiraListAssignableUsers(providerSettings, issue.key, undefined, issue.siteId) ]) .then(([nextTransitions, nextPriorities, nextUsers]) => { if (requestId !== requestIdRef.current) { @@ -220,22 +224,22 @@ export default function JiraIssueWorkspace({ .catch(() => {}) void loadComments(issue, requestId) - }, [issue, loadComments, settings]) + }, [issue, loadComments, providerSettings]) const refreshIssue = useCallback(async (): Promise<void> => { if (!displayed) { return } try { - const latest = await jiraGetIssue(settings, displayed.key, displayed.siteId) + const latest = await jiraGetIssue(providerSettings, displayed.key, displayed.siteId) if (latest) { setFullIssue(latest) - patchJiraIssue(latest.key, latest) + patchJiraIssue(latest.key, latest, { sourceContext }) } } catch { // Keep the visible issue snapshot if refresh fails. } - }, [displayed, patchJiraIssue, settings]) + }, [displayed, patchJiraIssue, providerSettings, sourceContext]) const mutateIssue = useCallback( async ( @@ -251,16 +255,16 @@ export default function JiraIssueWorkspace({ try { if (optimistic) { setFullIssue({ ...displayed, ...optimistic }) - patchJiraIssue(displayed.key, optimistic) + patchJiraIssue(displayed.key, optimistic, { sourceContext }) } - const result = await jiraUpdateIssue(settings, displayed.key, updates, siteId) + const result = await jiraUpdateIssue(providerSettings, displayed.key, updates, siteId) if (!result.ok) { throw new Error(result.error) } await refreshIssue() } catch (error) { setFullIssue(previous) - patchJiraIssue(previous.key, previous) + patchJiraIssue(previous.key, previous, { sourceContext }) toast.error( error instanceof Error ? error.message @@ -273,7 +277,7 @@ export default function JiraIssueWorkspace({ setPendingField(null) } }, - [displayed, patchJiraIssue, pendingField, refreshIssue, settings, siteId] + [displayed, patchJiraIssue, pendingField, refreshIssue, providerSettings, siteId, sourceContext] ) const handleSaveTitle = useCallback(() => { @@ -309,7 +313,12 @@ export default function JiraIssueWorkspace({ } setCommentSubmitting(true) try { - const result = await jiraAddIssueComment(settings, displayed.key, body, displayed.siteId) + const result = await jiraAddIssueComment( + providerSettings, + displayed.key, + body, + displayed.siteId + ) if (!result.ok) { throw new Error(result.error) } @@ -331,7 +340,7 @@ export default function JiraIssueWorkspace({ } finally { setCommentSubmitting(false) } - }, [commentDraft, commentSubmitting, displayed, settings]) + }, [commentDraft, commentSubmitting, displayed, providerSettings]) const actionItems = useMemo(() => { if (!displayed) { diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 3e97489e8df..473fa75d3c3 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -9,6 +9,7 @@ import { useAppStore } from '@/store' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { linearUpdateIssue } from '@/runtime/runtime-linear-client' import type { LinearIssue } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { getLinearIssueTextSavePlan, type LinearIssueTextField @@ -24,6 +25,7 @@ type LinearIssueTextEditorProps = { onIssueChange: (patch: Pick<LinearIssue, 'title'> | Pick<LinearIssue, 'description'>) => void density?: 'page' | 'drawer' fields?: 'all' | 'title' | 'description' + sourceContext?: TaskSourceContext | null } function useAutosizeTextArea(value: string): React.RefObject<HTMLTextAreaElement | null> { @@ -45,9 +47,11 @@ export function LinearIssueTextEditor({ issue, onIssueChange, density = 'page', - fields = 'all' + fields = 'all', + sourceContext }: LinearIssueTextEditorProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const [draftState, setDraftState] = useState(() => createLinearIssueTextDraftState(issue)) const [savingField, setSavingField] = useState<LinearIssueTextField | null>(null) @@ -111,7 +115,7 @@ export function LinearIssueTextEditor({ onIssueChange(patch) patchLinearIssue(issue.id, patch) try { - const result = await linearUpdateIssue(settings, issue.id, patch, issue.workspaceId) + const result = await linearUpdateIssue(providerSettings, issue.id, patch, issue.workspaceId) if (!result.ok) { throw new Error(result.error) } @@ -156,7 +160,7 @@ export function LinearIssueTextEditor({ mountedRef, onIssueChange, patchLinearIssue, - settings, + providerSettings, titleDraft, updateDescriptionDraft, updateTitleDraft diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index 0272093ee01..d3090782380 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -57,6 +57,7 @@ import type { LinearIssueChildSummary, LinearProjectSummary } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type LinearIssueWorkspaceProps = { @@ -66,6 +67,7 @@ type LinearIssueWorkspaceProps = { onClose: () => void variant?: 'sheet' | 'page' backLabel?: string + sourceContext?: TaskSourceContext | null } async function copyTextToClipboard(text: string, label: string): Promise<void> { @@ -111,12 +113,15 @@ function LinearIssueAvatar({ function LinearIssueSubIssueButton({ issue, - onOpenIssue + onOpenIssue, + sourceContext }: { issue: LinearIssue onOpenIssue: (issue: LinearIssue) => void + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) const [open, setOpen] = useState(false) const [title, setTitle] = useState('') @@ -145,7 +150,9 @@ function LinearIssueSubIssueButton({ async (subIssue: LinearIssueChildSummary) => { setOpeningSubIssueId(subIssue.id) try { - const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId) + const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId, { + sourceContext + }) if (!mountedRef.current) { return } @@ -173,7 +180,7 @@ function LinearIssueSubIssueButton({ } } }, - [fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue] + [fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue, sourceContext] ) const handleCreate = useCallback(async () => { @@ -183,7 +190,7 @@ function LinearIssueSubIssueButton({ } setSubmitting(true) try { - const result = await linearCreateSubIssue(settings, { + const result = await linearCreateSubIssue(providerSettings, { parentIssueId: issue.id, teamId: issue.team.id, title: trimmed, @@ -235,7 +242,7 @@ function LinearIssueSubIssueButton({ issue.subIssues, issue.team.id, issue.workspaceId, - settings, + providerSettings, title ]) @@ -311,12 +318,15 @@ function LinearIssueSubIssueButton({ function LinearIssueSidebarProjectCard({ issue, - onProjectChanged + onProjectChanged, + sourceContext }: { issue: LinearIssue onProjectChanged: (project: LinearProjectSummary) => void + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const [open, setOpen] = useState(false) const [query, setQuery] = useState('') @@ -331,7 +341,7 @@ function LinearIssueSidebarProjectCard({ let cancelled = false const timeout = window.setTimeout(() => { setLoading(true) - void linearListProjects(settings, query, 20, issue.workspaceId) + void linearListProjects(providerSettings, query, 20, issue.workspaceId) .then((result) => { if (!cancelled) { setProjects(result.items) @@ -359,21 +369,21 @@ function LinearIssueSidebarProjectCard({ cancelled = true window.clearTimeout(timeout) } - }, [issue.workspaceId, open, query, settings]) + }, [issue.workspaceId, open, providerSettings, query]) const handleSelectProject = useCallback( async (project: LinearProjectSummary) => { setSavingProjectId(project.id) try { const result = await linearUpdateIssue( - settings, + providerSettings, issue.id, { projectId: project.id }, issue.workspaceId ) if (result.ok) { onProjectChanged(project) - patchLinearIssue(issue.id, { project }) + patchLinearIssue(issue.id, { project }, { sourceContext }) toast.success( translate('auto.components.LinearIssueWorkspace.f9d4ef9807', 'Project updated') ) @@ -394,7 +404,14 @@ function LinearIssueSidebarProjectCard({ setSavingProjectId(null) } }, - [issue.id, issue.workspaceId, onProjectChanged, patchLinearIssue, settings] + [ + issue.id, + issue.workspaceId, + onProjectChanged, + patchLinearIssue, + providerSettings, + sourceContext + ] ) return ( @@ -482,9 +499,11 @@ export default function LinearIssueWorkspace({ onOpenIssue, onClose, variant = 'sheet', - backLabel = 'Back' + backLabel = 'Back', + sourceContext }: LinearIssueWorkspaceProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null) const [issueLoading, setIssueLoading] = useState(false) const [comments, setComments] = useState<LinearComment[]>([]) @@ -519,7 +538,7 @@ export default function LinearIssueWorkspace({ } try { let fetched = (await linearIssueComments( - settings, + providerSettings, targetIssue.id, targetIssue.workspaceId )) as LinearComment[] @@ -542,7 +561,7 @@ export default function LinearIssueWorkspace({ } } }, - [mountedRef, settings] + [mountedRef, providerSettings] ) useEffect(() => { @@ -558,7 +577,7 @@ export default function LinearIssueWorkspace({ return } - const issueKey = `${settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` + const issueKey = `${sourceContext?.hostId ?? settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` if (hydratedIssueKeyRef.current === issueKey) { return } @@ -576,7 +595,7 @@ export default function LinearIssueWorkspace({ // Why: issue hydration and comments are separate surfaces; a comments // failure should not blank the issue detail the user selected. - void linearGetIssue(settings, issue.id, issue.workspaceId) + void linearGetIssue(providerSettings, issue.id, issue.workspaceId) .then((issueResult) => { if (!mountedRef.current || requestId !== requestIdRef.current) { return @@ -616,7 +635,7 @@ export default function LinearIssueWorkspace({ }) void loadComments(issue, requestId) - }, [issue, loadComments, mountedRef, settings]) + }, [issue, loadComments, mountedRef, providerSettings, settings, sourceContext?.hostId]) const displayed = fullIssue ?? issue @@ -796,9 +815,17 @@ export default function LinearIssueWorkspace({ <div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"> <div className="mx-auto grid w-full grid-cols-1 gap-10 px-7 py-10 lg:grid-cols-[minmax(0,1fr)_320px] lg:px-10 xl:px-12"> <main className="min-w-0"> - <LinearIssueTextEditor issue={displayed} onIssueChange={handleIssueTextChange} /> + <LinearIssueTextEditor + issue={displayed} + onIssueChange={handleIssueTextChange} + sourceContext={sourceContext} + /> - <LinearIssueSubIssueButton issue={displayed} onOpenIssue={onOpenIssue} /> + <LinearIssueSubIssueButton + issue={displayed} + onOpenIssue={onOpenIssue} + sourceContext={sourceContext} + /> <section className="mt-12 border-t border-border/60 pt-9"> <div className="mb-8 flex items-center justify-between gap-3"> @@ -894,6 +921,7 @@ export default function LinearIssueWorkspace({ workspaceId={displayed.workspaceId} onCommentAdded={handleCommentAdded} variant="linear-page" + sourceContext={sourceContext} /> </section> </main> @@ -905,11 +933,13 @@ export default function LinearIssueWorkspace({ editState={editState} onEditStateChange={handleEditStateChange} layout="properties" + sourceContext={sourceContext} /> ) : null} <LinearIssueSidebarProjectCard issue={displayed} onProjectChanged={handleProjectChanged} + sourceContext={sourceContext} /> <section className="rounded-xl border border-border/60 bg-card text-card-foreground shadow-xs"> <div className="flex h-10 items-center gap-1 border-b border-border/50 px-4 text-sm font-medium text-muted-foreground"> diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 501cd5c2b8e..d123808f227 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -38,6 +38,7 @@ import { } from '@/components/linear-state-pill-style' import { LinearPriorityIcon } from '@/components/linear-priority-icon' import type { LinearIssue, LinearComment } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import { linearAddIssueComment, linearGetIssue, @@ -118,6 +119,7 @@ type LinearItemDrawerProps = { issue: LinearIssue | null onUse: (issue: LinearIssue) => void onClose: () => void + sourceContext?: TaskSourceContext | null } export type LinearEditState = { @@ -134,18 +136,21 @@ type EditSectionProps = { editState: LinearEditState onEditStateChange: (patch: Partial<LinearEditState>) => void layout?: 'chips' | 'properties' + sourceContext?: TaskSourceContext | null } export function LinearIssueEditSection({ issue, editState, onEditStateChange, - layout = 'chips' + layout = 'chips', + sourceContext }: EditSectionProps): React.JSX.Element { const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) const [estimatePopoverOpen, setEstimatePopoverOpen] = useState(false) const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const { isPending, run } = useImmediateMutation() const { @@ -159,9 +164,9 @@ export function LinearIssueEditSection({ const [estimateInput, setEstimateInput] = useState(() => formatLinearEstimateInput(localEstimate)) const teamId = issue.team?.id || null - const states = useTeamStates(teamId, settings, issue.workspaceId) - const labels = useTeamLabels(teamId, settings, issue.workspaceId) - const members = useTeamMembers(teamId, settings, issue.workspaceId) + const states = useTeamStates(teamId, providerSettings, issue.workspaceId) + const labels = useTeamLabels(teamId, providerSettings, issue.workspaceId) + const members = useTeamMembers(teamId, providerSettings, issue.workspaceId) const handleEstimatePopoverOpenChange = useCallback( (open: boolean) => { @@ -184,14 +189,14 @@ export function LinearIssueEditSection({ const stateValue = { name: newState.name, type: newState.type, color: newState.color } run('state', { - mutate: () => linearUpdateIssue(settings, issue.id, { stateId }, issue.workspaceId), + mutate: () => linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ state: stateValue }) - patchLinearIssue(issue.id, { state: stateValue }) + patchLinearIssue(issue.id, { state: stateValue }, { sourceContext }) }, onRevert: () => { onEditStateChange({ state: prevState }) - patchLinearIssue(issue.id, { state: prevState }) + patchLinearIssue(issue.id, { state: prevState }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -203,11 +208,12 @@ export function LinearIssueEditSection({ issue.id, issue.workspaceId, localState, - settings, + providerSettings, states.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -216,14 +222,15 @@ export function LinearIssueEditSection({ const priority = parseInt(value, 10) const prevPriority = localPriority run('priority', { - mutate: () => linearUpdateIssue(settings, issue.id, { priority }, issue.workspaceId), + mutate: () => + linearUpdateIssue(providerSettings, issue.id, { priority }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ priority }) - patchLinearIssue(issue.id, { priority }) + patchLinearIssue(issue.id, { priority }, { sourceContext }) }, onRevert: () => { onEditStateChange({ priority: prevPriority }) - patchLinearIssue(issue.id, { priority: prevPriority }) + patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -231,22 +238,32 @@ export function LinearIssueEditSection({ onError: (err) => toast.error(err) }) }, - [issue.id, issue.workspaceId, localPriority, settings, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + issue.workspaceId, + localPriority, + providerSettings, + patchLinearIssue, + run, + onEditStateChange, + sourceContext + ] ) const handleEstimateChange = useCallback( (estimate: number | null) => { const prevEstimate = localEstimate run('estimate', { - mutate: () => linearUpdateIssue(settings, issue.id, { estimate }, issue.workspaceId), + mutate: () => + linearUpdateIssue(providerSettings, issue.id, { estimate }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ estimate }) - patchLinearIssue(issue.id, { estimate }) + patchLinearIssue(issue.id, { estimate }, { sourceContext }) setEstimatePopoverOpen(false) }, onRevert: () => { onEditStateChange({ estimate: prevEstimate }) - patchLinearIssue(issue.id, { estimate: prevEstimate }) + patchLinearIssue(issue.id, { estimate: prevEstimate }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -254,7 +271,16 @@ export function LinearIssueEditSection({ onError: (err) => toast.error(err) }) }, - [issue.id, issue.workspaceId, localEstimate, settings, patchLinearIssue, run, onEditStateChange] + [ + issue.id, + issue.workspaceId, + localEstimate, + providerSettings, + patchLinearIssue, + run, + onEditStateChange, + sourceContext + ] ) const handleEstimateSubmit = useCallback(() => { @@ -287,14 +313,15 @@ export function LinearIssueEditSection({ ? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl } : undefined run('assignee', { - mutate: () => linearUpdateIssue(settings, issue.id, { assigneeId }, issue.workspaceId), + mutate: () => + linearUpdateIssue(providerSettings, issue.id, { assigneeId }, issue.workspaceId), onOptimistic: () => { onEditStateChange({ assignee: newAssignee }) - patchLinearIssue(issue.id, { assignee: newAssignee }) + patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext }) }, onRevert: () => { onEditStateChange({ assignee: prevAssignee }) - patchLinearIssue(issue.id, { assignee: prevAssignee }) + patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext }) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -306,11 +333,12 @@ export function LinearIssueEditSection({ issue.id, issue.workspaceId, localAssignee, - settings, + providerSettings, members.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -328,14 +356,27 @@ export function LinearIssueEditSection({ run('labels', { mutate: () => - linearUpdateIssue(settings, issue.id, { labelIds: newLabelIds }, issue.workspaceId), + linearUpdateIssue( + providerSettings, + issue.id, + { labelIds: newLabelIds }, + issue.workspaceId + ), onOptimistic: () => { onEditStateChange({ labelIds: newLabelIds, labels: newLabels }) - patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels }) + patchLinearIssue( + issue.id, + { labelIds: newLabelIds, labels: newLabels }, + { sourceContext } + ) }, onRevert: () => { onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels }) - patchLinearIssue(issue.id, { labelIds: prevLabelIds, labels: prevLabels }) + patchLinearIssue( + issue.id, + { labelIds: prevLabelIds, labels: prevLabels }, + { sourceContext } + ) }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('linear-tasks') @@ -348,11 +389,12 @@ export function LinearIssueEditSection({ issue.workspaceId, localLabelIds, localLabels, - settings, + providerSettings, labels.data, patchLinearIssue, run, - onEditStateChange + onEditStateChange, + sourceContext ] ) @@ -994,14 +1036,17 @@ export function LinearIssueCommentFooter({ issueId, workspaceId, onCommentAdded, - variant = 'compact' + variant = 'compact', + sourceContext }: { issueId: string workspaceId?: string | null onCommentAdded: (comment: LinearLocalComment) => void variant?: 'compact' | 'linear-page' + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const submitShortcutLabel = getScreenSubmitShortcutLabel() const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) @@ -1030,7 +1075,7 @@ export function LinearIssueCommentFooter({ } setSubmitting(true) try { - const result = await linearAddIssueComment(settings, issueId, trimmed, workspaceId) + const result = await linearAddIssueComment(providerSettings, issueId, trimmed, workspaceId) const typed = result as { ok: boolean; id?: string; error?: string } if (!mountedRef.current) { return @@ -1062,7 +1107,7 @@ export function LinearIssueCommentFooter({ setSubmitting(false) } } - }, [body, issueId, onCommentAdded, settings, workspaceId]) + }, [body, issueId, onCommentAdded, providerSettings, workspaceId]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -1168,7 +1213,8 @@ export function initLinearIssueEditState(issue: LinearIssue): LinearEditState { export default function LinearItemDrawer({ issue, onUse, - onClose + onClose, + sourceContext }: LinearItemDrawerProps): React.JSX.Element { const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null) const [comments, setComments] = useState<LinearComment[]>([]) @@ -1178,6 +1224,7 @@ export default function LinearItemDrawer({ const hasEditedRef = useRef(false) const optimisticCommentsRef = useRef<LinearComment[]>([]) const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const handleEditStateChange = useCallback((patch: Partial<LinearEditState>) => { hasEditedRef.current = true @@ -1214,7 +1261,7 @@ export default function LinearItemDrawer({ // Why: fetch issue and comments independently so a transient comments // failure doesn't discard the successfully-fetched issue data. - linearGetIssue(settings, issue.id, issue.workspaceId) + linearGetIssue(providerSettings, issue.id, issue.workspaceId) .then((issueResult) => { if (requestId !== requestIdRef.current) { return @@ -1231,7 +1278,7 @@ export default function LinearItemDrawer({ }) .catch(() => {}) - linearIssueComments(settings, issue.id, issue.workspaceId) + linearIssueComments(providerSettings, issue.id, issue.workspaceId) .then((commentsResult) => { if (requestId !== requestIdRef.current) { return @@ -1256,7 +1303,7 @@ export default function LinearItemDrawer({ } }) // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [issue?.id, issue?.workspaceId, settings]) + }, [issue?.id, issue?.workspaceId, providerSettings]) // Why: same pointer-events fix as GitHubItemDialog — Radix may leave // pointer-events: none on body when overlays transition. @@ -1343,6 +1390,7 @@ export default function LinearItemDrawer({ onIssueChange={handleIssueTextChange} density="drawer" fields="title" + sourceContext={sourceContext} /> </div> <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground"> @@ -1400,6 +1448,7 @@ export default function LinearItemDrawer({ issue={displayed} editState={editState} onEditStateChange={handleEditStateChange} + sourceContext={sourceContext} /> )} @@ -1411,6 +1460,7 @@ export default function LinearItemDrawer({ onIssueChange={handleIssueTextChange} density="drawer" fields="description" + sourceContext={sourceContext} /> </div> @@ -1472,6 +1522,7 @@ export default function LinearItemDrawer({ issueId={displayed.id} workspaceId={displayed.workspaceId} onCommentAdded={handleCommentAdded} + sourceContext={sourceContext} /> <div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3"> <Button diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 70b29ba4cf6..66c9c5d35b1 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -15,7 +15,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import RepoCombobox from '@/components/repo/RepoCombobox' +import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' @@ -35,12 +35,18 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' +import ProjectHostSetupCombobox from '@/components/new-workspace/ProjectHostSetupCombobox' import type { SetupConfig } from '@/lib/new-workspace' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number] +const EMPTY_PROJECT_HOST_SETUP_OPTIONS: ProjectHostSetupOption[] = [] +const EMPTY_PROJECT_OPTIONS: NewWorkspaceProjectOption[] = [] type NewWorkspaceComposerCardProps = { contextualTourSource?: string @@ -52,8 +58,14 @@ type NewWorkspaceComposerCardProps = { onQuickAgentChange: (agent: TuiAgent | null) => void eligibleRepos: RepoOption[] repoId: string + projectOptions?: NewWorkspaceProjectOption[] + selectedProjectId?: string | null selectedRepoIsGit: boolean onRepoChange: (value: string) => void + onProjectChange: (value: string) => void + projectHostSetupOptions?: ProjectHostSetupOption[] + selectedProjectHostSetupId?: string | null + onProjectHostSetupChange?: (setupId: string) => void primaryActionLabel: string projectLabel?: string projectPlaceholder?: string @@ -274,8 +286,14 @@ export default function NewWorkspaceComposerCard({ onQuickAgentChange, eligibleRepos, repoId, + projectOptions = EMPTY_PROJECT_OPTIONS, + selectedProjectId = null, selectedRepoIsGit, onRepoChange, + onProjectChange, + projectHostSetupOptions = EMPTY_PROJECT_HOST_SETUP_OPTIONS, + selectedProjectHostSetupId = null, + onProjectHostSetupChange, primaryActionLabel, projectLabel, projectPlaceholder, @@ -425,6 +443,16 @@ export default function NewWorkspaceComposerCard({ openModal('add-repo') }, [openModal]) const projectDescriptionId = React.useId() + const readyProjectHostSetupOptions = React.useMemo( + () => projectHostSetupOptions.filter((option) => option.kind === 'ready'), + [projectHostSetupOptions] + ) + const handleProjectHostSetupChange = React.useCallback( + (setupId: string): void => { + onProjectHostSetupChange?.(setupId) + }, + [onProjectHostSetupChange] + ) useContextualTour( 'workspace-creation', eligibleRepos.length > 0 && Boolean(repoId), @@ -481,10 +509,10 @@ export default function NewWorkspaceComposerCard({ </Tooltip> ) : null} </div> - <RepoCombobox - repos={eligibleRepos} - value={repoId} - onValueChange={onRepoChange} + <ProjectCombobox + options={projectOptions} + value={selectedProjectId} + onValueChange={onProjectChange} onValueSelected={focusNameInput} placeholder={ projectPlaceholder ?? @@ -497,7 +525,6 @@ export default function NewWorkspaceComposerCard({ // paints the familiar field ring instead of leaving no visible // focus state. triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50" - showStandaloneAddButton={false} invalid={Boolean(projectError)} describedBy={projectDescriptionId} /> @@ -514,6 +541,18 @@ export default function NewWorkspaceComposerCard({ )} </p> ) : null} + {readyProjectHostSetupOptions.length > 1 ? ( + <div className="space-y-1"> + <label className="block min-w-0 truncate text-xs font-medium text-muted-foreground"> + {translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')} + </label> + <ProjectHostSetupCombobox + options={readyProjectHostSetupOptions} + value={selectedProjectHostSetupId ?? null} + onValueChange={handleProjectHostSetupChange} + /> + </div> + ) : null} {selectedRepoRequiresConnection && selectedRepoConnectionId ? ( <div role="status" 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 6b4c706afa0..159e9148b5d 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 @@ -184,6 +178,7 @@ import type { PRComment } from '../../../shared/types' import { translate } from '@/i18n/i18n' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' // 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. @@ -207,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' @@ -547,7 +523,9 @@ function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) const reviewerInputRef = useRef<HTMLInputElement | null>(null) const reviewerInputFocusFrameRef = useRef<number | null>(null) const reviewerPanelMountedRef = useRef(true) @@ -622,11 +600,12 @@ function PRReviewersPanel({ open && reviewSlug ? reviewSlug.owner : null, open && reviewSlug ? reviewSlug.repo : null, reviewerSeedUsers.map((user) => user.login), - settings + repoOwnerSettings ) const reviewerMetadataByPath = useRepoAssignees( open && !reviewSlug ? repoPath : null, - open && !reviewSlug ? item.repoId : null + open && !reviewSlug ? item.repoId : null, + repoOwnerSettings ) const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } @@ -725,7 +704,8 @@ function PRReviewersPanel({ localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment' + const canRequestReview = + !!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment' const measureReviewerPickerPlacement = useCallback(() => { const rect = reviewerInputRef.current?.getBoundingClientRect() @@ -768,7 +748,7 @@ function PRReviewersPanel({ ) return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(repoOwnerSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -842,7 +822,7 @@ function PRReviewersPanel({ if (logins.length === 0) { return } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(repoOwnerSettings) if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -2702,6 +2682,7 @@ function CommentCodeContext({ function ConversationTab({ item, repoPath, + repoId, body, comments, files, @@ -2749,7 +2730,10 @@ function ConversationTab({ const [bodySaving, setBodySaving] = useState(false) const bodyTextareaRef = useRef<HTMLTextAreaElement>(null) const bodyTextareaFocusFrameRef = useRef<number | null>(null) - const repoAssignees = useRepoAssignees(repoPath, item.repoId) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) + ) + const repoAssignees = useRepoAssignees(repoPath, item.repoId, repoOwnerSettings) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( () => filterPRCommentsByAudience(comments, commentFilter), @@ -3488,7 +3472,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> @@ -3497,8 +3481,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' )} > @@ -3507,13 +3490,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> @@ -3559,8 +3540,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' )} @@ -3574,11 +3554,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> @@ -4813,6 +4791,13 @@ function MentionTextarea({ // repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt // to a thrown rejection so the existing `useImmediateMutation` flow // (which expects throws on failure) continues to work unchanged. +function getGitHubMutationSettings(repoId: string | null | undefined) { + const state = useAppStore.getState() + // Why: project-origin mutations are slug-addressed, but when we know the + // backing repo id they must still execute on that repo's owner host. + return getSettingsForRepoRuntimeOwner(state, repoId ?? null) +} + async function runIssueUpdate(args: { repoPath: string | null repoId?: string | null @@ -4821,7 +4806,7 @@ async function runIssueUpdate(args: { updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates'] }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4870,7 +4855,7 @@ async function runWorkItemBodyUpdate(args: { if (!targetSlug) { throw new Error('No GitHub repository context available for this pull request.') } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.item.repoId)) const updateArgs = { owner: targetSlug.owner, repo: targetSlug.repo, @@ -4909,7 +4894,7 @@ async function runPullRequestStateUpdate(args: { updates: { state: 'open' | 'closed' } }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId)) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -4979,8 +4964,10 @@ function GHEditSection({ const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) + ) 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 @@ -5003,15 +4990,22 @@ function GHEditSection({ const slugRepo = projectOrigin?.repo ?? null const repoLabelsByPath = useRepoLabels( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + repoOwnerSettings ) - const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, repoOwnerSettings) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath const repoAssigneesByPath = useRepoAssignees( projectOrigin ? null : repoPath, - projectOrigin ? null : repoId + projectOrigin ? null : repoId, + repoOwnerSettings + ) + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assignees, + repoOwnerSettings ) - const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath // Why: sync local assignees when item changes or when the detail fetch @@ -5221,6 +5215,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 */} @@ -5290,16 +5296,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> @@ -5330,14 +5354,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 f127caf344d..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,18 @@ 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 - } + const handleDisable = (): void => { setVisible(false) void window.api.starNag.disable() - }, [busy]) + } useEffect(() => { if (!visible) { @@ -69,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 @@ -82,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) @@ -90,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) } @@ -100,6 +96,7 @@ export function StarNagCard(): React.JSX.Element | null { } return } + await window.api.starNag.complete() if (mountedRef.current) { setVisible(false) } @@ -129,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" /> @@ -160,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 d89d314868e..a52c55b3a0b 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -43,6 +43,10 @@ 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, + parseExecutionHostId +} from '../../../shared/execution-host' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' @@ -81,7 +85,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' +import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' @@ -89,6 +93,14 @@ 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 { + getTaskSourceAvailabilityNotice, + getTaskSourceContextSummary +} from './task-source-context-summary' +import type { + TaskSourceAvailabilityNotice, + TaskSourceHostAvailability +} from './task-source-context-summary' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { getGitHubPRPrimaryReviewer, @@ -118,6 +130,12 @@ import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDia 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, + type ExecutionHostRegistryEntry +} from '../../../shared/execution-host-registry' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import LinearIssueWorkspace from '@/components/LinearIssueWorkspace' import { LinearCollectionNotice, @@ -138,6 +156,15 @@ 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 { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + normalizeTaskSourceContext, + type TaskSourceContext +} from '../../../shared/task-source-context' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import { buildTaskPageRepoSourceState, @@ -154,6 +181,16 @@ 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, + getTaskProjectPickerGroups, + normalizeTaskRepoSelection +} from '@/components/task-page-default-repo-selection' +import { + getRepoBackedProviderAvailability, + type RuntimeProviderPreflightStatus +} from '@/components/task-source-provider-availability' import { createTaskPageGitHubStatusStateDraft, resolveTaskPageGitHubStatusStateDraft, @@ -189,6 +226,8 @@ import type { TaskProvider, TaskViewPresetId } from '../../../shared/types' +import type { PreflightStatus } from '../../../preload/api-types' +import type { GitLabProjectRef } from '../../../shared/gitlab-types' import { LINEAR_ISSUE_LIST_MAX, clampLinearIssueListLimit @@ -300,6 +339,96 @@ function getJiraIssueWorkspaceSeed(issue: JiraIssue): string { ) } +function getTaskPageRepoSourceContext( + repo: Repo | null | undefined, + provider: 'github' | 'gitlab', + gitlabProjectRef?: GitLabProjectRef | null +): 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 + : provider === 'gitlab' && gitlabProjectRef + ? buildGitLabProviderIdentity(gitlabProjectRef) + : null + return normalizeTaskSourceContext({ + provider, + projectId: setup?.projectId ?? project?.id ?? repo.id, + hostId: setup?.hostId ?? getRepoExecutionHostId(repo), + projectHostSetupId: setup?.id, + repoId: repo.id, + providerIdentity + }) +} + +function buildGitLabProviderIdentity(projectRef: GitLabProjectRef) { + const pathParts = projectRef.path + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + const projectName = pathParts.at(-1) ?? null + const namespace = pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : null + return { + provider: 'gitlab' as const, + projectId: projectRef.path, + namespace, + project: projectName, + webUrl: `https://${projectRef.host}/${projectRef.path}` + } +} + +function getTaskSourceHostAvailabilityForHost( + host: ExecutionHostRegistryEntry | null | undefined, + hostId: TaskSourceContext['hostId'] +): TaskSourceHostAvailability | null { + if (!host) { + return null + } + if (host.kind === 'runtime') { + if (!host.capabilities) { + return { + hostId, + reason: 'checking-task-source-capability' + } + } + if (!host.capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { + return { + hostId, + reason: 'missing-task-source-capability' + } + } + } + if (host.health === 'local' || host.health === 'available') { + return null + } + return { + hostId, + health: host.health, + status: host.connectionStatus + } +} + +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. @@ -445,14 +574,17 @@ function findLinearWorkflowStateForStatus( function LinearStateCell({ issue, - className + className, + sourceContext }: { issue: LinearIssue className?: string + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const providerSettings = sourceContext ?? settings const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) - const states = useTeamStates(issue.team.id, settings, issue.workspaceId) + const states = useTeamStates(issue.team.id, providerSettings, issue.workspaceId) const [open, setOpen] = useState(false) const [pending, setPending] = useState(false) const reqRef = useRef(0) @@ -479,7 +611,7 @@ function LinearStateCell({ setPending(true) patchLinearIssue(issue.id, { state: nextState }) - void linearUpdateIssue(settings, issue.id, { stateId }, issue.workspaceId) + void linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId) .then((result) => { if (reqId !== reqRef.current) { return @@ -516,7 +648,7 @@ function LinearStateCell({ issue.workspaceId, patchLinearIssue, pending, - settings, + providerSettings, states.data ] ) @@ -692,6 +824,10 @@ function getLinearIssueGridTemplate(visibleProperties: ReadonlySet<LinearDisplay return columns.join(' ') } +function areStringSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)) +} + function getJiraStatusTone(categoryKey: string): string { if (categoryKey === 'done') { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' @@ -839,12 +975,27 @@ function buildJiraCreateCustomFields( function GHStatusCell({ item, - repo + repo, + sourceContext }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const [statusStateDraft, setStatusStateDraft] = useState(() => createTaskPageGitHubStatusStateDraft(item) ) @@ -875,19 +1026,22 @@ function GHStatusCell({ reqRef.current += 1 const reqId = reqRef.current updateLocalState(newState) - patchWorkItem(item.id, { state: newState }, item.repoId) - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const updatePromise = target.kind === 'environment' ? callRuntimeRpc<{ ok?: boolean; error?: string }>( target, 'github.updateIssue', - { repo: repo.id, number: item.number, updates: { state: newState } }, + { repo: runtimeRepoId, number: item.number, updates: { state: newState } }, { timeoutMs: 30_000 } ) : window.api.gh.updateIssue({ repoPath: repo.path, repoId: repo.id, + sourceContext, number: item.number, updates: { state: newState } }) @@ -902,7 +1056,8 @@ function GHStatusCell({ patchWorkItem( item.id, { state: newState === 'closed' ? 'open' : 'closed' }, - item.repoId + item.repoId, + { sourceContext } ) toast.error( typed.error ?? @@ -917,11 +1072,18 @@ function GHStatusCell({ return } updateLocalState(newState === 'closed' ? 'open' : 'closed') - patchWorkItem(item.id, { state: newState === 'closed' ? 'open' : 'closed' }, item.repoId) + patchWorkItem( + item.id, + { state: newState === 'closed' ? 'open' : 'closed' }, + item.repoId, + { + sourceContext + } + ) toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) }) }, - [item, localState, repo, patchWorkItem, updateLocalState] + [item, localState, patchWorkItem, repo, sourceContext, sourceSettings, updateLocalState] ) if (item.type !== 'issue' || !repo) { @@ -1270,13 +1432,27 @@ function GitHubIssueAssigneeSelector({ function GHAssigneesCell({ item, - repo + repo, + sourceContext }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const [open, setOpen] = useState(false) const [pendingLogin, setPendingLogin] = useState<string | null>(null) const assignees = useMemo(() => item.assignees ?? [], [item.assignees]) @@ -1295,7 +1471,7 @@ function GHAssigneesCell({ open ? owner : null, open ? repoName : null, seedLogins, - settings + sourceSettings ) const toggleAssignee = useCallback( @@ -1310,11 +1486,11 @@ function GHAssigneesCell({ ? assignees.filter((a) => a.login.toLowerCase() !== userLoginKey) : [...assignees, user] setPendingLogin(user.login) - patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) try { const updates = isOn ? { removeAssignees: [user.login] } : { addAssignees: [user.login] } - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) if (owner && repoName) { const args = { owner, @@ -1335,17 +1511,20 @@ function GHAssigneesCell({ throw new Error(res.error.message) } } else if (repo) { + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const res = target.kind === 'environment' ? await callRuntimeRpc<{ ok?: boolean; error?: string }>( target, 'github.updateIssue', - { repo: repo.id, number: item.number, updates }, + { repo: runtimeRepoId, number: item.number, updates }, { timeoutMs: 30_000 } ) : await window.api.gh.updateIssue({ repoPath: repo.path, repoId: repo.id, + sourceContext, number: item.number, updates }) @@ -1357,7 +1536,7 @@ function GHAssigneesCell({ } useAppStore.getState().recordFeatureInteraction('github-tasks') } catch (err) { - patchWorkItem(item.id, { assignees: previousAssignees }, item.repoId) + patchWorkItem(item.id, { assignees: previousAssignees }, item.repoId, { sourceContext }) toast.error( err instanceof Error ? err.message @@ -1378,7 +1557,8 @@ function GHAssigneesCell({ pendingLogin, repo, repoName, - settings + sourceContext, + sourceSettings ] ) @@ -1583,10 +1763,12 @@ function buildRequestedReviewUsers( function PRReviewCell({ item, - repo + repo, + sourceContext }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') @@ -1601,7 +1783,19 @@ function PRReviewCell({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 }) const [submitting, setSubmitting] = useState(false) - const settings = useAppStore((s) => s.settings) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) const reviewerInputRef = useRef<HTMLInputElement | null>(null) const reviewerInputFocusFrameRef = useRef<number | null>(null) @@ -1668,7 +1862,7 @@ function PRReviewCell({ open && reviewSlug ? reviewSlug.owner : null, open && reviewSlug ? reviewSlug.repo : null, reviewerSeedUsers.map((user) => user.login), - settings + sourceSettings ) const authorLogin = item.author?.toLowerCase() ?? null @@ -1796,18 +1990,21 @@ function PRReviewCell({ } setSubmitting(true) try { - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const result = target.kind === 'environment' ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.requestPRReviewers', - { repo: repo.id, prNumber: item.number, reviewers: logins }, + { repo: runtimeRepoId, prNumber: item.number, reviewers: logins }, { timeoutMs: 30_000 } ) : await window.api.gh.requestPRReviewers({ repoPath: repo.path, repoId: repo.id, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -1819,7 +2016,9 @@ function PRReviewCell({ localReviewRequests ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) setReviewerInput('') useAppStore.getState().recordFeatureInteraction('github-tasks') } else { @@ -1845,18 +2044,21 @@ function PRReviewCell({ } setSubmitting(true) try { - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id const result = target.kind === 'environment' ? await callRuntimeRpc<{ ok: boolean; error?: string }>( target, 'github.removePRReviewers', - { repo: repo.id, prNumber: item.number, reviewers: logins }, + { repo: runtimeRepoId, prNumber: item.number, reviewers: logins }, { timeoutMs: 30_000 } ) : await window.api.gh.removePRReviewers({ repoPath: repo.path, repoId: repo.id, + sourceContext, prNumber: item.number, reviewers: logins }) @@ -1871,7 +2073,9 @@ function PRReviewCell({ (reviewer) => !removed.has(reviewer.login.toLowerCase()) ) setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) setReviewerInput('') } else { toast.error(result.error) @@ -2160,14 +2364,29 @@ function PRChecksCell({ function PRMergeCell({ item, repo, + sourceContext, onRefresh }: { item: GitHubWorkItem repo: Repo | null + sourceContext?: TaskSourceContext | null onRefresh: () => void }): React.JSX.Element { const [merging, setMerging] = useState(false) const confirm = useConfirmationDialog() + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) if (item.type !== 'pr') { return ( <span className="text-[11px] text-muted-foreground"> @@ -2200,13 +2419,30 @@ function PRMergeCell({ } setMerging(true) try { - const result = await window.api.gh.mergePR({ - repoPath: repo.path, - repoId: repo.id, - prNumber: item.number, - method, - prRepo: item.prRepo ?? null - }) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.mergePR', + { + repo: runtimeRepoId, + prNumber: item.number, + method, + prRepo: item.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.mergePR({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + prNumber: item.number, + method, + prRepo: item.prRepo ?? null + }) if (result.ok) { useAppStore.getState().recordFeatureInteraction('github-tasks') toast.success(translate('auto.components.TaskPage.a161925adc', 'Pull request merged')) @@ -2228,13 +2464,30 @@ function PRMergeCell({ const enabled = mergePresentation.autoMergeAction.kind === 'enable' setMerging(true) try { - const result = await window.api.gh.setPRAutoMerge({ - repoPath: repo.path, - repoId: repo.id, - prNumber: item.number, - enabled, - prRepo: item.prRepo ?? null - }) + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.setPRAutoMerge', + { + repo: runtimeRepoId, + prNumber: item.number, + enabled, + prRepo: item.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRAutoMerge({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + prNumber: item.number, + enabled, + prRepo: item.prRepo ?? null + }) if (result.ok) { useAppStore.getState().recordFeatureInteraction('github-tasks') toast.success( @@ -2451,6 +2704,10 @@ 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 runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const repoMap = useRepoMap() const allWorktrees = useAllWorktrees() const openModal = useAppStore((s) => s.openModal) @@ -2526,27 +2783,33 @@ export default function TaskPage(): React.JSX.Element { if (Array.isArray(persisted)) { const filtered = persisted.filter((id) => eligibleRepos.some((r) => r.id === id)) if (filtered.length > 0) { - return new Set(filtered) + return normalizeTaskRepoSelection(eligibleRepos, 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) + const taskPickerGroups = useMemo( + () => getTaskProjectPickerGroups(eligibleRepos, repoSelection), + [eligibleRepos, repoSelection] + ) + const taskPickerRepos = useMemo( + () => taskPickerGroups.map((group) => group.repo), + [taskPickerGroups] + ) // Why: prune selection when a previously-selected repo is removed, and - // preserve sticky-all (when the selection equaled every eligible repo - // pre-change, keep it equal to every eligible repo post-change so "All - // repos" stays truthful). Recreating the Set every time eligibleRepos - // changes would churn the fetch effect — only write when the identity of - // the selection actually needs to change. - const prevEligibleCountRef = useRef(eligibleRepos.length) + // preserve sticky-all (when the selection equaled every logical project + // pre-change, keep it equal to every logical project post-change). Recreating + // the Set every time eligibleRepos changes would churn the fetch effect. + const prevTaskPickerCountRef = useRef(taskPickerRepos.length) useEffect(() => { - const prevCount = prevEligibleCountRef.current - prevEligibleCountRef.current = eligibleRepos.length + const prevCount = prevTaskPickerCountRef.current + prevTaskPickerCountRef.current = taskPickerRepos.length const eligibleIds = new Set(eligibleRepos.map((r) => r.id)) const wasAll = repoSelection.size === prevCount && prevCount > 0 const pruned = new Set<string>() @@ -2556,20 +2819,20 @@ export default function TaskPage(): React.JSX.Element { } } if (wasAll) { - const allNow = new Set(eligibleIds) - if (allNow.size !== repoSelection.size || [...allNow].some((id) => !repoSelection.has(id))) { + const allNow = new Set(taskPickerRepos.map((repo) => repo.id)) + if (!areStringSetsEqual(allNow, repoSelection)) { setRepoSelection(allNow) } return } - if (pruned.size === 0 && eligibleIds.size > 0) { - setRepoSelection(new Set(eligibleIds)) + if (pruned.size === 0 && eligibleIds.size === 0) { return } - if (pruned.size !== repoSelection.size) { - setRepoSelection(pruned) + const normalized = normalizeTaskRepoSelection(eligibleRepos, pruned) + if (!areStringSetsEqual(normalized, repoSelection)) { + setRepoSelection(normalized) } - }, [eligibleRepos, repoSelection]) + }, [eligibleRepos, repoSelection, taskPickerRepos]) const selectedRepos = useMemo( () => eligibleRepos.filter((r) => repoSelection.has(r.id)), @@ -2593,6 +2856,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] @@ -2670,6 +2937,365 @@ export default function TaskPage(): React.JSX.Element { const [taskSource, setTaskSource] = useState<TaskProvider>( resolveVisibleTaskProvider(preferredTaskSource, visibleTaskProviders) ) + const runtimePreflightMountedRef = useRef(true) + const runtimePreflightRequestedHostIdsRef = useRef<Set<TaskSourceContext['hostId']>>(new Set()) + const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< + ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus> + >(() => new Map()) + useEffect( + () => () => { + runtimePreflightMountedRef.current = false + }, + [] + ) + 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, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(settings) + }).map((host) => [host.id, host]) + ), + [ + repos, + settings, + sshConnectionStates, + sshTargetLabels, + runtimeEnvironments, + runtimeStatusByEnvironmentId + ] + ) + const hostLabelById = useMemo( + () => new Map([...hostRegistryById].map(([hostId, host]) => [hostId, host.label])), + [hostRegistryById] + ) + const runtimeTaskSourceHostIds = useMemo(() => { + if (taskSource !== 'github' && taskSource !== 'gitlab') { + return [] + } + const hostIds = new Set<TaskSourceContext['hostId']>() + for (const context of taskSourceRepoContexts) { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + continue + } + const host = hostRegistryById.get(context.hostId) + if ( + host?.kind !== 'runtime' || + host.health !== 'available' || + !host.capabilities?.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY) + ) { + continue + } + hostIds.add(parsed.id) + } + return [...hostIds].sort() + }, [hostRegistryById, taskSource, taskSourceRepoContexts]) + useEffect(() => { + const unrequestedHostIds = runtimeTaskSourceHostIds.filter( + (hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId) + ) + if (unrequestedHostIds.length === 0) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + for (const hostId of unrequestedHostIds) { + next.set(hostId, { checked: false, status: null }) + } + return next + }) + for (const hostId of unrequestedHostIds) { + runtimePreflightRequestedHostIdsRef.current.add(hostId) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'runtime') { + continue + } + // Why: task sources can span multiple runtime hosts; each runtime owns + // its own gh/glab installation and auth state. + void callRuntimeRpc<PreflightStatus>( + { kind: 'environment', environmentId: parsed.environmentId }, + 'preflight.check', + undefined, + { timeoutMs: 15_000 } + ) + .then((status) => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status }) + return next + }) + }) + .catch(() => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status: null }) + return next + }) + }) + } + }, [runtimeTaskSourceHostIds]) + const getTaskPickerRepoHostLabel = useCallback( + (repo: Repo): string | null => { + const provider = taskSource === 'gitlab' ? 'gitlab' : 'github' + const context = getTaskPageRepoSourceContext(repo, provider) + const hostId = context?.hostId ?? repo.executionHostId ?? 'local' + return hostRegistryById.get(hostId)?.label ?? null + }, + [hostRegistryById, taskSource] + ) + const taskSourceHostAvailability = useMemo<TaskSourceHostAvailability[]>(() => { + if (taskSource !== 'github' && taskSource !== 'gitlab') { + return [] + } + return [ + ...taskSourceRepoContexts.flatMap((context) => { + const host = hostRegistryById.get(context.hostId) + const availability = getTaskSourceHostAvailabilityForHost(host, context.hostId) + return availability ? [availability] : [] + }), + ...getRepoBackedProviderAvailability({ + provider: taskSource, + contexts: taskSourceRepoContexts, + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId + }) + ] + }, [ + hostRegistryById, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimePreflightStatusByHostId, + taskSource, + taskSourceRepoContexts + ]) + const accountBackedTaskSourceHostId = useMemo( + () => getSettingsFocusedExecutionHostId(settings), + [settings] + ) + const fallbackTaskSourceProjectId = useMemo(() => { + const firstRepoContext = selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'github')) + .find((context): context is TaskSourceContext => context !== null) + return firstRepoContext?.projectId ?? 'account-backed-task-source' + }, [selectedRepos]) + const linearTaskSourceContext = useMemo( + () => + normalizeTaskSourceContext({ + provider: 'linear', + projectId: fallbackTaskSourceProjectId, + hostId: accountBackedTaskSourceHostId, + providerIdentity: { + provider: 'linear', + workspaceId: + selectedLinearWorkspaceId && selectedLinearWorkspaceId !== 'all' + ? selectedLinearWorkspaceId + : null, + workspaceName: + selectedLinearWorkspace?.organizationName ?? + selectedLinearWorkspace?.displayName ?? + null + }, + accountLabel: + selectedLinearWorkspace?.organizationName ?? selectedLinearWorkspace?.displayName ?? null + }), + [ + accountBackedTaskSourceHostId, + fallbackTaskSourceProjectId, + selectedLinearWorkspace, + selectedLinearWorkspaceId + ] + ) + const jiraTaskSourceContext = useMemo( + () => + normalizeTaskSourceContext({ + provider: 'jira', + projectId: fallbackTaskSourceProjectId, + hostId: accountBackedTaskSourceHostId, + providerIdentity: { + provider: 'jira', + siteId: selectedJiraSiteId && selectedJiraSiteId !== 'all' ? selectedJiraSiteId : null, + siteUrl: selectedJiraSite?.siteUrl ?? null + }, + accountLabel: selectedJiraSite?.displayName ?? selectedJiraSite?.siteUrl ?? null + }), + [ + accountBackedTaskSourceHostId, + fallbackTaskSourceProjectId, + selectedJiraSite, + selectedJiraSiteId + ] + ) + const accountBackedTaskSourceHostAvailability = useMemo<TaskSourceHostAvailability[]>(() => { + if (taskSource !== 'linear' && taskSource !== 'jira') { + return [] + } + const host = hostRegistryById.get(accountBackedTaskSourceHostId) + const availability = getTaskSourceHostAvailabilityForHost(host, accountBackedTaskSourceHostId) + return availability ? [availability] : [] + }, [accountBackedTaskSourceHostId, hostRegistryById, taskSource]) + const taskSourceAvailabilityNoticeByProvider = useMemo< + Partial<Record<TaskProvider, TaskSourceAvailabilityNotice>> + >(() => { + const availabilityForContexts = ( + provider: Extract<TaskProvider, 'github' | 'gitlab'>, + contexts: readonly TaskSourceContext[] + ): TaskSourceHostAvailability[] => [ + ...contexts.flatMap((context) => { + const host = hostRegistryById.get(context.hostId) + const availability = getTaskSourceHostAvailabilityForHost(host, context.hostId) + return availability ? [availability] : [] + }), + ...getRepoBackedProviderAvailability({ + provider, + contexts, + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId + }) + ] + const accountHost = hostRegistryById.get(accountBackedTaskSourceHostId) + const accountHostAvailability = getTaskSourceHostAvailabilityForHost( + accountHost, + accountBackedTaskSourceHostId + ) + const accountAvailability = accountHostAvailability ? [accountHostAvailability] : [] + const labelFor = (provider: TaskProvider): string => + sourceOptions.find((source) => source.id === provider)?.label ?? provider + return { + github: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('github'), + sourceCount: selectedRepos.length, + hostLabelById, + hostAvailability: availabilityForContexts( + 'github', + selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'github')) + .filter((context): context is TaskSourceContext => context !== null) + ) + }) ?? undefined, + gitlab: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('gitlab'), + sourceCount: selectedRepos.length, + hostLabelById, + hostAvailability: availabilityForContexts( + 'gitlab', + selectedRepos + .map((repo) => getTaskPageRepoSourceContext(repo, 'gitlab')) + .filter((context): context is TaskSourceContext => context !== null) + ) + }) ?? undefined, + linear: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('linear'), + sourceCount: 1, + hostLabelById, + hostAvailability: accountAvailability + }) ?? undefined, + jira: + getTaskSourceAvailabilityNotice({ + providerLabel: labelFor('jira'), + sourceCount: 1, + hostLabelById, + hostAvailability: accountAvailability + }) ?? undefined + } + }, [ + accountBackedTaskSourceHostId, + hostRegistryById, + hostLabelById, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimePreflightStatusByHostId, + selectedRepos, + sourceOptions + ]) + 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, + hostLabelById, + selectedRepoCount: selectedRepos.length, + linearWorkspaceName: + selectedLinearWorkspace?.organizationName ?? selectedLinearWorkspace?.id ?? null, + jiraSiteName: selectedJiraSite?.displayName ?? selectedJiraSite?.siteUrl ?? null + }) + }, [ + selectedJiraSite, + selectedLinearWorkspace, + selectedRepos.length, + sourceOptions, + taskSource, + accountBackedTaskSourceHostAvailability, + accountBackedTaskSourceHostId, + hostLabelById, + taskSourceHostAvailability, + taskSourceRepoContexts + ]) + const taskSourceAvailabilityNotice = useMemo(() => { + const providerLabel = + sourceOptions.find((source) => source.id === taskSource)?.label ?? taskSource + return getTaskSourceAvailabilityNotice({ + providerLabel, + sourceCount: + taskSource === 'linear' || taskSource === 'jira' + ? 1 + : Math.max(1, taskSourceRepoContexts.length), + hostAvailability: + taskSource === 'linear' || taskSource === 'jira' + ? accountBackedTaskSourceHostAvailability + : taskSourceHostAvailability, + hostLabelById + }) + }, [ + accountBackedTaskSourceHostAvailability, + hostLabelById, + sourceOptions, + taskSource, + taskSourceHostAvailability, + taskSourceRepoContexts.length + ]) + const githubEmptyState = useMemo( + () => + getRepoBackedTaskEmptyState({ + provider: 'github', + selectedRepoCount: selectedRepos.length + }), + [selectedRepos.length] + ) const taskSourceManuallyChangedRef = useRef(false) const lastPageTaskSourceRef = useRef(pageData.taskSource) const taskResumeAppliedRef = useRef(false) @@ -2739,6 +3365,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' @@ -2799,7 +3434,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) + const cached = getCachedWorkItems( + r.id, + PER_REPO_FETCH_LIMIT, + trimmed, + r.path, + getTaskPageRepoSourceContext(r, 'github') + ) if (cached) { merged.push(...cached) } @@ -2844,7 +3485,7 @@ export default function TaskPage(): React.JSX.Element { useShallow((s) => selectTaskPageWorkItemsCacheEntries( s.workItemsCache, - selectedRepos, + selectedRepos.map(getTaskPageRepoCacheInput), PER_REPO_FETCH_LIMIT, appliedWorkItemsCacheQuery ) @@ -2863,8 +3504,45 @@ 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(() => { + if (!gitlabDialogItem) { + return null + } + if ( + pageData.openGitLabSourceContext?.provider === 'gitlab' && + pageData.openGitLabWorkItem?.id === gitlabDialogItem.id && + pageData.openGitLabWorkItem.repoId === gitlabDialogItem.repoId + ) { + return pageData.openGitLabSourceContext + } + return getTaskPageRepoSourceContext(gitlabDialogRepo, 'gitlab', gitlabDialogItem.projectRef) + }, [ + gitlabDialogItem, + gitlabDialogRepo, + pageData.openGitLabSourceContext, + pageData.openGitLabWorkItem + ]) const setDialogWorkItem = useCallback( (item: GitHubWorkItem | null, initialTab: ItemDialogTab = 'conversation') => { @@ -2883,15 +3561,43 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItem(pageData.openGitHubWorkItem, pageData.openGitHubInitialTab) }, [pageData.openGitHubInitialTab, pageData.openGitHubWorkItem, setDialogWorkItem]) + useEffect(() => { + setGitlabDialogItem(pageData.openGitLabWorkItem ?? null) + }, [pageData.openGitLabWorkItem]) + 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 openGitLabDetailPage = useCallback( + (item: GitLabWorkItem) => { + openTaskPage( + { + taskSource: 'gitlab', + preselectedRepoId: item.repoId, + openGitLabWorkItem: item, + openGitLabSourceContext: getTaskPageRepoSourceContext( + repoMap.get(item.repoId), + 'gitlab', + item.projectRef + ) + }, + { recordTasksInteraction: false } + ) + }, + [openTaskPage, repoMap] ) const patchTaskPageWorkItemRows = useCallback( @@ -2989,33 +3695,33 @@ export default function TaskPage(): React.JSX.Element { // Why: on a partial-failure retry the cache still holds successful-side // data, so `tasksLoading` (which is gated on `anyUncached`) never flips // true and the Retry button would otherwise give no feedback. Track - // retry-in-flight per repo (keyed by `repoPath`) so that clicking Retry - // on one banner only flips that banner's button into its "Retrying…" + // retry-in-flight per selected source so that clicking Retry + // on one banner only flips that source's button into its "Retrying…" // state — other still-failing banners stay in their "Retry" state rather // than misleadingly flipping in lockstep. The fetch effect clears the set // when the nonce-driven refresh settles. - const [retryingRepoPaths, setRetryingRepoPaths] = useState<ReadonlySet<string>>(() => new Set()) + const [retryingSourceKeys, setRetryingSourceKeys] = useState<ReadonlySet<string>>(() => new Set()) const handleRetryIssuesFetch = useCallback( - (repoPath: string) => { - const repo = selectedRepos.find((r) => r.path === repoPath) - if (!repo) { + (sourceKey: string) => { + const source = perRepoSourceState.find((s) => s.sourceKey === sourceKey) + if (!source) { return } // Why: bumping the shared refresh nonce reuses the Tasks list's // single fetch path — nonce changes are treated as force=true so // retry doesn't silently dedupe onto a still-failing in-flight request. // The nonce bump refreshes ALL selected repos, but the Retrying… - // state is scoped to the clicked repo so other banners stay in their + // state is scoped to the clicked source so other banners stay in their // "Retry" state rather than misleadingly flipping to "Retrying…". - setRetryingRepoPaths((prev) => { + setRetryingSourceKeys((prev) => { const next = new Set(prev) - next.add(repoPath) + next.add(source.sourceKey) return next }) setTaskRefreshNonce((n) => n + 1) }, - [selectedRepos] + [perRepoSourceState] ) const handleRefreshGithubTasks = useCallback((): void => { setTasksRefreshing(true) @@ -3036,16 +3742,31 @@ export default function TaskPage(): React.JSX.Element { () => selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null, [selectedRepos, newIssueRepoId] ) + const newIssueSourceContext = useMemo( + () => getTaskPageRepoSourceContext(newIssueTargetRepo, 'github'), + [newIssueTargetRepo] + ) const newIssueRuntimeTarget = useMemo(() => { if (!newIssueTargetRepo?.id) { return null } - const target = getActiveRuntimeTarget(settings) + const repoOwnerSettings = getSettingsForRepoRuntimeOwner( + { repos: [newIssueTargetRepo], settings }, + newIssueTargetRepo.id + ) + const targetSettings = + newIssueSourceContext?.provider === 'github' + ? { + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(newIssueSourceContext) + } + : repoOwnerSettings + const target = getActiveRuntimeTarget(targetSettings) if (target.kind !== 'environment') { return null } return repos.some((repo) => repo.id === newIssueTargetRepo.id) ? target : null - }, [newIssueTargetRepo?.id, repos, settings]) + }, [newIssueSourceContext, newIssueTargetRepo, repos, settings]) const newIssueRepoLabels = useRepoLabels( newIssueOpen ? (newIssueTargetRepo?.path ?? null) : null, newIssueOpen ? (newIssueTargetRepo?.id ?? null) : null, @@ -3086,6 +3807,21 @@ export default function TaskPage(): React.JSX.Element { const selectedLinearIssue = selectedLinearIssueId ? (cachedSelectedLinearIssue ?? selectedLinearIssueFallback) : null + const linearDetailSourceContext = useMemo(() => { + if ( + selectedLinearIssue && + pageData.openLinearSourceContext?.provider === 'linear' && + pageData.openLinearIssue?.id === selectedLinearIssue.id + ) { + return pageData.openLinearSourceContext + } + return linearTaskSourceContext + }, [ + linearTaskSourceContext, + pageData.openLinearIssue, + pageData.openLinearSourceContext, + selectedLinearIssue + ]) const setSelectedLinearIssue = useCallback( (issue: LinearIssue | null, options?: { allowOutsideList?: boolean }) => { @@ -3113,11 +3849,15 @@ export default function TaskPage(): React.JSX.Element { const openLinearDetailPage = useCallback( (issue: LinearIssue) => { openTaskPage( - { taskSource: 'linear', openLinearIssue: issue }, + { + taskSource: 'linear', + openLinearIssue: issue, + openLinearSourceContext: linearTaskSourceContext + }, { recordTasksInteraction: false } ) }, - [openTaskPage] + [linearTaskSourceContext, openTaskPage] ) const openRelatedLinearIssue = useCallback( @@ -3144,8 +3884,14 @@ export default function TaskPage(): React.JSX.Element { taskPageData: { ...s.taskPageData, openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, - openLinearIssue: undefined + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined } })) }, [clearSelectedLinearIssue, setDialogWorkItem]) @@ -3161,17 +3907,55 @@ export default function TaskPage(): React.JSX.Element { const cachedSelectedJiraIssue = findTaskPageJiraIssue( jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, - selectedJiraIssueKey + selectedJiraIssueKey, + { + sourceContext: jiraTaskSourceContext, + siteId: selectedJiraIssueFallback?.siteId ?? pageData.openJiraIssue?.siteId ?? null + } ) const selectedJiraIssue = selectedJiraIssueKey ? (cachedSelectedJiraIssue ?? selectedJiraIssueFallback) : null + const jiraDetailSourceContext = useMemo(() => { + if ( + selectedJiraIssue && + pageData.openJiraSourceContext?.provider === 'jira' && + pageData.openJiraIssue?.key === selectedJiraIssue.key && + pageData.openJiraIssue.siteId === selectedJiraIssue.siteId + ) { + return pageData.openJiraSourceContext + } + return jiraTaskSourceContext + }, [ + jiraTaskSourceContext, + pageData.openJiraIssue, + pageData.openJiraSourceContext, + selectedJiraIssue + ]) const setSelectedJiraIssue = useCallback((issue: JiraIssue | null) => { setSelectedJiraIssueKey(issue?.key ?? null) setSelectedJiraIssueFallback(issue) }, []) + useEffect(() => { + setSelectedJiraIssue(pageData.openJiraIssue ?? null) + }, [pageData.openJiraIssue, setSelectedJiraIssue]) + + const openJiraDetailPage = useCallback( + (issue: JiraIssue) => { + openTaskPage( + { + taskSource: 'jira', + openJiraIssue: issue, + openJiraSourceContext: jiraTaskSourceContext + }, + { recordTasksInteraction: false } + ) + }, + [jiraTaskSourceContext, openTaskPage] + ) + // Linear tab state const [linearMode, setLinearMode] = useState<LinearMode>('issues') const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([]) @@ -3437,7 +4221,10 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false if (context.kind === 'project') { - void fetchLinearProject(context.id, context.workspaceId, { force: true }) + void fetchLinearProject(context.id, context.workspaceId, { + force: true, + sourceContext: linearTaskSourceContext + }) .then((project) => { if (cancelled) { return @@ -3473,7 +4260,8 @@ export default function TaskPage(): React.JSX.Element { setLinearCustomViewsLoading(true) setLinearCustomViewsError(null) void fetchLinearCustomView(context.id, context.workspaceId, context.model, { - force: true + force: true, + sourceContext: linearTaskSourceContext }) .then((restoredView) => { if (cancelled) { @@ -3506,6 +4294,7 @@ export default function TaskPage(): React.JSX.Element { fetchLinearProject, listLinearCustomViews, linearConnected, + linearTaskSourceContext, setTaskResumeState, taskResumeApplied, taskResumeState?.linearContext, @@ -3527,12 +4316,14 @@ export default function TaskPage(): React.JSX.Element { return } let cancelled = false - const cachedTeams = getCachedLinearTeams(selectedLinearWorkspaceId) + const cachedTeams = getCachedLinearTeams(selectedLinearWorkspaceId, { + sourceContext: linearTaskSourceContext + }) // Why: workspace switches must not leave the prior workspace's teams // available for new-issue creation while the replacement fetch is pending, // but a workspace-scoped cache can keep the selector usable immediately. setAvailableTeams(cachedTeams ?? []) - void listLinearTeams(selectedLinearWorkspaceId) + void listLinearTeams(selectedLinearWorkspaceId, { sourceContext: linearTaskSourceContext }) .then((teams) => { if (!cancelled) { setAvailableTeams(teams) @@ -3554,7 +4345,8 @@ export default function TaskPage(): React.JSX.Element { linearTeamRefreshNonce, taskResumeApplied, getCachedLinearTeams, - listLinearTeams + listLinearTeams, + linearTaskSourceContext ]) const [availableJiraProjects, setAvailableJiraProjects] = useState<JiraProject[]>([]) @@ -3572,7 +4364,7 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false setAvailableJiraProjects([]) setJiraProjectsLoading(true) - void jiraListProjects(settings, selectedJiraSiteId) + void jiraListProjects(jiraTaskSourceContext ?? settings, selectedJiraSiteId) .then((projects) => { if (!cancelled) { setAvailableJiraProjects(projects) @@ -3591,14 +4383,24 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [settings, taskSource, jiraConnected, selectedJiraSiteId, taskResumeApplied]) + }, [ + settings, + taskSource, + jiraConnected, + selectedJiraSiteId, + taskResumeApplied, + jiraTaskSourceContext + ]) // Why: stable key for `selectedRepos` so the GitLab fetch effect below // doesn't re-run on every parent re-render just because the array // reference changed. The memoized string keys off id + path + // connectionId — the only fields the effect actually reads. const selectedReposKey = useMemo( - () => selectedRepos.map((r) => `${r.id}|${r.path}|${r.connectionId ?? ''}`).join(','), + () => + selectedRepos + .map((r) => `${r.id}|${r.path}|${r.connectionId ?? ''}|${r.executionHostId ?? ''}`) + .join(','), [selectedRepos] ) @@ -3643,6 +4445,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 @@ -3663,6 +4467,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 @@ -3730,7 +4536,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[]) @@ -3749,7 +4559,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>>(() => { @@ -4192,7 +5002,11 @@ export default function TaskPage(): React.JSX.Element { } try { - const states = await linearTeamStates(settings, issue.team.id, issue.workspaceId) + const states = await linearTeamStates( + linearTaskSourceContext ?? settings, + issue.team.id, + issue.workspaceId + ) const workflowState = findLinearWorkflowStateForStatus(states, targetState) if (!workflowState) { toast.error( @@ -4216,7 +5030,7 @@ export default function TaskPage(): React.JSX.Element { applyFallbackState(nextState) const result = await linearUpdateIssue( - settings, + linearTaskSourceContext ?? settings, issue.id, { stateId: workflowState.id }, issue.workspaceId @@ -4254,6 +5068,7 @@ export default function TaskPage(): React.JSX.Element { linearStatusBoardEnabled, patchScopedLinearIssue, patchLinearIssue, + linearTaskSourceContext, settings ] ) @@ -4280,10 +5095,14 @@ export default function TaskPage(): React.JSX.Element { findTaskPageJiraIssue( jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, - issue.key + issue.key, + { + sourceContext: jiraTaskSourceContext, + siteId: issue.siteId + } ) ?? issue ), - [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache] + [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, jiraTaskSourceContext] ) // New Linear project dialog state @@ -4353,7 +5172,7 @@ export default function TaskPage(): React.JSX.Element { const targetWorkspaceId = newLinearIssueTargetTeam.workspaceId || (selectedLinearWorkspaceId !== 'all' ? selectedLinearWorkspaceId : null) - linearListProjects(settings, undefined, 100, targetWorkspaceId) + linearListProjects(linearTaskSourceContext ?? settings, undefined, 100, targetWorkspaceId) .then((p) => { if (!cancelled) { setNewLinearIssueProjects(p.items) @@ -4374,6 +5193,7 @@ export default function TaskPage(): React.JSX.Element { linearConnected, newLinearIssueOpen, newLinearIssueTargetTeam, + linearTaskSourceContext, settings, selectedLinearWorkspaceId ]) @@ -4639,7 +5459,7 @@ export default function TaskPage(): React.JSX.Element { setAvailableJiraIssueTypes([]) setJiraIssueTypesLoading(true) void jiraListIssueTypes( - settings, + jiraTaskSourceContext ?? settings, newJiraIssueTargetProject.id, newJiraIssueTargetProject.siteId ) @@ -4665,7 +5485,7 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject]) + }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject, jiraTaskSourceContext]) useEffect(() => { if ( @@ -4686,7 +5506,7 @@ export default function TaskPage(): React.JSX.Element { setJiraCreateFieldsError(null) setNewJiraIssueCustomFieldValues({}) void jiraListCreateFields( - settings, + jiraTaskSourceContext ?? settings, newJiraIssueTargetProject.id, newJiraIssueTargetType.id, newJiraIssueTargetProject.siteId @@ -4711,7 +5531,14 @@ export default function TaskPage(): React.JSX.Element { // responses after the user switches either selector. cancelled = true } - }, [settings, jiraConnected, newJiraIssueOpen, newJiraIssueTargetProject, newJiraIssueTargetType]) + }, [ + settings, + jiraConnected, + newJiraIssueOpen, + newJiraIssueTargetProject, + newJiraIssueTargetType, + jiraTaskSourceContext + ]) // Why: defense-in-depth safety net applied to the current page's items. // The active tab scopes requests to issues or PRs, and this keeps stale @@ -4784,7 +5611,7 @@ export default function TaskPage(): React.JSX.Element { item.branchName, item.headSha, item.prRepo ?? null, - { repoId: repo.id } + { repoId: repo.id, sourceContext: getTaskPageRepoSourceContext(repo, 'github') } ).then((checks) => { patchTaskPageWorkItemRows( { id: item.id, repoId: item.repoId }, @@ -4849,7 +5676,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 @@ -4933,19 +5765,19 @@ export default function TaskPage(): React.JSX.Element { if (!taskResumeApplied) { return } - // Why: both early-return branches must clear `retryingRepoPaths` — if the + // Why: both early-return branches must clear `retryingSourceKeys` — if the // user clicks Retry and then switches `taskSource` away from 'github' (or // somehow ends up with zero repos selected) before the fetch dispatches, // neither the `.then` nor the `.catch` below will fire, and the Retry // button would stay stuck in its disabled/Retrying state indefinitely. if (taskSource !== 'github' || githubMode !== 'items') { - setRetryingRepoPaths(new Set()) + setRetryingSourceKeys(new Set()) setTasksRefreshing(false) setTasksFiltering(false) return } if (selectedRepos.length === 0) { - setRetryingRepoPaths(new Set()) + setRetryingSourceKeys(new Set()) setTasksRefreshing(false) setTasksFiltering(false) return @@ -4965,7 +5797,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) + const cached = getCachedWorkItems( + r.id, + PER_REPO_FETCH_LIMIT, + q, + r.path, + getTaskPageRepoSourceContext(r, 'github') + ) if (cached === null) { anyUncached = true } else { @@ -5000,7 +5838,12 @@ export default function TaskPage(): React.JSX.Element { workItemsInvalidationNonce !== lastFetchedInvalidationNonceRef.current lastFetchedInvalidationNonceRef.current = workItemsInvalidationNonce const forcedFetch = (forceRefresh && taskRefreshNonce > 0) || preferenceInvalidated - 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 landingRefreshKey = `${repoArgs.map((r) => `${r.repoId}:${r.path}`).join('|')}::${q}` const shouldProbeOnLanding = !forcedFetch && anyRepoCached && !landingGitHubRefreshKeysRef.current.has(landingRefreshKey) @@ -5015,29 +5858,29 @@ export default function TaskPage(): React.JSX.Element { // so the toolbar still shows a refresh-in-progress affordance. setTasksRefreshing(forcedFetch) - // Why: snapshot the retrying paths at effect-dispatch so overlapping + // Why: snapshot the retrying source keys at effect-dispatch so overlapping // retries don't clear each other's pending state. An earlier cancelled // effect settling after a newer retry starts would otherwise wipe the - // newer retry's repo from the set. Clearing only the paths captured + // newer retry's source from the set. Clearing only the keys captured // when this effect dispatched preserves later additions. - const dispatchedRetryPaths = retryingRepoPaths + const dispatchedRetrySourceKeys = retryingSourceKeys void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, { ...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding) }) .then(({ items, failedCount: failed }) => { - // Why: clear only the repos this effect was responsible for + // Why: clear only the sources this effect was responsible for // retrying (the snapshot captured at dispatch time). Overlapping // retries — a second click while a prior fetch is still in flight - // — must not clear the newer repo from the set, so we can't just + // — must not clear the newer source from the set, so we can't just // reset the whole set here. The early-return branches above reset // the whole set because those branches won't dispatch a fetch. - setRetryingRepoPaths((prev) => { - if (dispatchedRetryPaths.size === 0) { + setRetryingSourceKeys((prev) => { + if (dispatchedRetrySourceKeys.size === 0) { return prev } const next = new Set(prev) - for (const p of dispatchedRetryPaths) { - next.delete(p) + for (const key of dispatchedRetrySourceKeys) { + next.delete(key) } return next }) @@ -5063,19 +5906,19 @@ export default function TaskPage(): React.JSX.Element { .catch((err) => { // Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a // reject here means an IPC-level or programmer error — surface it. - // Clear only the repos this effect was responsible for retrying + // Clear only the sources this effect was responsible for retrying // (the snapshot captured at dispatch time). Overlapping retries — // a second click while a prior fetch is still in flight — must - // not clear the newer repo from the set, so we can't just reset + // not clear the newer source from the set, so we can't just reset // the whole set here. The early-return branches above reset the // whole set because those branches won't dispatch a fetch. - setRetryingRepoPaths((prev) => { - if (dispatchedRetryPaths.size === 0) { + setRetryingSourceKeys((prev) => { + if (dispatchedRetrySourceKeys.size === 0) { return prev } const next = new Set(prev) - for (const p of dispatchedRetryPaths) { - next.delete(p) + for (const key of dispatchedRetrySourceKeys) { + next.delete(key) } return next }) @@ -5093,7 +5936,12 @@ export default function TaskPage(): React.JSX.Element { // The search API is cached 120s server-side so this doesn't add // meaningful latency or rate-limit pressure. void countWorkItemsAcrossRepos( - selectedRepos.map((r) => ({ repoId: r.id, path: r.path })), + selectedRepos.map((r) => ({ + repoId: r.id, + path: r.path, + executionHostId: r.executionHostId, + sourceContext: getTaskPageRepoSourceContext(r, 'github') + })), q ).then((count) => { if (!cancelled) { @@ -5307,12 +6155,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( @@ -5373,12 +6222,17 @@ export default function TaskPage(): React.JSX.Element { } openModal('new-workspace-composer', { linkedWorkItem, + taskSourceContext: getTaskPageRepoSourceContext( + repoMap.get(item.repoId), + 'gitlab', + item.projectRef + ), prefilledName: getGitLabWorkItemWorkspaceSeed(item), initialRepoId: item.repoId, telemetrySource: 'sidebar' }) }, - [openModal] + [openModal, repoMap] ) const handleUseGitLabItem = useCallback( @@ -5404,7 +6258,10 @@ export default function TaskPage(): React.JSX.Element { newIssueRuntimeTarget, 'github.createIssue', { - repo: newIssueTargetRepo.id, + repo: + newIssueSourceContext?.provider === 'github' + ? (newIssueSourceContext.repoId ?? newIssueTargetRepo.id) + : newIssueTargetRepo.id, title, body: newIssueBody, labels: newIssueLabels, @@ -5415,6 +6272,7 @@ export default function TaskPage(): React.JSX.Element { : await window.api.gh.createIssue({ repoPath: newIssueTargetRepo.path, repoId: newIssueTargetRepo.id, + sourceContext: newIssueSourceContext, title, body: newIssueBody, labels: newIssueLabels, @@ -5470,12 +6328,20 @@ export default function TaskPage(): React.JSX.Element { ? callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.workItem>>>( newIssueRuntimeTarget, 'github.workItem', - { repo: newIssueTargetRepo.id, number: result.number, type: 'issue' }, + { + repo: + newIssueSourceContext?.provider === 'github' + ? (newIssueSourceContext.repoId ?? newIssueTargetRepo.id) + : newIssueTargetRepo.id, + number: result.number, + type: 'issue' + }, { timeoutMs: 30_000 } ) : window.api.gh.workItem({ repoPath: newIssueTargetRepo.path, repoId: newIssueTargetRepo.id, + sourceContext: newIssueSourceContext, number: result.number, type: 'issue' }) @@ -5499,6 +6365,7 @@ export default function TaskPage(): React.JSX.Element { newIssueAssignees, newIssueLabels, newIssueRuntimeTarget, + newIssueSourceContext, newIssueSubmitting, newIssueTargetRepo, newIssueTitle, @@ -5516,7 +6383,7 @@ export default function TaskPage(): React.JSX.Element { } setNewLinearProjectSubmitting(true) try { - const result = await linearCreateProject(settings, { + const result = await linearCreateProject(linearTaskSourceContext ?? settings, { name, description: newLinearProjectDescription.trim() || undefined, content: newLinearProjectContent.trim() || undefined, @@ -5590,6 +6457,7 @@ export default function TaskPage(): React.JSX.Element { newLinearProjectTargetDate, newLinearProjectTargetTeam, openLinearProjectContext, + linearTaskSourceContext, settings ]) @@ -5617,7 +6485,7 @@ export default function TaskPage(): React.JSX.Element { setNewLinearIssueSubmitting(true) const submitProviderRuntimeContextKey = providerRuntimeContextKey try { - const result = await linearCreateIssue(settings, { + const result = await linearCreateIssue(linearTaskSourceContext ?? settings, { teamId: newLinearIssueTargetTeam.id, title, description: newLinearIssueBody || undefined, @@ -5664,7 +6532,11 @@ export default function TaskPage(): React.JSX.Element { // Why: auto-select the new issue in the inline workspace so the user // sees exactly what was filed, mirroring the GitHub create-issue flow. - void linearGetIssue(settings, result.id, newLinearIssueTargetTeam.workspaceId) + void linearGetIssue( + linearTaskSourceContext ?? settings, + result.id, + newLinearIssueTargetTeam.workspaceId + ) .then((full) => { if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { return @@ -5692,6 +6564,7 @@ export default function TaskPage(): React.JSX.Element { providerRuntimeContextKey, selectedLinearProject, setSelectedLinearIssue, + linearTaskSourceContext, settings ]) @@ -5710,7 +6583,7 @@ export default function TaskPage(): React.JSX.Element { setNewJiraIssueSubmitting(true) const submitProviderRuntimeContextKey = providerRuntimeContextKey try { - const result = await jiraCreateIssue(settings, { + const result = await jiraCreateIssue(jiraTaskSourceContext ?? settings, { siteId: newJiraIssueTargetProject.siteId, projectId: newJiraIssueTargetProject.id, issueTypeId: newJiraIssueTargetType.id, @@ -5747,7 +6620,11 @@ export default function TaskPage(): React.JSX.Element { setNewJiraIssueCustomFieldValues({}) setJiraRefreshNonce((n) => n + 1) - void jiraGetIssue(settings, result.key, newJiraIssueTargetProject.siteId) + void jiraGetIssue( + jiraTaskSourceContext ?? settings, + result.key, + newJiraIssueTargetProject.siteId + ) .then((full) => { if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { return @@ -5775,6 +6652,7 @@ export default function TaskPage(): React.JSX.Element { newJiraIssueTargetType, newJiraIssueTitle, providerRuntimeContextKey, + jiraTaskSourceContext, settings, setSelectedJiraIssue, visibleJiraCreateFields @@ -5922,7 +6800,7 @@ export default function TaskPage(): React.JSX.Element { trimmed.length > 0 ? ({ kind: 'search', query: trimmed, limit: LINEAR_ITEM_LIMIT } as const) : ({ kind: 'list', filter: 'all', limit: effectiveLinearIssueLimit } as const) - const cachedResult = getCachedLinearIssues(readArgs) + const cachedResult = getCachedLinearIssues(readArgs, { sourceContext: linearTaskSourceContext }) if (readArgs.kind === 'search') { setLinearIssuesHasMore(false) if (cachedResult) { @@ -5964,10 +6842,12 @@ export default function TaskPage(): React.JSX.Element { const request = readArgs.kind === 'search' ? searchLinearIssues(readArgs.query, LINEAR_ITEM_LIMIT, { - force: forceRefresh || shouldProbeOnLanding + force: forceRefresh || shouldProbeOnLanding, + sourceContext: linearTaskSourceContext }) : listLinearIssues(readArgs.filter, effectiveLinearIssueLimit, { - force: forceRefresh || shouldProbeOnLanding + force: forceRefresh || shouldProbeOnLanding, + sourceContext: linearTaskSourceContext }) void request @@ -6029,7 +6909,8 @@ export default function TaskPage(): React.JSX.Element { linearIssueLimit, linearRefreshNonce, taskResumeApplied, - getCachedLinearIssues + getCachedLinearIssues, + linearTaskSourceContext ]) useEffect(() => { @@ -6051,7 +6932,9 @@ export default function TaskPage(): React.JSX.Element { } let cancelled = false const query = appliedLinearProjectSearch.trim() - const cached = getCachedLinearProjects(query || undefined, LINEAR_ITEM_LIMIT) + const cached = getCachedLinearProjects(query || undefined, LINEAR_ITEM_LIMIT, undefined, { + sourceContext: linearTaskSourceContext + }) if (cached) { setLinearProjectsResult(cached) } @@ -6059,7 +6942,8 @@ export default function TaskPage(): React.JSX.Element { setLinearProjectsLoading(force || cached === null) setLinearProjectsError(null) void listLinearProjectsFromStore(query || undefined, LINEAR_ITEM_LIMIT, undefined, { - force + force, + sourceContext: linearTaskSourceContext }) .then((result) => { if (!cancelled) { @@ -6088,7 +6972,8 @@ export default function TaskPage(): React.JSX.Element { selectedLinearProject, appliedLinearProjectSearch, linearRefreshNonce, - getCachedLinearProjects + getCachedLinearProjects, + linearTaskSourceContext ]) useEffect(() => { @@ -6100,7 +6985,8 @@ export default function TaskPage(): React.JSX.Element { setLinearProjectDetailLoading(true) setLinearProjectDetailError(null) void fetchLinearProject(selectedLinearProject.id, selectedLinearProject.workspaceId, { - force: linearRefreshNonce > 0 + force: linearRefreshNonce > 0, + sourceContext: linearTaskSourceContext }) .then((project) => { if (!cancelled) { @@ -6126,7 +7012,13 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [fetchLinearProject, linearRefreshNonce, selectedLinearProject, setTaskResumeState]) + }, [ + fetchLinearProject, + linearRefreshNonce, + selectedLinearProject, + setTaskResumeState, + linearTaskSourceContext + ]) useEffect(() => { if (!selectedLinearProject?.workspaceId || linearProjectTab !== 'issues') { @@ -6140,7 +7032,7 @@ export default function TaskPage(): React.JSX.Element { selectedLinearProject.id, selectedLinearProject.workspaceId, effectiveLimit, - { force: linearRefreshNonce > 0 } + { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } ) .then((result) => { if (!cancelled) { @@ -6164,6 +7056,7 @@ export default function TaskPage(): React.JSX.Element { linearProjectTab, linearRefreshNonce, listLinearProjectIssues, + linearTaskSourceContext, selectedLinearProject ]) @@ -6176,7 +7069,9 @@ export default function TaskPage(): React.JSX.Element { } let cancelled = false const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) => - getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT) + getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { + sourceContext: linearTaskSourceContext + }) ) const allCached = cachedResults.every( (result): result is LinearCollectionResult<LinearCustomViewSummary> => result !== null @@ -6191,7 +7086,10 @@ export default function TaskPage(): React.JSX.Element { // models avoids a second, redundant Issues/Projects switch. void Promise.all( LINEAR_CUSTOM_VIEW_MODELS.map((model) => - listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { force }) + listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { + force, + sourceContext: linearTaskSourceContext + }) ) ) .then((result) => { @@ -6221,7 +7119,8 @@ export default function TaskPage(): React.JSX.Element { selectedLinearCustomView, linearRefreshNonce, getCachedLinearCustomViews, - listLinearCustomViews + listLinearCustomViews, + linearTaskSourceContext ]) useEffect(() => { @@ -6240,13 +7139,13 @@ export default function TaskPage(): React.JSX.Element { selectedLinearCustomView.id, selectedLinearCustomView.workspaceId, issueLimit, - { force: linearRefreshNonce > 0 } + { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } ) : listLinearCustomViewProjects( selectedLinearCustomView.id, selectedLinearCustomView.workspaceId, LINEAR_ITEM_LIMIT, - { force: linearRefreshNonce > 0 } + { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } ) void request .then((result) => { @@ -6276,6 +7175,7 @@ export default function TaskPage(): React.JSX.Element { linearCustomViewIssueLimit, listLinearCustomViewIssues, listLinearCustomViewProjects, + linearTaskSourceContext, selectedLinearCustomView ]) @@ -6356,8 +7256,10 @@ export default function TaskPage(): React.JSX.Element { const trimmed = appliedJiraSearch.trim() const request = trimmed.length > 0 - ? searchJiraIssues(trimmed, JIRA_ITEM_LIMIT) - : listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT) + ? searchJiraIssues(trimmed, JIRA_ITEM_LIMIT, { sourceContext: jiraTaskSourceContext }) + : listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, { + sourceContext: jiraTaskSourceContext + }) void request .then((issues) => { @@ -6386,7 +7288,8 @@ export default function TaskPage(): React.JSX.Element { appliedJiraSearch, activeJiraPreset, jiraRefreshNonce, - taskResumeApplied + taskResumeApplied, + jiraTaskSourceContext ]) useEffect(() => { @@ -6427,11 +7330,12 @@ export default function TaskPage(): React.JSX.Element { const linkedWorkItem = buildLinearIssueLinkedWorkItem(issue) openModal('new-workspace-composer', { linkedWorkItem, + taskSourceContext: linearTaskSourceContext, prefilledName: getLinearIssueWorkspaceName(issue), telemetrySource: 'sidebar' }) }, - [openModal] + [linearTaskSourceContext, openModal] ) const handleUseLinearItem = useCallback( @@ -6525,15 +7429,17 @@ export default function TaskPage(): React.JSX.Element { } openModal('new-workspace-composer', { linkedWorkItem, + taskSourceContext: jiraTaskSourceContext, prefilledName: getJiraIssueWorkspaceSeed(issue), telemetrySource: 'sidebar' }) }, - [openModal] + [jiraTaskSourceContext, openModal] ) const handleUseJiraItem = useCallback( (issue: JiraIssue): void => { + useAppStore.getState().recordFeatureInteraction('jira-tasks') openComposerForJiraItem(issue) }, [openComposerForJiraItem] @@ -6622,13 +7528,19 @@ export default function TaskPage(): React.JSX.Element { <div className="mx-1 h-5 w-px bg-border/50" aria-hidden /> {visibleSourceOptions.map((source) => { const active = taskSource === source.id + const sourceAvailabilityNotice = + taskSourceAvailabilityNoticeByProvider[source.id] ?? null + const sourceDisabled = source.disabled || sourceAvailabilityNotice?.blocking return ( <Tooltip key={source.id}> <TooltipTrigger asChild> <button type="button" - disabled={source.disabled} + disabled={sourceDisabled} onClick={() => { + if (sourceAvailabilityNotice?.blocking) { + return + } taskSourceManuallyChangedRef.current = true openTaskPage( { taskSource: source.id }, @@ -6643,24 +7555,30 @@ export default function TaskPage(): React.JSX.Element { ) }) }} - aria-label={source.label} + aria-label={sourceAvailabilityNotice?.label ?? source.label} className={cn( 'group flex h-8 w-8 items-center justify-center rounded-md border transition', active ? 'border-foreground/40 bg-muted/70 text-foreground shadow-sm' : 'border-border/40 bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground', - source.disabled && 'cursor-not-allowed opacity-55' + sourceDisabled && 'cursor-not-allowed opacity-55' )} > <source.Icon className="size-3.5" /> </button> </TooltipTrigger> <TooltipContent side="bottom" sideOffset={6}> - {source.label} + {sourceAvailabilityNotice?.label ?? source.label} </TooltipContent> </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"> @@ -6760,6 +7678,17 @@ export default function TaskPage(): React.JSX.Element { ) : null} </div> + {taskSourceAvailabilityNotice ? ( + <div + role="status" + className="flex max-w-3xl items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground" + title={taskSourceAvailabilityNotice.title} + > + <AlertCircle className="size-3.5 flex-none" /> + <span className="min-w-0 truncate">{taskSourceAvailabilityNotice.label}</span> + </div> + ) : null} + {taskSource === 'github' ? ( <div className="flex min-w-0 flex-wrap items-center gap-2"> {projectModeVisible ? ( @@ -6804,22 +7733,26 @@ export default function TaskPage(): React.JSX.Element { {githubMode !== 'project' && ( <> <div className="min-w-0 max-w-[220px] shrink-0"> - <RepoMultiCombobox - repos={eligibleRepos} + <TaskProjectSourceCombobox + groups={taskPickerGroups} selected={repoSelection} + getRepoHostLabel={getTaskPickerRepoHostLabel} onChange={(next) => { - setRepoSelection(next) - void updateSettings({ defaultRepoSelection: [...next] }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ defaultRepoSelection: [...normalized] }).catch( + () => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) ) - ) - }) + } + ) }} onSelectAll={() => { - const allIds = new Set(eligibleRepos.map((r) => r.id)) + const allIds = new Set(taskPickerRepos.map((r) => r.id)) setRepoSelection(allIds) void updateSettings({ defaultRepoSelection: null }).catch(() => { toast.error( @@ -7511,22 +8444,26 @@ export default function TaskPage(): React.JSX.Element { })} </div> <div className="min-w-0 w-full sm:w-[200px]"> - <RepoMultiCombobox - repos={eligibleRepos} + <TaskProjectSourceCombobox + groups={taskPickerGroups} selected={repoSelection} + getRepoHostLabel={getTaskPickerRepoHostLabel} onChange={(next) => { - setRepoSelection(next) - void updateSettings({ defaultRepoSelection: [...next] }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ defaultRepoSelection: [...normalized] }).catch( + () => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) ) - ) - }) + } + ) }} onSelectAll={() => { - const allIds = new Set(eligibleRepos.map((r) => r.id)) + const allIds = new Set(taskPickerRepos.map((r) => r.id)) setRepoSelection(allIds) void updateSettings({ defaultRepoSelection: null }).catch(() => { toast.error( @@ -7628,13 +8565,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) @@ -7645,10 +8582,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) => { @@ -7747,10 +8684,10 @@ export default function TaskPage(): React.JSX.Element { <Button variant="outline" size="sm" - onClick={() => handleRetryIssuesFetch(s.repoPath)} - disabled={tasksLoading || retryingRepoPaths.has(s.repoPath)} + onClick={() => handleRetryIssuesFetch(s.sourceKey)} + disabled={tasksLoading || retryingSourceKeys.has(s.sourceKey)} > - {retryingRepoPaths.has(s.repoPath) ? ( + {retryingSourceKeys.has(s.sourceKey) ? ( <span className="flex items-center gap-1"> <LoaderCircle className="h-3 w-3 animate-spin" /> {translate('auto.components.TaskPage.5b6b2af943', 'Retrying…')} @@ -7824,13 +8761,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} @@ -7955,14 +8889,22 @@ export default function TaskPage(): React.JSX.Element { {!showPRManagementColumns ? ( <div className="min-w-0 flex items-center text-xs text-muted-foreground"> - <GHAssigneesCell item={item} repo={itemRepo ?? null} /> + <GHAssigneesCell + item={item} + repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} + /> </div> ) : null} {showPRManagementColumns ? ( <> <div className="flex min-w-0 items-center"> - <PRReviewCell item={item} repo={itemRepo ?? null} /> + <PRReviewCell + item={item} + repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} + /> </div> <div className="flex min-w-0 items-center"> @@ -7977,13 +8919,18 @@ export default function TaskPage(): React.JSX.Element { <PRMergeCell item={item} repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} onRefresh={() => setTaskRefreshNonce((current) => current + 1)} /> </div> </> ) : ( <div className="flex items-center"> - <GHStatusCell item={item} repo={itemRepo ?? null} /> + <GHStatusCell + item={item} + repo={itemRepo ?? null} + sourceContext={getTaskPageRepoSourceContext(itemRepo, 'github')} + /> </div> )} @@ -8302,26 +9249,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"> @@ -8337,13 +9271,13 @@ export default function TaskPage(): React.JSX.Element { key={item.id} onClick={() => { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') - setGitlabDialogItem(item) + openGitLabDetailPage(item) }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() useAppStore.getState().recordFeatureInteraction('gitlab-tasks') - setGitlabDialogItem(item) + openGitLabDetailPage(item) } }} className="grid w-full cursor-pointer gap-3 px-3 py-2 text-left grid-cols-[80px_minmax(0,3fr)_120px_110px_50px] hover:bg-muted/50" @@ -8526,14 +9460,14 @@ export default function TaskPage(): React.JSX.Element { tabIndex={0} aria-current={selected ? 'true' : undefined} data-current={selected ? 'true' : undefined} - onClick={() => setSelectedJiraIssue(issue)} + onClick={() => openJiraDetailPage(issue)} onKeyDown={(e) => { if (e.target !== e.currentTarget) { return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - setSelectedJiraIssue(issue) + openJiraDetailPage(issue) } }} className={cn( @@ -8694,7 +9628,8 @@ export default function TaskPage(): React.JSX.Element { <JiraIssueWorkspace issue={selectedJiraIssue} onUse={handleUseJiraItem} - onClose={() => setSelectedJiraIssue(null)} + onClose={closeTaskDetailPage} + sourceContext={jiraDetailSourceContext} /> </div> ) @@ -8706,6 +9641,7 @@ export default function TaskPage(): React.JSX.Element { onUse={handleUseLinearItem} onOpenIssue={openRelatedLinearIssue} onClose={closeTaskDetailPage} + sourceContext={linearDetailSourceContext} /> ) : !linearStatusReady ? ( <div className="mt-4 flex items-center justify-center py-14"> @@ -9306,7 +10242,11 @@ export default function TaskPage(): React.JSX.Element { </div> <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground"> {effectiveLinearDisplayProperties.has('state') ? ( - <LinearStateCell issue={issue} className="px-1.5 py-0.5" /> + <LinearStateCell + issue={issue} + className="px-1.5 py-0.5" + sourceContext={linearTaskSourceContext} + /> ) : null} {effectiveLinearDisplayProperties.has('assignee') ? ( <span> @@ -9421,7 +10361,11 @@ export default function TaskPage(): React.JSX.Element { </div> <div className="mt-1 flex min-w-0 items-center gap-1.5 lg:!hidden"> {effectiveLinearDisplayProperties.has('state') ? ( - <LinearStateCell issue={issue} className="px-1.5 py-0.5" /> + <LinearStateCell + issue={issue} + className="px-1.5 py-0.5" + sourceContext={linearTaskSourceContext} + /> ) : null} {effectiveLinearDisplayProperties.has('assignee') ? ( <span className="min-w-0 truncate text-[11px] text-muted-foreground"> @@ -9463,7 +10407,11 @@ export default function TaskPage(): React.JSX.Element { {effectiveLinearDisplayProperties.has('state') ? ( <div className="flex min-w-0 max-lg:!hidden"> - <LinearStateCell issue={issue} className="max-w-full px-2 py-0.5" /> + <LinearStateCell + issue={issue} + className="max-w-full px-2 py-0.5" + sourceContext={linearTaskSourceContext} + /> </div> ) : null} @@ -11127,7 +12075,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 @@ -11136,6 +12084,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) @@ -11148,13 +12097,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/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 0027a4bc4f4..79aee7ec6e1 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -100,6 +100,7 @@ import { useContextualTour } from './contextual-tours/use-contextual-tour' import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' import { closeTerminalTab } from './terminal/terminal-tab-actions' import { translate } from '@/i18n/i18n' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -148,6 +149,10 @@ function isPinnedVisibleTab( return findUnifiedTabByVisibleId(state, worktreeId, visibleId)?.isPinned === true } +function getActiveWorktreeRuntimeEnvironmentId(worktreeId: string | null): string | null { + return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) +} + function isPinnedActiveEditorTab( state: TerminalStoreSnapshot, worktreeId: string, @@ -212,9 +217,6 @@ function Terminal(): React.JSX.Element | null { const closeTab = useAppStore((s) => s.closeTab) const setActiveTab = useAppStore((s) => s.setActiveTab) const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null - ) const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle) const setTabColor = useAppStore((s) => s.setTabColor) const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit) @@ -761,7 +763,7 @@ function Terminal(): React.JSX.Element | null { } // Why: in the paired web client, host session-tabs are authoritative. // Creating a local fallback races the host's initial terminal and duplicates tabs. - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + if (isWebRuntimeSessionActive(getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId))) { return } @@ -778,13 +780,7 @@ function Terminal(): React.JSX.Element | null { // activity and reshuffle the sidebar. Explicit "New Tab" actions // (handleNewTab below) still bump normally. createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) - }, [ - workspaceSessionReady, - activeWorktreeId, - activeRuntimeEnvironmentId, - createTab, - reconcileWorktreeTabModel - ]) + }, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel]) const handleNewTab = useCallback( (shellOverride?: string) => { @@ -798,10 +794,11 @@ function Terminal(): React.JSX.Element | null { void openNewTerminalTabInActiveWorkspace(targetGroupId) return } - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void createWebRuntimeSessionTerminal({ worktreeId: activeWorktreeId, - environmentId: activeRuntimeEnvironmentId, + environmentId: runtimeEnvironmentId, command: shellOverride, activate: true }) @@ -839,7 +836,6 @@ function Terminal(): React.JSX.Element | null { focusTerminalTabSurface(newTab.id) }, [ - activeRuntimeEnvironmentId, activeWorktreeId, createTab, openNewTerminalTabInActiveWorkspace, @@ -901,10 +897,11 @@ function Terminal(): React.JSX.Element | null { return } const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, - environmentId: activeRuntimeEnvironmentId, + environmentId: runtimeEnvironmentId, url: defaultUrl }) return @@ -913,12 +910,7 @@ function Terminal(): React.JSX.Element | null { title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), focusAddressBar: true }) - }, [ - activeRuntimeEnvironmentId, - activeWorktreeId, - createBrowserTab, - openNewBrowserTabInActiveWorkspace - ]) + }, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace]) const handleOpenEntry = useCallback(async (args: TabCreateEntryArgs) => { await openTabBarEntry(args) @@ -935,10 +927,11 @@ function Terminal(): React.JSX.Element | null { if (!source) { return } - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, - environmentId: activeRuntimeEnvironmentId, + environmentId: runtimeEnvironmentId, url: source.url, profileId: source.sessionProfileId }) @@ -949,7 +942,7 @@ function Terminal(): React.JSX.Element | null { sessionProfileId: source.sessionProfileId }) }, - [activeRuntimeEnvironmentId, activeWorktreeId, createBrowserTab] + [activeWorktreeId, createBrowserTab] ) const handleNewFile = useCallback(async () => { @@ -982,11 +975,12 @@ function Terminal(): React.JSX.Element | null { if (isPinnedVisibleTab(state, owningWorktreeId, tabId)) { return } - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(owningWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void closeWebRuntimeSessionTab({ worktreeId: owningWorktreeId, tabId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) return } @@ -1022,7 +1016,6 @@ function Terminal(): React.JSX.Element | null { closeBrowserTab(tabId) }, [ - activeRuntimeEnvironmentId, closeBrowserTab, setActiveBrowserTab, setActiveFile, @@ -1060,14 +1053,15 @@ function Terminal(): React.JSX.Element | null { if (unifiedTab?.isPinned) { continue } + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) if ( - isWebRuntimeSessionActive(activeRuntimeEnvironmentId) && + isWebRuntimeSessionActive(runtimeEnvironmentId) && (unifiedTab?.contentType === 'terminal' || unifiedTab?.contentType === 'browser') ) { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) continue } @@ -1093,14 +1087,7 @@ function Terminal(): React.JSX.Element | null { queueEditorCloseRequests(dirtyFileIds) } }, - [ - activeRuntimeEnvironmentId, - activeWorktreeId, - closeBrowserTab, - closeFile, - closeTab, - queueEditorCloseRequests - ] + [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] ) const handleCloseTabsToRight = useCallback( @@ -1123,14 +1110,15 @@ function Terminal(): React.JSX.Element | null { if (unifiedTab?.isPinned) { continue } + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) if ( - isWebRuntimeSessionActive(activeRuntimeEnvironmentId) && + isWebRuntimeSessionActive(runtimeEnvironmentId) && (unifiedTab?.contentType === 'terminal' || unifiedTab?.contentType === 'browser') ) { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) continue } @@ -1156,14 +1144,7 @@ function Terminal(): React.JSX.Element | null { queueEditorCloseRequests(dirtyFileIds) } }, - [ - activeRuntimeEnvironmentId, - activeWorktreeId, - closeBrowserTab, - closeFile, - closeTab, - queueEditorCloseRequests - ] + [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] ) const handleCloseAllFiles = useCallback(() => { @@ -1188,17 +1169,18 @@ function Terminal(): React.JSX.Element | null { const handleActivateTab = useCallback( (tabId: string) => { - if (activeWorktreeId && isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (activeWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) } setActiveTab(tabId) setActiveTabType('terminal') }, - [activeRuntimeEnvironmentId, activeWorktreeId, setActiveTab, setActiveTabType] + [activeWorktreeId, setActiveTab, setActiveTabType] ) const handleTogglePaneExpand = useCallback( @@ -1217,17 +1199,18 @@ function Terminal(): React.JSX.Element | null { const handleActivateBrowserTab = useCallback( (tabId: string) => { - if (activeWorktreeId && isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + if (activeWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId: activeWorktreeId, tabId, - environmentId: activeRuntimeEnvironmentId + environmentId: runtimeEnvironmentId }) } setActiveBrowserTab(tabId) setActiveTabType('browser') }, - [activeRuntimeEnvironmentId, activeWorktreeId, setActiveBrowserTab, setActiveTabType] + [activeWorktreeId, setActiveBrowserTab, setActiveTabType] ) // Keyboard shortcuts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 80861bff424..ee583c44743 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -56,6 +56,8 @@ import { queueBrowserFocusRequest } from '@/components/browser-pane/browser-focus' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' +import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { @@ -79,9 +81,15 @@ import { getComposerEligibleRepos, resolveComposerGitRepoId } from '@/lib/new-workspace-composer-repo' +import { + lookupGitHubWorkItemByOwnerRepoForSource, + lookupGitHubWorkItemForSource +} from '@/lib/github-work-item-source-lookup' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' +import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' type WorktreePaletteItem = { @@ -152,7 +160,8 @@ function getComposerPrefetchRepoId( return resolveComposerGitRepoId({ eligibleRepos: getComposerEligibleRepos(state.repos), initialRepoId, - activeRepoId: state.activeRepoId + activeRepoId: state.activeRepoId, + focusedHostScope: state.workspaceHostScope }) } @@ -212,6 +221,29 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme ) } +function PaletteHostBadgeChip({ + badge +}: { + badge: PaletteHostBadge | null +}): React.JSX.Element | null { + if (!badge) { + return null + } + // Host labels come from the registry and are intentionally not translated. + return ( + <span + aria-label={translate( + 'auto.components.WorktreeJumpPalette.paletteHostBadge', + 'Host: {{value0}}', + { value0: badge.label } + )} + className="max-w-[140px] truncate rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88" + > + {badge.label} + </span> + ) +} + function findBrowserSelection( pageId: string, workspaceId: string, @@ -283,8 +315,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) - useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) + const settings = useAppStore((s) => s.settings) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) @@ -317,6 +352,30 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const preserveCreateLookupOnCloseRef = useRef(false) const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos]) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + // Why: host badges only appear when more than one execution host exists; reuse + // the same registry the sidebar host-scope strip builds so labels stay in sync. + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) const canCreateWorktree = repos.length > 0 const hasQuery = deferredQuery.trim().length > 0 @@ -1150,6 +1209,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } prefetchCreateWorkspaceBaseForComposer(repoForLookup.id) + const sourceContext = buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: repoForLookup.id, + repo: repoForLookup + }) // Why: awaiting inside the user gesture would leave the palette open // indefinitely on slow networks. Close immediately and populate the // composer once the lookup returns. @@ -1157,15 +1221,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { preserveCreateLookupOnCloseRef.current = true recordFeatureInteraction('cmd-j-create-workspace') closeModal() - void window.api.gh - .workItemByOwnerRepo({ - repoPath: repoForLookup.path, - repoId: repoForLookup.id, - owner: slug.owner, - repo: slug.repo, - number, - type: ghLink.type - }) + void lookupGitHubWorkItemByOwnerRepoForSource({ + repoPath: repoForLookup.path, + repoId: repoForLookup.id, + sourceContext, + owner: slug.owner, + repo: slug.repo, + number, + type: ghLink.type + }) .then((item) => { if (!createLookupGuard.isCurrent(lookupToken)) { return @@ -1227,12 +1291,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } prefetchCreateWorkspaceBaseForComposer(repoForLookup.id) + const sourceContext = buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: repoForLookup.id, + repo: repoForLookup + }) const lookupToken = createLookupGuard.start() preserveCreateLookupOnCloseRef.current = true recordFeatureInteraction('cmd-j-create-workspace') closeModal() - void window.api.gh - .workItem({ repoPath: repoForLookup.path, repoId: repoForLookup.id, number: ghNumber }) + void lookupGitHubWorkItemForSource({ + repoPath: repoForLookup.path, + repoId: repoForLookup.id, + sourceContext, + number: ghNumber + }) .then((item) => { if (!createLookupGuard.isCurrent(lookupToken)) { return @@ -1452,6 +1525,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected') : null const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' + const hostBadge = getPaletteHostBadge(repo, hostOptions) return ( <CommandItem @@ -1567,6 +1641,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { </span> </span> )} + <PaletteHostBadgeChip badge={hostBadge} /> </div> </div> </div> @@ -1618,6 +1693,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ? repoMap.get(simulatorWorktree.repoId) : undefined const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName + const simulatorHostBadge = getPaletteHostBadge(simulatorRepo, hostOptions) return ( <CommandItem @@ -1683,6 +1759,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { </span> </span> )} + <PaletteHostBadgeChip badge={simulatorHostBadge} /> </div> </div> </div> @@ -1694,6 +1771,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const browserWorktree = worktreeMap.get(result.worktreeId) const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined const browserRepoName = browserRepo?.displayName ?? result.repoName + const browserHostBadge = getPaletteHostBadge(browserRepo, hostOptions) return ( <CommandItem @@ -1759,6 +1837,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { </span> </span> )} + <PaletteHostBadgeChip badge={browserHostBadge} /> </div> </div> </div> diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index 3bb37a76061..b0ffad51b3f 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -13,6 +13,8 @@ import { formatAutomationTokens, summarizeAutomationRunUsage } from './automation-usage-model' +import type { AutomationTargetAvailability } from './automation-target-availability' +import { getAutomationSourceDisplay } from './automation-source-display' import { translate } from '@/i18n/i18n' type AutomationDetailProps = { @@ -21,6 +23,8 @@ type AutomationDetailProps = { projectName: string workspaceName: string projectDefaultBaseRef: string | null + hostLabelById?: ReadonlyMap<string, string> + runNowAvailability: AutomationTargetAvailability | null now: number onRunNow: (automation: Automation) => void onEdit: (automation: Automation) => void @@ -28,11 +32,21 @@ type AutomationDetailProps = { onDelete: (automation: Automation) => void } -function DetailMetric({ label, value }: { label: string; value: string }): React.JSX.Element { +function DetailMetric({ + label, + value, + title +}: { + label: string + value: string + title?: string +}): React.JSX.Element { return ( <div className="min-w-0"> <div className="text-[11px] font-medium uppercase text-muted-foreground">{label}</div> - <div className="mt-1 break-words text-sm font-medium">{value}</div> + <div className="mt-1 break-words text-sm font-medium" title={title}> + {value} + </div> </div> ) } @@ -86,6 +100,8 @@ export function AutomationDetail({ projectName, workspaceName, projectDefaultBaseRef, + hostLabelById, + runNowAvailability, now, onRunNow, onEdit, @@ -115,6 +131,8 @@ export function AutomationDetail({ automation.workspaceMode === 'new_per_run' ? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default') : workspaceName + const sourceDisplay = getAutomationSourceDisplay(automation.sourceContext, hostLabelById) + const runNowDisabled = runNowAvailability?.canRunNow === false return ( <div className="flex w-full flex-col gap-4"> @@ -133,10 +151,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 +218,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')} @@ -209,6 +249,13 @@ export function AutomationDetail({ label={translate('auto.components.automations.AutomationDetail.15ea446b93', 'Session')} value={automation.reuseSession ? 'Reuse live session' : 'Fresh each run'} /> + {sourceDisplay ? ( + <DetailMetric + label={translate('auto.components.automations.AutomationDetail.29baf8f4c2', 'Source')} + value={sourceDisplay.label} + title={sourceDisplay.title} + /> + ) : null} <DetailMetric label={translate('auto.components.automations.AutomationDetail.620b22145e', 'Grace')} value={formatGrace(automation.missedRunGraceMinutes)} diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 53a80091392..6d113de78a4 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -58,6 +58,7 @@ type AutomationEditorDialogProps = { settings: GlobalSettings | null draft: AutomationDraft onProjectChange: (projectId: string) => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined onCreateTargetChange: (target: AutomationCreateTarget) => void onOpenChange: (open: boolean) => void onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void @@ -78,6 +79,7 @@ export function AutomationEditorDialog({ settings, draft, onProjectChange, + getRepoHostLabel, onCreateTargetChange, onOpenChange, onDraftChange, @@ -166,6 +168,7 @@ export function AutomationEditorDialog({ pickerTriggerClassName={PICKER_TRIGGER_CLASS} modeToggleItemClassName={MODE_TOGGLE_ITEM_CLASS} onProjectChange={onProjectChange} + getRepoHostLabel={getRepoHostLabel} onDraftChange={onDraftChange} onOpenChange={onOpenChange} onSave={onSave} diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 6a116852817..22edb5f40a2 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import AgentCombobox from '@/components/agent/AgentCombobox' -import RepoCombobox from '@/components/repo/RepoCombobox' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' @@ -15,6 +14,7 @@ import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField' import { AutomationSessionField } from './AutomationSessionField' import { CreateFromPicker } from './CreateFromPicker' import { WorkspaceCombobox } from './WorkspaceCombobox' +import AutomationProjectCombobox from './AutomationProjectCombobox' import type { AutomationDraft } from './AutomationEditorDialog' type AutomationEditorDialogFooterProps = { @@ -34,6 +34,7 @@ type AutomationEditorDialogFooterProps = { pickerTriggerClassName: string modeToggleItemClassName: string onProjectChange: (projectId: string) => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void onOpenChange: (open: boolean) => void onSave: () => void @@ -56,6 +57,7 @@ export function AutomationEditorDialogFooter({ pickerTriggerClassName, modeToggleItemClassName, onProjectChange, + getRepoHostLabel, onDraftChange, onOpenChange, onSave @@ -69,7 +71,7 @@ export function AutomationEditorDialogFooter({ 'Project' )} > - <RepoCombobox + <AutomationProjectCombobox repos={repos} value={draft.projectId} onValueChange={onProjectChange} @@ -78,7 +80,7 @@ export function AutomationEditorDialogFooter({ 'Select project' )} triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`} - showStandaloneAddButton={false} + getRepoHostLabel={getRepoHostLabel} /> </Field> <Field diff --git a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx new file mode 100644 index 00000000000..6e31661419f --- /dev/null +++ b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx @@ -0,0 +1,403 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronRight, ChevronsUpDown, FolderPlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandInput, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { useAppStore } from '@/store' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { searchRepos } from '@/lib/repo-search' +import { cn } from '@/lib/utils' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../shared/types' +import { + getAutomationProjectGroupForRepo, + getAutomationProjectGroups, + getAutomationProjectSelectedSource +} from './automation-project-groups' + +type AutomationProjectComboboxProps = { + repos: Repo[] + value: string + onValueChange: (repoId: string) => void + placeholder?: string + triggerClassName?: string + getRepoHostLabel?: (repo: Repo) => string | null | undefined +} + +function getRepoDetail(repo: Repo, hostLabel?: string | null): string { + const label = hostLabel?.trim() + return label ? `${label} · ${repo.path}` : repo.path +} + +function hasMultipleHosts(repos: readonly Repo[]): boolean { + const hostIds = new Set<string>() + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + if (hostIds.size > 1) { + return true + } + } + return false +} + +function hasMultipleHostsInGroup(sources: readonly Repo[]): boolean { + return hasMultipleHosts(sources) +} + +export default function AutomationProjectCombobox({ + repos, + value, + onValueChange, + placeholder = 'Select project', + triggerClassName, + getRepoHostLabel +}: AutomationProjectComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + const [hostMenuProjectKey, setHostMenuProjectKey] = useState<string | null>(null) + const hostMenuCloseTimerRef = useRef<number | null>(null) + const hostMenuHoverRef = useRef<{ + projectKey: string | null + row: boolean + content: boolean + }>({ projectKey: null, row: false, content: false }) + const addRepo = useAppStore((s) => s.addRepo) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const [isAdding, setIsAdding] = useState(false) + const inputRef = useRef<HTMLInputElement | null>(null) + const focusFrameRef = useRef<number | null>(null) + const mountedRef = useMountedRef() + + const groups = useMemo(() => getAutomationProjectGroups(repos, value), [repos, value]) + const selectedGroup = useMemo( + () => getAutomationProjectGroupForRepo(groups, value), + [groups, value] + ) + const selectedRepo = selectedGroup + ? getAutomationProjectSelectedSource(selectedGroup, value) + : null + const showHostLabels = useMemo(() => hasMultipleHosts(repos), [repos]) + const filteredGroups = useMemo(() => { + const trimmed = query.trim() + if (!trimmed) { + return groups + } + return groups.filter((group) => searchRepos(group.sources, trimmed).length > 0) + }, [groups, query]) + + const cancelFocusFrame = useCallback((): void => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + }, []) + + const setInputNode = useCallback( + (node: HTMLInputElement | null): void => { + if (node === null) { + cancelFocusFrame() + } + inputRef.current = node + }, + [cancelFocusFrame] + ) + + const focusSearchInput = useCallback(() => { + cancelFocusFrame() + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + inputRef.current?.focus() + }) + }, [cancelFocusFrame]) + + const clearHostMenuCloseTimer = useCallback(() => { + if (hostMenuCloseTimerRef.current !== null) { + window.clearTimeout(hostMenuCloseTimerRef.current) + hostMenuCloseTimerRef.current = null + } + }, []) + + const resetHostMenuHover = useCallback(() => { + hostMenuHoverRef.current = { projectKey: null, row: false, content: false } + }, []) + + const setHostMenuHover = useCallback( + (projectKey: string, region: 'row' | 'content', hovered: boolean) => { + clearHostMenuCloseTimer() + if (hostMenuHoverRef.current.projectKey !== projectKey) { + hostMenuHoverRef.current = { projectKey, row: false, content: false } + } + hostMenuHoverRef.current[region] = hovered + if (hovered) { + setHostMenuProjectKey(projectKey) + return + } + hostMenuCloseTimerRef.current = window.setTimeout(() => { + const hover = hostMenuHoverRef.current + if (hover.projectKey === projectKey && !hover.row && !hover.content) { + setHostMenuProjectKey((current) => (current === projectKey ? null : current)) + resetHostMenuHover() + } + hostMenuCloseTimerRef.current = null + }, 100) + }, + [clearHostMenuCloseTimer, resetHostMenuHover] + ) + + useEffect(() => clearHostMenuCloseTimer, [clearHostMenuCloseTimer]) + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen) + if (nextOpen) { + setCommandValue(value) + return + } + cancelFocusFrame() + setQuery('') + setHostMenuProjectKey(null) + resetHostMenuHover() + }, + [cancelFocusFrame, resetHostMenuHover, value] + ) + + const handleSelect = useCallback( + (repoId: string) => { + onValueChange(repoId) + setOpen(false) + setQuery('') + setHostMenuProjectKey(null) + resetHostMenuHover() + }, + [onValueChange, resetHostMenuHover] + ) + + const handleAddFolder = useCallback(async () => { + if (isAdding) { + return + } + setIsAdding(true) + try { + const repo = await addRepo() + if (repo) { + if (isGitRepoKind(repo)) { + await fetchWorktrees(repo.id) + } + if (!mountedRef.current) { + return + } + handleSelect(repo.id) + } + } finally { + if (mountedRef.current) { + setIsAdding(false) + } + } + }, [addRepo, fetchWorktrees, handleSelect, isAdding, mountedRef]) + + return ( + <Popover open={open} onOpenChange={handleOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + className={cn( + 'h-8 min-w-[184px] justify-between px-3 text-xs font-normal', + triggerClassName + )} + > + {selectedRepo ? ( + <span className="inline-flex min-w-0 items-center gap-1.5"> + <RepoBadgeLabel + name={selectedRepo.displayName} + color={selectedRepo.badgeColor} + badgeClassName="size-1.5" + /> + </span> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[var(--radix-popover-trigger-width)] min-w-[16rem] p-0" + onOpenAutoFocus={(event) => { + event.preventDefault() + focusSearchInput() + }} + > + <Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}> + <CommandInput + ref={setInputNode} + placeholder={translate( + 'auto.components.automations.AutomationProjectCombobox.search', + 'Search projects/folders...' + )} + value={query} + onValueChange={setQuery} + /> + <CommandList> + {filteredGroups.length === 0 ? ( + <div className="px-3 py-6 text-center text-xs text-muted-foreground"> + {translate( + 'auto.components.automations.AutomationProjectCombobox.empty', + 'No projects/folders match your search.' + )} + </div> + ) : null} + {filteredGroups.map((group) => { + const selectedSource = getAutomationProjectSelectedSource(group, value) + const selectedProject = group.sources.some((source) => source.id === value) + const hasHostMenu = hasMultipleHostsInGroup(group.sources) + const hostLabel = showHostLabels ? getRepoHostLabel?.(selectedSource) : null + const detail = hasHostMenu + ? `${hostLabel?.trim() || getRepoExecutionHostId(selectedSource)} · ${group.sources.length} hosts` + : getRepoDetail(selectedSource, hostLabel) + return ( + <div + key={group.projectKey} + onMouseEnter={() => { + setCommandValue(group.repo.id) + if (hasHostMenu) { + setHostMenuHover(group.projectKey, 'row', true) + } + }} + onMouseLeave={() => { + if (hasHostMenu) { + setHostMenuHover(group.projectKey, 'row', false) + } + }} + className={cn( + 'group/automation-project-row flex items-stretch transition-colors hover:bg-accent hover:text-accent-foreground', + commandValue === group.repo.id && 'bg-accent text-accent-foreground' + )} + > + <button + type="button" + onClick={() => handleSelect(selectedSource.id)} + onMouseDown={(event) => event.preventDefault()} + className="flex min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left text-xs" + > + <Check + className={cn( + 'size-3 text-foreground', + selectedProject ? 'opacity-100' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <RepoBadgeLabel + name={group.repo.displayName} + color={group.repo.badgeColor} + className="max-w-full" + /> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{detail}</p> + </div> + </button> + {hasHostMenu ? ( + <Popover + open={hostMenuProjectKey === group.projectKey} + onOpenChange={(nextOpen) => + setHostMenuProjectKey(nextOpen ? group.projectKey : null) + } + > + <PopoverTrigger asChild> + <button + type="button" + title={translate( + 'auto.components.automations.AutomationProjectCombobox.chooseHost', + 'Choose automation host' + )} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => event.preventDefault()} + className="flex w-7 shrink-0 items-center justify-center text-muted-foreground" + > + <ChevronRight className="size-3.5" /> + </button> + </PopoverTrigger> + <PopoverContent + side="right" + align="start" + sideOffset={6} + className="w-[min(260px,calc(100vw-1rem))] p-1" + onMouseEnter={() => setHostMenuHover(group.projectKey, 'content', true)} + onMouseLeave={() => setHostMenuHover(group.projectKey, 'content', false)} + > + <div className="py-1"> + {group.sources.map((source) => { + const sourceHostLabel = showHostLabels + ? getRepoHostLabel?.(source) + : null + const sourceSelected = source.id === selectedSource.id + return ( + <button + key={source.id} + type="button" + onMouseDown={(event) => event.preventDefault()} + onClick={() => handleSelect(source.id)} + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + sourceSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <div className="truncate text-xs"> + {sourceHostLabel ?? getRepoExecutionHostId(source)} + </div> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground"> + {source.path} + </p> + </div> + </button> + ) + })} + </div> + </PopoverContent> + </Popover> + ) : null} + </div> + ) + })} + </CommandList> + <div className="border-t border-border"> + <Button + type="button" + variant="ghost" + disabled={isAdding} + onClick={() => void handleAddFolder()} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => setCommandValue('')} + className="h-8 w-full justify-start rounded-none px-3 text-xs font-normal" + > + <FolderPlus className="size-3.5 text-muted-foreground" /> + <span> + {isAdding + ? translate( + 'auto.components.automations.AutomationProjectCombobox.adding', + 'Adding project…' + ) + : translate( + 'auto.components.automations.AutomationProjectCombobox.addProject', + 'Add project' + )} + </span> + </Button> + </div> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index b6007af94bb..f5add0ff4a0 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -36,6 +36,8 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { cn } from '@/lib/utils' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { getAgentCatalog } from '@/lib/agent-catalog' @@ -51,8 +53,14 @@ import type { AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' -import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { Worktree } from '../../../../shared/types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { PreflightStatus } from '../../../../preload/api-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { Repo, Worktree } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { buildAutomationCronSchedule, @@ -89,6 +97,27 @@ import { import { AutomationRunPageFrame } from './AutomationRunPageFrame' import { AutomationRunHistory } from './AutomationRunHistory' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import { getAutomationTargetAvailability } from './automation-target-availability' +import { buildAutomationRunContextForRepo } from './automation-run-context' +import { + getRepoBackedProviderAvailability, + type RuntimeProviderPreflightStatus +} from '../task-source-provider-availability' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability, + isSshConnectionBusy +} from './external-automation-source-availability' +import { + createAutomationForTarget, + deleteAutomationForTarget, + getAutomationListTarget, + listAutomationRunsForTarget, + listAutomationsForTarget, + runAutomationNowForTarget, + updateAutomationForTarget +} from './automation-host-client' import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' import { ExternalAutomationManagers } from './ExternalAutomationManagers' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' @@ -99,6 +128,7 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id) const DEFAULT_TIME = '09:00' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' type AutomationPaneTab = 'overview' | 'runs' +type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' } type ExternalAutomationListEntry = | { @@ -123,6 +153,46 @@ function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null } +function getRepoBackedAutomationSourceContext( + automation: Automation +): RepoBackedAutomationSourceContext | null { + const context = automation.sourceContext + return context?.provider === 'github' || context?.provider === 'gitlab' + ? (context as RepoBackedAutomationSourceContext) + : null +} + +function getRuntimeSourceHostAvailability( + context: TaskSourceContext, + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > +): TaskSourceHostAvailability | null { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + return null + } + const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) + if (!entry) { + return { hostId: context.hostId, reason: 'checking-task-source-capability' } + } + if (!entry.status) { + return { hostId: context.hostId, health: 'disconnected' } + } + if (entry.status.graphStatus !== 'ready') { + return { hostId: context.hostId, health: 'connecting' } + } + const capabilities = entry.status.capabilities + if (!capabilities) { + return { hostId: context.hostId, reason: 'checking-task-source-capability' } + } + if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { + return { hostId: context.hostId, reason: 'missing-task-source-capability' } + } + return null +} + function formatTimeInput(hour: number, minute: number): string { return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` } @@ -191,11 +261,7 @@ function getExternalProviderLabel(manager: ExternalAutomationManager): string { } function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'Remote SSH' : 'Local' -} - -function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean { - return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + return manager.target.type === 'ssh' ? 'SSH host' : 'Local' } function getExternalRunStatusLabel(run: ExternalAutomationRun): string { @@ -257,6 +323,7 @@ async function waitForAutomationRerunPendingVisibility(pendingStartedAt: number) export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -269,7 +336,17 @@ export default function AutomationsPage(): React.JSX.Element { const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const settings = useAppStore((s) => s.settings) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) const selectedId = useAppStore((s) => s.selectedAutomationId) const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) const repoMap = useRepoMap() @@ -306,6 +383,11 @@ export default function AutomationsPage(): React.JSX.Element { const [selectedExternalKey, setSelectedExternalKey] = useState<string | null>(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState<SelectedExternalRunPage | null>(null) + const runtimePreflightMountedRef = useRef(true) + const runtimePreflightRequestedHostIdsRef = useRef<Set<TaskSourceContext['hostId']>>(new Set()) + const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< + ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus> + >(() => new Map()) const selectAutomationId = useCallback( (automationId: string | null): void => { setSelectedAutomationRunPageId(null) @@ -482,9 +564,142 @@ 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 preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey + const repoBackedAutomationSourceContexts = useMemo( + () => + automations + .map((automation) => getRepoBackedAutomationSourceContext(automation)) + .filter((context): context is RepoBackedAutomationSourceContext => context !== null), + [automations] + ) + const runtimeAutomationSourceHostIds = useMemo(() => { + const hostIds = new Set<TaskSourceContext['hostId']>() + for (const context of repoBackedAutomationSourceContexts) { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + continue + } + const hostAvailability = getRuntimeSourceHostAvailability( + context, + runtimeStatusByEnvironmentId + ) + if (hostAvailability) { + continue + } + hostIds.add(parsed.id) + } + return [...hostIds].sort() + }, [repoBackedAutomationSourceContexts, runtimeStatusByEnvironmentId]) + useEffect( + () => () => { + runtimePreflightMountedRef.current = false + }, + [] + ) + useEffect(() => { + if (!preflightStatusCurrent || !preflightStatusChecked) { + void refreshPreflightStatus() + } + }, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus]) + useEffect(() => { + const unrequestedHostIds = runtimeAutomationSourceHostIds.filter( + (hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId) + ) + if (unrequestedHostIds.length === 0) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + for (const hostId of unrequestedHostIds) { + next.set(hostId, { checked: false, status: null }) + } + return next + }) + for (const hostId of unrequestedHostIds) { + runtimePreflightRequestedHostIdsRef.current.add(hostId) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'runtime') { + continue + } + // Why: automation sources can be owned by a different remote server than + // the run target; provider auth/tooling must be checked on the source host. + void callRuntimeRpc<PreflightStatus>( + { kind: 'environment', environmentId: parsed.environmentId }, + 'preflight.check', + undefined, + { timeoutMs: 15_000 } + ) + .then((status) => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status }) + return next + }) + }) + .catch(() => { + if (!runtimePreflightMountedRef.current) { + return + } + setRuntimePreflightStatusByHostId((current) => { + const next = new Map(current) + next.set(hostId, { checked: true, status: null }) + return next + }) + }) + } + }, [runtimeAutomationSourceHostIds]) + const automationSourceHostAvailabilityById = useMemo(() => { + const availabilityById = new Map<string, TaskSourceHostAvailability[]>() + for (const automation of automations) { + const context = getRepoBackedAutomationSourceContext(automation) + if (!context) { + continue + } + const hostAvailability = getRuntimeSourceHostAvailability( + context, + runtimeStatusByEnvironmentId + ) + const providerAvailability = getRepoBackedProviderAvailability({ + provider: context.provider, + contexts: [context], + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId + }) + const availability = [ + ...(hostAvailability ? [hostAvailability] : []), + ...providerAvailability + ] + if (availability.length > 0) { + availabilityById.set(automation.id, availability) + } + } + return availabilityById + }, [ + automations, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimePreflightStatusByHostId, + runtimeStatusByEnvironmentId + ]) + 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, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id) + }) + : null const canSaveDraft = editingAutomationId === null || !draftAtOpen || @@ -497,10 +712,56 @@ 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)) + const selectedExternalSourceAvailability = + selectedExternal?.kind === 'source' + ? getExternalAutomationSourceAvailability({ + manager: selectedExternal.manager, + providerLabel: getExternalProviderLabel(selectedExternal.manager), + targetKindLabel: getExternalTargetKindLabel(selectedExternal.manager), + sshStatus: selectedExternalSshStatus, + isConnectingOverride: isSelectedExternalSshConnecting + }) + : null + + const getAutomationRepoHostLabel = useCallback( + (repo: Repo): string => { + const hostId = getRepoExecutionHostId(repo) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'ssh') { + return sshTargetLabels.get(parsed.targetId) ?? parsed.targetId + } + if (parsed?.kind === 'runtime') { + return ( + runtimeEnvironments.find((environment) => environment.id === parsed.environmentId) + ?.name ?? parsed.environmentId + ) + } + return 'Local Mac' + }, + [runtimeEnvironments, sshTargetLabels] + ) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostLabelById = useMemo(() => { + const labels = new Map<string, string>([['local', 'Local Mac']]) + for (const [targetId, label] of sshTargetLabels) { + labels.set(`ssh:${encodeURIComponent(targetId)}`, label) + } + for (const environment of runtimeEnvironments) { + labels.set(`runtime:${encodeURIComponent(environment.id)}`, environment.name) + } + for (const [hostId, label] of hostLabelOverrides) { + labels.set(hostId, label) + } + return labels + }, [hostLabelOverrides, runtimeEnvironments, sshTargetLabels]) useEffect(() => { if ((!selected || selectedExternal) && activePaneTab === 'runs') { @@ -525,10 +786,11 @@ export default function AutomationsPage(): React.JSX.Element { const refresh = useCallback(async () => { setIsLoading(true) + const automationHostTarget = getAutomationListTarget(settings) try { const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([ - window.api.automations.list(), - window.api.automations.listRuns(), + listAutomationsForTarget(automationHostTarget), + listAutomationRunsForTarget(automationHostTarget), window.api.automations.listExternalManagers() ]) const currentSelectedId = useAppStore.getState().selectedAutomationId @@ -539,7 +801,7 @@ export default function AutomationsPage(): React.JSX.Element { ? currentSelectedId : (nextAutomations[0]?.id ?? null) const nextSelectedRuns = nextSelectedId - ? await window.api.automations.listRuns({ automationId: nextSelectedId }) + ? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId) : [] setAutomations(nextAutomations) setRuns(nextRuns) @@ -554,7 +816,7 @@ export default function AutomationsPage(): React.JSX.Element { } finally { setIsLoading(false) } - }, [selectAutomationId]) + }, [selectAutomationId, settings]) const hydratePersistedUIState = useCallback(async (): Promise<void> => { useAppStore.getState().hydratePersistedUI(await window.api.ui.get()) @@ -577,15 +839,17 @@ export default function AutomationsPage(): React.JSX.Element { return } let cancelled = false - void window.api.automations.listRuns({ automationId }).then((nextRuns) => { - if (!cancelled) { - setSelectedAutomationRuns({ automationId, runs: nextRuns }) + void listAutomationRunsForTarget(getAutomationListTarget(settings), automationId).then( + (nextRuns) => { + if (!cancelled) { + setSelectedAutomationRuns({ automationId, runs: nextRuns }) + } } - }) + ) return () => { cancelled = true } - }, [selected?.id, runs]) + }, [selected?.id, runs, settings]) useEffect(() => { const onAutomationsChanged = (): void => { @@ -783,7 +1047,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 ?? '', @@ -1035,13 +1299,27 @@ export default function AutomationsPage(): React.JSX.Element { ? Math.max(0, rawMissedRunGraceMinutes) : 720 const precheck = buildDraftPrecheck(draft) + const runContext = buildAutomationRunContextForRepo({ + repoId: draft.projectId, + repos, + projectHostSetups + }) + if (!runContext) { + toast.error( + translate( + 'auto.components.automations.AutomationsPage.32534e7c9c', + 'Choose an available workspace before saving.' + ) + ) + return + } let currentAutomation = editingAutomationId ? (automations.find((automation) => automation.id === editingAutomationId) ?? null) : null if (editingAutomationId) { try { currentAutomation = - (await window.api.automations.list()).find( + (await listAutomationsForTarget(getAutomationListTarget(settings))).find( (automation) => automation.id === editingAutomationId ) ?? currentAutomation } catch { @@ -1053,6 +1331,7 @@ export default function AutomationsPage(): React.JSX.Element { prompt: draft.prompt, precheck, agentId: draft.agentId, + runContext, projectId: draft.projectId, workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, @@ -1067,15 +1346,18 @@ export default function AutomationsPage(): React.JSX.Element { updates.dtstart = now } const automation = editingAutomationId - ? await window.api.automations.update({ - id: editingAutomationId, - updates - }) - : await window.api.automations.create({ + ? currentAutomation + ? await updateAutomationForTarget(currentAutomation, updates) + : await window.api.automations.update({ + id: editingAutomationId, + updates + }) + : await createAutomationForTarget({ name: draft.name, prompt: draft.prompt, precheck, agentId: draft.agentId, + runContext, projectId: draft.projectId, workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, @@ -1126,15 +1408,12 @@ export default function AutomationsPage(): React.JSX.Element { } const toggleAutomation = async (automation: Automation): Promise<void> => { - await window.api.automations.update({ - id: automation.id, - updates: { enabled: !automation.enabled } - }) + await updateAutomationForTarget(automation, { enabled: !automation.enabled }) await refresh() } const deleteAutomation = async (automation: Automation): Promise<void> => { - await window.api.automations.delete({ id: automation.id }) + await deleteAutomationForTarget(automation) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) } @@ -1195,7 +1474,24 @@ export default function AutomationsPage(): React.JSX.Element { } const runNow = async (automation: Automation): Promise<void> => { - await window.api.automations.runNow({ id: automation.id }) + const repo = repoMap.get(getAutomationRunRepoId(automation)) ?? null + const workspace = automation.workspaceId + ? (worktreeMap.get(automation.workspaceId) ?? null) + : null + const availability = getAutomationTargetAvailability({ + automation, + repo, + workspace, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + }) + if (!availability.canRunNow) { + toast.error(availability.message) + return + } + await runAutomationNowForTarget(automation) useAppStore.getState().recordFeatureInteraction('automation-run') await hydratePersistedUIState() await refresh() @@ -1205,7 +1501,6 @@ export default function AutomationsPage(): React.JSX.Element { } const rerunAutomationRun = async (automation: Automation, run: AutomationRun): Promise<void> => { - const automationId = automation.id const runId = run.id if (rerunRunIdsInFlightRef.current.has(runId)) { return @@ -1214,7 +1509,7 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - await window.api.automations.runNow({ id: automationId }) + await runAutomationNowForTarget(automation) await hydratePersistedUIState() await refresh() toast.message( @@ -1373,6 +1668,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( @@ -1568,6 +1873,7 @@ export default function AutomationsPage(): React.JSX.Element { settings={settings} draft={draft} onProjectChange={handleProjectChange} + getRepoHostLabel={getAutomationRepoHostLabel} onCreateTargetChange={handleCreateTargetChange} onOpenChange={setCreateOpen} onDraftChange={setDraft} @@ -1753,10 +2059,19 @@ 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, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + }) const workspaceLabel = automation.workspaceMode === 'new_per_run' ? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}` @@ -1836,12 +2151,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,11 +2210,16 @@ export default function AutomationsPage(): React.JSX.Element { const providerLabel = getExternalProviderLabel(entry.manager) const targetKindLabel = getExternalTargetKindLabel(entry.manager) if (entry.kind === 'source') { - const sourceStatus = - entry.manager.target.type === 'ssh' ? 'Connect to load jobs' : 'Unavailable' - const sourceSummary = - entry.manager.error ?? - `${providerLabel} source unavailable until ${targetKindLabel.toLowerCase()} connects.` + const sshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const sourceAvailability = getExternalAutomationSourceAvailability({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus + }) return ( <button key={entry.key} @@ -1919,12 +2252,12 @@ export default function AutomationsPage(): React.JSX.Element { <span className="truncate">{targetKindLabel}</span> </span> <span className="mt-1 block truncate text-xs text-muted-foreground"> - {sourceSummary} + {sourceAvailability.summary} </span> </span> <span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground"> <Clock className="size-3.5" /> - <span className="line-clamp-2">{sourceStatus}</span> + <span className="line-clamp-2">{sourceAvailability.statusLabel}</span> </span> </button> ) @@ -1932,7 +2265,18 @@ export default function AutomationsPage(): React.JSX.Element { const nextRunLabel = entry.job.enabled ? formatExternalDate(entry.job.nextRunAt, relativeNow) : 'Paused' - const actionDisabled = !entry.manager.canManage || externalActionKey !== null + const entrySshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus: entrySshStatus, + actionInProgress: externalActionKey !== null + }) + const actionDisabled = disabledMessage !== null const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) return ( <ContextMenu key={entry.key}> @@ -1995,10 +2339,13 @@ export default function AutomationsPage(): React.JSX.Element { onSelect={() => requestExternalAction(entry.manager, entry.job, 'run')} > <Play className="size-3.5" /> - {translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} + <span className="min-w-0 truncate"> + {disabledMessage ?? + translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + )} + </span> </ContextMenuItem> {entry.manager.provider === 'hermes' ? ( <ContextMenuItem @@ -2134,14 +2481,7 @@ export default function AutomationsPage(): React.JSX.Element { {selectedExternal.manager.targetLabel} </div> <div className="text-xs text-muted-foreground"> - {getExternalProviderLabel(selectedExternal.manager)}{' '} - {translate( - 'auto.components.automations.AutomationsPage.aaa007846f', - 'source unavailable' - )} - {selectedExternal.manager.error - ? ` - ${selectedExternal.manager.error}` - : null} + {selectedExternalSourceAvailability?.summary} </div> </div> {selectedExternalSshSource ? ( @@ -2149,31 +2489,33 @@ export default function AutomationsPage(): React.JSX.Element { type="button" variant="outline" size="sm" - disabled={isSelectedExternalSshConnecting} + disabled={selectedExternalSourceAvailability?.isConnecting ?? false} onClick={() => void connectExternalAutomationSource(selectedExternalSshSource.manager) } > - {isSelectedExternalSshConnecting ? ( + {selectedExternalSourceAvailability?.isConnecting ? ( <RefreshCw className="size-3.5 animate-spin" /> ) : null} - {isSelectedExternalSshConnecting + {selectedExternalSourceAvailability?.isConnecting ? translate( 'auto.components.automations.AutomationsPage.f93ed7a6f8', 'Connecting...' ) - : translate( - 'auto.components.automations.AutomationsPage.7934ee0d81', - 'Connect SSH' - )} + : selectedExternalSshConnected + ? translate( + 'auto.components.automations.AutomationsPage.53f06f0ad5', + 'Retry source' + ) + : translate( + 'auto.components.automations.AutomationsPage.7934ee0d81', + 'Connect SSH' + )} </Button> ) : 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.' - )} + {selectedExternalSourceAvailability?.detail} </div> </div> )} @@ -2213,6 +2555,8 @@ export default function AutomationsPage(): React.JSX.Element { ? 'New workspace each run' : (selectedWorktree?.displayName ?? 'Missing workspace') } + hostLabelById={hostLabelById} + 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/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index 8b89da1852b..3dea3e99680 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { cn } from '@/lib/utils' import type { Repo, Worktree } from '../../../../shared/types' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs @@ -40,8 +41,8 @@ export function CreateFromPicker({ triggerClassName?: string onValueChange: (baseBranch: string) => void }): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const activeRuntimeEnvironmentId = useAppStore((state) => + getRuntimeEnvironmentIdForRepo(state, repoId) ) const repo = repoMap.get(repoId) const [open, setOpen] = React.useState(false) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index e116564cbdd..27259681b3b 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -15,6 +15,7 @@ import { type FetchExternalAutomationRuns } from './ExternalAutomationRunTable' import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' import { translate } from '@/i18n/i18n' type ExternalAutomationManagersProps = { @@ -59,7 +60,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({ @@ -163,6 +164,10 @@ export function ExternalAutomationManagers({ <div className="divide-y divide-border/40"> {manager.jobs.map((job) => { const scheduleDisplay = getExternalAutomationScheduleDisplay(manager, job) + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager, + actionInProgress: runningActionKey !== null + }) return ( <div key={job.id} @@ -228,11 +233,14 @@ export function ExternalAutomationManagers({ </div> <div className="flex items-center justify-end gap-1"> <ExternalActionButton - label={translate( - 'auto.components.automations.ExternalAutomationManagers.cc77ba88ff', - 'Run external automation' - )} - disabled={!manager.canManage || runningActionKey !== null} + label={ + disabledMessage ?? + translate( + 'auto.components.automations.ExternalAutomationManagers.cc77ba88ff', + 'Run external automation' + ) + } + disabled={disabledMessage !== null} onClick={() => onAction(manager, job, 'run')} > {runningActionKey === actionKey(manager, job, 'run') ? ( @@ -243,11 +251,14 @@ export function ExternalAutomationManagers({ </ExternalActionButton> {manager.provider === 'hermes' ? ( <ExternalActionButton - label={translate( - 'auto.components.automations.ExternalAutomationManagers.1df491fd00', - 'Edit external automation' - )} - disabled={!manager.canManage || runningActionKey !== null} + label={ + disabledMessage ?? + translate( + 'auto.components.automations.ExternalAutomationManagers.1df491fd00', + 'Edit external automation' + ) + } + disabled={disabledMessage !== null} onClick={() => onEdit?.(manager, job)} > <Pencil className="size-3.5" /> @@ -255,7 +266,8 @@ export function ExternalAutomationManagers({ ) : null} <ExternalActionButton label={ - job.enabled + disabledMessage ?? + (job.enabled ? translate( 'auto.components.automations.ExternalAutomationManagers.0def1693bb', 'Pause external automation' @@ -263,9 +275,9 @@ export function ExternalAutomationManagers({ : translate( 'auto.components.automations.ExternalAutomationManagers.1c3bfd38fe', 'Resume external automation' - ) + )) } - disabled={!manager.canManage || runningActionKey !== null} + disabled={disabledMessage !== null} onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')} > {runningActionKey === @@ -278,12 +290,15 @@ export function ExternalAutomationManagers({ )} </ExternalActionButton> <ExternalActionButton - label={translate( - 'auto.components.automations.ExternalAutomationManagers.a42bf2b27e', - 'Delete external automation' - )} + label={ + disabledMessage ?? + translate( + 'auto.components.automations.ExternalAutomationManagers.a42bf2b27e', + 'Delete external automation' + ) + } className="text-destructive hover:text-destructive" - disabled={!manager.canManage || runningActionKey !== null} + disabled={disabledMessage !== null} onClick={() => onAction(manager, job, 'delete')} > {runningActionKey === actionKey(manager, job, 'delete') ? ( diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts new file mode 100644 index 00000000000..e2073cd04d4 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Automation, AutomationCreateInput } from '../../../../shared/automations-types' +import { + createAutomationForTarget, + getAutomationListTarget, + listAutomationsForTarget, + runAutomationNowForTarget +} from './automation-host-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +const mockApi = { + automations: { + list: vi.fn(), + listRuns: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + runNow: vi.fn() + } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function makeAutomation(overrides: Partial<Automation> = {}): Automation { + return { + id: 'auto-1', + name: 'Remote check', + prompt: 'Check', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'remote_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + projectHostSetupId: 'setup-gpu', + repoId: 'repo-1', + path: '/srv/orca' + }, + ...overrides + } +} + +describe('automation host client', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('lists automations from the active remote server when one is selected', async () => { + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ automations: [makeAutomation()] }) + + const target = getAutomationListTarget({ activeRuntimeEnvironmentId: 'gpu' }) + const automations = await listAutomationsForTarget(target) + + expect(automations).toHaveLength(1) + expect(mockApi.automations.list).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'gpu' }, + 'automation.list', + undefined, + { timeoutMs: 15_000 } + ) + }) + + it('creates and manually runs runtime-host automations through that server', async () => { + const automation = makeAutomation() + const input: AutomationCreateInput = { + name: automation.name, + prompt: automation.prompt, + precheck: null, + agentId: automation.agentId, + runContext: automation.runContext, + projectId: automation.projectId, + workspaceMode: automation.workspaceMode, + workspaceId: null, + timezone: automation.timezone, + rrule: automation.rrule, + dtstart: automation.dtstart + } + vi.mocked(callRuntimeRpc) + .mockResolvedValueOnce({ automation }) + .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) + + await createAutomationForTarget(input) + await runAutomationNowForTarget(automation) + + expect(mockApi.automations.create).not.toHaveBeenCalled() + expect(mockApi.automations.runNow).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + { kind: 'environment', environmentId: 'gpu' }, + 'automation.create', + expect.objectContaining({ + repo: 'repo-1', + workspace: undefined, + runContext: automation.runContext + }), + { timeoutMs: 15_000 } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + { kind: 'environment', environmentId: 'gpu' }, + 'automation.runNow', + { id: automation.id }, + { timeoutMs: 15_000 } + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts new file mode 100644 index 00000000000..cee3bf6f613 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -0,0 +1,156 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { GlobalSettings } from '../../../../shared/types' + +type RuntimeAutomationCreateInput = Omit< + AutomationCreateInput, + 'projectId' | 'workspaceId' | 'timezone' +> & { + repo?: string + workspace?: string + timezone?: string +} + +type RuntimeAutomationUpdateInput = Omit<AutomationUpdateInput, 'projectId' | 'workspaceId'> & { + repo?: string + workspace?: string +} + +type AutomationHostTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } + +function getRuntimeTargetFromHostId(hostId: string | null | undefined): AutomationHostTarget { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind === 'runtime' + ? { kind: 'environment', environmentId: parsed.environmentId } + : { kind: 'local' } +} + +export function getAutomationListTarget( + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): AutomationHostTarget { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } +} + +export function getAutomationOwnerTarget( + automation: Pick<Automation, 'runContext'> +): AutomationHostTarget { + return getRuntimeTargetFromHostId(automation.runContext?.hostId) +} + +export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget { + return getRuntimeTargetFromHostId(input.runContext?.hostId) +} + +function toRuntimeAutomationCreateInput( + input: AutomationCreateInput +): RuntimeAutomationCreateInput { + const { projectId, workspaceId, ...rest } = input + return { + ...rest, + repo: projectId, + workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined + } +} + +function toRuntimeAutomationUpdateInput( + input: AutomationUpdateInput +): RuntimeAutomationUpdateInput { + const { projectId, workspaceId, ...rest } = input + return { + ...rest, + ...(projectId !== undefined ? { repo: projectId } : {}), + ...(workspaceId !== undefined ? { workspace: workspaceId ?? undefined } : {}) + } +} + +export async function listAutomationsForTarget( + target: AutomationHostTarget +): Promise<Automation[]> { + if (target.kind === 'local') { + return await window.api.automations.list() + } + const result = await callRuntimeRpc<{ automations: Automation[] }>( + target, + 'automation.list', + undefined, + { timeoutMs: 15_000 } + ) + return result.automations +} + +export async function listAutomationRunsForTarget( + target: AutomationHostTarget, + automationId?: string +): Promise<AutomationRun[]> { + if (target.kind === 'local') { + return await window.api.automations.listRuns(automationId ? { automationId } : undefined) + } + const result = await callRuntimeRpc<{ runs: AutomationRun[] }>( + target, + 'automation.runs', + automationId ? { automationId } : {}, + { timeoutMs: 15_000 } + ) + return result.runs +} + +export async function createAutomationForTarget(input: AutomationCreateInput): Promise<Automation> { + const target = getAutomationCreateTarget(input) + if (target.kind === 'local') { + return await window.api.automations.create(input) + } + const result = await callRuntimeRpc<{ automation: Automation }>( + target, + 'automation.create', + toRuntimeAutomationCreateInput(input), + { timeoutMs: 15_000 } + ) + return result.automation +} + +export async function updateAutomationForTarget( + automation: Automation, + updates: AutomationUpdateInput +): Promise<Automation> { + const target = getAutomationOwnerTarget(automation) + if (target.kind === 'local') { + return await window.api.automations.update({ id: automation.id, updates }) + } + const result = await callRuntimeRpc<{ automation: Automation }>( + target, + 'automation.update', + { id: automation.id, updates: toRuntimeAutomationUpdateInput(updates) }, + { timeoutMs: 15_000 } + ) + return result.automation +} + +export async function deleteAutomationForTarget(automation: Automation): Promise<void> { + const target = getAutomationOwnerTarget(automation) + if (target.kind === 'local') { + await window.api.automations.delete({ id: automation.id }) + return + } + await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 }) +} + +export async function runAutomationNowForTarget(automation: Automation): Promise<AutomationRun> { + const target = getAutomationOwnerTarget(automation) + if (target.kind === 'local') { + return await window.api.automations.runNow({ id: automation.id }) + } + const result = await callRuntimeRpc<{ run: AutomationRun }>( + target, + 'automation.runNow', + { id: automation.id }, + { timeoutMs: 15_000 } + ) + return result.run +} diff --git a/src/renderer/src/components/automations/automation-project-groups.test.ts b/src/renderer/src/components/automations/automation-project-groups.test.ts new file mode 100644 index 00000000000..4423c381505 --- /dev/null +++ b/src/renderer/src/components/automations/automation-project-groups.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { + getAutomationProjectGroupForRepo, + getAutomationProjectGroups, + getAutomationProjectSelectedSource +} from './automation-project-groups' + +function repo(overrides: Partial<Repo>): Repo { + return { + id: overrides.id ?? 'repo-1', + displayName: overrides.displayName ?? 'repo', + path: overrides.path ?? '/repo', + kind: 'git', + addedAt: overrides.addedAt ?? 1, + badgeColor: overrides.badgeColor ?? '#777777', + connectionId: overrides.connectionId ?? null, + executionHostId: overrides.executionHostId, + upstream: overrides.upstream, + repoIcon: overrides.repoIcon + } as Repo +} + +describe('getAutomationProjectGroups', () => { + it('groups same logical project sources under one row', () => { + const groups = getAutomationProjectGroups( + [ + repo({ + id: 'local', + displayName: 'claude-swap', + path: '/Users/me/claude-swap', + repoIcon: { type: 'image', source: 'github', label: 'realiti4/claude-swap', src: '' } + }), + repo({ + id: 'ssh', + displayName: 'claude-swap', + path: '/home/orca/claude-swap', + connectionId: 'docker', + repoIcon: { type: 'image', source: 'github', label: 'realiti4/claude-swap', src: '' } + }), + repo({ + id: 'other', + displayName: 'other', + path: '/other' + }) + ], + 'ssh' + ) + + expect(groups).toHaveLength(2) + expect(groups[0]).toMatchObject({ + projectKey: 'github:realiti4/claude-swap', + repo: { id: 'ssh' } + }) + expect(groups[0]?.sources.map((source) => source.id)).toEqual(['local', 'ssh']) + }) + + it('finds and preserves the selected concrete source', () => { + const groups = getAutomationProjectGroups( + [ + repo({ id: 'local', upstream: { owner: 'stablyai', repo: 'orca' } }), + repo({ + id: 'ssh', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + 'ssh' + ) + const group = getAutomationProjectGroupForRepo(groups, 'ssh') + + expect(group).not.toBeNull() + expect(group ? getAutomationProjectSelectedSource(group, 'ssh').id : null).toBe('ssh') + }) +}) diff --git a/src/renderer/src/components/automations/automation-project-groups.ts b/src/renderer/src/components/automations/automation-project-groups.ts new file mode 100644 index 00000000000..eacea1f8c41 --- /dev/null +++ b/src/renderer/src/components/automations/automation-project-groups.ts @@ -0,0 +1,64 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' +import type { Repo } from '../../../../shared/types' + +export type AutomationProjectGroup = { + projectKey: string + repo: Repo + sources: Repo[] +} + +export function getAutomationProjectGroups( + repos: readonly Repo[], + selectedRepoId: string +): AutomationProjectGroup[] { + const groupsByProject = new Map<string, AutomationProjectGroup>() + for (const repo of repos) { + const projectKey = getProjectIdentityKey(repo) + const current = groupsByProject.get(projectKey) + if (!current) { + groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) + continue + } + current.sources.push(repo) + if (compareAutomationProjectCandidate(repo, current.repo, selectedRepoId) < 0) { + current.repo = repo + } + } + return [...groupsByProject.values()].map((group) => ({ + ...group, + sources: [...group.sources].sort(compareAutomationProjectSource) + })) +} + +export function getAutomationProjectGroupForRepo( + groups: readonly AutomationProjectGroup[], + repoId: string +): AutomationProjectGroup | null { + return groups.find((group) => group.sources.some((source) => source.id === repoId)) ?? null +} + +export function getAutomationProjectSelectedSource( + group: AutomationProjectGroup, + repoId: string +): Repo { + return group.sources.find((source) => source.id === repoId) ?? group.repo +} + +function compareAutomationProjectCandidate(a: Repo, b: Repo, selectedRepoId: string): number { + const aSelected = a.id === selectedRepoId + const bSelected = b.id === selectedRepoId + if (aSelected !== bSelected) { + return aSelected ? -1 : 1 + } + return compareAutomationProjectSource(a, b) +} + +function compareAutomationProjectSource(a: Repo, b: Repo): number { + 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/automations/automation-run-context.test.ts b/src/renderer/src/components/automations/automation-run-context.test.ts new file mode 100644 index 00000000000..345b03d56f7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-context.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import { buildAutomationRunContextForRepo } from './automation-run-context' + +function repo(id: string, path = `/repos/${id}`): Repo { + return { + id, + path, + displayName: id, + badgeColor: '#000000', + addedAt: 1 + } +} + +function setup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup { + return { + id: 'setup-builder', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + repoId: 'repo-builder', + path: '/remote/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('buildAutomationRunContextForRepo', () => { + it('persists logical project and host setup identity for the selected run repo', () => { + expect( + buildAutomationRunContextForRepo({ + repoId: 'repo-builder', + repos: [repo('repo-local', '/local/orca'), repo('repo-builder', '/remote/orca')], + projectHostSetups: [ + setup({ + id: 'setup-local', + hostId: 'local', + repoId: 'repo-local', + path: '/local/orca' + }), + setup() + ] + }) + ).toEqual({ + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder', + repoId: 'repo-builder', + path: '/remote/orca' + }) + }) + + it('does not build a run context for missing or not-ready setups', () => { + expect( + buildAutomationRunContextForRepo({ + repoId: 'repo-builder', + repos: [repo('repo-builder')], + projectHostSetups: [setup({ setupState: 'setting-up' })] + }) + ).toBeNull() + + expect( + buildAutomationRunContextForRepo({ + repoId: 'repo-builder', + repos: [], + projectHostSetups: [setup()] + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-context.ts b/src/renderer/src/components/automations/automation-run-context.ts new file mode 100644 index 00000000000..65bad47c9f3 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-context.ts @@ -0,0 +1,29 @@ +import { + buildWorkspaceRunContext, + type WorkspaceRunContext +} from '../../../../shared/task-source-context' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' + +export function buildAutomationRunContextForRepo(args: { + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] +}): WorkspaceRunContext | null { + const setup = args.projectHostSetups.find( + (candidate) => candidate.repoId === args.repoId && candidate.setupState === 'ready' + ) + if (!setup) { + return null + } + const repo = args.repos.find((candidate) => candidate.id === setup.repoId) + if (!repo) { + return null + } + return buildWorkspaceRunContext({ + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + path: setup.path || repo.path + }) +} diff --git a/src/renderer/src/components/automations/automation-source-display.test.ts b/src/renderer/src/components/automations/automation-source-display.test.ts new file mode 100644 index 00000000000..e52e96c71eb --- /dev/null +++ b/src/renderer/src/components/automations/automation-source-display.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { getAutomationSourceDisplay } from './automation-source-display' + +describe('automation source display', () => { + it('summarizes repo-backed source context separately from run location', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + hostId: 'ssh:devbox', + projectId: 'github:stablyai/orca', + projectHostSetupId: 'setup-devbox', + repoId: 'repo-devbox', + accountLabel: 'dev@example.com', + providerIdentity: { + provider: 'github', + owner: 'stablyai', + repo: 'orca' + } + } + + expect(getAutomationSourceDisplay(sourceContext)).toEqual({ + label: 'GitHub · devbox · stablyai/orca', + title: 'GitHub source · Host: devbox · Account: dev@example.com · Source: stablyai/orca' + }) + }) + + it('uses account identity for Linear sources', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'linear', + hostId: 'local', + projectId: 'repo-1', + projectHostSetupId: 'setup-local', + repoId: 'repo-1', + accountLabel: 'Linear API key', + providerIdentity: { + provider: 'linear', + workspaceId: 'legacy', + workspaceName: 'Saved Linear workspace' + } + } + + expect(getAutomationSourceDisplay(sourceContext)).toEqual({ + label: 'Linear · Local Mac · Saved Linear workspace', + title: + 'Linear source · Host: Local Mac · Account: Linear API key · Source: Saved Linear workspace' + }) + }) + + it('uses saved remote server labels for runtime-backed sources', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + projectId: 'github:stablyai/orca', + projectHostSetupId: 'setup-runtime', + repoId: 'repo-runtime', + providerIdentity: { + provider: 'github', + owner: 'stablyai', + repo: 'orca' + } + } + + expect( + getAutomationSourceDisplay( + sourceContext, + new Map([['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box']]) + ) + ).toEqual({ + label: 'GitHub · dev box · stablyai/orca', + title: 'GitHub source · Host: dev box · Source: stablyai/orca' + }) + }) + + it('returns null when no source context is saved', () => { + expect(getAutomationSourceDisplay(null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-source-display.ts b/src/renderer/src/components/automations/automation-source-display.ts new file mode 100644 index 00000000000..accfb9a73fc --- /dev/null +++ b/src/renderer/src/components/automations/automation-source-display.ts @@ -0,0 +1,64 @@ +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import type { TaskSourceContext } from '../../../../shared/task-source-context' + +export type AutomationSourceDisplay = { + label: string + title: string +} + +export function getAutomationSourceDisplay( + sourceContext: TaskSourceContext | null | undefined, + hostLabelById?: ReadonlyMap<string, string> +): AutomationSourceDisplay | null { + if (!sourceContext) { + return null + } + const providerLabel = getProviderLabel(sourceContext.provider) + const hostLabel = + hostLabelById?.get(sourceContext.hostId) ?? getExecutionHostLabel(sourceContext.hostId) + const identityLabel = getSourceIdentityLabel(sourceContext) + const label = [providerLabel, hostLabel, identityLabel] + .filter((part): part is string => Boolean(part)) + .join(' · ') + const title = [ + `${providerLabel} source`, + `Host: ${hostLabel}`, + sourceContext.accountLabel ? `Account: ${sourceContext.accountLabel}` : null, + identityLabel ? `Source: ${identityLabel}` : null + ] + .filter((part): part is string => Boolean(part)) + .join(' · ') + return { label, title } +} + +function getProviderLabel(provider: TaskSourceContext['provider']): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + case 'linear': + return 'Linear' + case 'jira': + return 'Jira' + } +} + +function getSourceIdentityLabel(sourceContext: TaskSourceContext): string | null { + const identity = sourceContext.providerIdentity + if (identity) { + 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 + } + } + return sourceContext.accountLabel ?? sourceContext.repoId ?? null +} 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..a640de7d7b5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { ProjectHostSetup, 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 +} + +function makeProjectHostSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/repo', + displayName: 'Repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeRuntimeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus { + return { + runtimeId: 'runtime-1', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + ...overrides + } +} + +describe('automation target availability', () => { + it('allows local automations with an available existing workspace', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation(), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + 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(), + projectHostSetups: [], + sshConnectionStates: new Map() + }).reason + ).toBe('missing-project') + + expect( + getAutomationTargetAvailability({ + automation: makeAutomation(), + repo: makeRepo(), + workspace: null, + projectHostSetups: [], + 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(), + projectHostSetups: [makeProjectHostSetup()], + sshConnectionStates: new Map() + }).reason + ).toBe('host-mismatch') + }) + + it('blocks saved run contexts whose project host setup is missing or not ready', () => { + const automation = makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }) + + expect( + getAutomationTargetAvailability({ + automation, + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map() + }).reason + ).toBe('missing-project-host-setup') + + expect( + getAutomationTargetAvailability({ + automation, + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ setupState: 'error' })], + sshConnectionStates: new Map() + }) + ).toMatchObject({ + reason: 'project-host-setup-not-ready', + message: 'Project setup on the selected automation host is error.' + }) + }) + + 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(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'connected' }]]) + }).canRunNow + ).toBe(true) + + expect( + getAutomationTargetAvailability({ + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'disconnected' }]]) + }).reason + ).toBe('ssh-unavailable') + + expect( + getAutomationTargetAvailability({ + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'auth-failed' }]]) + }).reason + ).toBe('ssh-auth-needed') + + expect( + getAutomationTargetAvailability({ + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'reconnecting' }]]) + }).reason + ).toBe('ssh-connecting') + }) + + it('blocks manual runs when the saved source account needs provider auth', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map(), + sourceHostAvailability: [{ hostId: 'local', reason: 'missing-provider-auth' }] + }) + ).toMatchObject({ + canRunNow: false, + reason: 'source-auth-needed', + message: 'Connect the saved GitHub source account before running manually.' + }) + }) + + it('blocks manual runs when the saved source host cannot support the provider', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + sourceContext: { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:stablyai/orca', + hostId: 'runtime:old-server', + repoId: 'repo-1', + providerIdentity: { + provider: 'gitlab', + projectId: 'stablyai/orca', + namespace: 'stablyai', + project: 'orca', + webUrl: 'https://gitlab.com/stablyai/orca' + } + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [], + sshConnectionStates: new Map(), + sourceHostAvailability: [ + { hostId: 'runtime:old-server', reason: 'missing-task-source-capability' } + ] + }) + ).toMatchObject({ + canRunNow: false, + reason: 'source-provider-unsupported', + message: 'The saved GitLab source is not supported on this automation host.' + }) + }) + + it('explains runtime-host automation availability before the unsupported manual-run fallback', () => { + const automation = makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'runtime:env-1', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }) + const repo = makeRepo({ executionHostId: 'runtime:env-1' }) + const setup = makeProjectHostSetup({ + hostId: 'runtime:env-1', + executionHostId: 'runtime:env-1' + }) + const base = { + automation, + repo, + workspace: makeWorkspace(), + projectHostSetups: [setup], + sshConnectionStates: new Map() + } + + expect(getAutomationTargetAvailability(base).reason).toBe('runtime-checking') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([['env-1', { status: null, checkedAt: 1 }]]) + }).reason + ).toBe('runtime-unavailable') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([ + ['env-1', { status: makeRuntimeStatus({ graphStatus: 'unavailable' }), checkedAt: 1 }] + ]) + }).message + ).toBe('The selected remote server is not ready to run automations yet.') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([ + ['env-1', { status: makeRuntimeStatus({ runtimeProtocolVersion: 0 }), checkedAt: 1 }] + ]) + }).reason + ).toBe('runtime-update-required') + expect( + getAutomationTargetAvailability({ + ...base, + runtimeStatusByEnvironmentId: new Map([ + ['env-1', { status: makeRuntimeStatus(), checkedAt: 1 }] + ]) + }) + ).toMatchObject({ + reason: 'available', + message: null + }) + }) +}) 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..d712117f897 --- /dev/null +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -0,0 +1,277 @@ +import type { Automation } from '../../../../shared/automations-types' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { + describeRuntimeCompatBlock, + evaluateRuntimeCompat +} from '../../../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' + +export type AutomationTargetAvailability = + | { + canRunNow: true + reason: 'available' + message: null + } + | { + canRunNow: false + reason: + | 'missing-project' + | 'missing-project-host-setup' + | 'project-host-setup-not-ready' + | 'missing-workspace' + | 'host-mismatch' + | 'unsupported-host' + | 'runtime-checking' + | 'runtime-unavailable' + | 'runtime-update-required' + | 'ssh-auth-needed' + | 'ssh-unavailable' + | 'ssh-connecting' + | 'source-auth-needed' + | 'source-tool-unavailable' + | 'source-provider-unsupported' + | 'source-host-unavailable' + message: string + } + +type AutomationTargetAvailabilityArgs = { + automation: Automation + repo: Repo | null | undefined + workspace: Worktree | null | undefined + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>> + runtimeStatusByEnvironmentId?: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + sourceHostAvailability?: readonly TaskSourceHostAvailability[] +} + +export function getAutomationTargetAvailability({ + automation, + repo, + workspace, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + sourceHostAvailability +}: 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') { + const runtimeAvailability = getRuntimeAutomationAvailability( + parsedHost.environmentId, + runtimeStatusByEnvironmentId + ) + if (!runtimeAvailability.canRunNow) { + return runtimeAvailability + } + } + const setup = projectHostSetups.find( + (candidate) => candidate.id === automation.runContext?.projectHostSetupId + ) + if (!setup) { + return unavailable( + 'missing-project-host-setup', + 'Project is not set up on the selected automation host anymore.' + ) + } + if (setup.setupState !== 'ready') { + return unavailable( + 'project-host-setup-not-ready', + `Project setup on the selected automation host is ${setup.setupState}.` + ) + } + if ( + setup.projectId !== automation.runContext.projectId || + setup.hostId !== automation.runContext.hostId || + setup.repoId !== automation.runContext.repoId || + setup.path !== automation.runContext.path || + 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 sourceAvailability = getAutomationSourceAvailability( + automation.sourceContext, + sourceHostAvailability + ) + if (sourceAvailability) { + return sourceAvailability + } + + 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 getAutomationSourceAvailability( + sourceContext: TaskSourceContext | null | undefined, + sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined +): AutomationTargetAvailability | null { + if (!sourceContext) { + return null + } + const availability = sourceHostAvailability?.find( + (entry) => entry.hostId === sourceContext.hostId + ) + if (!availability) { + return null + } + const providerLabel = getAutomationSourceProviderLabel(sourceContext.provider) + switch (availability.reason) { + case 'missing-provider-auth': + return unavailable( + 'source-auth-needed', + `Connect the saved ${providerLabel} source account before running manually.` + ) + case 'unavailable-source-tool': + return unavailable( + 'source-tool-unavailable', + `Install or configure the ${providerLabel} source tool before running manually.` + ) + case 'unsupported-provider': + case 'missing-task-source-capability': + return unavailable( + 'source-provider-unsupported', + `The saved ${providerLabel} source is not supported on this automation host.` + ) + case 'checking-task-source-capability': + return unavailable( + 'source-host-unavailable', + `Checking the saved ${providerLabel} source host before running manually.` + ) + } + if ( + availability.health === 'disconnected' || + availability.health === 'blocked' || + availability.health === 'error' || + availability.status === 'disconnected' || + availability.status === 'auth-failed' || + availability.status === 'reconnection-failed' || + availability.status === 'error' + ) { + return unavailable( + 'source-host-unavailable', + `Reconnect the saved ${providerLabel} source host before running manually.` + ) + } + if ( + availability.health === 'connecting' || + availability.status === 'connecting' || + availability.status === 'deploying-relay' || + availability.status === 'reconnecting' + ) { + return unavailable( + 'source-host-unavailable', + `The saved ${providerLabel} source host is still connecting.` + ) + } + return null +} + +function getAutomationSourceProviderLabel(provider: TaskSourceContext['provider']): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + case 'linear': + return 'Linear' + case 'jira': + return 'Jira' + } +} + +function getRuntimeAutomationAvailability( + environmentId: string, + runtimeStatusByEnvironmentId: + | ReadonlyMap<string, { status: RuntimeStatus | null; checkedAt: number }> + | undefined +): AutomationTargetAvailability { + const entry = runtimeStatusByEnvironmentId?.get(environmentId) + if (!entry) { + return unavailable( + 'runtime-checking', + 'Checking the selected remote server before running manually.' + ) + } + if (!entry.status) { + return unavailable( + 'runtime-unavailable', + 'Reconnect this remote server before running manually.' + ) + } + if (entry.status.graphStatus !== 'ready') { + return unavailable( + 'runtime-unavailable', + 'The selected remote server is not ready to run automations yet.' + ) + } + const compat = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: entry.status.runtimeProtocolVersion ?? entry.status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + entry.status.minCompatibleRuntimeClientVersion ?? entry.status.minCompatibleMobileVersion + }) + if (compat.kind === 'blocked') { + return unavailable('runtime-update-required', describeRuntimeCompatBlock(compat)) + } + return { canRunNow: true, reason: 'available', message: null } +} + +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/automations/external-automation-source-availability.test.ts b/src/renderer/src/components/automations/external-automation-source-availability.test.ts new file mode 100644 index 00000000000..37f895ea6fe --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-source-availability.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import type { ExternalAutomationManager } from '../../../../shared/automations-types' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability +} from './external-automation-source-availability' + +function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAutomationManager { + return { + id: 'hermes-local', + provider: 'hermes', + label: 'Hermes', + targetLabel: 'Local Mac', + target: { type: 'local' }, + status: 'unavailable', + error: null, + canManage: false, + jobs: [], + ...overrides + } +} + +describe('external automation source availability', () => { + it('uses local repair copy for unavailable local sources', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager(), + providerLabel: 'Hermes', + targetKindLabel: 'Local' + }) + ).toMatchObject({ + statusLabel: 'Source unavailable', + summary: 'Hermes source unavailable on local.', + detail: 'Install or repair the local automation source, then retry to load jobs.', + canConnectSsh: false, + isConnecting: false + }) + }) + + it('asks users to connect disconnected SSH sources before checking jobs', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager({ + id: 'hermes-devbox', + targetLabel: 'Devbox', + target: { type: 'ssh', connectionId: 'devbox' } + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'disconnected' + }) + ).toMatchObject({ + statusLabel: 'Connect SSH', + summary: 'Hermes source unavailable until ssh host connects.', + detail: 'Connect this SSH host to check for remote automation jobs.', + canConnectSsh: true, + isConnecting: false + }) + }) + + it('distinguishes connected SSH hosts with missing remote automation tooling', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager({ + id: 'hermes-devbox', + targetLabel: 'Devbox', + target: { type: 'ssh', connectionId: 'devbox' } + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'connected' + }) + ).toMatchObject({ + statusLabel: 'Source unavailable', + summary: 'Hermes source unavailable on this ssh host.', + detail: 'Install or repair the remote automation source, then retry to load jobs.', + canConnectSsh: true, + isConnecting: false + }) + }) + + it('preserves manager errors while still reporting a connecting SSH state', () => { + expect( + getExternalAutomationSourceAvailability({ + manager: manager({ + error: 'Hermes binary was not found.', + target: { type: 'ssh', connectionId: 'devbox' } + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'connected', + isConnectingOverride: true + }) + ).toMatchObject({ + statusLabel: 'Connecting...', + summary: 'Hermes binary was not found.', + detail: 'Waiting for this SSH host before checking the remote automation source.', + canConnectSsh: true, + isConnecting: true + }) + }) + + it('explains disabled local automation actions when the source tool is missing', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ error: 'Hermes jobs were found, but the hermes CLI is not on PATH.' }) + }) + ).toBe('Hermes jobs were found, but the hermes CLI is not on PATH.') + }) + + it('explains disabled SSH automation actions before the host is connected', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'SSH target is not connected.' + }), + providerLabel: 'Hermes', + targetKindLabel: 'SSH host', + sshStatus: 'disconnected' + }) + ).toBe('Connect this ssh host before managing Hermes automations.') + }) + + it('explains disabled SSH automation actions while the host is connecting', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'SSH target is not connected.' + }), + targetKindLabel: 'SSH host', + sshStatus: 'deploying-relay' + }) + ).toBe('Wait for this ssh host to finish connecting.') + }) + + it('explains disabled SSH automation actions when the remote source tool is missing', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'Hermes CLI is not on the remote PATH.' + }), + sshStatus: 'connected' + }) + ).toBe('Hermes CLI is not on the remote PATH.') + }) + + it('keeps concrete remote source errors when SSH status is unavailable to the caller', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ + target: { type: 'ssh', connectionId: 'devbox' }, + error: 'Hermes CLI is not on the remote PATH.' + }) + }) + ).toBe('Hermes CLI is not on the remote PATH.') + }) + + it('explains disabled actions while another automation action is running', () => { + expect( + getExternalAutomationActionDisabledMessage({ + manager: manager({ canManage: true }), + actionInProgress: true + }) + ).toBe('Another automation action is still running.') + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts new file mode 100644 index 00000000000..92f0749f892 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-source-availability.ts @@ -0,0 +1,124 @@ +import type { + ExternalAutomationManager, + ExternalAutomationProvider +} from '../../../../shared/automations-types' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +export type ExternalAutomationSourceAvailability = { + statusLabel: string + summary: string + detail: string + canConnectSsh: boolean + isConnecting: boolean +} + +type ExternalAutomationSourceAvailabilityArgs = { + manager: ExternalAutomationManager + providerLabel: string + targetKindLabel: string + sshStatus?: SshConnectionStatus + isConnectingOverride?: boolean +} + +export function getExternalAutomationSourceAvailability({ + manager, + providerLabel, + targetKindLabel, + sshStatus, + isConnectingOverride = false +}: ExternalAutomationSourceAvailabilityArgs): ExternalAutomationSourceAvailability { + if (manager.target.type === 'ssh') { + const isConnecting = isConnectingOverride || isSshConnectionBusy(sshStatus) + if (isConnecting) { + return { + statusLabel: 'Connecting...', + summary: + manager.error ?? + `${providerLabel} source unavailable while ${targetKindLabel.toLowerCase()} connects.`, + detail: 'Waiting for this SSH host before checking the remote automation source.', + canConnectSsh: true, + isConnecting: true + } + } + + if (sshStatus === 'connected') { + return { + statusLabel: 'Source unavailable', + summary: + manager.error ?? + `${providerLabel} source unavailable on this ${targetKindLabel.toLowerCase()}.`, + detail: 'Install or repair the remote automation source, then retry to load jobs.', + canConnectSsh: true, + isConnecting: false + } + } + + return { + statusLabel: 'Connect SSH', + summary: + manager.error ?? + `${providerLabel} source unavailable until ${targetKindLabel.toLowerCase()} connects.`, + detail: 'Connect this SSH host to check for remote automation jobs.', + canConnectSsh: true, + isConnecting: false + } + } + + return { + statusLabel: 'Source unavailable', + summary: + manager.error ?? `${providerLabel} source unavailable on ${targetKindLabel.toLowerCase()}.`, + detail: 'Install or repair the local automation source, then retry to load jobs.', + canConnectSsh: false, + isConnecting: false + } +} + +export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean { + return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' +} + +export function getExternalAutomationActionDisabledMessage(args: { + manager: ExternalAutomationManager + providerLabel?: string + targetKindLabel?: string + sshStatus?: SshConnectionStatus + actionInProgress?: boolean +}): string | null { + if (args.actionInProgress) { + return 'Another automation action is still running.' + } + if (args.manager.canManage) { + return null + } + const providerLabel = args.providerLabel ?? getProviderLabel(args.manager.provider) + const targetKindLabel = + args.targetKindLabel ?? (args.manager.target.type === 'ssh' ? 'SSH host' : 'Local') + if (args.manager.target.type === 'ssh') { + if (isSshConnectionBusy(args.sshStatus)) { + return `Wait for this ${targetKindLabel.toLowerCase()} to finish connecting.` + } + if (args.manager.error && !isSshDisconnectedError(args.manager.error)) { + return args.manager.error + } + if (args.sshStatus !== 'connected') { + return `Connect this ${targetKindLabel.toLowerCase()} before managing ${providerLabel} automations.` + } + return ( + args.manager.error ?? + `${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.` + ) + } + return ( + args.manager.error ?? + `${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.` + ) +} + +function getProviderLabel(provider: ExternalAutomationProvider): string { + return provider === 'hermes' ? 'Hermes' : 'OpenClaw' +} + +function isSshDisconnectedError(message: string): boolean { + return /ssh target is not connected/i.test(message) +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index a16377b9f68..abd7e1687d8 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -52,6 +52,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Label } from '@/components/ui/label' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' import type { BrowserLoadError, @@ -258,6 +259,16 @@ type RemoteBrowserViewportSize = { height: number } +function getBrowserPageRuntimeEnvironmentId( + page: BrowserPageState, + inferredRuntimeEnvironmentId: string | null | undefined +): string | null { + if (page.browserRuntimeEnvironmentId !== undefined) { + return page.browserRuntimeEnvironmentId?.trim() || null + } + return inferredRuntimeEnvironmentId?.trim() || null +} + type RemoteBrowserImagePoint = { x: number y: number @@ -735,8 +746,8 @@ export default function BrowserPane({ browserTab: BrowserWorkspaceState isActive: boolean }): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null + const activeRuntimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, browserTab.worktreeId) ) const browserPages = useAppStore((s) => getBrowserPagesForWorkspace(s.browserPagesByWorkspace, browserTab.id) @@ -745,14 +756,19 @@ export default function BrowserPane({ browserPages.find((page) => page.id === browserTab.activePageId) ?? browserPages[0] ?? null const updateBrowserPageState = useAppStore((s) => s.updateBrowserPageState) const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl) - const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) + const activeBrowserRuntimeEnvironmentId = activeBrowserPage + ? getBrowserPageRuntimeEnvironmentId(activeBrowserPage, activeRuntimeEnvironmentId) + : null + const runtimeEnvironmentActive = Boolean(activeBrowserRuntimeEnvironmentId) const activeBrowserPageId = activeBrowserPage?.id ?? null const browserPageIds = useMemo(() => browserPages.map((page) => page.id), [browserPages]) const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds) // Why: inactive Electron webviews must stay mounted in their original DOM // parent. Parking them by unmounting/reparenting loses form text and SPA // state on normal tab switches. - const renderedBrowserPages = browserPages + const renderedBrowserPages = browserPages.filter( + (page) => !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId) + ) const [activeBrowserDriver, setActiveBrowserDriver] = useState<BrowserDriverState>({ kind: 'idle' }) @@ -762,9 +778,11 @@ export default function BrowserPane({ return } for (const page of browserPages) { - destroyPersistentWebview(page.id) + if (getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId)) { + destroyPersistentWebview(page.id) + } } - }, [browserPages, runtimeEnvironmentActive]) + }, [activeRuntimeEnvironmentId, browserPages, runtimeEnvironmentActive]) useEffect(() => { if (runtimeEnvironmentActive || !activeBrowserPageId) { @@ -792,11 +810,12 @@ export default function BrowserPane({ await window.api.runtime.reclaimBrowserForDesktop(activeBrowserPageId) }, [activeBrowserPageId]) - if (runtimeEnvironmentActive) { + if (activeBrowserRuntimeEnvironmentId) { return activeBrowserPage ? ( <RemoteBrowserPagePane - key={`${activeRuntimeEnvironmentId?.trim() ?? ''}:${activeBrowserPage.id}`} + key={`${activeBrowserRuntimeEnvironmentId ?? ''}:${activeBrowserPage.id}`} browserTab={activeBrowserPage} + runtimeEnvironmentId={activeBrowserRuntimeEnvironmentId} worktreeId={browserTab.worktreeId} isActive={isActive} onUpdatePageState={updateBrowserPageState} @@ -837,18 +856,20 @@ export default function BrowserPane({ function RemoteBrowserPagePane({ browserTab, + runtimeEnvironmentId, worktreeId, isActive, onUpdatePageState, onSetUrl }: { browserTab: BrowserPageState + runtimeEnvironmentId: string worktreeId: string isActive: boolean onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void onSetUrl: (tabId: string, url: string) => void }): React.JSX.Element { - const settings = useAppStore((s) => s.settings) + const activeRuntimeEnvironmentId = runtimeEnvironmentId const addressBarInputRef = useRef<HTMLInputElement | null>(null) const imageRef = useRef<HTMLImageElement | null>(null) const remoteViewportRef = useRef<HTMLDivElement | null>(null) @@ -881,7 +902,6 @@ function RemoteBrowserPagePane({ const currentBrowserTabIdRef = useRef(browserTab.id) const currentBrowserTabUrlRef = useRef(browserTab.url) const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId]) - const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() ?? null const activeRuntimeEnvironmentIdRef = useRef<string | null>(activeRuntimeEnvironmentId) const startRemoteStreamRef = useRef< (pageId: string) => Promise<RemoteBrowserStreamSubscription | null> @@ -1249,7 +1269,7 @@ function RemoteBrowserPagePane({ return } const state = useAppStore.getState() - const currentEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() ?? null + const currentEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) const pageStillExists = browserPageExists(browserTab.id) if (currentEnvironmentId === activeRuntimeEnvironmentId && pageStillExists) { return @@ -1268,7 +1288,7 @@ function RemoteBrowserPagePane({ { timeoutMs: 15_000, suppressFeatureInteraction: true } ).catch(() => {}) } - }, [activeRuntimeEnvironmentId, browserTab.id, runtimeWorktree]) + }, [activeRuntimeEnvironmentId, browserTab.id, runtimeWorktree, worktreeId]) const applyRemoteTabInfo = useCallback( (tab: Pick<BrowserTabInfo, 'url' | 'title'>): void => { diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts new file mode 100644 index 00000000000..538939b8a4a --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { getPaletteHostBadge } from './palette-host-badge' +import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options' + +describe('getPaletteHostBadge', () => { + it('returns null for single-host (local-only) workspaces', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: null }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + }) + + it('badges the local host when multiple hosts exist', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({ connectionId: null }, hosts)).toEqual({ + hostId: 'local', + label: 'Local Mac' + }) + }) + + it('uses the ssh target label for ssh repos', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({ connectionId: 'ssh-1' }, hosts)).toEqual({ + hostId: 'ssh:ssh-1', + label: 'Builder' + }) + }) + + it('badges runtime-hosted repos', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ executionHostId: 'runtime:env-1' }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'env-2' } + }) + + expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts)).toEqual({ + hostId: 'runtime:env-1', + label: 'env-1' + }) + }) + + it('maps repos with no executionHostId/connectionId to local', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge({}, hosts)).toEqual({ + hostId: 'local', + label: 'Local Mac' + }) + }) + + it('returns null when the repo is missing', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getPaletteHostBadge(null, hosts)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts new file mode 100644 index 00000000000..fb42c9c63a3 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -0,0 +1,28 @@ +import type { Repo } from '../../../../shared/types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + shouldShowHostScopeControls, + type SidebarHostOption +} from '../sidebar/sidebar-host-options' + +export type PaletteHostBadge = { + hostId: ExecutionHostId + label: string +} + +// Why: Cmd+J results only need a host label when more than one host exists; a +// local-only user gets no badge at all, so single-host UIs stay unchanged. +export function getPaletteHostBadge( + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | null | undefined, + hostOptions: readonly SidebarHostOption[] +): PaletteHostBadge | null { + if (!repo || !shouldShowHostScopeControls(hostOptions)) { + return null + } + const hostId = getRepoExecutionHostId(repo) + const host = hostOptions.find((option) => option.id === hostId) + if (!host) { + return null + } + return { hostId, label: host.label } +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 200d492420e..bdaedb84776 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -107,12 +107,20 @@ const sections: SettingsNavSection[] = [ searchEntries: [{ title: 'Default Browser URL' }], group: 'workflows' }, + { + id: 'servers', + title: 'Remote Orca Servers', + description: 'Pair remote Orca runtimes.', + icon: Settings, + searchEntries: [{ title: 'Remote Orca Servers' }], + group: 'remote' + }, { id: 'ssh', title: 'SSH Hosts', - description: 'Remote hosts.', + description: 'Remote hosts over SSH.', icon: Settings, - searchEntries: [{ title: 'Remote Shell' }], + searchEntries: [{ title: 'SSH Connections' }], group: 'remote' }, { diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 5d5e170d8a8..d67975db4a8 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -9,6 +9,7 @@ import { buildWorkspaceSessionPayload, shouldPersistWorkspaceSession } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHostSync } from '@/lib/workspace-session-host-persistence' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { writeRuntimeFile } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' @@ -285,7 +286,13 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { // Why: restart/update may quit before the debounced session writer fires. // Write the full session now so dirty drafts restore as unsaved tabs. if (shouldPersistWorkspaceSession(state)) { - window.api.session.setSync(buildWorkspaceSessionPayload(state)) + // Why: runtime-owned worktree slices persist under their host + // partition, mirroring the debounced writer's split. + persistWorkspaceSessionByHostSync( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) } detail.resolve() } catch (error) { diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index d8767abbcf4..a890bce3bb7 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -77,6 +77,26 @@ describe('feature interaction writer boundaries', () => { } }) + it('threads GitHub task source context through inline task mutations', () => { + const source = componentSource('TaskPage.tsx') + const sections = [ + sourceBetween(source, 'function GHStatusCell', 'function GitHubAssigneeAvatar'), + sourceBetween(source, 'function GHAssigneesCell', 'const triggerContent ='), + sourceBetween(source, 'function PRReviewCell', 'function PRChecksCell'), + componentBodyBeforeRender(source, 'PRMergeCell'), + sourceBetween(source, 'const handleCreateNewIssue', 'const handleCreateNewLinearProject') + ] + + for (const section of sections) { + expect(section).toContain('sourceContext') + } + const rowRenderStart = source.indexOf('filteredWorkItems.map((item) => {') + expect(rowRenderStart).toBeGreaterThanOrEqual(0) + expect(source.slice(rowRenderStart, rowRenderStart + 12_000)).toContain( + 'sourceContext={getTaskPageRepoSourceContext(itemRepo,' + ) + }) + it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { const source = componentSource('TaskPage.tsx') const suppression = 'recordTasksInteraction: false' @@ -91,9 +111,9 @@ describe('feature interaction writer boundaries', () => { sourceBetween(source, 'taskSourceManuallyChangedRef.current = true', 'void updateSettings') ] - expect(githubDetailSection).toContain("recordFeatureInteraction('github-tasks')") - expect(githubDetailSection).toContain('setDialogWorkItem(item, initialTab)') - expect(githubDetailSection).not.toContain('openTaskPage') + expect(githubDetailSection).toContain('openGitHubSourceContext') + expect(githubDetailSection).toContain('openTaskPage') + expect(githubDetailSection).toContain(suppression) for (const section of inPageNavigationSections) { expect(section).toContain(suppression) @@ -189,6 +209,15 @@ describe('feature interaction writer boundaries', () => { } }) + it('records Jira provider-depth for workspace use', () => { + const taskPageSource = componentSource('TaskPage.tsx') + const jiraWriter = "recordFeatureInteraction('jira-tasks')" + + expect( + sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const handleJiraConnect') + ).toContain(jiraWriter) + }) + it('records browser annotation agent handoff only from the prompt-delivered callback', () => { const source = componentSource('browser-pane/BrowserPane.tsx') expect( diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts new file mode 100644 index 00000000000..1e70364f09c --- /dev/null +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const COMPONENT_ROOT = __dirname + +function componentSource(relativePath: string): string { + return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') +} + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('GitHubItemDialog source host boundaries', () => { + it('routes reviewer metadata and reviewer mutations through the task source context', () => { + const source = componentSource('GitHubItemDialog.tsx') + const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') + + expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('sourceSettings') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('sourceSettings') + expect(section).toContain('getActiveRuntimeTarget(sourceSettings)') + }) + + it('routes edit metadata through the same task source as issue mutations', () => { + const source = componentSource('GitHubItemDialog.tsx') + const section = sourceBetween(source, 'function GHEditSection', 'const hasAttachedWorkspace') + + expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(section).toContain('useRepoLabels(') + expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('sourceSettings') + }) +}) diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 0f916be3ae7..b9642d0c2e8 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -4,7 +4,8 @@ // built-in ASSIGNEES/LABELS cells render their dedicated content) and fall // through to `fieldValuesByFieldId[field.id].kind` as a safety net so a // fetched value is never silently dropped. -import React, { useState } from 'react' +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' @@ -13,6 +14,8 @@ import { cn } from '@/lib/utils' import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { useRepoSlugIndex } from '@/lib/repo-slug-index' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import type { GitHubIssueType, GitHubProjectField, @@ -22,6 +25,7 @@ import type { GitHubProjectUser, ListIssueTypesBySlugResult } from '../../../../shared/github-project-types' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type Props = { @@ -39,6 +43,7 @@ type Props = { onEditLabels?: (add: string[], remove: string[]) => void onEditIssueType?: (issueType: GitHubIssueType | null) => void onOpenDialog?: () => void + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined } export default function ProjectCell({ @@ -49,7 +54,8 @@ export default function ProjectCell({ onEditAssignees, onEditLabels, onEditIssueType, - onOpenDialog + onOpenDialog, + sourceSettings }: Props): React.JSX.Element { const value = row.fieldValuesByFieldId[field.id] const isRedacted = row.itemType === 'REDACTED' @@ -60,15 +66,36 @@ export default function ProjectCell({ } if (field.dataType === TYPE_FIELD_DATA_TYPE) { const editableHere = editable && !isRedacted && row.itemType === 'ISSUE' - return <TypeCell row={row} editable={editableHere} onEditIssueType={onEditIssueType} /> + return ( + <TypeCell + row={row} + editable={editableHere} + sourceSettings={sourceSettings} + onEditIssueType={onEditIssueType} + /> + ) } if (field.dataType === 'ASSIGNEES') { const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE' - return <AssigneesCell row={row} editable={editableHere} onEditAssignees={onEditAssignees} /> + return ( + <AssigneesCell + row={row} + editable={editableHere} + sourceSettings={sourceSettings} + onEditAssignees={onEditAssignees} + /> + ) } if (field.dataType === 'LABELS') { const editableHere = editable && !isRedacted && row.itemType !== 'DRAFT_ISSUE' - return <LabelsCell row={row} editable={editableHere} onEditLabels={onEditLabels} /> + return ( + <LabelsCell + row={row} + editable={editableHere} + sourceSettings={sourceSettings} + onEditLabels={onEditLabels} + /> + ) } if (field.dataType === 'REPOSITORY') { return ( @@ -236,17 +263,26 @@ function TitleCell({ function TypeCell({ row, editable, + sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { // Why: for issues we surface the repo's `issueType` (Bug/Feature/Task etc) // when set — that's the editable taxonomy. PR/Draft/Restricted rows render // the static itemType glyph because there's no equivalent editable type. if (row.itemType === 'ISSUE') { - return <IssueTypeCell row={row} editable={editable} onEditIssueType={onEditIssueType} /> + return ( + <IssueTypeCell + row={row} + editable={editable} + sourceSettings={sourceSettings} + onEditIssueType={onEditIssueType} + /> + ) } const meta = row.itemType === 'PULL_REQUEST' @@ -275,18 +311,27 @@ function TypeCell({ function IssueTypeCell({ row, editable, + sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { const issueType = row.content.issueType const [open, setOpen] = useState(false) const [options, setOptions] = useState<GitHubIssueType[]>([]) const [loading, setLoading] = useState(false) - const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') + const { lookupSlug } = useRepoSlugIndex() + const matchedRepo = useMemo( + () => lookupSlug(row.content.repository)[0] ?? null, + [lookupSlug, row.content.repository] + ) + const ownerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, matchedRepo?.id ?? null)) + ) React.useEffect(() => { if (!open || !owner || !repo) { @@ -294,7 +339,7 @@ function IssueTypeCell({ } let cancelled = false setLoading(true) - const target = getActiveRuntimeTarget(settings) + const target = getActiveRuntimeTarget(matchedRepo ? ownerSettings : sourceSettings) const request = target.kind === 'environment' ? callRuntimeRpc<ListIssueTypesBySlugResult>( @@ -321,7 +366,7 @@ function IssueTypeCell({ return () => { cancelled = true } - }, [open, owner, repo, settings]) + }, [matchedRepo, open, owner, ownerSettings, repo, sourceSettings]) const trigger = ( <span className="inline-flex items-center gap-1 text-xs"> @@ -773,15 +818,16 @@ function UserChip({ user }: { user: GitHubProjectUser }): React.JSX.Element { function AssigneesCell({ row, editable, + sourceSettings, onEditAssignees }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditAssignees?: (add: string[], remove: string[]) => void }): React.JSX.Element { const assignees = row.content.assignees const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') @@ -803,7 +849,7 @@ function AssigneesCell({ open ? owner : null, open ? repo : null, seedKey ? seedKey.split(',') : [], - settings + sourceSettings ) const labelContent = @@ -887,18 +933,19 @@ function AssigneesCell({ function LabelsCell({ row, editable, + sourceSettings, onEditLabels }: { row: GitHubProjectRow editable: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onEditLabels?: (add: string[], remove: string[]) => void }): React.JSX.Element { const labels = row.content.labels const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') - const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings) + const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings) const labelContent = labels.length === 0 ? null : labels.map((l) => <LabelChip key={l.name} label={l} />) diff --git a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx index 51db853fc1b..2b778e29c10 100644 --- a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx +++ b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx @@ -12,15 +12,18 @@ import { VisuallyHidden } from 'radix-ui' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { SlugDialogBody } from './slug-dialog/SlugDialogBody' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type Props = { projectOrigin: GitHubItemDialogProjectOrigin | null + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onClose: () => void } export default function ProjectItemSlugDialog({ projectOrigin, + sourceSettings, onClose }: Props): React.JSX.Element { const open = projectOrigin !== null @@ -49,7 +52,13 @@ export default function ProjectItemSlugDialog({ )} </SheetDescription> </VisuallyHidden.Root> - {projectOrigin ? <SlugDialogBody projectOrigin={projectOrigin} onClose={onClose} /> : null} + {projectOrigin ? ( + <SlugDialogBody + projectOrigin={projectOrigin} + sourceSettings={sourceSettings} + onClose={onClose} + /> + ) : null} </SheetContent> </Sheet> ) diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index c77ce48948e..40ecdb2f9bf 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -44,11 +44,20 @@ type Props = { } const BROWSE_CACHE_TTL_MS = 5 * 60_000 -let browseCache: { +type BrowseCacheEntry = { fetchedAt: number projects: GitHubProjectSummary[] partialFailures?: { owner: string; message: string }[] -} | null = null +} + +const browseCacheByRuntimeScope = new Map<string, BrowseCacheEntry>() + +function getProjectPickerRuntimeScope( + settings: Parameters<typeof getActiveRuntimeTarget>[0] +): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' +} async function listAccessibleProjectsForRuntime( settings: Parameters<typeof getActiveRuntimeTarget>[0] @@ -110,6 +119,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [query, setQuery] = useState('') const [browseLoading, setBrowseLoading] = useState(false) const [browseError, setBrowseError] = useState<GitHubProjectViewError | null>(null) + const browseCache = browseCacheByRuntimeScope.get(getProjectPickerRuntimeScope(settings)) const [browseProjects, setBrowseProjects] = useState<GitHubProjectSummary[]>( () => browseCache?.projects ?? [] ) @@ -130,9 +140,11 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [viewLoading, setViewLoading] = useState(false) const loadBrowse = useCallback(async () => { - if (browseCache && Date.now() - browseCache.fetchedAt < BROWSE_CACHE_TTL_MS) { - setBrowseProjects(browseCache.projects) - setPartialFailures(browseCache.partialFailures ?? []) + const cacheKey = getProjectPickerRuntimeScope(settings) + const cached = browseCacheByRuntimeScope.get(cacheKey) ?? null + if (cached && Date.now() - cached.fetchedAt < BROWSE_CACHE_TTL_MS) { + setBrowseProjects(cached.projects) + setPartialFailures(cached.partialFailures ?? []) return } setBrowseLoading(true) @@ -140,11 +152,11 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React try { const res = await listAccessibleProjectsForRuntime(settings) if (res.ok) { - browseCache = { + browseCacheByRuntimeScope.set(cacheKey, { fetchedAt: Date.now(), projects: res.projects, partialFailures: res.partialFailures - } + }) if (!mountedRef.current) { return } diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index 1513f275da9..55e01e52f99 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -12,6 +12,7 @@ import type { GitHubProjectFieldMutationValue, GitHubProjectRow as GitHubProjectRowType } from '../../../../shared/github-project-types' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' const PROJECT_FROZEN_COLUMN_SURFACE_CLASS = @@ -33,6 +34,7 @@ type Props = { onEditIssueType?: (issueType: GitHubIssueType | null) => void onStartWork?: () => void onOpenInBrowser?: () => void + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined } export default function ProjectRow({ @@ -48,7 +50,8 @@ export default function ProjectRow({ onEditLabels, onEditIssueType, onStartWork, - onOpenInBrowser + onOpenInBrowser, + sourceSettings }: Props): React.JSX.Element { const disabled = row.itemType === 'REDACTED' // Why: design doc §Row actions — draft-issue rows have no URL or number, so @@ -97,6 +100,7 @@ export default function ProjectRow({ onEditLabels={onEditLabels} onEditIssueType={onEditIssueType} onOpenDialog={f.dataType === 'TITLE' ? onOpenDialog : undefined} + sourceSettings={sourceSettings} /> </div> {next ? ( diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index ebaaba0b7c4..76752d3c314 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -22,6 +22,7 @@ import type { GitHubProjectSortDirection, GitHubProjectTable } from '../../../../shared/github-project-types' +import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection } @@ -57,6 +58,7 @@ type Props = { onEditIssueType?: (row: GitHubProjectRow, issueType: GitHubIssueType | null) => void onStartWork?: (row: GitHubProjectRow) => void onOpenInBrowser?: (row: GitHubProjectRow) => void + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined } export default function ProjectViewList({ @@ -67,7 +69,8 @@ export default function ProjectViewList({ onEditLabels, onEditIssueType, onStartWork, - onOpenInBrowser + onOpenInBrowser, + sourceSettings }: Props): React.JSX.Element { const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set()) // Why: column-header clicks override the view's saved sortByFields locally @@ -255,6 +258,7 @@ export default function ProjectViewList({ onEditIssueType={(issueType) => onEditIssueType?.(row, issueType)} onStartWork={() => onStartWork?.(row)} onOpenInBrowser={() => onOpenInBrowser?.(row)} + sourceSettings={sourceSettings} /> )) : null} diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index d1c6dcd9a2e..c46d8120aba 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 @@ -791,6 +807,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } }} onStartWork={handleStartWork} + sourceSettings={settings} /> ) : null} @@ -831,6 +848,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J button here would only confuse the user. */} <ProjectItemSlugDialog projectOrigin={resolvedMissingRepoDialogs.slugDialog?.origin ?? null} + sourceSettings={settings} onClose={() => setSlugDialog(null)} /> diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx index 1b9e3a2b028..ded043e3d68 100644 --- a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { useAppStore } from '@/store' +import type { GlobalSettings } from '../../../../../shared/types' import { translate } from '@/i18n/i18n' export function AssigneesEditor({ @@ -10,16 +10,17 @@ export function AssigneesEditor({ repo, selected, disabled, + sourceSettings, onChange }: { owner: string repo: string selected: string[] disabled?: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onChange: (add: string[], remove: string[]) => void | Promise<void> }): React.JSX.Element { const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) // Why: stabilize the assignee seed identity. `selected` is a fresh array on // every parent render — depending on it directly would refire the IPC for // every unrelated re-render while the popover is open. @@ -28,7 +29,7 @@ export function AssigneesEditor({ open ? owner : null, open ? repo : null, seedKey ? seedKey.split(',') : [], - settings + sourceSettings ) return ( <Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}> 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 4a9186657e3..6b553ba797c 100644 --- a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -1,33 +1,53 @@ -import React, { useState } from 'react' +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' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' -import type { PRComment } from '../../../../../shared/types' +import { useRepoSlugIndex } from '@/lib/repo-slug-index' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import type { GlobalSettings, PRComment } from '../../../../../shared/types' import type { GitHubProjectCommentMutationResult, GitHubProjectMutationResult } from '../../../../../shared/github-project-types' import { translate } from '@/i18n/i18n' -function getRuntimeTarget() { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) +function getRuntimeTarget(settings: Parameters<typeof getActiveRuntimeTarget>[0]) { + const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? target : null } +function useRuntimeSettingsForSlug(owner: string, repo: string) { + const { lookupSlug } = useRepoSlugIndex() + const matchedRepo = useMemo( + () => lookupSlug(`${owner}/${repo}`)[0] ?? null, + [lookupSlug, owner, repo] + ) + return useAppStore( + useShallow((s) => + matchedRepo ? getSettingsForRepoRuntimeOwner(s, matchedRepo.id) : s.settings + ) + ) +} + export function CommentsList({ owner, repo, comments, + sourceSettings, onChange }: { owner: string repo: string comments: PRComment[] + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onChange: (next: PRComment[]) => void }): React.JSX.Element { + const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo) + const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings return ( <div className="flex flex-col gap-3"> {comments.length === 0 ? ( @@ -45,7 +65,7 @@ export function CommentsList({ repo={repo} comment={c} onDelete={async () => { - const target = getRuntimeTarget() + const target = getRuntimeTarget(runtimeSettings) const args = { owner, repo, @@ -66,7 +86,7 @@ export function CommentsList({ onChange(comments.filter((x) => x.id !== c.id)) }} onEdit={async (next) => { - const target = getRuntimeTarget() + const target = getRuntimeTarget(runtimeSettings) const args = { owner, repo, @@ -164,15 +184,19 @@ export function NewCommentForm({ owner, repo, number, + sourceSettings, onAdded }: { owner: string repo: string number: number + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onAdded: (c: PRComment) => void }): React.JSX.Element { const [draft, setDraft] = useState('') const [submitting, setSubmitting] = useState(false) + const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo) + const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings return ( <div className="flex flex-col gap-2"> <textarea @@ -195,7 +219,7 @@ export function NewCommentForm({ } setSubmitting(true) try { - const target = getRuntimeTarget() + const target = getRuntimeTarget(runtimeSettings) const args = { owner, repo, number, body } const res = target ? await callRuntimeRpc<GitHubProjectCommentMutationResult>( diff --git a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx index a2e64a8e968..1c87fc3fd28 100644 --- a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' -import { useAppStore } from '@/store' +import type { GlobalSettings } from '../../../../../shared/types' import { translate } from '@/i18n/i18n' export function LabelsEditor({ @@ -10,17 +10,18 @@ export function LabelsEditor({ repo, selected, disabled, + sourceSettings, onChange }: { owner: string repo: string selected: string[] disabled?: boolean + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onChange: (add: string[], remove: string[]) => void | Promise<void> }): React.JSX.Element { const [open, setOpen] = useState(false) - const settings = useAppStore((s) => s.settings) - const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings) + const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings) return ( <Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}> <PopoverTrigger asChild> diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index 29534bfdccb..ad7f9b5eb62 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -6,8 +6,10 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubWorkItemDetails } from '../../../../../shared/types' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' +import type { GlobalSettings } from '../../../../../shared/types' import { LabelsEditor } from './LabelsEditor' import { AssigneesEditor } from './AssigneesEditor' import { CommentsList, NewCommentForm } from './Comments' @@ -15,9 +17,11 @@ import { translate } from '@/i18n/i18n' export function SlugDialogBody({ projectOrigin, + sourceSettings, onClose }: { projectOrigin: GitHubItemDialogProjectOrigin + sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined onClose: () => void }): React.JSX.Element { const { owner, repo, number, type, cacheKey } = projectOrigin @@ -52,8 +56,19 @@ export function SlugDialogBody({ setLoading(true) setError(null) setDetails(null) - window.api.gh - .projectWorkItemDetailsBySlug({ owner, repo, number, type }) + const target = getActiveRuntimeTarget(sourceSettings) + const request = + target.kind === 'environment' + ? callRuntimeRpc< + { ok: true; details: GitHubWorkItemDetails } | { ok: false; error: { message: string } } + >( + target, + 'github.project.workItemDetailsBySlug', + { owner, repo, number, type }, + { timeoutMs: 30_000 } + ) + : window.api.gh.projectWorkItemDetailsBySlug({ owner, repo, number, type }) + request .then((res) => { if (rid !== requestIdRef.current) { return @@ -76,7 +91,7 @@ export function SlugDialogBody({ } setLoading(false) }) - }, [owner, repo, number, type]) + }, [owner, repo, number, type, sourceSettings]) const title = row?.content.title ?? details?.item.title ?? '' const url = row?.content.url ?? details?.item.url ?? null @@ -208,6 +223,7 @@ export function SlugDialogBody({ repo={repo} selected={labels} disabled={!row} + sourceSettings={sourceSettings} onChange={async (add, remove) => { // Why: bail rather than call the helper with an empty id — // see commitTitle above. Trigger is also disabled when !row. @@ -228,6 +244,7 @@ export function SlugDialogBody({ repo={repo} selected={assignees} disabled={!row} + sourceSettings={sourceSettings} onChange={async (add, remove) => { if (!row) { return @@ -321,12 +338,14 @@ export function SlugDialogBody({ owner={owner} repo={repo} comments={details.comments} + sourceSettings={sourceSettings} onChange={(next) => setDetails((d) => (d ? { ...d, comments: next } : d))} /> <NewCommentForm owner={owner} repo={repo} number={number} + sourceSettings={sourceSettings} onAdded={(c) => setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))} /> </section> diff --git a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx index 7de32c72d92..d23329d2580 100644 --- a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx +++ b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx @@ -20,8 +20,10 @@ import type { GitHubOwnerRepo, GitHubViewer, GitHubWorkItem, + GlobalSettings, PRComment } from '../../../../shared/types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import { translate } from '@/i18n/i18n' export function GitHubIssueCommentComposer({ @@ -32,6 +34,8 @@ export function GitHubIssueCommentComposer({ itemType, itemState, itemId, + sourceContext, + sourceSettings, projectOrigin, previewGithubRepo, onCommentAdded, @@ -45,6 +49,8 @@ export function GitHubIssueCommentComposer({ itemType: 'issue' | 'pr' itemState?: GitHubWorkItem['state'] itemId?: string + sourceContext?: TaskSourceContext | null + sourceSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined projectOrigin?: GitHubIssueCommentProjectOrigin previewGithubRepo?: GitHubOwnerRepo | null onCommentAdded: (comment: PRComment) => void @@ -127,6 +133,7 @@ export function GitHubIssueCommentComposer({ const result = await addIssueCommentForRepo({ repoPath, repoId: repoId ?? undefined, + sourceContext, number: issueNumber, body: trimmed, type: itemType @@ -162,7 +169,7 @@ export function GitHubIssueCommentComposer({ setSubmitting(false) } } - }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath]) + }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath, sourceContext]) const handleCloseIssue = useCallback( async (reason: GitHubIssueCloseReason = closeReason) => { @@ -176,6 +183,8 @@ export function GitHubIssueCommentComposer({ await runIssueStateUpdate({ repoPath, repoId, + sourceContext, + sourceSettings, projectOrigin, number: issueNumber, updates: { state: 'closed', stateReason: reason } @@ -212,6 +221,8 @@ export function GitHubIssueCommentComposer({ projectOrigin, repoId, repoPath, + sourceContext, + sourceSettings, statePending ] ) @@ -227,6 +238,8 @@ export function GitHubIssueCommentComposer({ await runIssueStateUpdate({ repoPath, repoId, + sourceContext, + sourceSettings, projectOrigin, number: issueNumber, updates: { state: 'open' } @@ -261,6 +274,8 @@ export function GitHubIssueCommentComposer({ projectOrigin, repoId, repoPath, + sourceContext, + sourceSettings, statePending ]) diff --git a/src/renderer/src/components/github/github-issue-comment-helpers.ts b/src/renderer/src/components/github/github-issue-comment-helpers.ts index 6bb52ea9b60..ab6c3ad4bfc 100644 --- a/src/renderer/src/components/github/github-issue-comment-helpers.ts +++ b/src/renderer/src/components/github/github-issue-comment-helpers.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GitHubIssueCloseReason } from '../../../../shared/types' +import type { GitHubIssueCloseReason, GlobalSettings } from '../../../../shared/types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' export type GitHubIssueCommentProjectOrigin = { owner: string @@ -12,6 +13,8 @@ export type GitHubIssueCommentProjectOrigin = { export async function runIssueStateUpdate(args: { repoPath: string repoId?: string | null + sourceContext?: TaskSourceContext | null + sourceSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined projectOrigin: GitHubIssueCommentProjectOrigin | undefined number: number updates: { @@ -21,7 +24,7 @@ export async function runIssueStateUpdate(args: { } }): Promise<void> { if (args.projectOrigin) { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(args.sourceSettings ?? useAppStore.getState().settings) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -45,6 +48,7 @@ export async function runIssueStateUpdate(args: { const res = await window.api.gh.updateIssue({ repoPath: args.repoPath, repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, number: args.number, updates: args.updates }) @@ -56,6 +60,7 @@ export async function runIssueStateUpdate(args: { export async function addIssueCommentForRepo(args: { repoId?: string repoPath: string + sourceContext?: TaskSourceContext | null number: number body: string type?: 'issue' | 'pr' @@ -63,6 +68,7 @@ export async 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 diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx index e40fb04c62a..38297503285 100644 --- a/src/renderer/src/components/github/github-rate-limit-display.tsx +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -5,6 +5,8 @@ import { installWindowVisibilityInterval } from '@/lib/window-visibility-interva import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types' +import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope' +import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl' import { translate } from '@/i18n/i18n' const REFRESH_INTERVAL_MS = 60_000 @@ -169,6 +171,8 @@ function GitHubRateLimitRows({ export function GitHubRateLimitPanel({ className }: { className?: string }): React.JSX.Element { const { snapshot, hasError, isFetching, refresh } = useGitHubRateLimitSnapshot() + const settings = useAppStore((s) => s.settings) + const budgetScope = getProviderRateLimitScope(settings, 'GitHub') return ( <div className={cn('space-y-3 rounded-md border border-border/60 p-3', className)}> @@ -187,6 +191,14 @@ export function GitHubRateLimitPanel({ className }: { className?: string }): Rea 'Orca uses REST, Search, and GraphQL through the GitHub CLI.' )} </p> + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.github.github.rate.limit.display.budget_scope_prefix', + 'Budget scope' + )} + scope={budgetScope} + className="text-xs" + /> </div> <button type="button" diff --git a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx index e477146fd1c..6bc87d967e8 100644 --- a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx +++ b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx @@ -6,6 +6,8 @@ import { installWindowVisibilityInterval } from '@/lib/window-visibility-interva import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GetGitLabRateLimitResult, GitLabRateLimitSnapshot } from '../../../../shared/types' +import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope' +import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl' import { translate } from '@/i18n/i18n' const REFRESH_INTERVAL_MS = 60_000 @@ -146,6 +148,8 @@ function GitLabRateLimitRows({ export function GitLabRateLimitPanel({ className }: { className?: string }): React.JSX.Element { const { snapshot, hasError, isFetching, refresh } = useGitLabRateLimitSnapshot() + const settings = useAppStore((s) => s.settings) + const budgetScope = getProviderRateLimitScope(settings, 'GitLab') return ( <div className={cn('space-y-3 rounded-md border border-border/60 p-3', className)}> @@ -164,6 +168,14 @@ export function GitLabRateLimitPanel({ className }: { className?: string }): Rea 'Orca uses REST through the GitLab CLI.' )} </p> + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.gitlab.gitlab.rate.limit.display.budget_scope_prefix', + 'Budget scope' + )} + scope={budgetScope} + className="text-xs" + /> </div> <Button type="button" diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx new file mode 100644 index 00000000000..bce2153656b --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -0,0 +1,101 @@ +// @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 { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import ProjectCombobox from './ProjectCombobox' + +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: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>( + (props, ref) => <input ref={ref} {...props} /> + ), + CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value: string + }) => ( + <button type="button" data-command-value={value} onClick={() => onSelect?.(value)}> + {children} + </button> + ) +})) + +let container: HTMLDivElement +let root: Root + +const projects: NewWorkspaceProjectOption[] = [ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + detail: 'stablyai/orca' + }, + { + id: 'github:stablyai/noqa', + displayName: 'noqa', + badgeColor: '#222222', + detail: 'stablyai/noqa' + } +] + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +describe('ProjectCombobox', () => { + it('renders a logical project label without host-specific SSH chrome', () => { + act(() => { + root.render( + <ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} /> + ) + }) + + const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]') + expect(trigger?.textContent).toContain('orca') + expect(trigger?.textContent).not.toContain('SSH') + }) + + it('selects projects by logical project id', () => { + const onValueChange = vi.fn() + + act(() => { + root.render( + <ProjectCombobox + options={projects} + value="github:stablyai/orca" + onValueChange={onValueChange} + /> + ) + }) + act(() => { + container + .querySelector<HTMLButtonElement>('[data-command-value="github:stablyai/noqa"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx new file mode 100644 index 00000000000..dc9a424a807 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -0,0 +1,212 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { Check, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import { + searchNewWorkspaceProjectOptions, + type NewWorkspaceProjectOption +} from '@/lib/new-workspace-project-options' +import { translate } from '@/i18n/i18n' + +type ProjectComboboxProps = { + options: readonly NewWorkspaceProjectOption[] + value: string | null + onValueChange: (projectId: string) => void + onValueSelected?: (projectId: string) => void + placeholder?: string + triggerClassName?: string + invalid?: boolean + describedBy?: string +} + +export default function ProjectCombobox({ + options, + value, + onValueChange, + onValueSelected, + placeholder = 'Choose project', + triggerClassName, + invalid = false, + describedBy +}: ProjectComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + const inputRef = React.useRef<HTMLInputElement | null>(null) + const focusFrameRef = React.useRef<number | null>(null) + const selectedProject = useMemo( + () => options.find((option) => option.id === value) ?? null, + [options, value] + ) + const filteredOptions = useMemo( + () => searchNewWorkspaceProjectOptions(options, query), + [options, query] + ) + + const cancelFocusFrame = useCallback((): void => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + }, []) + + const setInputNode = useCallback( + (node: HTMLInputElement | null): void => { + if (node === null) { + cancelFocusFrame() + } + inputRef.current = node + }, + [cancelFocusFrame] + ) + + const focusSearchInput = useCallback((): void => { + cancelFocusFrame() + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + inputRef.current?.focus() + }) + }, [cancelFocusFrame]) + + const handleOpenChange = useCallback( + (nextOpen: boolean): void => { + setOpen(nextOpen) + if (nextOpen) { + setCommandValue(value ?? '') + return + } + cancelFocusFrame() + setQuery('') + }, + [cancelFocusFrame, value] + ) + + const handleSelect = useCallback( + (projectId: string): void => { + onValueChange(projectId) + setOpen(false) + setQuery('') + onValueSelected?.(projectId) + }, + [onValueChange, onValueSelected] + ) + + const handleTriggerKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLButtonElement>): void => { + if (open) { + return + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setCommandValue(value ?? '') + setOpen(true) + return + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } + if (event.key.length === 1 && /\S/.test(event.key)) { + event.preventDefault() + setCommandValue(value ?? '') + setQuery(event.key) + setOpen(true) + } + }, + [open, value] + ) + + return ( + <Popover open={open} onOpenChange={handleOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + aria-invalid={invalid ? true : undefined} + aria-describedby={describedBy} + onKeyDown={handleTriggerKeyDown} + className={cn( + 'h-8 min-w-[184px] justify-between px-3 text-xs font-normal', + triggerClassName + )} + data-project-combobox-root="true" + > + {selectedProject ? ( + <RepoBadgeLabel + name={selectedProject.displayName} + color={selectedProject.badgeColor} + badgeClassName="size-1.5" + /> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0" + data-project-combobox-root="true" + onOpenAutoFocus={(event) => { + event.preventDefault() + focusSearchInput() + }} + > + <Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}> + <CommandInput + ref={setInputNode} + placeholder={translate( + 'auto.components.new.workspace.ProjectCombobox.search', + 'Search projects...' + )} + value={query} + onValueChange={setQuery} + /> + <CommandList> + <CommandEmpty> + {translate( + 'auto.components.new.workspace.ProjectCombobox.empty', + 'No projects match your search.' + )} + </CommandEmpty> + {filteredOptions.map((option) => ( + <CommandItem + key={option.id} + value={option.id} + onSelect={() => handleSelect(option.id)} + className="items-center gap-2 px-3 py-2" + > + <Check + className={cn( + 'size-4 text-foreground', + option.id === value ? 'opacity-100' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <RepoBadgeLabel + name={option.displayName} + color={option.badgeColor} + className="max-w-full" + /> + <p className="mt-0.5 truncate text-[11px] text-muted-foreground"> + {option.detail} + </p> + </div> + </CommandItem> + ))} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx new file mode 100644 index 00000000000..3026fb1b241 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx @@ -0,0 +1,155 @@ +// @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 { + NeedsSetupProjectHostOption, + ProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import ProjectHostSetupCombobox from './ProjectHostSetupCombobox' + +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>, + CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandItem: ({ + children, + disabled, + onSelect, + value + }: { + children: React.ReactNode + disabled?: boolean + onSelect?: (value: string) => void + value: string + }) => ( + <button + type="button" + data-command-value={value} + disabled={disabled} + onClick={() => onSelect?.(value)} + > + {children} + </button> + ) +})) + +let container: HTMLDivElement +let root: Root + +const readyOption: ProjectHostSetupOption = { + id: 'local-setup', + kind: 'ready', + projectId: 'project-1', + hostId: 'local', + repoId: 'local-repo', + label: 'Local Mac', + detail: 'Orca', + path: '/Users/alice/orca' +} + +const needsSetupOption: NeedsSetupProjectHostOption = { + id: 'needs-setup:ssh:builder', + kind: 'needs-setup', + projectId: 'project-1', + hostId: 'ssh:builder', + label: 'Builder', + detail: 'Project not set up on this host', + isAvailable: true +} + +const unavailableOption: NeedsSetupProjectHostOption = { + id: 'needs-setup:runtime:old', + kind: 'needs-setup', + projectId: 'project-1', + hostId: 'runtime:old', + label: 'Old server', + detail: 'Update Orca on this host to set up projects', + isAvailable: false +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +function renderCombobox({ + onValueChange = vi.fn() +}: { + onValueChange?: (setupId: string) => void +} = {}): void { + act(() => { + root.render( + <ProjectHostSetupCombobox + options={[readyOption, needsSetupOption]} + value={readyOption.id} + onValueChange={onValueChange} + /> + ) + }) +} + +describe('ProjectHostSetupCombobox', () => { + it('routes ready setup rows through onValueChange', () => { + const onValueChange = vi.fn() + + renderCombobox({ onValueChange }) + + act(() => { + container + .querySelector<HTMLButtonElement>('[data-command-value="local-setup"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('local-setup') + }) + + it('hides hosts that need setup from the run target list', () => { + const onValueChange = vi.fn() + + renderCombobox({ onValueChange }) + + expect( + container.querySelector<HTMLButtonElement>('[data-command-value="needs-setup:ssh:builder"]') + ).toBeNull() + expect(container.textContent).not.toContain('Project not set up on this host') + expect(onValueChange).not.toHaveBeenCalled() + }) + + it('hides unavailable setup rows from the run target list', () => { + const onValueChange = vi.fn() + + act(() => { + root.render( + <ProjectHostSetupCombobox + options={[readyOption, unavailableOption]} + value={readyOption.id} + onValueChange={onValueChange} + /> + ) + }) + + const unavailableButton = container.querySelector<HTMLButtonElement>( + '[data-command-value="needs-setup:runtime:old"]' + ) + expect(unavailableButton).toBeNull() + expect(container.textContent).not.toContain('Update Orca on this host') + + expect(onValueChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx new file mode 100644 index 00000000000..22df2d5f192 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { Check, ChevronsUpDown, Server } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' +import { translate } from '@/i18n/i18n' + +type ProjectHostSetupComboboxProps = { + options: readonly ProjectHostSetupOption[] + value: string | null + onValueChange: (setupId: string) => void +} + +export default function ProjectHostSetupCombobox({ + options, + value, + onValueChange +}: ProjectHostSetupComboboxProps): React.JSX.Element { + const [open, setOpen] = React.useState(false) + const readyOptions = options.filter((option) => option.kind === 'ready') + const selected = readyOptions.find((option) => option.id === value) ?? readyOptions[0] ?? null + + const handleSelect = React.useCallback( + (setupId: string): void => { + const option = options.find((candidate) => candidate.id === setupId) + if (!option) { + return + } + if (!readyOptions.some((candidate) => candidate.id === setupId)) { + return + } + onValueChange(setupId) + setOpen(false) + }, + [onValueChange, options, readyOptions] + ) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + className="h-9 w-full justify-between border-input px-3 text-sm font-normal focus:border-ring focus:ring-[3px] focus:ring-ring/50" + > + {selected ? ( + <span className="inline-flex min-w-0 items-center gap-1.5"> + <Server className="size-3.5 shrink-0 text-muted-foreground" /> + <span className="truncate">{selected.label}</span> + </span> + ) : ( + <span className="text-muted-foreground"> + {translate( + 'auto.components.new.workspace.ProjectHostSetupCombobox.placeholder', + 'Choose host' + )} + </span> + )} + <ChevronsUpDown className="size-3.5 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0" + > + <Command value={selected?.id ?? ''}> + <CommandList> + <CommandEmpty> + {translate( + 'auto.components.new.workspace.ProjectHostSetupCombobox.empty', + 'No hosts are ready for this project.' + )} + </CommandEmpty> + {readyOptions.map((option) => ( + <CommandItem + key={option.id} + value={option.id} + onSelect={() => handleSelect(option.id)} + className="items-center gap-2 px-3 py-2" + > + <Check + className={cn( + 'size-4 text-foreground', + option.id === selected?.id ? 'opacity-100' : 'opacity-0' + )} + /> + <Server className="size-3.5 shrink-0 text-muted-foreground" /> + <div className="min-w-0 flex-1"> + <div className="truncate text-sm">{option.label}</div> + <div className="mt-0.5 truncate text-[11px] text-muted-foreground"> + {option.path} + </div> + </div> + </CommandItem> + ))} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 38ca4ff3348..51740a51062 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -37,9 +37,14 @@ import { parseGitHubIssueOrPRLink, type RepoSlug } from '@/lib/github-links' +import { + lookupGitHubWorkItemByOwnerRepoForSource, + lookupGitHubWorkItemForSource +} from '@/lib/github-work-item-source-lookup' import { lookupSmartGitHubSubmitItem } from '@/lib/smart-github-submit' import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { JiraIcon } from '@/components/icons/JiraIcon' @@ -66,6 +71,7 @@ import { getSmartWorkspaceNameModes, type MrStateFilter } from './smart-workspace-localized-options' +import { buildTaskSourceContextFromRepo } from '../../../../shared/task-source-context' type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number] @@ -161,6 +167,43 @@ export default function SmartWorkspaceNameField({ () => repos.find((repo) => repo.id === repoId) ?? null, [repoId, repos] ) + const selectedRepoOwnerSettings = useMemo( + () => getRepoOwnerRoutedSettings(settings, selectedRepo), + [selectedRepo, settings] + ) + const githubSourceContext = useMemo( + () => + selectedRepo + ? buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedRepo.id, + repo: selectedRepo + }) + : null, + [selectedRepo] + ) + const gitlabSourceContext = useMemo( + () => + selectedRepo + ? buildTaskSourceContextFromRepo({ + provider: 'gitlab', + projectId: selectedRepo.id, + repo: selectedRepo + }) + : null, + [selectedRepo] + ) + const linearSourceContext = useMemo( + () => + selectedRepo + ? buildTaskSourceContextFromRepo({ + provider: 'linear', + projectId: selectedRepo.id, + repo: selectedRepo + }) + : null, + [selectedRepo] + ) const [mode, setMode] = useState<SmartNameMode>(textOnly ? 'text' : 'smart') const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened') const [open, setOpen] = useState(false) @@ -354,6 +397,7 @@ export default function SmartWorkspaceNameField({ const item = await lookupSmartGitHubSubmitItem({ repoPath: selectedRepo.path, repoId: selectedRepo.id, + sourceContext: githubSourceContext, intent: { kind: 'link', owner: directLink.slug.owner, @@ -361,9 +405,8 @@ export default function SmartWorkspaceNameField({ number: directLink.number, type: directLink.type }, - workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>, - workItemByOwnerRepo: (args) => - window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null> + workItem: lookupGitHubWorkItemForSource, + workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }) if (!stale) { setGithubItems(item ? [item] : []) @@ -405,10 +448,10 @@ export default function SmartWorkspaceNameField({ const request = lookupSmartGitHubSubmitItem({ repoPath: selectedRepo.path, repoId: selectedRepo.id, + sourceContext: githubSourceContext, intent, - workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>, - workItemByOwnerRepo: (args) => - window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null> + workItem: lookupGitHubWorkItemForSource, + workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }) void request .then((item) => { @@ -433,14 +476,22 @@ export default function SmartWorkspaceNameField({ const trimmed = normalizedGhQuery.query.trim() const query = trimmed ? normalizedGhQuery.query : '' - const cached = getCachedWorkItems(selectedRepo.id, RESULT_LIMIT, query) + const cached = getCachedWorkItems( + selectedRepo.id, + RESULT_LIMIT, + query, + selectedRepo.path, + githubSourceContext + ) if (cached) { setGithubItems(cached.slice(0, RESULT_LIMIT)) setGithubLoading(false) } else { setGithubLoading(true) } - void fetchWorkItems(selectedRepo.id, selectedRepo.path, RESULT_LIMIT, query) + void fetchWorkItems(selectedRepo.id, selectedRepo.path, RESULT_LIMIT, query, { + sourceContext: githubSourceContext + }) .then((items) => { if (!stale) { setGithubItems(items.slice(0, RESULT_LIMIT)) @@ -468,6 +519,7 @@ export default function SmartWorkspaceNameField({ parsedGhLink, repos, selectedRepo, + githubSourceContext, shouldQueryGithub ]) @@ -497,7 +549,7 @@ export default function SmartWorkspaceNameField({ setBranchResultsSource(null) setBranchesLoading(true) void searchRuntimeRepoBaseRefDetails( - settings, + selectedRepoOwnerSettings, branchSearchRequest.repoId, branchSearchRequest.query, branchSearchRequest.limit @@ -525,7 +577,7 @@ export default function SmartWorkspaceNameField({ return () => { stale = true } - }, [branchSearchRequest, settings]) + }, [branchSearchRequest, selectedRepoOwnerSettings]) useEffect(() => { if (disabled || !shouldQueryLinear || !linearStatus.connected) { @@ -537,8 +589,10 @@ export default function SmartWorkspaceNameField({ setLinearLoading(true) const trimmed = debouncedQuery.trim() const request = trimmed - ? searchLinearIssues(trimmed, RESULT_LIMIT) - : listLinearIssues('assigned', RESULT_LIMIT).then((result) => result.items) + ? searchLinearIssues(trimmed, RESULT_LIMIT, { sourceContext: linearSourceContext }) + : listLinearIssues('assigned', RESULT_LIMIT, { sourceContext: linearSourceContext }).then( + (result) => result.items + ) void request .then((issues) => { if (!stale) { @@ -561,7 +615,7 @@ export default function SmartWorkspaceNameField({ // Why: list/search actions are stable store methods; depending on them // would refetch on unrelated store writes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedQuery, disabled, linearStatus.connected, shouldQueryLinear]) + }, [debouncedQuery, disabled, linearSourceContext, linearStatus.connected, shouldQueryLinear]) // Why: GitLab paste-URL flow. Watches the debounced query for a GitLab // issue/MR URL (parseGitLabIssueOrMRLink already filters non-GitLab URLs @@ -599,6 +653,8 @@ export default function SmartWorkspaceNameField({ void window.api.gl .workItemByPath({ repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: gitlabSourceContext, // Why: parseGitLabIssueOrMRLink doesn't carry the host (the URL // pattern is host-agnostic on purpose so self-hosted instances // work). Use 'gitlab.com' as the IPC arg — the main process maps @@ -628,7 +684,15 @@ export default function SmartWorkspaceNameField({ return () => { stale = true } - }, [disabled, mode, onGitLabItemSelect, parsedGlLink, selectedRepo, shouldQueryGitlab]) + }, [ + disabled, + gitlabSourceContext, + mode, + onGitLabItemSelect, + parsedGlLink, + selectedRepo, + shouldQueryGitlab + ]) // Why: when the user is on the GitLab tab (or in 'smart' mix) and // hasn't pasted a URL, surface the project's MRs filtered by the @@ -657,6 +721,8 @@ export default function SmartWorkspaceNameField({ void window.api.gl .listMRs({ repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: gitlabSourceContext, state: mrStateFilter, page: 1, perPage: RESULT_LIMIT @@ -694,6 +760,7 @@ export default function SmartWorkspaceNameField({ onGitLabItemSelect, parsedGlLink, selectedRepo, + gitlabSourceContext, shouldQueryGitlab ]) @@ -804,9 +871,15 @@ export default function SmartWorkspaceNameField({ handledCrossRepoUrlRef.current = debouncedQuery.trim() setGithubLoading(true) try { - const item = await window.api.gh.workItemByOwnerRepo({ + const sourceContext = buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: targetRepo.id, + repo: targetRepo + }) + const item = await lookupGitHubWorkItemByOwnerRepoForSource({ repoPath: targetRepo.path, repoId: targetRepo.id, + sourceContext, owner: crossRepoPrompt.link.slug.owner, repo: crossRepoPrompt.link.slug.repo, number: crossRepoPrompt.link.number, @@ -1088,7 +1161,9 @@ export default function SmartWorkspaceNameField({ align="start" sideOffset={4} className="popover-scroll-content flex w-[var(--radix-popover-trigger-width)] flex-col p-0" - style={{ maxHeight: 'min(var(--radix-popover-content-available-height,22rem),22rem)' }} + // Why: this popover lives inside the create-workspace dialog; a + // taller result list can cover the submit footer while typing. + style={{ maxHeight: 'min(var(--radix-popover-content-available-height,7rem),7rem)' }} onOpenAutoFocus={(event) => event.preventDefault()} onPointerDownOutside={(event) => { // Why: the input is a PopoverAnchor, not a PopoverTrigger, so diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx index 18941683a51..832af22219c 100644 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx +++ b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx @@ -24,7 +24,7 @@ describe('AgentFeatureSetupStep', () => { expect(html).toContain('Computer Use') expect(html).toContain('Agent Orchestration') expect(html).toContain('Linear agent skill') - expect(html).toContain('Install CLI & Skills') + expect(html).toContain('Enable capabilities') expect(html).toContain('role="checkbox"') }) }) diff --git a/src/renderer/src/components/onboarding/RepoStep.tsx b/src/renderer/src/components/onboarding/RepoStep.tsx index decf49da34f..0202ef6a6a6 100644 --- a/src/renderer/src/components/onboarding/RepoStep.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.tsx @@ -1,5 +1,4 @@ import { - ArrowLeft, ArrowRight, CircleStop, FolderOpen, @@ -10,12 +9,10 @@ import { } from 'lucide-react' import type { Dispatch, SetStateAction } from 'react' import { Button } from '@/components/ui/button' -import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { NestedRepoScanResult } from '../../../../shared/types' -import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice' -import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' import { translate } from '@/i18n/i18n' +import { RepoStepNestedImportPanel } from './RepoStepNestedImportPanel' type RepoStepProps = { cloneUrl: string @@ -65,92 +62,20 @@ export function RepoStep({ error }: RepoStepProps) { const disabled = Boolean(busyLabel) - const nestedImportDisabled = disabled || nestedScanInProgress if (nestedScan) { - const folderName = getRuntimePathBasename(nestedScan.selectedPath) || nestedScan.selectedPath return ( - <div className="flex h-full min-h-0 min-w-0 flex-col gap-3"> - <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-muted/30 p-5"> - <div className="flex min-w-0 shrink-0 items-center gap-4"> - <div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground"> - <FolderOpen className="size-5" /> - </div> - <div className="min-w-0 flex-1"> - <div className="text-base font-semibold text-foreground">{translate("auto.components.onboarding.RepoStep.2d20200346", "Import repositories")}</div> - <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground"> - {nestedScanInProgress ? ( - <Tooltip> - <TooltipTrigger asChild> - <Button - type="button" - variant="ghost" - size="icon-xs" - className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40" - aria-label={translate("auto.components.onboarding.RepoStep.c3d9d44ca2", "Stop scan")} - title={translate("auto.components.onboarding.RepoStep.c7af322fc3", "Stop scanning")} - onClick={onStopNestedScan} - > - <Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" /> - <CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" /> - </Button> - </TooltipTrigger> - <TooltipContent side="top" sideOffset={4}> - {translate("auto.components.onboarding.RepoStep.e8fdb36338", "Scanning repositories. Click to stop.")}</TooltipContent> - </Tooltip> - ) : null} - <span className="min-w-0 truncate"> - {translate("auto.components.onboarding.RepoStep.2e6438dd34", "{{value0}}Found {{value1}} {{value2}} in this folder.", { value0: nestedScanInProgress ? 'Scanning... ' : '', value1: nestedScan.repos.length, value2: nestedScan.repos.length === 1 ? 'repository' : 'repositories' })} - </span> - </div> - <div className="mt-0.5 truncate text-[11px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.cecd6593fa", "Scanned folder:")} {folderName} - {nestedScan.selectedPath} - </div> - </div> - </div> - <NestedRepoChecklist - scan={nestedScan} - selectedPaths={nestedSelectedPaths} - onSelectedPathsChange={onNestedSelectedPathsChange} - disabled={nestedImportDisabled} - className="mt-4 flex-1" - /> - {nestedScanInProgress || - nestedScan.truncated || - nestedScan.timedOut || - nestedScan.stopped ? ( - <div className="mt-2 shrink-0"> - <NestedRepoScanLimitNotice scan={nestedScan} /> - </div> - ) : null} - <div className="mt-4 flex shrink-0 flex-wrap items-center gap-2"> - <button - type="button" - className="inline-flex items-center gap-1 rounded-lg px-3 py-3 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground disabled:opacity-40" - disabled={disabled && !nestedScanInProgress} - onClick={onCancelNested} - > - <ArrowLeft className="size-3.5" /> - {translate("auto.components.onboarding.RepoStep.27ca610db1", "Back")}</button> - <button - type="button" - className="ml-auto rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" - disabled={nestedImportDisabled || nestedSelectedPaths.size === 0} - onClick={onImportNested} - > - {translate("auto.components.onboarding.RepoStep.2d20200346", "Import repositories")}</button> - </div> - </div> - {busyLabel && ( - <div className="shrink-0 rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200"> - {busyLabel} - </div> - )} - {error && ( - <div className="shrink-0 rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200"> - {error} - </div> - )} - </div> + <RepoStepNestedImportPanel + nestedScan={nestedScan} + nestedScanInProgress={nestedScanInProgress} + nestedSelectedPaths={nestedSelectedPaths} + onNestedSelectedPathsChange={onNestedSelectedPathsChange} + onImportNested={onImportNested} + onCancelNested={onCancelNested} + onStopNestedScan={onStopNestedScan} + busyLabel={busyLabel} + error={error} + disabled={disabled} + /> ) } return ( @@ -168,15 +93,27 @@ export function RepoStep({ <FolderOpen className="size-5" /> </div> <div className="min-w-0 flex-1"> - <div className="text-base font-semibold text-foreground">{translate("auto.components.onboarding.RepoStep.8cab104e3c", "Open a server project")}</div> + <div className="text-base font-semibold text-foreground"> + {translate( + 'auto.components.onboarding.RepoStep.8cab104e3c', + 'Open a project on this host' + )} + </div> <div className="mt-0.5 text-[13px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.466108ab89", "Enter a path that exists on the runtime server.")}</div> + {translate( + 'auto.components.onboarding.RepoStep.466108ab89', + 'Enter a path that exists on the selected host.' + )} + </div> </div> </div> <div className="mt-4 flex flex-col gap-2 sm:flex-row"> <input className="min-w-0 flex-1 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15" - placeholder={translate("auto.components.onboarding.RepoStep.2ebbc26343", "/home/user/project")} + placeholder={translate( + 'auto.components.onboarding.RepoStep.2ebbc26343', + '/home/user/project' + )} value={serverPath} disabled={disabled} spellCheck={false} @@ -187,14 +124,16 @@ export function RepoStep({ className="shrink-0 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" disabled={!serverPath.trim() || disabled} > - {translate("auto.components.onboarding.RepoStep.3863747c56", "Add Git Project")}</button> + {translate('auto.components.onboarding.RepoStep.3863747c56', 'Add Git Project')} + </button> <button type="button" className="shrink-0 rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground hover:bg-muted/60 disabled:opacity-40" disabled={!serverPath.trim() || disabled} onClick={() => onOpenServerFolder('folder')} > - {translate("auto.components.onboarding.RepoStep.e8214aa632", "Open as Folder")}</button> + {translate('auto.components.onboarding.RepoStep.e8214aa632', 'Open as Folder')} + </button> </div> </form> ) : ( @@ -212,18 +151,31 @@ export function RepoStep({ <div className="min-w-0 flex-1"> <div className="flex min-w-0 items-center gap-2"> <div className="min-w-0 text-base font-semibold text-foreground"> - {translate("auto.components.onboarding.RepoStep.f4e9c8dcf8", "Browse for a folder")}</div> + {translate( + 'auto.components.onboarding.RepoStep.f4e9c8dcf8', + 'Browse for a folder' + )} + </div> <ArrowRight className="size-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-foreground" /> </div> <div className="mt-0.5 text-[13px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.831524961f", "Choose any local directory, git repo or not.")}</div> + {translate( + 'auto.components.onboarding.RepoStep.831524961f', + 'Choose any local directory, git repo or not.' + )} + </div> </div> </div> <div className="ml-[3.75rem] mt-3 flex w-fit max-w-[calc(100%-3.75rem)] items-center gap-2 rounded-lg border border-border bg-muted px-3 py-2 text-[12px] text-muted-foreground"> <span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground"> <Lightbulb className="size-3.5" /> </span> - <span>{translate("auto.components.onboarding.RepoStep.6558d50c69", "Want to import many repos at once? Select the parent folder.")}</span> + <span> + {translate( + 'auto.components.onboarding.RepoStep.6558d50c69', + 'Want to import many repos at once? Select the parent folder.' + )} + </span> </div> </button> )} @@ -240,15 +192,24 @@ export function RepoStep({ <GitBranch className="size-5" /> </div> <div className="min-w-0 flex-1"> - <div className="text-base font-semibold text-foreground">{translate("auto.components.onboarding.RepoStep.132425a3e3", "Clone a repo")}</div> + <div className="text-base font-semibold text-foreground"> + {translate('auto.components.onboarding.RepoStep.132425a3e3', 'Clone a repo')} + </div> <div className="mt-0.5 text-[13px] text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.288d8444b7", "Paste an HTTPS or SSH URL.")}</div> + {translate( + 'auto.components.onboarding.RepoStep.288d8444b7', + 'Paste an HTTPS or SSH URL.' + )} + </div> </div> </div> <div className="mt-4 flex gap-2"> <input className="min-w-0 flex-1 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15" - placeholder={translate("auto.components.onboarding.RepoStep.955134915e", "git@github.com:org/repo.git")} + placeholder={translate( + 'auto.components.onboarding.RepoStep.955134915e', + 'git@github.com:org/repo.git' + )} value={cloneUrl} disabled={disabled} onChange={(event) => onCloneUrlChange(event.target.value)} @@ -258,15 +219,20 @@ export function RepoStep({ className="shrink-0 rounded-lg bg-primary px-5 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" disabled={!cloneUrl.trim() || (runtimeActive && !cloneDestination.trim()) || disabled} > - {translate("auto.components.onboarding.RepoStep.7932e95f68", "Clone")}</button> + {translate('auto.components.onboarding.RepoStep.7932e95f68', 'Clone')} + </button> </div> {runtimeActive && ( <div className="mt-2 space-y-1"> <label className="text-[11px] font-medium text-muted-foreground"> - {translate("auto.components.onboarding.RepoStep.24c7c8696c", "Clone into server path")}</label> + {translate('auto.components.onboarding.RepoStep.24c7c8696c', 'Clone into host path')} + </label> <input className="w-full rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15" - placeholder={translate("auto.components.onboarding.RepoStep.7ec3f48820", "/home/user")} + placeholder={translate( + 'auto.components.onboarding.RepoStep.7ec3f48820', + '/home/user' + )} value={cloneDestination} disabled={disabled} spellCheck={false} @@ -278,15 +244,19 @@ export function RepoStep({ <div className="flex flex-wrap items-center justify-between gap-3 px-1 pt-1 text-xs text-muted-foreground"> <div className="flex min-w-0 items-center gap-2"> - <span>{translate("auto.components.onboarding.RepoStep.7b679207e4", "Workspace")}</span> + <span>{translate('auto.components.onboarding.RepoStep.7b679207e4', 'Workspace')}</span> <span className="truncate font-mono text-foreground"> - {runtimeActive ? translate("auto.components.onboarding.RepoStep.cf23006ba7", "Runtime server") : workspaceDir} + {runtimeActive + ? translate('auto.components.onboarding.RepoStep.cf23006ba7', 'Selected host') + : workspaceDir} </span> </div> {runtimeActive ? ( <div className="flex items-center gap-1.5"> <Server className="size-3.5" /> - <span>{translate("auto.components.onboarding.RepoStep.c33b190ca3", "Server paths only")}</span> + <span> + {translate('auto.components.onboarding.RepoStep.c33b190ca3', 'Host paths only')} + </span> </div> ) : ( <button @@ -296,7 +266,12 @@ export function RepoStep({ onClick={onOpenSshSettings} > <Server className="size-3.5 shrink-0" /> - <span className="truncate">{translate("auto.components.onboarding.RepoStep.b7c4da0504", "SSH? Set hosts up in Settings")}</span> + <span className="truncate"> + {translate( + 'auto.components.onboarding.RepoStep.b7c4da0504', + 'SSH? Set hosts up in Settings' + )} + </span> <ArrowRight className="size-3.5 shrink-0" /> </button> )} @@ -313,8 +288,14 @@ export function RepoStep({ variant="ghost" size="icon-xs" className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40" - aria-label={translate("auto.components.onboarding.RepoStep.c3d9d44ca2", "Stop scan")} - title={translate("auto.components.onboarding.RepoStep.c7af322fc3", "Stop scanning")} + aria-label={translate( + 'auto.components.onboarding.RepoStep.c3d9d44ca2', + 'Stop scan' + )} + title={translate( + 'auto.components.onboarding.RepoStep.c7af322fc3', + 'Stop scanning' + )} onClick={onStopNestedScan} > <Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" /> @@ -322,7 +303,11 @@ export function RepoStep({ </Button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> - {translate("auto.components.onboarding.RepoStep.e8fdb36338", "Scanning repositories. Click to stop.")}</TooltipContent> + {translate( + 'auto.components.onboarding.RepoStep.e8fdb36338', + 'Scanning repositories. Click to stop.' + )} + </TooltipContent> </Tooltip> ) : null} </div> diff --git a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx new file mode 100644 index 00000000000..e7b24e2b71e --- /dev/null +++ b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx @@ -0,0 +1,145 @@ +import { ArrowLeft, CircleStop, FolderOpen, Loader2 } from 'lucide-react' +import type { Dispatch, SetStateAction } from 'react' +import { Button } from '@/components/ui/button' +import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { NestedRepoScanResult } from '../../../../shared/types' +import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' +import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice' + +type RepoStepNestedImportPanelProps = { + nestedScan: NestedRepoScanResult + nestedScanInProgress: boolean + nestedSelectedPaths: Set<string> + onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>> + onImportNested: () => void + onCancelNested: () => void + onStopNestedScan: () => void + busyLabel: string | null + error: string | null + disabled: boolean +} + +export function RepoStepNestedImportPanel({ + nestedScan, + nestedScanInProgress, + nestedSelectedPaths, + onNestedSelectedPathsChange, + onImportNested, + onCancelNested, + onStopNestedScan, + busyLabel, + error, + disabled +}: RepoStepNestedImportPanelProps) { + const folderName = getRuntimePathBasename(nestedScan.selectedPath) || nestedScan.selectedPath + const nestedImportDisabled = disabled || nestedScanInProgress + return ( + <div className="flex h-full min-h-0 min-w-0 flex-col gap-3"> + <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-muted/30 p-5"> + <div className="flex min-w-0 shrink-0 items-center gap-4"> + <div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground"> + <FolderOpen className="size-5" /> + </div> + <div className="min-w-0 flex-1"> + <div className="text-base font-semibold text-foreground"> + {translate('auto.components.onboarding.RepoStep.2d20200346', 'Import repositories')} + </div> + <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground"> + {nestedScanInProgress ? ( + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon-xs" + className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40" + aria-label={translate( + 'auto.components.onboarding.RepoStep.c3d9d44ca2', + 'Stop scan' + )} + title={translate( + 'auto.components.onboarding.RepoStep.c7af322fc3', + 'Stop scanning' + )} + onClick={onStopNestedScan} + > + <Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" /> + <CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" /> + </Button> + </TooltipTrigger> + <TooltipContent side="top" sideOffset={4}> + {translate( + 'auto.components.onboarding.RepoStep.e8fdb36338', + 'Scanning repositories. Click to stop.' + )} + </TooltipContent> + </Tooltip> + ) : null} + <span className="min-w-0 truncate"> + {translate( + 'auto.components.onboarding.RepoStep.2e6438dd34', + '{{value0}}Found {{value1}} {{value2}} in this folder.', + { + value0: nestedScanInProgress ? 'Scanning... ' : '', + value1: nestedScan.repos.length, + value2: nestedScan.repos.length === 1 ? 'repository' : 'repositories' + } + )} + </span> + </div> + <div className="mt-0.5 truncate text-[11px] text-muted-foreground"> + {translate('auto.components.onboarding.RepoStep.cecd6593fa', 'Scanned folder:')}{' '} + {folderName} - {nestedScan.selectedPath} + </div> + </div> + </div> + <NestedRepoChecklist + scan={nestedScan} + selectedPaths={nestedSelectedPaths} + onSelectedPathsChange={onNestedSelectedPathsChange} + disabled={nestedImportDisabled} + className="mt-4 flex-1" + /> + {nestedScanInProgress || + nestedScan.truncated || + nestedScan.timedOut || + nestedScan.stopped ? ( + <div className="mt-2 shrink-0"> + <NestedRepoScanLimitNotice scan={nestedScan} /> + </div> + ) : null} + <div className="mt-4 flex shrink-0 flex-wrap items-center gap-2"> + <button + type="button" + className="inline-flex items-center gap-1 rounded-lg px-3 py-3 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground disabled:opacity-40" + disabled={disabled && !nestedScanInProgress} + onClick={onCancelNested} + > + <ArrowLeft className="size-3.5" /> + {translate('auto.components.onboarding.RepoStep.27ca610db1', 'Back')} + </button> + <button + type="button" + className="ml-auto rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40" + disabled={nestedImportDisabled || nestedSelectedPaths.size === 0} + onClick={onImportNested} + > + {translate('auto.components.onboarding.RepoStep.2d20200346', 'Import repositories')} + </button> + </div> + </div> + {busyLabel ? ( + <div className="shrink-0 rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200"> + {busyLabel} + </div> + ) : null} + {error ? ( + <div className="shrink-0 rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200"> + {error} + </div> + ) : null} + </div> + ) +} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index a305456c07c..88d02e23af2 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -705,7 +705,7 @@ export function useOnboardingFlow( if (settings?.activeRuntimeEnvironmentId?.trim()) { const path = serverPath.trim() if (!path) { - const message = 'Enter a server path.' + const message = 'Enter a path on the selected host.' setError(message) return } @@ -996,7 +996,7 @@ export function useOnboardingFlow( const destination = target.kind === 'environment' ? cloneDestination.trim() : settings.workspaceDir if (!destination) { - const message = 'Enter a server path for the clone destination.' + const message = 'Enter a host path for the clone destination.' setError(message) return } diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.tsx index 2d3153e5dcf..490e3a66ff1 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.tsx @@ -3,11 +3,14 @@ import { useAppStore } from '@/store' import { getHasAnyWorktreesFromState } from '@/store/selectors' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { + mergeWorkspacePortScans, + runtimeTargetForExecutionHostId, scanWorkspacePortsForTarget, - workspacePortRuntimeTargetKey + workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' +import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' const WORKSPACE_PORT_SCAN_INTERVAL_MS = 30_000 const WORKSPACE_PORT_ADVERTISED_URL_SETTLE_MS = 1_000 @@ -23,17 +26,26 @@ function makeUnavailableScan(reason: string): WorkspacePortScanResult { export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): null { const settings = useAppStore((s) => s.settings) + const repos = useAppStore((s) => s.repos) const hasWorktrees = useAppStore(getHasAnyWorktreesFromState) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const inFlightRef = useRef<Promise<void> | null>(null) const generationRef = useRef(0) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) - const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` + const scanKey = workspacePortScanKeyForTarget(runtimeTarget) + const scanTargets = useMemo( + () => + buildExecutionHostRegistry({ repos, settings }) + .map((host) => runtimeTargetForExecutionHostId(host.id)) + .filter((target): target is NonNullable<typeof target> => target !== null), + [repos, settings] + ) const refresh = useCallback(() => { - if (!hasWorktrees) { + if (!hasWorktrees || scanTargets.length === 0) { setWorkspacePortScan(null) setWorkspacePortScanRefreshing(false) return Promise.resolve() @@ -43,30 +55,57 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): } const generation = generationRef.current - const promise = scanWorkspacePortsForTarget(runtimeTarget) - .then((result) => { - if (generation === generationRef.current) { - setWorkspacePortScan({ key: scanKey, result }) + setWorkspacePortScanRefreshing(true) + const promise = Promise.all( + scanTargets.map(async (target) => { + const key = workspacePortScanKeyForTarget(target) + try { + const result = await scanWorkspacePortsForTarget(target) + return { key, result } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { key, result: makeUnavailableScan(message || 'Workspace port scan failed.') } } }) - .catch((error) => { - if (generation !== generationRef.current) { - return + ) + .then((results) => { + if (generation === generationRef.current) { + const scansByKey = Object.fromEntries(results.map(({ key, result }) => [key, result])) + for (const { key, result } of results) { + setWorkspacePortScanForKey(key, result) + } + const activeScan = scansByKey[scanKey] + const merged = mergeWorkspacePortScans(scansByKey) + const projectionKey = + results.length > 1 ? 'all-hosts:all' : activeScan ? scanKey : results[0].key + setWorkspacePortScan( + merged + ? { + key: projectionKey, + result: merged + } + : null + ) } - const message = error instanceof Error ? error.message : String(error) - setWorkspacePortScan({ - key: scanKey, - result: makeUnavailableScan(message || 'Workspace port scan failed.') - }) }) .finally(() => { if (inFlightRef.current === promise) { inFlightRef.current = null } + if (generation === generationRef.current) { + setWorkspacePortScanRefreshing(false) + } }) inFlightRef.current = promise return promise - }, [hasWorktrees, runtimeTarget, scanKey, setWorkspacePortScan, setWorkspacePortScanRefreshing]) + }, [ + hasWorktrees, + scanKey, + scanTargets, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ]) useEffect(() => { if (!enabled) { diff --git a/src/renderer/src/components/pull-request-page-host-boundary.test.ts b/src/renderer/src/components/pull-request-page-host-boundary.test.ts new file mode 100644 index 00000000000..9c1148a51d8 --- /dev/null +++ b/src/renderer/src/components/pull-request-page-host-boundary.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const COMPONENT_ROOT = __dirname + +function componentSource(relativePath: string): string { + return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') +} + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('PullRequestPage host boundaries', () => { + it('routes reviewer metadata and mutations through the PR repo owner host', () => { + const source = componentSource('PullRequestPage.tsx') + const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') + + expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('repoOwnerSettings') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('repoOwnerSettings') + expect(section).toContain('getActiveRuntimeTarget(repoOwnerSettings)') + }) + + it('routes PR edit metadata through the same repo owner host as mutations', () => { + const source = componentSource('PullRequestPage.tsx') + const section = sourceBetween(source, 'function GHEditSection', 'function GHCommentComposer') + + expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)') + expect(section).toContain('useRepoLabels(') + expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, repoOwnerSettings)') + expect(section).toContain('useRepoAssignees(') + expect(section).toContain('useRepoAssigneesBySlug(') + expect(section).toContain('repoOwnerSettings') + }) + + it('routes PR mention metadata through the PR repo owner host', () => { + const source = componentSource('PullRequestPage.tsx') + const section = sourceBetween(source, 'function ConversationTab', 'const mentionOptions') + + expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)') + expect(section).toContain('useRepoAssignees(repoPath, item.repoId, repoOwnerSettings)') + }) +}) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 2c3241e97ef..cbca96f1d4e 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { Worktree } from '../../../shared/types' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { getConnectionId } from '@/lib/connection-context' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { listRuntimeFiles } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { useWorktreeById, useWorktreesForRepo } from '@/store/selectors' @@ -122,7 +123,9 @@ export function useRuntimeFileListForWorktree({ void listRuntimeFiles( { - settings: useAppStore.getState().settings, + // Why: Quick Open lists files for the selected workspace. It must + // follow that workspace's owner host, not the globally focused host. + settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), worktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index fb7da464340..91b087f0350 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -247,7 +247,7 @@ export default function AiVaultPanel(): React.JSX.Element { <div className="border-b border-sidebar-border px-3 py-2 text-[11px] leading-4 text-muted-foreground"> {translate( 'auto.components.right.sidebar.AiVaultPanel.remoteBrowseLocalHistory', - 'Remote workspaces can browse local history. Resume actions run from local workspaces.' + 'SSH-host workspaces can browse local history. Resume actions run from Local Mac workspaces.' )} </div> ) : null} diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx index 1f5e324ab74..291574181c0 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx @@ -72,13 +72,13 @@ describe('ChecksPanelReviewHeader', () => { expect(markup).toContain('unlink PR') }) - it('shows GitLab MR unlink actions in the menu', () => { + it('shows GitLab MR identity without GitHub-only link management actions', () => { const markup = renderHeader({ provider: 'gitlab' }) expect(markup).toContain('Open on GitLab') expect(markup).toContain('!31') - expect(markup).toContain('More MR actions') - expect(markup).toContain('unlink MR') + expect(markup).not.toContain('More PR actions') + expect(markup).not.toContain('unlink PR') expect(markup).not.toContain('Link another PR') }) }) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index ce6d0acea24..904ad1396e9 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -12,7 +12,7 @@ import { Link, Unlink } from 'lucide-react' -import { useAppStore } from '@/store' +import { useAppStore, type AppState } from '@/store' import { mergePRCommentIntoList, prChecksCacheSuffix, @@ -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> )} @@ -326,6 +289,7 @@ async function fetchGitLabMRDetailsForChecks(args: { } return (await window.api.gl.workItemDetails({ repoPath: args.repoPath, + repoId: args.repoId, iid: args.iid, type: 'mr' })) as GitLabWorkItemDetails | null @@ -355,6 +319,7 @@ async function resolveGitLabMRDiscussionForChecks(args: { } return window.api.gl.resolveMRDiscussion({ repoPath: args.repoPath, + repoId: args.repoId, iid: args.iid, discussionId: args.discussionId, resolved: args.resolved @@ -378,12 +343,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 +399,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 +410,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,30 +458,22 @@ 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 }) - const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId) + ) + const ownerSettings = useMemo<AppState['settings']>( + () => + !settings + ? settings + : runtimeEnvironmentId + ? { ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : { ...settings, activeRuntimeEnvironmentId: null }, + [runtimeEnvironmentId, settings] + ) const repoConnectionId = repo?.connectionId?.trim() || null const sshConnectionStatus = useAppStore((s) => repoConnectionId ? s.sshConnectionStates.get(repoConnectionId)?.status : undefined @@ -588,7 +537,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 @@ -602,11 +551,25 @@ export default function ChecksPanel(): React.JSX.Element { const isFolder = repo ? isFolderRepo(repo) : false const prCacheKey = repo && branch - ? getGitHubPRCacheKey(repo.path, repo.id, branch, settings, repo.connectionId) + ? getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId + ) : '' const hostedReviewCacheKey = repo && branch - ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId) + ? getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId + ) : '' const refreshContextKey = `${activeWorktreeId ?? ''}::${prCacheKey}::${branch}` if (refreshContextKey !== refreshContextKeyRef.current) { @@ -648,7 +611,8 @@ export default function ChecksPanel(): React.JSX.Element { repo.id, prChecksCacheSuffix(prNumber, pr?.prRepo), settings, - repo.connectionId + repo.connectionId, + repo.executionHostId ) : '' const commentsCacheKey = @@ -658,7 +622,8 @@ export default function ChecksPanel(): React.JSX.Element { repo.id, prCommentsCacheSuffix(prNumber, pr?.prRepo), settings, - repo.connectionId + repo.connectionId, + repo.executionHostId ) : '' const checksFetchedAt = useAppStore((s) => @@ -678,7 +643,7 @@ export default function ChecksPanel(): React.JSX.Element { runtimeEnvironmentId, connectionId: repoConnectionId, branch, - base: hostedReviewCreationBaseRef, + base: repo.worktreeBaseRef ?? null, hasUncommittedChanges: gitStatusSnapshot?.contextKey === panelContextKey ? gitStatusSnapshot.hasUncommittedChanges @@ -731,143 +696,16 @@ export default function ChecksPanel(): React.JSX.Element { // moved, the embedded composer should push before creating the review. setCreatePrPushFirst(true) 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) + await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId, undefined, { + runtimeTargetSettings: ownerSettings }) - 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]) + }, [ + activeConnectionId, + activeWorktree?.path, + activeWorktreeId, + fetchUpstreamStatus, + ownerSettings + ]) const prCreationDefaults = useMemo(() => { if (!settings) { return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS @@ -890,12 +728,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, @@ -916,8 +754,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 ?? '', @@ -926,60 +763,11 @@ export default function ChecksPanel(): React.JSX.Element { branch, eligibility: hostedReviewCreation, repo, - settings, + settings: ownerSettings, 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) @@ -1125,7 +913,7 @@ export default function ChecksPanel(): React.JSX.Element { shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot ) const context = { - settings: useAppStore.getState().settings, + settings: ownerSettings, worktreeId: activeWorktreeId, worktreePath: activeWorktreePath, connectionId @@ -1208,6 +996,7 @@ export default function ChecksPanel(): React.JSX.Element { gitStatusRefreshNonce, isFolder, isPanelVisible, + ownerSettings, panelContextKey, repo, repoConnectionId, @@ -1227,9 +1016,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, @@ -1267,7 +1057,6 @@ export default function ChecksPanel(): React.JSX.Element { getHostedReviewCreationEligibility, gitStatusReadyForPanelContext, hasUncommittedChanges, - hostedReviewCreationBaseRef, hostedReviewCreationRequestKey, isFolder, isPanelVisible, @@ -1366,13 +1155,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( @@ -1445,13 +1235,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 @@ -1488,7 +1277,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. @@ -1503,7 +1292,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(), @@ -1755,13 +1544,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)) { @@ -1839,8 +1629,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, @@ -1856,7 +1647,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 }) @@ -1920,9 +1711,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]) @@ -2011,6 +1802,7 @@ export default function ChecksPanel(): React.JSX.Element { if (activeReview.provider === 'gitlab') { const result = await window.api.gl.updateMR({ repoPath: repo.path, + repoId: repo.id, iid: activeReview.number, updates: { title: nextTitle } }) @@ -2334,7 +2126,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', @@ -2709,17 +2501,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') { @@ -2752,14 +2538,24 @@ export default function ChecksPanel(): React.JSX.Element { activeWorktree.path, false, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) - await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId) + await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId, undefined, { + runtimeTargetSettings: ownerSettings + }) return true } catch { return false } - }, [activeConnectionId, activeWorktree, activeWorktreeId, fetchUpstreamStatus, pushBranch]) + }, [ + activeConnectionId, + activeWorktree, + activeWorktreeId, + fetchUpstreamStatus, + ownerSettings, + pushBranch + ]) const handlePublishBranch = useCallback(async (): Promise<void> => { if ( @@ -2778,13 +2574,15 @@ export default function ChecksPanel(): React.JSX.Element { activeWorktree.path, true, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) await fetchUpstreamStatus( activeWorktreeId, activeWorktree.path, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) } catch { // Store remote actions already surface the publish failure toast. @@ -2801,6 +2599,7 @@ export default function ChecksPanel(): React.JSX.Element { fetchUpstreamStatus, isPublishingBranch, isRemoteOperationActive, + ownerSettings, pushBranch ]) @@ -2914,6 +2713,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), @@ -3200,9 +3000,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} @@ -3302,7 +3100,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 @@ -3315,10 +3113,10 @@ export default function ChecksPanel(): React.JSX.Element { <PRCommentsList comments={comments} commentsLoading={commentsLoading} + reviewKind={reviewShortLabel} commentsDisabled={!canTargetPRComments} commentsDisabledReason={commentsDisabledReason} selectionContextKey={stateRequestKey} - selectionClearRequest={commentSelectionClearRequest} resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)} resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason} onAddComment={pr ? handleAddPRComment : undefined} @@ -3382,14 +3180,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/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 22f4e73bc8a..32317c593ee 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -384,7 +384,7 @@ describe('ConflictSummaryCard', () => { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('renders abort actions with the quiet outline review-conflicts button treatment', () => { + it('renders abort actions with operation-specific button treatment', () => { const mergeMarkup = renderToStaticMarkup( <ConflictSummaryCard conflictOperation="merge" @@ -407,7 +407,7 @@ describe('ConflictSummaryCard', () => { ) expect(buttonContaining(mergeMarkup, 'Review conflicts')).toContain('data-variant="outline"') - expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"') + expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="destructive"') expect(buttonContaining(rebaseMarkup, 'Review conflicts')).toContain('data-variant="outline"') expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"') }) @@ -447,7 +447,7 @@ describe('OperationBanner', () => { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('renders abort actions with the quiet outline button treatment', () => { + it('renders abort actions with operation-specific button treatment', () => { const mergeMarkup = renderToStaticMarkup( <OperationBanner conflictOperation="merge" onAbortOperation={vi.fn()} /> ) @@ -455,7 +455,7 @@ describe('OperationBanner', () => { <OperationBanner conflictOperation="rebase" onAbortOperation={vi.fn()} /> ) - expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"') + expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="destructive"') expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"') }) }) diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx index 937dec11bb2..31226479460 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/lib/worktree-activation', () => ({ import { getLocalWorkspacePortSections } from './PortsPanel' import { killWorkspacePortForTarget, + mergeWorkspacePortScans, openWorkspacePortInBrowser, refreshWorkspacePortScanAfterStop, scanWorkspacePortsForTarget @@ -219,6 +220,35 @@ describe('PortsPanel runtime routing', () => { ]) }) + it('merges local and runtime scans with host-prefixed row ids', () => { + const runtimePort: WorkspacePort = { + ...workspacePort, + id: workspacePort.id, + port: 3000, + owner: { + ...workspacePort.owner, + repoId: 'runtime-repo', + worktreeId: 'runtime-repo::/srv/app', + displayName: 'runtime app', + path: '/srv/app' + } + } + + const merged = mergeWorkspacePortScans({ + 'local:all': { ...emptyScan, scannedAt: 10, ports: [workspacePort] }, + 'environment:env-1:all': { ...emptyScan, scannedAt: 20, ports: [runtimePort] } + }) + + expect(merged).toMatchObject({ platform: 'unknown', scannedAt: 20 }) + expect(merged?.ports.map((port) => port.id)).toEqual([ + `environment:env-1:all:${workspacePort.id}`, + `local:all:${workspacePort.id}` + ]) + expect( + merged?.ports.map((port) => (port.kind === 'workspace' ? port.owner.worktreeId : null)) + ).toEqual(['runtime-repo::/srv/app', 'repo::/workspace/app']) + }) + it('opens remote workspace ports in the server-side browser and binds the local page handle', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => Promise.resolve({ @@ -352,6 +382,78 @@ describe('PortsPanel runtime routing', () => { expect(setWorkspacePortScanRefreshing).toHaveBeenNthCalledWith(2, false) }) + it('preserves an all-host projection after refreshing one host post-stop', async () => { + const setWorkspacePortScan = vi.fn() + const setWorkspacePortScanForKey = vi.fn() + const setWorkspacePortScanRefreshing = vi.fn() + const localPort: WorkspacePort = { ...workspacePort, id: 'local-port', port: 5173 } + const refreshedRemotePort: WorkspacePort = { + ...workspacePort, + id: 'remote-port', + port: 3000, + owner: { + ...workspacePort.owner, + repoId: 'runtime-repo', + worktreeId: 'runtime-repo::/srv/app', + displayName: 'runtime app', + path: '/srv/app' + } + } + const localHostScan: WorkspacePortScanResult = { + ...emptyScan, + scannedAt: 10, + ports: [localPort] + } + const remoteHostScan: WorkspacePortScanResult = { + ...emptyScan, + scannedAt: 20, + ports: [refreshedRemotePort] + } + let scanCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + return Promise.resolve({ + id: method, + ok: true, + result: compatibleStatus, + _meta: { runtimeId: 'runtime-1' } + }) + } + if (method === 'workspacePorts.scan') { + scanCalls += 1 + return Promise.resolve({ + id: method, + ok: true, + result: remoteHostScan, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.reject(new Error(`Unexpected method ${method}`)) + }) + + await expect( + refreshWorkspacePortScanAfterStop({ + runtimeTarget: { kind: 'environment', environmentId: 'env-1' }, + setWorkspacePortScan: setWorkspacePortScan as never, + setWorkspacePortScanForKey: setWorkspacePortScanForKey as never, + getWorkspacePortScansByKey: () => ({ 'local:all': localHostScan }), + setWorkspacePortScanRefreshing: setWorkspacePortScanRefreshing as never + }) + ).resolves.toEqual({ ok: true }) + + expect(setWorkspacePortScanForKey).toHaveBeenCalledWith('environment:env-1:all', remoteHostScan) + expect(setWorkspacePortScan).toHaveBeenLastCalledWith({ + key: 'all-hosts:all', + result: expect.objectContaining({ + ports: expect.arrayContaining([ + expect.objectContaining({ port: 5173 }), + expect.objectContaining({ port: 3000 }) + ]) + }) + }) + expect(scanCalls).toBe(2) + }) + it('keeps remote workspace ports in the server-side browser when link routing is off', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => Promise.resolve({ diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.tsx index 60a37e1cbac..1226e4e6e13 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.tsx @@ -19,6 +19,7 @@ import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { killWorkspacePortForTarget, openWorkspacePortInBrowser, @@ -160,9 +161,10 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. const settings = useAppStore((s) => s.settings) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) - const scan = useAppStore((s) => s.workspacePortScan) + const scansByKey = useAppStore((s) => s.workspacePortScansByKey) const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const [detailsPort, setDetailsPort] = useState<WorkspacePort | null>(null) const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({ @@ -170,7 +172,15 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. external: true }) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const runtimeTarget = useMemo(() => { + const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + activeWorktree?.id + ) + // Why: the Ports panel acts on the active workspace; use that workspace's + // host owner even if the sidebar is focused elsewhere. + return getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId }) + }, [activeWorktree?.id, settings]) const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` const refresh = useCallback(() => { @@ -180,6 +190,7 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. setWorkspacePortScanRefreshing(true) const promise = scanWorkspacePortsForTarget(runtimeTarget) .then((nextScan) => { + setWorkspacePortScanForKey(scanKey, nextScan) setWorkspacePortScan({ key: scanKey, result: nextScan }) }) .catch((error) => { @@ -203,11 +214,18 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. setWorkspacePortScanRefreshing(false) }) return promise - }, [activeRepo, runtimeTarget, scanKey, setWorkspacePortScan, setWorkspacePortScanRefreshing]) + }, [ + activeRepo, + runtimeTarget, + scanKey, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ]) // Why: WorkspacePortScanner already owns the 30s all-worktree poll. The // panel scopes that shared result instead of starting a second scan loop. - const displayScan = scan?.key === scanKey && isVisible ? scan.result : null + const displayScan = isVisible ? (scansByKey[scanKey] ?? null) : null const toggleSection = useCallback((sectionId: string) => { setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] })) @@ -237,6 +255,8 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) if (!refreshResult.ok) { @@ -251,7 +271,13 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React. ) } }, - [activeRepo, runtimeTarget, setWorkspacePortScan, setWorkspacePortScanRefreshing] + [ + activeRepo, + runtimeTarget, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ] ) const handleOpenPortInBrowser = useCallback( diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index 9aa49dba9ca..d1d4bddf00b 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -8,6 +8,7 @@ import { buildSearchRows } from './search-rows' import { cancelRevealFrame, openMatchResult } from './search-match-open' import { SearchHeader } from './SearchHeader' import { SearchResultsPane } from './SearchResultsPane' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { translate } from '@/i18n/i18n' const SEARCH_DEBOUNCE_MS = 300 @@ -188,7 +189,7 @@ export default function Search(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId!) ?? undefined const results = await searchRuntimeFiles( { - settings: state.settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts new file mode 100644 index 00000000000..2b64ffacc1f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SOURCE_CONTROL_SOURCE = readFileSync(join(__dirname, 'SourceControl.tsx'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('SourceControl host-context boundaries', () => { + it('snapshots PR generation host ownership and reuses it after async branch preparation', () => { + const generateSection = sourceBetween( + SOURCE_CONTROL_SOURCE, + 'const handleGeneratePullRequestFieldsForActive = useCallback(', + 'const handleCancelGeneratePullRequestFieldsForActive = useCallback(' + ) + expect(generateSection).toContain('runtimeTargetSettings: activeRepoSettings') + expect(generateSection).toContain('settings: context.runtimeTargetSettings') + + const cancelSection = sourceBetween( + SOURCE_CONTROL_SOURCE, + 'const handleCancelGeneratePullRequestFieldsForActive = useCallback(', + 'const {' + ) + expect(cancelSection).toContain('settings: record.context.runtimeTargetSettings') + + const refreshSection = sourceBetween( + SOURCE_CONTROL_SOURCE, + 'const refreshGitStatusAfterPullRequestGeneration = useCallback(', + 'useEffect(() => {' + ) + expect(refreshSection).toContain('settings: context.runtimeTargetSettings') + expect(refreshSection).not.toContain('settings: activeRepoSettings') + }) + + it('routes create-review field generation through caller-provided owner settings', () => { + const sourceControlCall = sourceBetween( + SOURCE_CONTROL_SOURCE, + '} = useCreatePullRequestDialogFields({', + 'const handleGeneratePullRequestFieldsClick = useCallback' + ) + expect(sourceControlCall).toContain('settings: activeRepoSettings') + + const hookSource = readFileSync(join(__dirname, 'useCreatePullRequestDialogFields.ts'), 'utf8') + const requestContext = sourceBetween(hookSource, 'const requestContext = {', 'const seed = {') + expect(requestContext).toContain('settings,') + expect(requestContext).not.toContain('useAppStore.getState().settings') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 25ef6c1468c..18a3c9cdece 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -122,6 +122,7 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { getConnectionId } from '@/lib/connection-context' +import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' import { abortRuntimeGitMerge, abortRuntimeGitRebase, @@ -145,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' @@ -160,8 +156,6 @@ import type { GitBranchCompareSummary, GitConflictOperation, GitStatusEntry, - GlobalSettings, - Repo, SourceControlViewMode, TuiAgent } from '../../../../shared/types' @@ -214,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, @@ -238,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 @@ -688,6 +650,12 @@ function SourceControlInner(): React.JSX.Element { const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind) const settings = useAppStore((s) => s.settings) + // Why: git/file mutations and repo metadata requests belong to the repo + // OWNER host, not the currently focused host in the sidebar. + const activeRepoSettings = useMemo( + () => getRepoOwnerRoutedSettings(settings, activeRepo ?? null), + [activeRepo, settings] + ) const updateSettings = useAppStore((s) => s.updateSettings) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -883,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>>({}) @@ -892,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 @@ -936,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, @@ -974,7 +929,8 @@ function SourceControlInner(): React.JSX.Element { } const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ - settings: useAppStore.getState().settings, + // Why: route git status by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -987,6 +943,7 @@ function SourceControlInner(): React.JSX.Element { } }) }, [ + activeRepoSettings, activeWorktreeId, activeWorktree?.pushTarget, fetchUpstreamStatus, @@ -1012,7 +969,9 @@ function SourceControlInner(): React.JSX.Element { } try { await refreshGitStatusForWorktree({ - settings: useAppStore.getState().settings, + // Why: generation can finish after the user switches hosts; refresh + // the same host that owned the generation request. + settings: context.runtimeTargetSettings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId, @@ -1051,7 +1010,7 @@ function SourceControlInner(): React.JSX.Element { setDefaultBaseRef(null) let stale = false - void getRuntimeRepoBaseRefDefault(useAppStore.getState().settings, activeRepo.id) + void getRuntimeRepoBaseRefDefault(activeRepoSettings, activeRepo.id) .then((result) => { if (!stale) { // Why: IPC now returns a `{ defaultBaseRef, remoteCount }` envelope; @@ -1073,7 +1032,7 @@ function SourceControlInner(): React.JSX.Element { return () => { stale = true } - }, [activeRepo, isBranchVisible, isFolder]) + }, [activeRepo, activeRepoSettings, isBranchVisible, isFolder]) const normalizedWorktreeBaseRef = activeWorktree?.baseRef?.trim() || null const normalizedRepoBaseRef = activeRepo?.worktreeBaseRef?.trim() || null @@ -1103,7 +1062,8 @@ function SourceControlInner(): React.JSX.Element { branchName, settings, activeRepo.id, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) : null const hostedReviewEntry = hostedReviewCacheKey @@ -1116,7 +1076,8 @@ function SourceControlInner(): React.JSX.Element { activeRepo.id, branchName, settings, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) : null const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null @@ -1321,7 +1282,7 @@ function SourceControlInner(): React.JSX.Element { handleSavePullRequestGenerationDefaults, openSourceControlAiSettings } = useSourceControlAi({ - settings, + settings: activeRepoSettings, activeRepo: activeRepo ?? null, activeWorktreeId, activeConnectionId, @@ -1339,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 @@ -1415,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 @@ -1496,7 +1409,8 @@ function SourceControlInner(): React.JSX.Element { try { const commitResult = await commitRuntimeGit( { - settings: useAppStore.getState().settings, + // Why: route the commit by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -1563,6 +1477,7 @@ function SourceControlInner(): React.JSX.Element { commitInFlightRef.current[activeWorktreeId] = false } }, [ + activeRepoSettings, activeWorktreeId, beginGitBranchCompareRequest, commitMessage, @@ -1574,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 @@ -1820,7 +1631,8 @@ function SourceControlInner(): React.JSX.Element { worktreePath, true, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1832,7 +1644,9 @@ function SourceControlInner(): React.JSX.Element { false, connectionId, activeWorktree?.pushTarget, - forceWithLease ? { forceWithLease: true } : undefined + forceWithLease + ? { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } + : { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1843,12 +1657,20 @@ function SourceControlInner(): React.JSX.Element { false, connectionId, activeWorktree?.pushTarget, - { forceWithLease: true } + { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } ) return } if (kind === 'pull') { - await pullBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget) + await pullBranch( + activeWorktreeId, + worktreePath, + connectionId, + activeWorktree?.pushTarget, + { + runtimeTargetSettings: activeRepoSettings + } + ) return } if (kind === 'fast_forward') { @@ -1856,7 +1678,8 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1865,7 +1688,10 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { + runtimeTargetSettings: activeRepoSettings + } ) return } @@ -1878,11 +1704,14 @@ function SourceControlInner(): React.JSX.Element { worktreePath, effectiveBaseRef, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } - await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget) + await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget, { + runtimeTargetSettings: activeRepoSettings + }) setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep @@ -1905,6 +1734,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktree?.pushTarget, activeWorktreeId, fetchBranch, @@ -1952,7 +1782,8 @@ function SourceControlInner(): React.JSX.Element { setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { const context = { - settings: useAppStore.getState().settings, + // Why: route the abort by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -1983,6 +1814,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktreeId, confirmAction, conflictOperation, @@ -2118,90 +1950,44 @@ 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, + runtimeTargetSettings: activeRepoSettings } - 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: context.runtimeTargetSettings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId @@ -2255,11 +2041,16 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activePullRequestGenerationKey, + activeRepo, + activeRepoSettings, + activeWorktreeId, allocatePullRequestGenerationRequestId, - createPullRequestGenerationTarget, + branchName, refreshGitStatusAfterPullRequestGeneration, setPullRequestGenerationRecord, - updatePullRequestGenerationRecord + updatePullRequestGenerationRecord, + worktreePath ] ) @@ -2279,6 +2070,8 @@ function SourceControlInner(): React.JSX.Element { return resolvePullRequestGenerationCancel(current) }) void cancelRuntimeGeneratePullRequestFields({ + // Why: the user can switch hosts while generation runs; cancel the + // original request owner instead of the current focused host. settings: record.context.runtimeTargetSettings, worktreeId: record.context.worktreeId, worktreePath: record.context.worktreePath, @@ -2308,7 +2101,6 @@ function SourceControlInner(): React.JSX.Element { setBody: setPrBody, draft: prDraft, setDraft: setPrDraft, - fieldRevisions: prFieldRevisions, baseQuery: prBaseQuery, setBaseQuery: setPrBaseQuery, baseResults: prBaseResults, @@ -2329,7 +2121,7 @@ function SourceControlInner(): React.JSX.Element { branch: branchName, eligibility: hostedReviewCreation, repo: activeRepo ?? null, - settings, + settings: activeRepoSettings, submitting: isCreatingPr, prCreationDefaults: resolvedPrCreationDefaults, onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration, @@ -2354,36 +2146,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 || @@ -2418,7 +2184,6 @@ function SourceControlInner(): React.JSX.Element { activePullRequestGenerationKey, activePullRequestGenerationRecord, applyGeneratedPullRequestFields, - hostedReviewCreation?.canCreate, updatePullRequestGenerationRecord ]) @@ -2439,6 +2204,7 @@ function SourceControlInner(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: activeRepo.path, + repoId: activeRepo.id, ...(worktreePath ? { worktreePath } : {}), branch: branchName, base: effectiveBaseRef ?? null, @@ -2533,6 +2299,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), @@ -2924,7 +2691,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -2937,6 +2705,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, bulkStagePaths, clearSelection, @@ -2953,7 +2722,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -2966,6 +2736,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, bulkUnstagePaths, clearSelection, @@ -2983,7 +2754,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -2997,6 +2769,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktreeId, clearSelection, isExecutingBulk, @@ -3015,7 +2788,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3029,6 +2803,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktreeId, clearSelection, isExecutingBulk, @@ -3055,7 +2830,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3069,6 +2845,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, worktreePath, grouped, activeWorktreeId, @@ -3098,7 +2875,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkStageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3111,6 +2889,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, isExecutingBulk, grouped, @@ -3154,7 +2933,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3167,6 +2947,7 @@ function SourceControlInner(): React.JSX.Element { setIsExecutingBulk(false) } }, [ + activeRepoSettings, worktreePath, grouped.staged, activeWorktreeId, @@ -3213,7 +2994,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const result = await getRuntimeGitBranchCompare( { - settings: useAppStore.getState().settings, + // Why: route the branch compare by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3237,6 +3019,7 @@ function SourceControlInner(): React.JSX.Element { }) } }, [ + activeRepoSettings, activeWorktreeId, beginGitBranchCompareRequest, branchName, @@ -3310,7 +3093,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(worktreeId) ?? undefined const result = await getRuntimeGitHistory( { - settings: useAppStore.getState().settings, + // Why: route the history read by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId, worktreePath, connectionId @@ -3337,6 +3121,7 @@ function SourceControlInner(): React.JSX.Element { }) } }, [ + activeRepoSettings, activeWorktreeId, effectiveBaseRef, isBranchVisible, @@ -3393,9 +3178,11 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) }, [ + activeRepoSettings, activeWorktree?.pushTarget, activeWorktreeId, fetchUpstreamStatus, @@ -3460,7 +3247,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId) ?? undefined const result = await getRuntimeGitCommitCompare( { - settings: useAppStore.getState().settings, + // Why: route the commit compare by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3496,7 +3284,7 @@ function SourceControlInner(): React.JSX.Element { ) } }, - [activeWorktreeId, openCommitAllDiffs, worktreePath] + [activeRepoSettings, activeWorktreeId, openCommitAllDiffs, worktreePath] ) // Why: a note's filePath is the same relative path used by GitStatusEntry / @@ -3613,7 +3401,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await stageRuntimeGitPath( { - settings: useAppStore.getState().settings, + // Why: route staging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3625,7 +3414,7 @@ function SourceControlInner(): React.JSX.Element { // git operation failed silently } }, - [worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] + [activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] ) const handleUnstage = useCallback( @@ -3637,7 +3426,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await unstageRuntimeGitPath( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3649,7 +3439,7 @@ function SourceControlInner(): React.JSX.Element { // git operation failed silently } }, - [worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] + [activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] ) // Why: split into two variants — `discardSingle` throws so bulk callers can @@ -3672,7 +3462,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await discardRuntimeGitPath( { - settings: useAppStore.getState().settings, + // Why: route the discard by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3685,7 +3476,7 @@ function SourceControlInner(): React.JSX.Element { relativePath: filePath }) }, - [activeWorktreeId, worktreePath] + [activeRepoSettings, activeWorktreeId, worktreePath] ) const discardMany = useCallback( @@ -3708,7 +3499,8 @@ function SourceControlInner(): React.JSX.Element { const connectionId = getConnectionId(activeWorktreeId) ?? undefined await bulkDiscardRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route the discard by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3723,7 +3515,7 @@ function SourceControlInner(): React.JSX.Element { }) } }, - [activeWorktreeId, worktreePath] + [activeRepoSettings, activeWorktreeId, worktreePath] ) const handleDiscard = useCallback( @@ -3769,7 +3561,8 @@ function SourceControlInner(): React.JSX.Element { bulkUnstage: (filePaths) => bulkUnstageRuntimeGitPaths( { - settings: useAppStore.getState().settings, + // Why: route unstaging by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -3826,6 +3619,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, worktreePath, activeWorktreeId, grouped, @@ -4724,7 +4518,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} @@ -4754,7 +4548,7 @@ function SourceControlInner(): React.JSX.Element { /> <SourceControlTextGenerationDialog open={commitGenerationDialogOpen} - onOpenChange={handleCommitGenerationDialogOpenChange} + onOpenChange={setCommitGenerationDialogOpen} actionId="commitMessage" title={translate( 'auto.components.right.sidebar.SourceControl.6b122529d4', @@ -4765,23 +4559,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', @@ -4792,27 +4580,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} /> @@ -4879,7 +4651,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} @@ -4956,7 +4728,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} @@ -5272,7 +5044,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 @@ -5281,16 +5053,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 ? ( @@ -5298,9 +5067,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> @@ -5524,7 +5291,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> @@ -5992,6 +5759,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, @@ -6038,12 +5813,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} > @@ -6058,7 +5833,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" /> @@ -6067,11 +5842,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)} > @@ -6113,28 +5886,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/active-checks-status.ts b/src/renderer/src/components/right-sidebar/active-checks-status.ts index 4aab1785016..f3db9b1a2f7 100644 --- a/src/renderer/src/components/right-sidebar/active-checks-status.ts +++ b/src/renderer/src/components/right-sidebar/active-checks-status.ts @@ -39,14 +39,16 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat activeRepo.id, branch, state.settings, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) const hostedReviewCacheKey = getHostedReviewCacheKey( activeRepo.path, branch, state.settings, activeRepo.id, - activeRepo.connectionId + activeRepo.connectionId, + activeRepo.executionHostId ) const hostedReview = state.hostedReviewCache?.[hostedReviewCacheKey]?.data ?? null if (hostedReview && hostedReview.provider !== 'github') { 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 b618b8eaf1f..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 : ( @@ -1983,10 +1980,10 @@ function scrollElementBottomIntoView(element: HTMLElement): void { export function PRCommentsList({ comments, commentsLoading, + reviewKind = 'PR', commentsDisabled, commentsDisabledReason, selectionContextKey, - selectionClearRequest, resolveCommentsWithAIDisabled, resolveCommentsWithAIDisabledReason, onAddComment, @@ -1998,10 +1995,10 @@ export function PRCommentsList({ }: { comments: PRComment[] commentsLoading: boolean + reviewKind?: 'PR' | 'MR' commentsDisabled?: boolean commentsDisabledReason?: string selectionContextKey?: string - selectionClearRequest?: PRCommentsListSelectionClearRequest | null resolveCommentsWithAIDisabled?: boolean resolveCommentsWithAIDisabledReason?: string onAddComment?: (body: string) => Promise<RightPanelCommentSubmitResult> @@ -2026,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] @@ -2168,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"> @@ -2189,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={ @@ -2207,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/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts new file mode 100644 index 00000000000..27ae54c2b51 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = process.cwd() + +function source(path: string): string { + return readFileSync(join(root, path), 'utf8') +} + +describe('right sidebar file/git runtime ownership boundaries', () => { + it.each([ + 'src/renderer/src/components/right-sidebar/useFileExplorerTree.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerImport.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts', + 'src/renderer/src/components/right-sidebar/useFileDuplicate.ts', + 'src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts', + 'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts', + 'src/renderer/src/components/right-sidebar/Search.tsx', + 'src/renderer/src/components/quick-open-file-list.ts' + ])('%s routes file/git requests by the selected worktree owner', (path) => { + const text = source(path) + + expect(text).toMatch( + /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner/ + ) + expect(text).not.toContain('settings: useAppStore.getState().settings') + expect(text).not.toContain('const settings = useAppStore.getState().settings') + }) + + it('derives owner settings through the shared worktree runtime owner helper', () => { + const text = source('src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts') + + expect(text).toContain('getSettingsForWorktreeRuntimeOwner') + expect(text).toContain('useAppStore.getState()') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts new file mode 100644 index 00000000000..eddc593d31f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts @@ -0,0 +1,12 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { useAppStore } from '@/store' + +export function getRightSidebarWorktreeRuntimeSettings( + worktreeId: string | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + const store = useAppStore.getState() + // Why: right-sidebar file/git actions operate on the selected workspace. + // Route by that workspace owner so global focused-host changes cannot retarget them. + return getSettingsForWorktreeRuntimeOwner(store, worktreeId) +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index bba4521ae80..1613f3c8b54 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -76,7 +76,9 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() - expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined) + expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { + runtimeTargetSettings: undefined + }) }) it('falls back to explicit upstream refresh for legacy status payloads', async () => { @@ -103,7 +105,9 @@ describe('refreshGitStatusForWorktree', () => { branch: 'refs/heads/main' }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() - expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2') + expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { + runtimeTargetSettings: undefined + }) }) it('leaves ignored-file discovery to the File Explorer instead of status polling', async () => { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 17d25d58bf5..79c3408da9e 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -17,7 +17,8 @@ export type GitStatusRefreshDeps = { worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: { runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null } ) => Promise<void> } @@ -56,7 +57,9 @@ export async function refreshGitStatusForWorktree({ // Why: porcelain status reports Git's configured upstream. Source Control // actions for PR-created worktrees must instead reconcile with Orca's // explicit publish target. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: settings + }) return } if (status.upstreamStatus) { @@ -68,11 +71,15 @@ export async function refreshGitStatusForWorktree({ // Why: porcelain status has counts but cannot tell stale post-rebase // upstream commits from real remote work. Writing it first makes the // primary action flicker between Sync and Force Push on every poll. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { + runtimeTargetSettings: settings + }) return } deps.setUpstreamStatus(worktreeId, status.upstreamStatus) return } - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { + runtimeTargetSettings: settings + }) } 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..121c8241504 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 @@ -6,16 +6,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { PRComment } from '../../../../shared/types' import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import { clearPRCommentsListSelection } from './pr-comments-list-selection' import { PRCommentsList } from './checks-panel-content' -import { - clearPRCommentsListSelection, - type PRCommentsListSelectionClearRequest -} from './pr-comments-list-selection' let container: HTMLDivElement let root: Root beforeEach(() => { + clearPRCommentsListSelection('review:42') container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -26,8 +24,6 @@ afterEach(() => { root.unmount() }) container.remove() - clearPRCommentsListSelection('review:42') - clearPRCommentsListSelection('review:43') }) function comment(overrides: Partial<PRComment>): PRComment { @@ -44,8 +40,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 +48,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,21 +56,25 @@ function renderList(props: { }) } -function remountList(): void { - act(() => { - root.unmount() - }) - root = createRoot(container) -} - function clickButton(label: string): void { - const button = [...container.querySelectorAll('button')].find( - (candidate) => - candidate.textContent?.includes(label) || - candidate.getAttribute('aria-label')?.includes(label) - ) + const button = + [...container.querySelectorAll('button')].find( + (candidate) => + candidate.textContent === label || candidate.getAttribute('aria-label') === label + ) ?? + [...container.querySelectorAll('button')].find( + (candidate) => + candidate.textContent?.includes(label) || + candidate.getAttribute('aria-label')?.includes(label) + ) if (!button) { - throw new Error(`Button not found: ${label}`) + const availableButtons = [...container.querySelectorAll('button')] + .map( + (candidate) => + candidate.getAttribute('aria-label') ?? candidate.textContent?.trim() ?? '<unlabeled>' + ) + .join(', ') + throw new Error(`Button not found: ${label}. Available buttons: ${availableButtons}`) } act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) @@ -101,13 +98,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 +142,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[] @@ -176,7 +173,7 @@ describe('PRCommentsList comment resolution selection', () => { onResolveSelectedCommentsWithAI }) - clickButton('Add') + clickButton('Add comment to resolve list') expect(hasButton('Send 1 queued comments')).toBe(true) clickButton('Send 1 queued comments') @@ -200,7 +197,7 @@ describe('PRCommentsList comment resolution selection', () => { onResolveSelectedCommentsWithAI }) - clickButton('Add') + clickButton('Add comment to resolve list') expect(hasButton('Send 1 queued comments')).toBe(true) clickButton('Send 1 queued comments') @@ -218,7 +215,7 @@ describe('PRCommentsList comment resolution selection', () => { renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] }) - clickButton('Add') + clickButton('Add comment to resolve list') expect(hasButton('Send 1 queued comments')).toBe(true) clickButton('Clear queued comments') @@ -227,66 +224,11 @@ 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 })] }) - clickButton('Add') + clickButton('Add comment to resolve list') renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: true })] diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index 525d2460f7f..f5d7c4d726b 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -60,6 +60,7 @@ export function useHostedReviewActions({ const result = isGitLab ? await window.api.gl.mergeMR({ repoPath: repo.path, + repoId: repo.id, iid: review.number, method }) @@ -158,8 +159,16 @@ export function useHostedReviewActions({ try { const result = isGitLab ? isClosing - ? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number }) - : await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number }) + ? await window.api.gl.closeMR({ + repoPath: repo.path, + repoId: repo.id, + iid: review.number + }) + : await window.api.gl.reopenMR({ + repoPath: repo.path, + repoId: repo.id, + iid: review.number + }) : await window.api.gh.updatePRState({ repoPath: repo.path, repoId: repo.id, diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 085d39ffa87..4be16c0ba73 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -346,7 +346,9 @@ export function useCreatePullRequestDialogFields({ generationRequestIdRef.current = requestId const connectionId = getConnectionId(worktreeId) ?? undefined const requestContext = { - settings: useAppStore.getState().settings, + // Why: PR generation belongs to the visible worktree owner. Global + // focused-host changes must not retarget an in-flight generation. + settings, worktreeId, worktreePath, connectionId @@ -418,6 +420,7 @@ export function useCreatePullRequestDialogFields({ generation, generateDisabled, onBranchChangedByGeneration, + settings, title, worktreeId, worktreePath diff --git a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts index 82ef96b1353..e7e7737294e 100644 --- a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts +++ b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts @@ -2,9 +2,9 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { basename, dirname, joinPath } from '@/lib/path' import type { TreeNode } from './file-explorer-types' -import { useAppStore } from '@/store' import { copyRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client' import { getConnectionId } from '@/lib/connection-context' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -42,9 +42,8 @@ export function useFileDuplicate({ const ext = dotIndex > 0 ? name.slice(dotIndex) : '' const run = async (): Promise<void> => { - const settings = useAppStore.getState().settings const context = { - settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId: getConnectionId(activeWorktreeId) ?? undefined diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index c4f932cc931..79784b72c01 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -12,6 +12,7 @@ import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-f import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' import { renameRuntimePath } from '@/runtime/runtime-file-client' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' function extractIpcErrorMessage(err: unknown, fallback: string): string { if (!(err instanceof Error)) { @@ -233,7 +234,7 @@ export function useFileExplorerDragDrop({ try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const fileContext = { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index 2b314c536cf..8e0951cf9bf 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -2,9 +2,9 @@ import { useEffect, useRef } from 'react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { extractIpcErrorMessage } from '@/lib/ipc-error' -import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { translate } from '@/i18n/i18n' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerImportParams = { worktreePath: string | null @@ -63,10 +63,9 @@ export function useFileExplorerImport({ void (async () => { try { - const settings = useAppStore.getState().settings const { results } = await importExternalPathsToRuntime( { - settings, + settings: getRightSidebarWorktreeRuntimeSettings(wtId), worktreeId: wtId, worktreePath: worktreePathRef.current, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index 480ecca2a54..aae50da62dc 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -11,6 +11,7 @@ import type { TreeNode } from './file-explorer-types' import type { FileExplorerRowProjection } from './file-explorer-row-projection' import { commitFileExplorerOp } from './fileExplorerUndoRedo' import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerInlineInputParams = { activeWorktreeId: string | null @@ -110,7 +111,7 @@ export function useFileExplorerInlineInput({ const run = async (): Promise<void> => { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const fileContext = { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index f9b32378ecf..7481bb4c877 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -6,8 +6,8 @@ import type { DirCache, TreeNode } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client' -import { useAppStore } from '@/store' import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerTreeResult = { dirCache: Record<string, DirCache> @@ -63,7 +63,7 @@ export function useFileExplorerTree( const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined const entries = await readRuntimeDirectory( { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -133,7 +133,7 @@ export function useFileExplorerTree( const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined return statRuntimePath( { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index 7839a3b3372..7b87cc5402a 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { isDotfileRelativePath } from './file-explorer-entries' import type { DirCache, TreeNode } from './file-explorer-types' import { @@ -162,7 +163,7 @@ export function useFileExplorerVisibleRowProjection( const connectionId = getConnectionId(activeWorktreeId) ?? undefined void getRuntimeGitIgnoredPaths( { - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts index 6aae3550b35..36145455e8b 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import type { FsChangedPayload } from '../../../../shared/types' import { canonicalizeFileExplorerWatchPath, + getFileExplorerWatchRuntimeEnvironmentId, getExternalFileChangeRelativePath, payloadRequiresDeferredTreeRefresh } from './useFileExplorerWatch' +import type { AppState } from '@/store/types' describe('getExternalFileChangeRelativePath', () => { it('returns a worktree-relative file path for external file updates', () => { @@ -134,3 +136,70 @@ describe('payloadRequiresDeferredTreeRefresh', () => { expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(false) }) }) + +describe('getFileExplorerWatchRuntimeEnvironmentId', () => { + function makeState(args: { + activeRuntimeEnvironmentId?: string | null + executionHostId?: AppState['repos'][number]['executionHostId'] + connectionId?: string | null + }): Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'> { + return { + settings: { + activeRuntimeEnvironmentId: args.activeRuntimeEnvironmentId ?? null + } as AppState['settings'], + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: args.connectionId ?? null, + executionHostId: args.executionHostId + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/worktree' + } as AppState['worktreesByRepo'][string][number] + ] + } + } + } + + it('uses the active runtime for legacy unowned active worktrees', () => { + expect( + getFileExplorerWatchRuntimeEnvironmentId( + makeState({ activeRuntimeEnvironmentId: 'focused-runtime' }), + 'wt-1' + ) + ).toBe('focused-runtime') + }) + + it('uses the explicit runtime owner when another host is focused', () => { + expect( + getFileExplorerWatchRuntimeEnvironmentId( + makeState({ + activeRuntimeEnvironmentId: 'focused-runtime', + executionHostId: 'runtime:owner-runtime' + }), + 'wt-1' + ) + ).toBe('owner-runtime') + }) + + it('keeps explicitly local active worktrees local when a runtime is focused', () => { + expect( + getFileExplorerWatchRuntimeEnvironmentId( + makeState({ + activeRuntimeEnvironmentId: 'focused-runtime', + executionHostId: 'local' + }), + 'wt-1' + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 5f926604e81..23c718a09d5 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -16,6 +16,8 @@ import { } from './file-explorer-watcher-reconcile' import { useAppStore } from '@/store' import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import type { AppState } from '@/store/types' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' type UseFileExplorerWatchParams = { worktreePath: string | null @@ -87,6 +89,13 @@ export function payloadRequiresDeferredTreeRefresh( return payload.events.some((evt) => evt.kind === 'rename') } +export function getFileExplorerWatchRuntimeEnvironmentId( + state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>, + activeWorktreeId: string | null +): string | null { + return getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) +} + /** * Reconciles File Explorer state on filesystem events for the active worktree. * @@ -110,7 +119,11 @@ export function useFileExplorerWatch({ dragSourcePath, isNativeDragOver }: UseFileExplorerWatchParams): void { - const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) + // Why: Explorer subscriptions are for the selected worktree. Host focus is + // only a default for legacy untagged worktrees, not an ownership signal. + const activeRuntimeEnvironmentId = useAppStore((s) => + getFileExplorerWatchRuntimeEnvironmentId(s, activeWorktreeId) + ) // Keep refs for values accessed inside the event handler to avoid // re-subscribing the IPC listener on every render. diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index a07e9e8042c..9f045022168 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -163,7 +163,15 @@ describe('useGitStatusPolling', () => { }) expect(state.setUpstreamStatus).not.toHaveBeenCalled() - expect(state.fetchUpstreamStatus).toHaveBeenCalledWith(worktree.id, '/repo', undefined) + expect(state.fetchUpstreamStatus).toHaveBeenCalledWith( + worktree.id, + '/repo', + undefined, + undefined, + { + runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + } + ) }) it('passes the explicit push target to upstream refreshes', async () => { @@ -182,7 +190,10 @@ describe('useGitStatusPolling', () => { worktree.id, '/repo', undefined, - pushTarget + pushTarget, + { + runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + } ) }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 3e5013bc628..d3e09f6fae0 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -9,6 +9,7 @@ import { refreshGitStatusForWorktree } from './git-status-refresh' import { createCoalescedPollRunner } from './coalesced-poll-runner' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' const POLL_INTERVAL_MS = 3000 @@ -93,7 +94,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { try { const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -175,7 +176,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { continue } const op = (await getRuntimeGitConflictOperation({ - settings: useAppStore.getState().settings, + settings: getRightSidebarWorktreeRuntimeSettings(id), worktreeId: id, worktreePath: path, connectionId 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/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index 6c0513e5dbe..8e668ebd4e8 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -4,6 +4,7 @@ import { ScrollArea } from '../ui/scroll-area' import { Button } from '../ui/button' import { Input } from '../ui/input' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs @@ -23,8 +24,8 @@ export function BaseRefPicker({ onSelect, onUsePrimary }: BaseRefPickerProps): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const activeRuntimeEnvironmentId = useAppStore((state) => + getRuntimeEnvironmentIdForRepo(state, repoId) ) // Why: null until the IPC resolves (or when the repo has no default base ref // available). We avoid seeding with 'origin/main' because that would display diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index ae43e19ef74..30a747291d6 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type MutableRefObject } from 'react' +import { useCallback, useMemo, useRef, useState, type MutableRefObject } from 'react' import type { GlobalSettings } from '../../../../shared/types' import { useAppStore } from '../../store' import { matchesSettingsSearch } from './settings-search' @@ -16,7 +16,15 @@ import { createBrowserHomePageDraftState, resolveBrowserHomePageDraftState } from './browser-home-page-draft-state' +import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { + getSettingsFocusedExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers' +import { translate } from '@/i18n/i18n' export { getBrowserPaneCombinedSearchEntries } type BrowserPaneProps = { @@ -45,6 +53,12 @@ export function BrowserPane({ const browserDefaultZoomLevel = useAppStore((s) => s.browserDefaultZoomLevel) const setBrowserDefaultZoomLevel = useAppStore((s) => s.setBrowserDefaultZoomLevel) const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) + const repos = useAppStore((s) => s.repos) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) const defaultBrowserSessionProfileId = useAppStore((s) => s.defaultBrowserSessionProfileId) @@ -87,6 +101,54 @@ export function BrowserPane({ const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription({ isMac }) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const browserSessionHostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }) + .filter((host) => host.kind === 'local' || host.kind === 'runtime') + .map((host) => ({ + id: host.id, + label: host.label, + detail: + host.kind === 'local' + ? translate('auto.components.settings.BrowserPane.86b7c83fee', 'This computer') + : translate( + 'auto.components.settings.BrowserPane.c0f85056d9', + 'Browser profiles on this Orca server.' + ) + })), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const selectedBrowserSessionHostId = getSettingsFocusedExecutionHostId(settings) + const selectBrowserSessionHost = useCallback( + (hostId: ExecutionHostId) => { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + void switchRuntimeEnvironment(parsed.environmentId) + return + } + if (parsed?.kind === 'local') { + void switchRuntimeEnvironment(null) + } + }, + [switchRuntimeEnvironment] + ) const requestSessionCookieScrollFrame = (callback: FrameRequestCallback): void => { let completed = false @@ -171,7 +233,10 @@ export function BrowserPane({ detectedBrowsers={detectedBrowsers} importState={browserSessionImportState} defaultBrowserSessionProfileId={defaultBrowserSessionProfileId} + hostOptions={browserSessionHostOptions} + selectedHostId={selectedBrowserSessionHostId} onAddProfile={() => setNewProfileDialogOpen(true)} + onSelectHost={selectBrowserSessionHost} onSelectDefaultProfile={() => setDefaultBrowserSessionProfileId(null)} onSelectProfile={setDefaultBrowserSessionProfileId} /> diff --git a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx index 7f7d78c7e0f..f1c39ca8fec 100644 --- a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx +++ b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx @@ -2,17 +2,28 @@ import { Plus } from 'lucide-react' import type { BrowserSessionProfile } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { BrowserProfileRow, type BrowserProfileRowProps } from './BrowserProfileRow' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' +type BrowserSessionHostOption = { + id: ExecutionHostId + label: string + detail: string +} + type BrowserSessionCookiesSectionProps = { defaultProfile: BrowserSessionProfile | undefined nonDefaultProfiles: BrowserSessionProfile[] detectedBrowsers: BrowserProfileRowProps['detectedBrowsers'] importState: BrowserProfileRowProps['importState'] defaultBrowserSessionProfileId: string | null + hostOptions: readonly BrowserSessionHostOption[] + selectedHostId: ExecutionHostId onAddProfile: () => void + onSelectHost: (hostId: ExecutionHostId) => void onSelectDefaultProfile: () => void onSelectProfile: (profileId: string) => void } @@ -23,10 +34,14 @@ export function BrowserSessionCookiesSection({ detectedBrowsers, importState, defaultBrowserSessionProfileId, + hostOptions, + selectedHostId, onAddProfile, + onSelectHost, onSelectDefaultProfile, onSelectProfile }: BrowserSessionCookiesSectionProps): React.JSX.Element { + const selectedHost = hostOptions.find((host) => host.id === selectedHostId) ?? hostOptions[0] return ( <SearchableSetting id="browser-session-cookies" @@ -68,6 +83,38 @@ export function BrowserSessionCookiesSection({ </Button> </div> + {hostOptions.length > 1 ? ( + <div className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2"> + <div className="min-w-0 space-y-0.5"> + <Label className="text-xs"> + {translate('auto.components.settings.BrowserPane.5e19a692f7', 'Host')} + </Label> + <p className="truncate text-[11px] text-muted-foreground"> + {selectedHost?.detail ?? + translate( + 'auto.components.settings.BrowserPane.6480776a03', + 'Browser profiles for the selected host.' + )} + </p> + </div> + <Select + value={selectedHostId} + onValueChange={(value) => onSelectHost(value as ExecutionHostId)} + > + <SelectTrigger size="sm" className="max-w-48"> + <SelectValue /> + </SelectTrigger> + <SelectContent align="end"> + {hostOptions.map((host) => ( + <SelectItem key={host.id} value={host.id}> + {host.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) : null} + <div className="space-y-2"> <BrowserProfileRow profile={ 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..9b8ebc6d9ff 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -24,7 +24,9 @@ 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' +import { HostedReviewCreationDefaults } from './HostedReviewCreationDefaults' type CommitMessageAiPaneProps = { settings: GlobalSettings @@ -99,6 +101,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 +153,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 +233,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 +290,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,89 +303,33 @@ 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') ] }) ) { const prDefaults = config.prCreationDefaults ?? {} - const rows: { - key: keyof NonNullable<SourceControlAiSettings['prCreationDefaults']> - label: string - description: string - }[] = [ - { - 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.") - }, - { - 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.") - }, - { - 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.") - }, - { - 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.") - } - ] sections.push( - <SearchableSetting + <HostedReviewCreationDefaults 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.")} - keywords={[ - 'hosted review', - 'pull request', - 'merge request', - 'pr', - 'draft', - 'template', - 'generate', - 'open' - ]} - 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> - <p className="text-xs text-muted-foreground"> - {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) => ( - <label - key={row.key} - className="flex items-start justify-between gap-4 rounded-md border border-border px-3 py-2" - > - <span className="space-y-0.5"> - <span className="block text-xs font-medium text-foreground">{row.label}</span> - <span className="block text-[11px] text-muted-foreground">{row.description}</span> - </span> - <input - type="checkbox" - checked={prDefaults[row.key] === true} - onChange={(event) => onPrDefaultChange(row.key, event.target.checked)} - className="mt-0.5 size-4 rounded border-border accent-primary" - /> - </label> - ))} - </div> - </SearchableSetting> + prDefaults={prDefaults} + onPrDefaultChange={onPrDefaultChange} + /> ) } @@ -331,9 +340,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..8e43a9a7f09 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 + labelKey: string + labelDefault: string + descriptionKey: string + descriptionDefault: string + icon: ReactNode } -export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Element { +const PERMISSIONS: PermissionDefinition[] = [ + { + id: 'accessibility', + labelKey: 'auto.components.settings.ComputerUsePane.6b5a2cd3a5', + labelDefault: 'Accessibility', + descriptionKey: 'auto.components.settings.ComputerUsePane.4d03dec2d0', + descriptionDefault: 'Read app interface trees and perform requested actions.', + icon: <Accessibility className="size-4" /> + }, + { + id: 'screenshots', + labelKey: 'auto.components.settings.ComputerUsePane.07bbe4c4cb', + labelDefault: 'Screenshots', + descriptionKey: 'auto.components.settings.ComputerUsePane.0c9a33f468', + descriptionDefault: '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 @@ -312,16 +328,20 @@ export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Ele <div className="mt-0.5 text-muted-foreground">{permission.icon}</div> <div className="min-w-0 space-y-1"> <div className="flex flex-wrap items-center gap-2"> - <span className="text-sm font-medium">{permission.label}</span> + <span className="text-sm font-medium"> + {translate(permission.labelKey, permission.labelDefault)} + </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> + <p className="text-xs text-muted-foreground"> + {translate(permission.descriptionKey, permission.descriptionDefault)} + </p> </div> </div> <div className="flex w-28 shrink-0 justify-end"> @@ -371,23 +391,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 9686ac70781..4405810eca3 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -1,12 +1,9 @@ import type React from 'react' -import { FolderOpen } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' -import { Button } from '../ui/button' -import { Input } from '../ui/input' -import { Label } from '../ui/label' import { OpenInMenuSetting } from './OpenInMenuSetting' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' +import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting' import { translate } from '@/i18n/i18n' type GeneralWorkspaceSettingsSectionProps = { @@ -18,13 +15,6 @@ export function GeneralWorkspaceSettingsSection({ settings, updateSettings }: GeneralWorkspaceSettingsSectionProps): React.JSX.Element { - const handleBrowseWorkspace = async (): Promise<void> => { - const path = await window.api.repos.pickFolder() - if (path) { - updateSettings({ workspaceDir: path }) - } - } - return ( <section key="workspace" className="space-y-4"> <SettingsSubsectionHeader @@ -38,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/HostedReviewCreationDefaults.tsx b/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx new file mode 100644 index 00000000000..3f4b1ac7298 --- /dev/null +++ b/src/renderer/src/components/settings/HostedReviewCreationDefaults.tsx @@ -0,0 +1,128 @@ +import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' +import { Label } from '../ui/label' +import { SearchableSetting } from './SearchableSetting' +import { translate } from '@/i18n/i18n' + +type HostedReviewDefaultKey = keyof NonNullable<SourceControlAiSettings['prCreationDefaults']> + +const KEYWORDS = [ + 'hosted review', + 'pull request', + 'merge request', + 'pr', + 'draft', + 'template', + 'generate', + 'open' +] + +function getHostedReviewDefaultRows(): { + key: HostedReviewDefaultKey + label: string + description: string +}[] { + return [ + { + 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.' + ) + }, + { + 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.' + ) + }, + { + 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.' + ) + }, + { + 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.' + ) + } + ] +} + +export function HostedReviewCreationDefaults({ + prDefaults, + onPrDefaultChange +}: { + prDefaults: NonNullable<SourceControlAiSettings['prCreationDefaults']> + onPrDefaultChange: (key: HostedReviewDefaultKey, value: boolean) => void +}): React.JSX.Element { + return ( + <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.' + )} + keywords={KEYWORDS} + 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> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.CommitMessageAiPane.347094560b', + 'Used by repositories that inherit global hosted-review defaults.' + )} + </p> + </div> + <div className="space-y-2"> + {getHostedReviewDefaultRows().map((row) => ( + <label + key={row.key} + className="flex items-start justify-between gap-4 rounded-md border border-border px-3 py-2" + > + <span className="space-y-0.5"> + <span className="block text-xs font-medium text-foreground">{row.label}</span> + <span className="block text-[11px] text-muted-foreground">{row.description}</span> + </span> + <input + type="checkbox" + checked={prDefaults[row.key] === true} + onChange={(event) => onPrDefaultChange(row.key, event.target.checked)} + className="mt-0.5 size-4 rounded border-border accent-primary" + /> + </label> + ))} + </div> + </SearchableSetting> + ) +} diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index a7a895ef577..de48b535e3d 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -14,9 +14,6 @@ import { translate } from '@/i18n/i18n' type ConfirmKind = 'killOne' export function ManageSessionsSection(): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null - ) const [sessions, setSessions] = useState<PtyManagementSession[]>([]) const [isRefreshing, setIsRefreshing] = useState(true) const [hasLoadedOnce, setHasLoadedOnce] = useState(false) @@ -72,14 +69,6 @@ export function ManageSessionsSection(): React.JSX.Element { }, []) const refresh = useCallback(async (): Promise<PtyManagementSession[]> => { - if (activeRuntimeEnvironmentId?.trim()) { - if (isMounted.current) { - setSessions([]) - setIsRefreshing(false) - setHasLoadedOnce(true) - } - return [] - } setIsRefreshing(true) try { const result = await window.api.pty.management.listSessions() @@ -108,7 +97,7 @@ export function ManageSessionsSection(): React.JSX.Element { setHasLoadedOnce(true) } } - }, [activeRuntimeEnvironmentId]) + }, []) useEffect(() => { void refresh() @@ -192,40 +181,6 @@ export function ManageSessionsSection(): React.JSX.Element { const isBusy = busyKind !== null || daemonActions.isBusy - if (activeRuntimeEnvironmentId?.trim()) { - return ( - <section className="space-y-4"> - <div className="space-y-1"> - <h3 className="text-sm font-semibold"> - {translate( - 'auto.components.settings.ManageSessionsSection.d1b80fd5cd', - 'Manage Sessions' - )} - </h3> - <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.ManageSessionsSection.ad467eaadc', - 'Session management is unavailable while a remote runtime server is active.' - )} - </p> - </div> - <SearchableSetting - title={getManageSessionsSearchEntries()[0].title} - description={getManageSessionsSearchEntries()[0].description} - keywords={getManageSessionsSearchEntries()[0].keywords} - className="space-y-3" - > - <div className="rounded-lg border border-border/60 px-3 py-3 text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.ManageSessionsSection.9c940434af', - 'Switch back to the local runtime to restart or kill local daemon sessions.' - )} - </div> - </SearchableSetting> - </section> - ) - } - return ( <section className="space-y-4"> <div className="space-y-1"> 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/ProviderHostScopeControl.tsx b/src/renderer/src/components/settings/ProviderHostScopeControl.tsx new file mode 100644 index 00000000000..4326ee3428f --- /dev/null +++ b/src/renderer/src/components/settings/ProviderHostScopeControl.tsx @@ -0,0 +1,49 @@ +import { ServerCog } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import type { ProviderAccountScope, ProviderRateLimitScope } from './provider-account-scope' +import { translate } from '@/i18n/i18n' + +type ProviderHostScopeControlProps = { + labelPrefix: string + scope: ProviderAccountScope | ProviderRateLimitScope + className?: string +} + +export function ProviderHostScopeControl({ + labelPrefix, + scope, + className +}: ProviderHostScopeControlProps): React.JSX.Element { + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + + const openHostsSettings = (): void => { + openSettingsPage() + openSettingsTarget({ pane: 'servers', repoId: null, sectionId: 'default-runtime' }) + } + + return ( + <div className={className}> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <span className="font-medium text-foreground"> + {translate( + 'auto.components.settings.ProviderHostScopeControl.scope_label', + '{{value0}}: {{value1}}', + { value0: labelPrefix, value1: scope.label } + )} + </span> + <div className="mt-0.5 text-muted-foreground">{scope.description}</div> + </div> + <Button type="button" variant="ghost" size="sm" onClick={openHostsSettings}> + <ServerCog className="size-3.5" /> + {translate( + 'auto.components.settings.ProviderHostScopeControl.change_host', + 'Open Remote Servers' + )} + </Button> + </div> + </div> + ) +} diff --git a/src/renderer/src/components/settings/QuickCommandsList.tsx b/src/renderer/src/components/settings/QuickCommandsList.tsx new file mode 100644 index 00000000000..bffc71f2d82 --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsList.tsx @@ -0,0 +1,163 @@ +import { Pencil, Trash2 } from 'lucide-react' +import type { + Repo, + TerminalQuickCommand, + TerminalQuickCommandScope +} from '../../../../shared/types' +import { + getTerminalQuickCommandBody, + getTerminalQuickCommandScope, + isTerminalAgentQuickCommand +} from '../../../../shared/terminal-quick-commands' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { RepoBadgeMark } from '../repo/RepoBadgeLabel' +import { getQuickCommandRepoLabel } from './QuickCommandsScopeFilter' + +function getScopeLabel( + scope: TerminalQuickCommandScope, + repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> +): string { + if (scope.type === 'global') { + return 'Global' + } + const repo = repoById.get(scope.repoId) + return repo ? getQuickCommandRepoLabel(repo) : 'Missing project' +} + +function QuickCommandRow({ + command, + repoById, + onEdit, + onRemove +}: { + command: TerminalQuickCommand + repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> + onEdit: (command: TerminalQuickCommand) => void + onRemove: (command: TerminalQuickCommand) => void +}): React.JSX.Element { + const scope = getTerminalQuickCommandScope(command) + return ( + <div className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 shadow-xs"> + <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')} + </div> + <Badge variant="outline" className="max-w-44 gap-1.5"> + {scope.type === 'repo' ? ( + <> + <RepoBadgeMark color={repoById.get(scope.repoId)?.badgeColor} /> + <span className="truncate">{getScopeLabel(scope, repoById)}</span> + </> + ) : ( + <span className="truncate">{getScopeLabel(scope, repoById)}</span> + )} + </Badge> + </div> + <div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground/80"> + {isTerminalAgentQuickCommand(command) ? ( + <span className="shrink-0 text-muted-foreground"> + <AgentIcon agent={command.agent} size={12} /> + </span> + ) : null} + <span className={cn('truncate', isTerminalAgentQuickCommand(command) ? '' : 'font-mono')}> + {isTerminalAgentQuickCommand(command) + ? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}` + : 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') + : command.appendEnter + ? 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' + } + )} + onClick={() => onEdit(command)} + > + <Pencil /> + </Button> + <Button + type="button" + variant="ghost" + size="icon-sm" + aria-label={translate( + 'auto.components.settings.QuickCommandsPane.8764c6e9e4', + 'Remove {{value0}}', + { + value0: command.label || 'quick command' + } + )} + onClick={() => onRemove(command)} + className="text-muted-foreground hover:text-destructive" + > + <Trash2 /> + </Button> + </div> + ) +} + +export function QuickCommandsList({ + commands, + visibleCommands, + repoById, + onEdit, + onRemove +}: { + commands: TerminalQuickCommand[] + visibleCommands: TerminalQuickCommand[] + repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> + onEdit: (command: TerminalQuickCommand) => void + onRemove: (command: TerminalQuickCommand) => void +}): React.JSX.Element { + return ( + <div className="overflow-hidden rounded-lg border border-border/50 bg-muted/20"> + {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.' + )} + </div> + ) : ( + <div className="max-h-[60vh] space-y-2 overflow-y-auto p-2 scrollbar-sleek"> + {visibleCommands.map((command) => ( + <QuickCommandRow + key={command.id} + command={command} + repoById={repoById} + onEdit={onEdit} + onRemove={onRemove} + /> + ))} + </div> + )} + </div> + ) +} diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index acba1735bc0..4b70c72c900 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -1,31 +1,19 @@ import { useCallback, useMemo, useRef, useState } from 'react' -import { Check, ChevronsUpDown, Pencil, Plus, Trash2 } from 'lucide-react' -import type { - GlobalSettings, - Repo, - TerminalQuickCommand, - TerminalQuickCommandScope -} from '../../../../shared/types' -import { - getTerminalQuickCommandBody, - getTerminalQuickCommandScope, - isTerminalAgentQuickCommand -} from '../../../../shared/terminal-quick-commands' +import { Plus } from 'lucide-react' +import type { GlobalSettings, TerminalQuickCommand } from '../../../../shared/types' +import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands' import { createTerminalQuickCommandDraft, TerminalQuickCommandDialog } from '@/components/terminal-quick-commands/TerminalQuickCommandDialog' import { useAppStore } from '../../store' -import { Badge } from '../ui/badge' import { Button } from '../ui/button' -import { Command, CommandItem, CommandList } from '../ui/command' import { Label } from '../ui/label' -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' -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' +import { QuickCommandsList } from './QuickCommandsList' +import { GLOBAL_SCOPE_KEY, QuickCommandsScopeFilter } from './QuickCommandsScopeFilter' type QuickCommandsPaneProps = { settings: GlobalSettings @@ -33,8 +21,6 @@ type QuickCommandsPaneProps = { addCommandIntentSignal?: number } -const GLOBAL_SCOPE_KEY = '__global__' - type EditorState = | { mode: 'add' @@ -46,10 +32,6 @@ type EditorState = } | null -function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string { - return repo.displayName || repo.path -} - export function shouldOpenQuickCommandAddIntent( addCommandIntentSignal: number | undefined, consumedAddIntentSignal: number @@ -57,17 +39,6 @@ export function shouldOpenQuickCommandAddIntent( return Boolean(addCommandIntentSignal && consumedAddIntentSignal !== addCommandIntentSignal) } -function getScopeLabel( - scope: TerminalQuickCommandScope, - repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>> -): string { - if (scope.type === 'global') { - return 'Global' - } - const repo = repoById.get(scope.repoId) - return repo ? getRepoLabel(repo) : 'Missing project' -} - export function QuickCommandsPane({ settings, updateSettings, @@ -76,6 +47,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) @@ -162,23 +134,6 @@ export function QuickCommandsPane({ setScopeSelection(null) } - const renderTriggerLabel = (): React.JSX.Element => { - if (showAll) { - 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)) - const parts: string[] = [] - if (includesGlobal) { - parts.push('Global') - } - if (selectedRepos.length > 0) { - 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> - } - const saveCommand = (next: TerminalQuickCommand): void => { // Why: re-read from the store so save lands on the latest list when // multiple edit dialogs fire in quick succession. @@ -193,9 +148,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 +176,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,172 +188,27 @@ 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"> - <Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}> - <PopoverTrigger asChild> - <Button - type="button" - variant="outline" - role="combobox" - aria-expanded={scopePopoverOpen} - className="h-8 min-w-52 justify-between px-3 text-xs font-normal" - > - {renderTriggerLabel()} - <ChevronsUpDown className="size-3.5 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent - align="start" - className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" - > - <Command> - <div className="border-b border-border"> - <button - type="button" - onClick={handleSelectAll} - onMouseDown={(event) => event.preventDefault()} - className={cn( - 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground', - showAll && 'opacity-80' - )} - > - <Check - className={cn( - 'size-3 text-muted-foreground', - showAll ? 'opacity-70' : 'opacity-0' - )} - /> - <span>{translate("auto.components.settings.QuickCommandsPane.c6b155911b", "All commands")}</span> - </button> - </div> - <CommandList> - <CommandItem - value={GLOBAL_SCOPE_KEY} - onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - <Check - className={cn( - 'size-3 text-muted-foreground', - effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0' - )} - /> - <span>{translate("auto.components.settings.QuickCommandsPane.8c877dec41", "Global")}</span> - </CommandItem> - {repos.map((repo) => { - const isSelected = effectiveSelection.has(repo.id) - return ( - <CommandItem - key={repo.id} - value={repo.id} - onSelect={() => toggleScope(repo.id)} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - <Check - className={cn( - 'size-3 text-muted-foreground', - isSelected ? 'opacity-70' : 'opacity-0' - )} - /> - <RepoBadgeLabel - name={getRepoLabel(repo)} - color={repo.badgeColor} - className="max-w-full" - /> - </CommandItem> - ) - })} - </CommandList> - </Command> - </PopoverContent> - </Popover> - </div> + <QuickCommandsScopeFilter + repos={repos} + effectiveSelection={effectiveSelection} + showAll={showAll} + scopePopoverOpen={scopePopoverOpen} + setScopePopoverOpen={setScopePopoverOpen} + handleSelectAll={handleSelectAll} + toggleScope={toggleScope} + /> - <div className="overflow-hidden rounded-lg border border-border/50 bg-muted/20"> - {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.")} - </div> - ) : ( - <div className="max-h-[60vh] space-y-2 overflow-y-auto p-2 scrollbar-sleek"> - {visibleCommands.map((command) => { - const scope = getTerminalQuickCommandScope(command) - return ( - <div - key={command.id} - className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 shadow-xs" - > - <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")} - </div> - <Badge variant="outline" className="max-w-44 gap-1.5"> - {scope.type === 'repo' ? ( - <> - <RepoBadgeMark color={repoById.get(scope.repoId)?.badgeColor} /> - <span className="truncate">{getScopeLabel(scope, repoById)}</span> - </> - ) : ( - <span className="truncate">{getScopeLabel(scope, repoById)}</span> - )} - </Badge> - </div> - <div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground/80"> - {isTerminalAgentQuickCommand(command) ? ( - <span className="shrink-0 text-muted-foreground"> - <AgentIcon agent={command.agent} size={12} /> - </span> - ) : null} - <span - className={cn( - 'truncate', - isTerminalAgentQuickCommand(command) ? '' : 'font-mono' - )} - > - {isTerminalAgentQuickCommand(command) - ? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}` - : 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") - : command.appendEnter - ? 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' })} - onClick={() => setEditor({ mode: 'edit', command })} - > - <Pencil /> - </Button> - <Button - type="button" - variant="ghost" - size="icon-sm" - 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" - > - <Trash2 /> - </Button> - </div> - ) - })} - </div> - )} - </div> + <QuickCommandsList + commands={commands} + visibleCommands={visibleCommands} + repoById={repoById} + onEdit={(command) => setEditor({ mode: 'edit', command })} + onRemove={(command) => void removeCommand(command)} + /> {editor !== null ? ( <TerminalQuickCommandDialog diff --git a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx new file mode 100644 index 00000000000..e845b9dd0b4 --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx @@ -0,0 +1,161 @@ +import { Check, ChevronsUpDown } from 'lucide-react' +import type { Dispatch, SetStateAction } from 'react' +import type { Repo } from '../../../../shared/types' +import { Button } from '../ui/button' +import { Command, CommandItem, CommandList } from '../ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import RepoBadgeLabel from '../repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export const GLOBAL_SCOPE_KEY = '__global__' + +export function getQuickCommandRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string { + return repo.displayName || repo.path +} + +function ScopeTriggerLabel({ + showAll, + effectiveSelection, + repos +}: { + showAll: boolean + effectiveSelection: ReadonlySet<string> + repos: Repo[] +}): React.JSX.Element { + if (showAll) { + return ( + <span> + {translate('auto.components.settings.QuickCommandsPane.c6b155911b', 'All commands')} + </span> + ) + } + const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY) + const selectedRepos = repos.filter((repo) => effectiveSelection.has(repo.id)) + const parts: string[] = [] + if (includesGlobal) { + parts.push('Global') + } + if (selectedRepos.length > 0) { + 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> + ) +} + +export function QuickCommandsScopeFilter({ + repos, + effectiveSelection, + showAll, + scopePopoverOpen, + setScopePopoverOpen, + handleSelectAll, + toggleScope +}: { + repos: Repo[] + effectiveSelection: ReadonlySet<string> + showAll: boolean + scopePopoverOpen: boolean + setScopePopoverOpen: Dispatch<SetStateAction<boolean>> + handleSelectAll: () => void + toggleScope: (key: string) => void +}): React.JSX.Element { + return ( + <div className="flex flex-wrap items-center gap-2"> + <Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={scopePopoverOpen} + className="h-8 min-w-52 justify-between px-3 text-xs font-normal" + > + <ScopeTriggerLabel + showAll={showAll} + effectiveSelection={effectiveSelection} + repos={repos} + /> + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" + > + <Command> + <div className="border-b border-border"> + <button + type="button" + onClick={handleSelectAll} + onMouseDown={(event) => event.preventDefault()} + className={cn( + 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground', + showAll && 'opacity-80' + )} + > + <Check + className={cn( + 'size-3 text-muted-foreground', + showAll ? 'opacity-70' : 'opacity-0' + )} + /> + <span> + {translate( + 'auto.components.settings.QuickCommandsPane.c6b155911b', + 'All commands' + )} + </span> + </button> + </div> + <CommandList> + <CommandItem + value={GLOBAL_SCOPE_KEY} + onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0' + )} + /> + <span> + {translate('auto.components.settings.QuickCommandsPane.8c877dec41', 'Global')} + </span> + </CommandItem> + {repos.map((repo) => { + const isSelected = effectiveSelection.has(repo.id) + return ( + <CommandItem + key={repo.id} + value={repo.id} + onSelect={() => toggleScope(repo.id)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + isSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <RepoBadgeLabel + name={getQuickCommandRepoLabel(repo)} + color={repo.badgeColor} + className="max-w-full" + /> + </CommandItem> + ) + })} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx new file mode 100644 index 00000000000..092bd558376 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react' +import { type ExecutionHostId } from '../../../../shared/execution-host' +import type { + ProjectHostSetup, + ProjectHostSetupCreateResult, + ProjectHostSetupResult +} from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import type { SetupHostOption } from './repository-host-setup-options' + +type RepositoryHostSetupActionsProps = { + repoDisplayName: string + selectedProjectHostSetup: ProjectHostSetup + setupHostOptions: SetupHostOption[] + setupProjectExistingFolder: (args: { + projectId: string + hostId: ExecutionHostId + path: string + kind: 'git' | 'folder' + displayName: string + }) => Promise<ProjectHostSetupResult | null> + setupProjectClone: (args: { + projectId: string + hostId: ExecutionHostId + url: string + destination: string + displayName: string + }) => Promise<ProjectHostSetupResult | null> + createProjectHostSetup: (args: { + projectId: string + hostId: ExecutionHostId + displayName: string + setupState: 'not-set-up' + setupMethod: 'provisioned' + }) => Promise<ProjectHostSetupCreateResult | null> + onOpenSetup: (repoId: string) => void +} + +export function RepositoryHostSetupActions({ + repoDisplayName, + selectedProjectHostSetup, + setupHostOptions, + setupProjectExistingFolder, + setupProjectClone, + createProjectHostSetup, + onOpenSetup +}: RepositoryHostSetupActionsProps): React.JSX.Element | null { + const [selectedSetupHostId, setSelectedSetupHostId] = useState<ExecutionHostId | null>(null) + const [setupPath, setSetupPath] = useState('') + const [setupKind, setSetupKind] = useState<'git' | 'folder'>('git') + const [cloneUrl, setCloneUrl] = useState('') + const [cloneDestination, setCloneDestination] = useState('') + const [isSettingUp, setIsSettingUp] = useState(false) + const [isCloning, setIsCloning] = useState(false) + const [isCreatingPendingSetup, setIsCreatingPendingSetup] = useState(false) + const defaultSetupHostOption = + setupHostOptions.find((option) => option.isAvailable) ?? setupHostOptions[0] ?? null + const setupTargetHostId = selectedSetupHostId ?? defaultSetupHostOption?.id ?? null + const setupTargetHostOption = + setupHostOptions.find((option) => option.id === setupTargetHostId) ?? null + const canUseSetupTargetHost = setupTargetHostOption?.isAvailable ?? false + + if (setupHostOptions.length === 0) { + return null + } + + return ( + <div className="space-y-3 rounded-md border border-border bg-muted/20 p-3"> + <div className="space-y-1"> + <Label className="text-sm font-semibold"> + {translate( + 'auto.components.settings.RepositoryPane.setupProjectOnHost', + 'Set up on another host' + )} + </Label> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RepositoryPane.setupProjectOnHostHelp', + 'Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.' + )} + </p> + </div> + <div className="max-w-48"> + <Select + value={setupTargetHostId ?? undefined} + onValueChange={(value) => setSelectedSetupHostId(value as ExecutionHostId)} + > + <SelectTrigger className="h-9 min-w-0"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {setupHostOptions.map((option) => ( + <SelectItem key={option.id} value={option.id} disabled={!option.isAvailable}> + <span className="min-w-0"> + <span className="block truncate">{option.label}</span> + {!option.isAvailable ? ( + <span className="block truncate text-[11px] text-muted-foreground"> + {option.detail} + </span> + ) : null} + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]"> + <Input + value={setupPath} + onChange={(event) => setSetupPath(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.setupExistingFolderPathPlaceholder', + '/path/to/project/on/host' + )} + className="h-9 min-w-0" + /> + <Select + value={setupKind} + onValueChange={(value) => setSetupKind(value as 'git' | 'folder')} + > + <SelectTrigger className="h-9 w-32 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="git"> + {translate('auto.components.settings.RepositoryPane.setupKindGit', 'Git repo')} + </SelectItem> + <SelectItem value="folder"> + {translate('auto.components.settings.RepositoryPane.setupKindFolder', 'Folder')} + </SelectItem> + </SelectContent> + </Select> + <Button + type="button" + size="sm" + disabled={!canUseSetupTargetHost || !setupPath.trim() || isSettingUp} + onClick={async () => { + if (!setupTargetHostId || !canUseSetupTargetHost || !setupPath.trim()) { + return + } + setIsSettingUp(true) + const result = await setupProjectExistingFolder({ + projectId: selectedProjectHostSetup.projectId, + hostId: setupTargetHostId, + path: setupPath.trim(), + kind: setupKind, + displayName: repoDisplayName + }) + setIsSettingUp(false) + if (result) { + setSetupPath('') + setSelectedSetupHostId(null) + onOpenSetup(result.repo.id) + } + }} + > + {isSettingUp + ? translate('auto.components.settings.RepositoryPane.settingUpHost', 'Importing...') + : translate('auto.components.settings.RepositoryPane.setupHost', 'Import')} + </Button> + </div> + <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"> + <Input + value={cloneUrl} + onChange={(event) => setCloneUrl(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.cloneUrlPlaceholder', + 'Repository URL' + )} + className="h-9 min-w-0" + /> + <Input + value={cloneDestination} + onChange={(event) => setCloneDestination(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.cloneDestinationPlaceholder', + '/destination/on/host' + )} + className="h-9 min-w-0" + /> + <Button + type="button" + size="sm" + disabled={ + !canUseSetupTargetHost || !cloneUrl.trim() || !cloneDestination.trim() || isCloning + } + onClick={async () => { + if ( + !setupTargetHostId || + !canUseSetupTargetHost || + !cloneUrl.trim() || + !cloneDestination.trim() + ) { + return + } + setIsCloning(true) + const result = await setupProjectClone({ + projectId: selectedProjectHostSetup.projectId, + hostId: setupTargetHostId, + url: cloneUrl.trim(), + destination: cloneDestination.trim(), + displayName: repoDisplayName + }) + setIsCloning(false) + if (result) { + setCloneUrl('') + setCloneDestination('') + setSelectedSetupHostId(null) + onOpenSetup(result.repo.id) + } + }} + > + {isCloning + ? translate('auto.components.settings.RepositoryPane.cloningHost', 'Cloning...') + : translate('auto.components.settings.RepositoryPane.cloneHost', 'Clone')} + </Button> + </div> + <div className="flex justify-end"> + <Button + type="button" + variant="outline" + size="sm" + disabled={!canUseSetupTargetHost || isCreatingPendingSetup} + onClick={async () => { + if (!setupTargetHostId || !canUseSetupTargetHost) { + return + } + setIsCreatingPendingSetup(true) + const result = await createProjectHostSetup({ + projectId: selectedProjectHostSetup.projectId, + hostId: setupTargetHostId, + displayName: repoDisplayName, + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + setIsCreatingPendingSetup(false) + if (result) { + setSelectedSetupHostId(null) + } + }} + > + {isCreatingPendingSetup + ? translate( + 'auto.components.settings.RepositoryPane.creatingPendingSetup', + 'Creating...' + ) + : translate( + 'auto.components.settings.RepositoryPane.createPendingSetup', + 'Track setup' + )} + </Button> + </div> + </div> + ) +} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx new file mode 100644 index 00000000000..f0be673da8d --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -0,0 +1,606 @@ +// @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 { toSshExecutionHostId } from '../../../../shared/execution-host' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' + +let container: HTMLDivElement +let root: Root + +function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'displayName' | 'path'>): Repo { + return { + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...overrides + } +} + +function makeProject({ id, ...overrides }: Partial<Project> & Pick<Project, 'id'>): Project { + return { + id, + displayName: 'Orca', + badgeColor: '#737373', + sourceRepoIds: ['local-repo', 'remote-repo'], + createdAt: 100, + updatedAt: 100, + ...overrides + } +} + +function makeSetup( + overrides: Partial<ProjectHostSetup> & + Pick<ProjectHostSetup, 'id' | 'projectId' | 'repoId' | 'hostId' | 'path'> +): ProjectHostSetup { + return { + displayName: 'Orca', + kind: 'git', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 100, + updatedAt: 100, + ...overrides + } +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +function renderSection(repo: Repo): void { + act(() => { + root.render( + React.createElement(RepositoryHostSetupsSection, { + repo, + forceVisible: true, + searchQuery: '', + searchEntries: [] + }) + ) + }) +} + +function typeIntoInput(input: HTMLInputElement, value: string): void { + act(() => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setValue?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('RepositoryHostSetupsSection', () => { + it('shows a viewing-host selector when the project has multiple settings-backed hosts', () => { + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + const remoteRepo = makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + useAppStore.setState({ + repos: [localRepo, remoteRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }), + makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }) + ], + sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]) + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('Viewing host') + expect(container.textContent).toContain('Local Mac') + }) + + it('opens the selected host setup settings pane through the setup repo id', () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + const remoteRepo = makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + useAppStore.setState({ + repos: [localRepo, remoteRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }), + makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }) + ], + openSettingsPage, + openSettingsTarget + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('openclaw 2') + const openButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Open' + ) + expect(openButton).toBeTruthy() + + act(() => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' }) + }) + + it('removes independent setup metadata instead of opening an empty repo target', async () => { + const deleteProjectHostSetup = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '' + }) + }) + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }), + makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ], + openSettingsPage, + openSettingsTarget, + deleteProjectHostSetup + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('Path pending') + const removeButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Remove' + ) + expect(removeButton).toBeTruthy() + + await act(async () => { + removeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'gpu-setup' }) + expect(openSettingsPage).not.toHaveBeenCalled() + expect(openSettingsTarget).not.toHaveBeenCalled() + }) + + it('sets up the project on another known host from an existing folder path', async () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const setupProjectExistingFolder = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }), + repo: makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]), + openSettingsPage, + openSettingsTarget, + setupProjectExistingFolder + }) + + renderSection(localRepo) + const pathInput = container.querySelector<HTMLInputElement>( + 'input[placeholder="/path/to/project/on/host"]' + ) + expect(pathInput).toBeTruthy() + typeIntoInput(pathInput!, '/home/alice/orca') + + const importButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Import' + ) + expect(importButton).toBeTruthy() + + await act(async () => { + importButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(setupProjectExistingFolder).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw%202', + path: '/home/alice/orca', + kind: 'git', + displayName: 'Orca' + }) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' }) + }) + + it('clones the project onto another known host from settings', async () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + const setupProjectClone = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'remote-repo', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: toSshExecutionHostId('openclaw 2'), + path: '/home/alice/orca' + }), + repo: makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/home/alice/orca', + connectionId: 'openclaw 2' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]), + openSettingsPage, + openSettingsTarget, + setupProjectClone + }) + + renderSection(localRepo) + const urlInput = container.querySelector<HTMLInputElement>( + 'input[placeholder="Repository URL"]' + ) + const destinationInput = container.querySelector<HTMLInputElement>( + 'input[placeholder="/destination/on/host"]' + ) + expect(urlInput).toBeTruthy() + expect(destinationInput).toBeTruthy() + typeIntoInput(urlInput!, 'https://github.com/stablyai/orca.git') + typeIntoInput(destinationInput!, '/home/alice') + + const cloneButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Clone' + ) + expect(cloneButton).toBeTruthy() + + await act(async () => { + cloneButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(setupProjectClone).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw%202', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/alice', + displayName: 'Orca' + }) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' }) + }) + + it('creates pending setup metadata for a known host without requiring a path', async () => { + const createProjectHostSetup = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + settings: { activeRuntimeEnvironmentId: 'gpu' } as never, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + checkedAt: 1, + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ] + } + } + ] + ]), + createProjectHostSetup + }) + + renderSection(localRepo) + + const trackButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Track setup' + ) + expect(trackButton).toBeTruthy() + + await act(async () => { + trackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(createProjectHostSetup).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + displayName: 'Orca', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) + + it('shows unsupported runtime hosts without enabling setup actions', async () => { + const createProjectHostSetup = vi.fn() + const setupProjectClone = vi.fn() + const setupProjectExistingFolder = vi.fn() + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + settings: { activeRuntimeEnvironmentId: null } as never, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + checkedAt: 1, + appVersion: '1.7.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [] + } + } + ] + ]), + createProjectHostSetup, + setupProjectClone, + setupProjectExistingFolder + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('Update Orca on this host to set up projects') + const trackButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Track setup' + ) + expect(trackButton?.disabled).toBe(true) + + await act(async () => { + trackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(createProjectHostSetup).not.toHaveBeenCalled() + expect(setupProjectClone).not.toHaveBeenCalled() + expect(setupProjectExistingFolder).not.toHaveBeenCalled() + }) + + it('offers inactive runtime hosts discovered from hydrated runtime status', async () => { + const createProjectHostSetup = vi.fn().mockResolvedValue({ + project: makeProject({ id: 'github:stablyai/orca' }), + setup: makeSetup({ + id: 'gpu-setup', + projectId: 'github:stablyai/orca', + repoId: '', + hostId: 'runtime:gpu', + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) + const localRepo = makeRepo({ + id: 'local-repo', + displayName: 'Orca', + path: '/Users/alice/orca' + }) + useAppStore.setState({ + repos: [localRepo], + projects: [makeProject({ id: 'github:stablyai/orca' })], + projectHostSetups: [ + makeSetup({ + id: 'local-repo', + projectId: 'github:stablyai/orca', + repoId: 'local-repo', + hostId: 'local', + path: '/Users/alice/orca' + }) + ], + settings: { activeRuntimeEnvironmentId: null } as never, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + checkedAt: 1, + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ] + } + } + ] + ]), + createProjectHostSetup + }) + + renderSection(localRepo) + + expect(container.textContent).toContain('gpu') + const trackButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Track setup' + ) + + await act(async () => { + trackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(createProjectHostSetup).toHaveBeenCalledWith({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu', + displayName: 'Orca', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx new file mode 100644 index 00000000000..f149567f6aa --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState } from 'react' +import { getExecutionHostLabel } from '../../../../shared/execution-host' +import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import type { Repo } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { getProjectHostSetupProjectionFromState } from '../../store/selectors' +import { cn } from '../../lib/utils' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { SettingsBadge } from './SettingsFormControls' +import { matchesSettingsSearch } from './settings-search' +import type { SettingsSearchEntry } from './settings-search' +import { translate } from '@/i18n/i18n' +import { buildSetupHostOptions, getSetupStateLabel } from './repository-host-setup-options' +import { RepositoryHostSetupActions } from './RepositoryHostSetupActions' + +type RepositoryHostSetupsSectionProps = { + repo: Repo + forceVisible: boolean + searchQuery: string + searchEntries: SettingsSearchEntry[] +} + +export function RepositoryHostSetupsSection({ + repo, + forceVisible, + searchQuery, + searchEntries +}: RepositoryHostSetupsSectionProps): React.JSX.Element | null { + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const setupProjectExistingFolder = useAppStore((state) => state.setupProjectExistingFolder) + const setupProjectClone = useAppStore((state) => state.setupProjectClone) + const createProjectHostSetup = useAppStore((state) => state.createProjectHostSetup) + const deleteProjectHostSetup = useAppStore((state) => state.deleteProjectHostSetup) + const repos = useAppStore((state) => state.repos) + const sshTargetLabels = useAppStore((state) => state.sshTargetLabels) + const sshConnectionStates = useAppStore((state) => state.sshConnectionStates) + const settings = useAppStore((state) => state.settings) + const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostOptions = useMemo( + () => + buildExecutionHostRegistry({ + repos, + settings, + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + settings, + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const projectHostSetupProjection = useAppStore((state) => + getProjectHostSetupProjectionFromState(state) + ) + const selectedProjectHostSetup = projectHostSetupProjection.setups.find( + (setup) => setup.repoId === repo.id + ) + const projectHostSetups = selectedProjectHostSetup + ? projectHostSetupProjection.setups.filter( + (setup) => setup.projectId === selectedProjectHostSetup.projectId + ) + : [] + const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim()) + const setupHostOptions = buildSetupHostOptions({ + projectHostSetups, + hostOptions + }) + const hostOptionById = new Map(hostOptions.map((option) => [option.id, option])) + const [deletingSetupId, setDeletingSetupId] = useState<string | null>(null) + const openSetup = (repoId: string) => { + openSettingsPage() + openSettingsTarget({ pane: 'repo', repoId }) + } + + if ( + (projectHostSetups.length <= 1 && setupHostOptions.length === 0) || + (!forceVisible && !matchesSettingsSearch(searchQuery, searchEntries)) + ) { + return null + } + + return ( + <SearchableSetting + title={translate('auto.components.settings.RepositoryPane.availableHosts', 'Available Hosts')} + description={translate( + 'auto.components.settings.RepositoryPane.availableHostsDescription', + 'Hosts where this project is set up.' + )} + keywords={[repo.displayName, 'host', 'ssh', 'remote', 'vm', 'path']} + className="space-y-3" + forceVisible={forceVisible} + > + <div className="space-y-1"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <Label className="text-sm font-semibold"> + {translate('auto.components.settings.RepositoryPane.availableHosts', 'Available Hosts')} + </Label> + {openableProjectHostSetups.length > 1 ? ( + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground"> + {translate('auto.components.settings.RepositoryPane.viewingHost', 'Viewing host')} + </span> + <Select + value={repo.id} + onValueChange={(repoId) => { + if (repoId === repo.id) { + return + } + openSetup(repoId) + }} + > + <SelectTrigger className="h-8 w-44 min-w-0 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {openableProjectHostSetups.map((setup) => ( + <SelectItem key={setup.id} value={setup.repoId}> + <span className="block min-w-0 truncate"> + {hostOptionById.get(setup.hostId)?.label ?? + getExecutionHostLabel(setup.hostId)} + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) : null} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RepositoryPane.availableHostsHelp', + 'Project paths and worktree settings are host-specific; creating a workspace can target any ready setup.' + )} + </p> + </div> + <div className="divide-y divide-border rounded-md border border-border"> + {projectHostSetups.map((setup) => { + const isCurrentSetup = setup.repoId === repo.id + const canOpenSetup = setup.repoId.trim().length > 0 + const canRemoveSetup = !canOpenSetup && deletingSetupId !== setup.id + return ( + <div + key={setup.id} + className={cn( + 'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors', + isCurrentSetup ? 'bg-muted/30' : '' + )} + > + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <span className="truncate text-sm font-medium"> + {hostOptionById.get(setup.hostId)?.label ?? getExecutionHostLabel(setup.hostId)} + </span> + <SettingsBadge tone={setup.setupState === 'ready' ? 'accent' : 'muted'}> + {getSetupStateLabel(setup.setupState)} + </SettingsBadge> + </div> + <p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground"> + {setup.path || + translate( + 'auto.components.settings.RepositoryPane.setupPathPending', + 'Path pending' + )} + </p> + </div> + {isCurrentSetup ? ( + <SettingsBadge> + {translate('auto.components.settings.RepositoryPane.currentSetup', 'Current')} + </SettingsBadge> + ) : null} + {!isCurrentSetup && canOpenSetup ? ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + openSetup(setup.repoId) + }} + > + {translate('auto.components.settings.RepositoryPane.openSetup', 'Open')} + </Button> + ) : null} + {canRemoveSetup ? ( + <Button + type="button" + variant="outline" + size="sm" + onClick={async () => { + setDeletingSetupId(setup.id) + await deleteProjectHostSetup({ setupId: setup.id }) + setDeletingSetupId(null) + }} + > + {translate('auto.components.settings.RepositoryPane.removeSetup', 'Remove')} + </Button> + ) : null} + </div> + ) + })} + </div> + {selectedProjectHostSetup ? ( + <RepositoryHostSetupActions + repoDisplayName={repo.displayName} + selectedProjectHostSetup={selectedProjectHostSetup} + setupHostOptions={setupHostOptions} + setupProjectExistingFolder={setupProjectExistingFolder} + setupProjectClone={setupProjectClone} + createProjectHostSetup={createProjectHostSetup} + onOpenSetup={openSetup} + /> + ) : null} + </SearchableSetting> + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 50ea2d598c5..f7b2b924663 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -10,6 +10,7 @@ import { Label } from '../ui/label' import { RepoIconGlyph, getRepoLucideIconOptions } from '../repo/repo-icon' import { useAppStore } from '@/store' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { useMountedRef } from '@/hooks/useMountedRef' import { RepositoryIconColorSection } from './RepositoryIconColorSection' import { RepositoryIconTabs } from './RepositoryIconTabs' @@ -29,8 +30,10 @@ export function RepositoryIconPicker({ const [loadingGitHub, setLoadingGitHub] = useState(false) const [resetting, setResetting] = useState(false) const mountedRef = useMountedRef() - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + // Why: resolve this repo's upstream/avatar on the host that owns it, not the + // focused runtime. + const activeRuntimeEnvironmentId = useAppStore((state) => + getRuntimeEnvironmentIdForRepo(state, repo.id) ) const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : '' diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 40dce362ce2..f23de9a28cd 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import { Button } from '../ui/button' -import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' import { Trash2 } from 'lucide-react' @@ -19,6 +18,8 @@ import { useAppStore } from '../../store' import { getRepositoryIconSectionId } from './repository-settings-targets' import { RepositoryIconPicker } from './RepositoryIconPicker' import { getRepositoryPaneSearchEntries } from './repository-search' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' +import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' import { translate } from '@/i18n/i18n' export { getRepositoryPaneSearchEntries } @@ -36,61 +37,6 @@ type RepositoryPaneProps = { removeProject: (repoId: string) => void } -type RepoTextDraft = { repoId: string; text: string } - -// Why: updateRepo persists via async IPC before the store value updates, so a -// store-controlled input resets mid-IME-composition (Hangul decomposes into -// jamo). Keep keystrokes in local draft state; persist stays per-keystroke. -export function RepoSettingsDraftInput({ - repoId, - storeValue, - onTextChange, - ...inputProps -}: { - repoId: string - storeValue: string - onTextChange: (text: string) => void -} & Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'>): React.JSX.Element { - const [draft, setDraft] = useState<RepoTextDraft>({ repoId, text: storeValue }) - const pendingStoreEchoesRef = useRef<string[]>([]) - - useEffect(() => { - setDraft((current) => { - if (current.repoId !== repoId) { - pendingStoreEchoesRef.current = [] - return { repoId, text: storeValue } - } - if (storeValue === current.text) { - pendingStoreEchoesRef.current = [] - return current - } - const pendingEchoIndex = pendingStoreEchoesRef.current.indexOf(storeValue) - if (pendingEchoIndex !== -1) { - // Why: queued updateRepo calls can echo older input text after newer - // keystrokes; accepting that echo re-cancels active IME composition. - pendingStoreEchoesRef.current.splice(0, pendingEchoIndex + 1) - return current - } - pendingStoreEchoesRef.current = [] - return { repoId, text: storeValue } - }) - }, [repoId, storeValue]) - - const text = draft.repoId === repoId ? draft.text : storeValue - return ( - <Input - {...inputProps} - value={text} - onChange={(e) => { - const nextText = e.target.value - pendingStoreEchoesRef.current.push(nextText) - setDraft({ repoId, text: nextText }) - onTextChange(nextText) - }} - /> - ) -} - export function matchesRepositoryIdentitySearch(query: string, repo: Repo): boolean { const normalizedQuery = normalizeSettingsSearchQuery(query) if (!normalizedQuery) { @@ -201,6 +147,7 @@ export function RepositoryPane({ const mcpEntries = allEntries.filter((entry) => entry.title === 'MCP Configs') const symlinkEntries = allEntries.filter((entry) => entry.title === 'Worktree Symlinks') const sourceControlAiEntries = allEntries.filter((entry) => entry.title === 'Git AI Author') + const hostSetupEntries = allEntries.filter((entry) => entry.title === 'Available Hosts') const removeProjectLabel = confirmingRemove === repo.id ? 'Confirm Remove Project' : 'Remove Project' @@ -228,20 +175,37 @@ export function RepositoryPane({ <section key="identity" className="relative space-y-8"> <div className="flex items-start justify-between gap-4"> <div className="space-y-1 pr-12"> - <h3 className="text-sm font-semibold">{translate("auto.components.settings.RepositoryPane.499a437335", "Identity")}</h3> + <h3 className="text-sm font-semibold"> + {translate('auto.components.settings.RepositoryPane.499a437335', 'Identity')} + </h3> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.b0a0c14a1c", "Project-specific display details for the sidebar and tabs.")}</p> + {translate( + 'auto.components.settings.RepositoryPane.b0a0c14a1c', + 'Project-specific display details for the sidebar and tabs.' + )} + </p> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.323debba71", "Type:")}<span className="text-foreground">{getRepoKindLabel(repo)}</span> + {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')} + <span className="text-foreground">{getRepoKindLabel(repo)}</span> </p> {isFolder ? ( <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.ee5a290616", "Opened as folder. Git features are unavailable for this workspace.")}</p> + {translate( + 'auto.components.settings.RepositoryPane.ee5a290616', + 'Opened as folder. Git features are unavailable for this workspace.' + )} + </p> ) : null} </div> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.0909e5d650", "Remove Project")} - description={translate("auto.components.settings.RepositoryPane.170624bdfb", "Remove this project from Orca.")} + title={translate( + 'auto.components.settings.RepositoryPane.0909e5d650', + 'Remove Project' + )} + description={translate( + 'auto.components.settings.RepositoryPane.170624bdfb', + 'Remove this project from Orca.' + )} keywords={[repo.displayName, 'delete', 'project', 'repository']} className="absolute top-0 right-0 z-10 w-auto max-w-none" forceVisible={forceFullPaneForRepoMatch} @@ -267,14 +231,18 @@ export function RepositoryPane({ </div> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.c7ef4415de", "Display Name")} - description={translate("auto.components.settings.RepositoryPane.b0a0c14a1c", "Project-specific display details for the sidebar and tabs.")} + title={translate('auto.components.settings.RepositoryPane.c7ef4415de', 'Display Name')} + description={translate( + 'auto.components.settings.RepositoryPane.b0a0c14a1c', + 'Project-specific display details for the sidebar and tabs.' + )} keywords={[repo.displayName, repo.path, 'project name', 'repository name']} className="space-y-2" forceVisible={forceFullPaneForRepoMatch} > <Label htmlFor={`repo-display-name-${repo.id}`} className="text-sm font-semibold"> - {translate("auto.components.settings.RepositoryPane.c7ef4415de", "Display Name")}</Label> + {translate('auto.components.settings.RepositoryPane.c7ef4415de', 'Display Name')} + </Label> <RepoSettingsDraftInput id={`repo-display-name-${repo.id}`} repoId={repo.id} @@ -285,8 +253,11 @@ export function RepositoryPane({ </SearchableSetting> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.26fef02bf3", "Project Icon")} - description={translate("auto.components.settings.RepositoryPane.e641c359de", "Project icon and color used in the sidebar and tabs.")} + title={translate('auto.components.settings.RepositoryPane.26fef02bf3', 'Project Icon')} + description={translate( + 'auto.components.settings.RepositoryPane.e641c359de', + 'Project icon and color used in the sidebar and tabs.' + )} keywords={[ repo.displayName, repo.path, @@ -306,14 +277,32 @@ export function RepositoryPane({ {!isFolder ? ( <> + <RepositoryHostSetupsSection + repo={repo} + forceVisible={forceFullPaneForRepoMatch} + searchQuery={searchQuery} + searchEntries={hostSetupEntries} + /> + <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.f88db4fece", "Default Worktree Base")} - description={translate("auto.components.settings.RepositoryPane.8984d06520", "Default base branch or ref when creating worktrees.")} + title={translate( + 'auto.components.settings.RepositoryPane.f88db4fece', + 'Default Worktree Base' + )} + description={translate( + 'auto.components.settings.RepositoryPane.8984d06520', + 'Default base branch or ref when creating worktrees.' + )} keywords={[repo.displayName, 'base ref', 'branch']} className="space-y-3" forceVisible={forceFullPaneForRepoMatch} > - <Label className="text-sm font-semibold">{translate("auto.components.settings.RepositoryPane.f88db4fece", "Default Worktree Base")}</Label> + <Label className="text-sm font-semibold"> + {translate( + 'auto.components.settings.RepositoryPane.f88db4fece', + 'Default Worktree Base' + )} + </Label> <BaseRefPicker repoId={repo.id} currentBaseRef={repo.worktreeBaseRef} @@ -323,8 +312,14 @@ export function RepositoryPane({ </SearchableSetting> <SearchableSetting - title={translate("auto.components.settings.RepositoryPane.e9bd57a336", "Worktree Location")} - description={translate("auto.components.settings.RepositoryPane.e63bb96a9b", "Project-specific directory for new worktrees.")} + title={translate( + 'auto.components.settings.RepositoryPane.e9bd57a336', + 'Worktree Location' + )} + description={translate( + 'auto.components.settings.RepositoryPane.e63bb96a9b', + 'Project-specific directory for new worktrees.' + )} keywords={[ repo.displayName, 'worktree path', @@ -337,7 +332,12 @@ export function RepositoryPane({ forceVisible={forceFullPaneForRepoMatch} > <div className="flex items-center justify-between gap-3"> - <Label className="text-sm font-semibold">{translate("auto.components.settings.RepositoryPane.e9bd57a336", "Worktree Location")}</Label> + <Label className="text-sm font-semibold"> + {translate( + 'auto.components.settings.RepositoryPane.e9bd57a336', + 'Worktree Location' + )} + </Label> {repo.worktreeBasePath ? ( <Button type="button" @@ -345,7 +345,8 @@ export function RepositoryPane({ size="sm" onClick={() => updateRepo(repo.id, { worktreeBasePath: undefined })} > - {translate("auto.components.settings.RepositoryPane.8ccacbeb5a", "Use Global")}</Button> + {translate('auto.components.settings.RepositoryPane.8ccacbeb5a', 'Use Global')} + </Button> ) : null} </div> <RepoSettingsDraftInput @@ -358,7 +359,11 @@ export function RepositoryPane({ className="h-9 text-sm" /> <p className="text-xs text-muted-foreground"> - {translate("auto.components.settings.RepositoryPane.15a99d9b9f", "Relative paths resolve from this project root.")}</p> + {translate( + 'auto.components.settings.RepositoryPane.15a99d9b9f', + 'Relative paths resolve from this project root.' + )} + </p> </SearchableSetting> </> ) : null} diff --git a/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx b/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx index e97134a7d34..e7d49a6e9a0 100644 --- a/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx +++ b/src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx @@ -3,7 +3,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RepoSettingsDraftInput } from './RepositoryPane' +import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' let container: HTMLDivElement let root: Root diff --git a/src/renderer/src/components/settings/RepositorySettingsDraftInput.tsx b/src/renderer/src/components/settings/RepositorySettingsDraftInput.tsx new file mode 100644 index 00000000000..c58c2343b59 --- /dev/null +++ b/src/renderer/src/components/settings/RepositorySettingsDraftInput.tsx @@ -0,0 +1,58 @@ +import type React from 'react' +import { useEffect, useRef, useState } from 'react' +import { Input } from '../ui/input' + +type RepoTextDraft = { repoId: string; text: string } + +// Why: updateRepo persists via async IPC before the store value updates, so a +// store-controlled input resets mid-IME-composition (Hangul decomposes into +// jamo). Keep keystrokes in local draft state; persist stays per-keystroke. +export function RepoSettingsDraftInput({ + repoId, + storeValue, + onTextChange, + ...inputProps +}: { + repoId: string + storeValue: string + onTextChange: (text: string) => void +} & Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'>): React.JSX.Element { + const [draft, setDraft] = useState<RepoTextDraft>({ repoId, text: storeValue }) + const pendingStoreEchoesRef = useRef<string[]>([]) + + useEffect(() => { + setDraft((current) => { + if (current.repoId !== repoId) { + pendingStoreEchoesRef.current = [] + return { repoId, text: storeValue } + } + if (storeValue === current.text) { + pendingStoreEchoesRef.current = [] + return current + } + const pendingEchoIndex = pendingStoreEchoesRef.current.indexOf(storeValue) + if (pendingEchoIndex !== -1) { + // Why: queued updateRepo calls can echo older input text after newer + // keystrokes; accepting that echo re-cancels active IME composition. + pendingStoreEchoesRef.current.splice(0, pendingEchoIndex + 1) + return current + } + pendingStoreEchoesRef.current = [] + return { repoId, text: storeValue } + }) + }, [repoId, storeValue]) + + const text = draft.repoId === repoId ? draft.text : storeValue + return ( + <Input + {...inputProps} + value={text} + onChange={(e) => { + const nextText = e.target.value + pendingStoreEchoesRef.current.push(nextText) + setDraft({ repoId, text: nextText }) + onTextChange(nextText) + }} + /> + ) +} 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 new file mode 100644 index 00000000000..ad4f4dca632 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { + evaluateHostDetails, + getActiveServerModeDescription, + getHostDetailsDescription, + getHostDetailsSummary, + getHostModelCapabilitySummary, + getRuntimeCapabilitiesSummary, + type RuntimeHostDetails +} from './RuntimeEnvironmentsPane' + +function details(overrides: Partial<RuntimeHostDetails>): RuntimeHostDetails { + return { + status: 'ready', + runtimeStatus: null, + compatibility: null, + error: null, + ...overrides + } +} + +describe('RuntimeEnvironmentsPane host details', () => { + it('summarizes loading, error, compatible, and blocked hosts', () => { + expect(getHostDetailsSummary(undefined)).toBe('Checking…') + expect(getHostDetailsSummary(details({ status: 'error', error: 'offline' }))).toBe( + 'Status unavailable' + ) + expect( + getHostDetailsSummary( + details({ + compatibility: { + kind: 'ok', + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION + } + }) + ) + ).toBe('Compatible') + expect( + getHostDetailsSummary( + 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 + } + }) + ) + ).toBe('Update server') + expect( + getHostDetailsSummary( + details({ + compatibility: { + kind: 'blocked', + reason: 'client-too-old', + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + serverProtocolVersion: RUNTIME_PROTOCOL_VERSION, + requiredClientProtocolVersion: RUNTIME_PROTOCOL_VERSION + 1 + } + }) + ) + ).toBe('Update client') + }) + + it('evaluates runtime protocol compatibility from status aliases', () => { + expect( + evaluateHostDetails({ + runtimeId: 'runtime-old', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + minCompatibleMobileVersion: 0 + }) + ).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') + }) + + it('summarizes Host model capability support for version-skewed servers', () => { + expect( + getHostModelCapabilitySummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0 + }) + ).toBe('Host model support: checking server capabilities') + + expect( + getHostModelCapabilitySummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ] + }) + ).toBe('Host model support: ready') + + expect( + getHostModelCapabilitySummary({ + runtimeId: 'runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + }) + ).toBe('Host model support: update server for task source context, workspace run context') + }) + + it('explains that selecting a saved server is the explicit default Host mode', () => { + expect(getActiveServerModeDescription(true)).toContain('default Host') + expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff') + expect(getActiveServerModeDescription(false)).toContain('default Host') + expect(getActiveServerModeDescription(false)).toContain('paired Orca runtime') + }) +}) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 087f281345f..8e1a433de76 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -1,12 +1,35 @@ /* eslint-disable max-lines -- Why: the server settings pane keeps active server selection, saved server mutation, and confirmation dialogs together so the state transitions stay auditable. */ -import { Loader2, Plus, RefreshCw, Share2, Trash2 } from 'lucide-react' +import { + AlertTriangle, + ChevronDown, + Loader2, + Plus, + RefreshCw, + Server, + ServerOff, + Share2, + Trash2 +} from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' 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' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' @@ -25,7 +48,10 @@ import { getRuntimeEnvironmentsSearchEntry, getWebRuntimeEnvironmentsSearchEntry } from './runtime-environments-search' +import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' const LOCAL_RUNTIME_VALUE = '__local__' const NO_RUNTIME_VALUE = '__none__' @@ -37,6 +63,186 @@ type RuntimeEnvironmentsPaneProps = { allowLocalRuntime?: boolean } +export type RuntimeHostDetails = { + status: 'loading' | 'ready' | 'error' + runtimeStatus: RuntimeStatus | null + compatibility: RuntimeCompatVerdict | null + error: string | null +} + +export function evaluateHostDetails(status: RuntimeStatus): RuntimeCompatVerdict { + return evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) +} + +export function getHostDetailsSummary(details: RuntimeHostDetails | undefined): string { + if (!details || details.status === 'loading') { + return translate('auto.components.settings.RuntimeEnvironmentsPane.5120beaac6', 'Checking…') + } + if (details.status === 'error') { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.c8791efc45', + 'Status unavailable' + ) + } + if (details.compatibility?.kind === 'blocked') { + return details.compatibility.reason === 'client-too-old' + ? translate('auto.components.settings.RuntimeEnvironmentsPane.62ac182a27', 'Update client') + : translate('auto.components.settings.RuntimeEnvironmentsPane.86ed75bec8', 'Update server') + } + 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 getHostModelCapabilitySummary( + status: RuntimeStatus | null | undefined +): string | null { + if (!status) { + return null + } + const capabilities = status.capabilities + if (!capabilities) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityUnknown', + 'Host model support: checking server capabilities' + ) + } + const missing = [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ].filter((capability) => !capabilities.includes(capability)) + if (missing.length === 0) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilitySupported', + 'Host model support: ready' + ) + } + const missingLabels = missing.map(getHostModelCapabilityLabel) + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityMissing', + 'Host model support: update server for {{value0}}', + { value0: missingLabels.join(', ') } + ) +} + +function getHostModelCapabilityLabel(capability: string): string { + switch (capability) { + case PROJECT_HOST_SETUP_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityProjectSetup', + 'project setup' + ) + case TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityTaskSourceContext', + 'task source context' + ) + case WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityWorkspaceRunContext', + 'workspace run context' + ) + default: + return capability + } +} + +export function getActiveServerModeDescription(allowLocalRuntime: boolean): string { + return allowLocalRuntime + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a', + "Local keeps today's desktop behavior. Selecting a saved server makes that server the default Host for server-routed projects, files, terminals, provider accounts, and browser/mobile handoff." + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8', + 'Selecting a saved server makes this browser use that paired Orca runtime as its default Host.' + ) +} + +type RuntimeServerConnectionState = 'connected' | 'available' | 'checking' | 'disconnected' + +function getRuntimeServerConnectionState( + details: RuntimeHostDetails | undefined, + active: boolean +): RuntimeServerConnectionState { + if (!details || details.status === 'loading') { + return 'checking' + } + if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') { + return 'disconnected' + } + return active ? 'connected' : 'available' +} + +function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string { + switch (state) { + case 'connected': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverConnected', + 'Connected' + ) + case 'available': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverAvailable', + 'Available' + ) + case 'checking': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverChecking', + 'Checking…' + ) + case 'disconnected': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected', + 'Disconnected' + ) + } +} + +function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string { + switch (state) { + case 'connected': + return 'bg-emerald-500' + case 'available': + return 'bg-muted-foreground/50' + case 'checking': + return 'bg-yellow-500' + case 'disconnected': + return 'bg-muted-foreground/40' + } +} + export function RuntimeEnvironmentsPane({ settings, switchRuntimeEnvironment, @@ -46,12 +252,17 @@ export function RuntimeEnvironmentsPane({ const [environments, setEnvironments] = useState<PublicKnownRuntimeEnvironment[]>([]) const [isLoading, setIsLoading] = useState(false) const [isSaving, setIsSaving] = useState(false) + const [detailsByEnvironmentId, setDetailsByEnvironmentId] = useState< + Record<string, RuntimeHostDetails> + >({}) const [switchingValue, setSwitchingValue] = useState<string | null>(null) const [removingId, setRemovingId] = useState<string | null>(null) + const [disconnectingId, setDisconnectingId] = useState<string | null>(null) const [pendingSwitchValue, setPendingSwitchValue] = useState<string | null>(null) const [pendingRemove, setPendingRemove] = useState<PublicKnownRuntimeEnvironment | null>(null) const [addServerFormOpen, setAddServerFormOpen] = useState(false) const [shareServerFormOpen, setShareServerFormOpen] = useState(false) + const [advancedOpen, setAdvancedOpen] = useState(false) const [switchError, setSwitchError] = useState<string | null>(null) const [removeError, setRemoveError] = useState<string | null>(null) const [name, setName] = useState('') @@ -60,7 +271,8 @@ export function RuntimeEnvironmentsPane({ const activeValue = settings.activeRuntimeEnvironmentId ?? (allowLocalRuntime ? LOCAL_RUNTIME_VALUE : NO_RUNTIME_VALUE) - const isBusy = isSaving || switchingValue !== null || removingId !== null + const isBusy = + isSaving || switchingValue !== null || removingId !== null || disconnectingId !== null const removingActiveServer = pendingRemove?.id === settings.activeRuntimeEnvironmentId const searchEntry = canGeneratePairingUrl ? getRuntimeEnvironmentsSearchEntry() @@ -72,9 +284,72 @@ export function RuntimeEnvironmentsPane({ } try { const nextEnvironments = await window.api.runtimeEnvironments.list() + // Why: drop store status for servers no longer saved so stale hosts don't + // linger in the sidebar registry. + useAppStore.getState().setRuntimeEnvironments(nextEnvironments) if (mountedRef.current) { setEnvironments(nextEnvironments) + setDetailsByEnvironmentId((current) => { + const next: Record<string, RuntimeHostDetails> = {} + for (const environment of nextEnvironments) { + next[environment.id] = current[environment.id] ?? { + status: 'loading', + runtimeStatus: null, + compatibility: null, + error: null + } + } + return next + }) } + await Promise.allSettled( + nextEnvironments.map(async (environment) => { + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 10_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response) + // Why: feed the live status into the store so sidebar host pickers + // reflect manual refreshes, not just the settings pane. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: runtimeStatus, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'ready', + runtimeStatus, + compatibility: evaluateHostDetails(runtimeStatus), + error: null + } + })) + } catch (error) { + // Why: record the failed probe (null status) so the sidebar can + // distinguish unreachable from never-checked. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: error instanceof Error ? error.message : String(error) + } + })) + } + }) + ) } catch (error) { if (mountedRef.current) { toast.error( @@ -169,7 +444,7 @@ export function RuntimeEnvironmentsPane({ toast.success( translate( 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', - 'Saved {{value0}}. Use Active Server to switch when ready.', + 'Saved {{value0}}. Use Advanced > Default runtime to make it the default.', { value0: result.environment.name } ) ) @@ -255,6 +530,65 @@ export function RuntimeEnvironmentsPane({ } } + const disconnectEnvironment = async ( + environment: PublicKnownRuntimeEnvironment + ): Promise<boolean> => { + setDisconnectingId(environment.id) + setSwitchError(null) + try { + if (settings.activeRuntimeEnvironmentId === environment.id) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + if (mountedRef.current) { + setSwitchError( + allowLocalRuntime + ? 'Could not switch to Local desktop. Fix the issue and try again.' + : 'Could not disconnect from this server. Fix the issue and try again.' + ) + } + return false + } + } + await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) + // Why: disconnect is non-destructive; keep the saved server but show the + // user that this live client is no longer attached to it. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (mountedRef.current) { + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: null + } + })) + toast.success( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.disconnectedServer', + 'Disconnected from {{value0}}.', + { value0: environment.name } + ) + ) + } + return true + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to disconnect server.' + if (mountedRef.current) { + setSwitchError(message) + toast.error(message) + } + return false + } finally { + if (mountedRef.current) { + setDisconnectingId(null) + } + } + } + const switchToValue = async (value: string): Promise<boolean> => { if (value === NO_RUNTIME_VALUE) { return false @@ -312,94 +646,21 @@ export function RuntimeEnvironmentsPane({ keywords={searchEntry.keywords} className="space-y-4 py-2" > - <div className="space-y-2"> - <div className="space-y-1"> - <Label id="runtime-active-server-label"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541', - 'Active Server' - )} - </Label> - <p className="text-xs text-muted-foreground"> - {allowLocalRuntime - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.f75ce1c7a5', - "Local keeps today's desktop behavior. Saved servers route supported client calls through the remote runtime." - ) - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.8cf8790697', - 'Saved servers route this browser through a paired Orca runtime.' - )} - </p> - </div> - <div className="flex flex-wrap items-center gap-2"> - <Select - value={activeValue} - onValueChange={(value) => { - if (value !== activeValue) { - setSwitchError(null) - setPendingSwitchValue(value) - } - }} - disabled={isBusy} - > - <SelectTrigger - size="sm" - className="min-w-[260px]" - aria-labelledby="runtime-active-server-label" - > - <SelectValue /> - </SelectTrigger> - <SelectContent> - {allowLocalRuntime ? ( - <SelectItem value={LOCAL_RUNTIME_VALUE}> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.78692becbd', - 'Local desktop' - )} - </SelectItem> - ) : environments.length === 0 ? ( - <SelectItem value={NO_RUNTIME_VALUE} disabled> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c', - 'No server connected' - )} - </SelectItem> - ) : null} - {environments.map((environment) => ( - <SelectItem key={environment.id} value={environment.id}> - {environment.name} - </SelectItem> - ))} - </SelectContent> - </Select> - <Button - type="button" - variant="outline" - size="icon-sm" - aria-label={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', - 'Refresh servers' - )} - title={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', - 'Refresh servers' - )} - onClick={() => void loadEnvironments()} - disabled={isLoading || isBusy} - > - {isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />} - </Button> - </div> - </div> - <div className="space-y-3"> <div className="flex items-center justify-between gap-3"> - <div className="text-sm font-medium"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.1826bd0608', - 'Saved Servers' - )} + <div className="min-w-0 space-y-0.5"> + <div className="text-sm font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServers', + 'Connect to remote servers' + )} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServersHelp', + 'Pair another Orca runtime, then connect when you want this client to use it as the default host.' + )} + </p> </div> {addServerFormOpen ? null : ( <Button @@ -505,7 +766,7 @@ export function RuntimeEnvironmentsPane({ </form> ) : null} - <div className="rounded-lg border border-border/50"> + <div className="rounded-lg border border-border/50 bg-card/30"> {environments.length === 0 ? ( <div className="px-3 py-4 text-sm text-muted-foreground"> {translate( @@ -518,35 +779,126 @@ export function RuntimeEnvironmentsPane({ {environments.map((environment) => ( <div key={environment.id} - className="flex items-center justify-between gap-3 px-3 py-2" + data-settings-section={environment.id} + className="flex items-center gap-3 px-4 py-3" > - <div className="min-w-0"> - <div className="truncate text-sm font-medium">{environment.name}</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> - <Button - type="button" - variant="ghost" - size="icon-sm" - onClick={() => { - setRemoveError(null) - setPendingRemove(environment) - }} - disabled={isBusy} - aria-label={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2', - 'Remove {{value0}}', - { value0: environment.name } - )} - > - <Trash2 /> - </Button> + {(() => { + const details = detailsByEnvironmentId[environment.id] + const detailsDescription = getHostDetailsDescription(details) + const isActive = settings.activeRuntimeEnvironmentId === environment.id + const connectionState = getRuntimeServerConnectionState(details, isActive) + const actionBusy = + switchingValue === environment.id || + disconnectingId === environment.id || + removingId === environment.id + return ( + <> + <Server className="size-4 shrink-0 text-muted-foreground" /> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <div className="truncate text-sm font-medium">{environment.name}</div> + <span + className={cn( + 'size-2 shrink-0 rounded-full', + getRuntimeServerDotClass(connectionState) + )} + /> + <span className="text-[11px] text-muted-foreground"> + {getRuntimeServerConnectionLabel(connectionState)} + </span> + {details?.compatibility?.kind === 'blocked' ? ( + <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> + ) : details?.status === 'loading' ? ( + <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" /> + ) : null} + </div> + <p className="truncate text-xs text-muted-foreground"> + {isActive + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', + 'Default host for server-routed projects, terminals, and provider checks.' + ) + : getHostDetailsSummary(details)} + </p> + {detailsDescription ? ( + <p + className={cn( + 'mt-0.5 truncate text-xs', + details?.compatibility?.kind === 'blocked' + ? 'text-destructive' + : 'text-muted-foreground' + )} + > + {detailsDescription} + </p> + ) : null} + </div> + <div className="flex shrink-0 items-center gap-1"> + {isActive ? ( + <Button + type="button" + variant="ghost" + size="xs" + className="gap-1.5" + onClick={() => void disconnectEnvironment(environment)} + disabled={actionBusy} + > + {disconnectingId === environment.id ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <ServerOff className="size-3" /> + )} + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.disconnect', + 'Disconnect' + )} + </Button> + ) : ( + <Button + type="button" + variant="ghost" + size="xs" + className="gap-1.5" + onClick={() => void switchToValue(environment.id)} + disabled={actionBusy || connectionState === 'checking'} + > + {switchingValue === environment.id ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Server className="size-3" /> + )} + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connect', + 'Connect' + )} + </Button> + )} + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => { + setRemoveError(null) + setPendingRemove(environment) + }} + className="size-7 text-muted-foreground hover:text-red-400" + disabled={isBusy} + aria-label={translate( + 'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2', + 'Remove {{value0}}', + { value0: environment.name } + )} + > + {removingId === environment.id ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Trash2 className="size-3" /> + )} + </Button> + </div> + </> + ) + })()} </div> ))} </div> @@ -554,48 +906,227 @@ export function RuntimeEnvironmentsPane({ </div> </div> - {canGeneratePairingUrl ? ( - <div className="overflow-hidden rounded-lg border border-border/50"> - <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2.5"> - <div className="min-w-0 space-y-0.5"> - <div className="text-sm font-medium"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55', - 'Share this Orca server' - )} - </div> - <p className="text-xs text-muted-foreground"> - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05', - 'Create a revocable access grant so a browser or another Orca client can connect.' - )} - </p> - </div> - <Button - type="button" - variant="outline" - size="sm" - className="gap-1.5" - onClick={() => setShareServerFormOpen((open) => !open)} + <div data-settings-section="default-runtime"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => setAdvancedOpen((current) => !current)} + className="-ml-2 text-xs" + > + {translate('auto.components.settings.RuntimeEnvironmentsPane.advanced', 'Advanced')} + <ChevronDown + className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')} + /> + </Button> + + <div + className={cn( + 'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out', + advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' + )} + aria-hidden={!advancedOpen} + > + <div className="min-h-0"> + <div + className={cn( + 'space-y-2 px-1 pt-1 pb-1 transition-[opacity,transform] duration-150 ease-out', + advancedOpen + ? 'translate-y-0 opacity-100 delay-200' + : '-translate-y-1 opacity-0 delay-0' + )} > - <Share2 /> - {shareServerFormOpen - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.54dee18f5c', - 'Hide Form' - ) - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.3595fd1948', - 'New Link' + <div className="space-y-1"> + <Label id="runtime-active-server-label"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541', + 'Default runtime' )} - </Button> + </Label> + <p className="text-xs text-muted-foreground"> + {getActiveServerModeDescription(allowLocalRuntime)} + </p> + </div> + <div className="flex flex-wrap items-center gap-2"> + <Select + value={activeValue} + onValueChange={(value) => { + if (value !== activeValue) { + setSwitchError(null) + setPendingSwitchValue(value) + } + }} + disabled={isBusy} + > + <SelectTrigger + size="sm" + className="min-w-[260px]" + aria-labelledby="runtime-active-server-label" + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + {allowLocalRuntime ? ( + <SelectItem value={LOCAL_RUNTIME_VALUE}> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.78692becbd', + 'Local desktop' + )} + </SelectItem> + ) : environments.length === 0 ? ( + <SelectItem value={NO_RUNTIME_VALUE} disabled> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c', + 'No server connected' + )} + </SelectItem> + ) : null} + {environments.map((environment) => ( + <SelectItem key={environment.id} value={environment.id}> + {environment.name} + </SelectItem> + ))} + </SelectContent> + </Select> + <Button + type="button" + variant="outline" + size="icon-sm" + aria-label={translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', + 'Refresh servers' + )} + title={translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003', + 'Refresh servers' + )} + onClick={() => void loadEnvironments()} + disabled={isLoading || isBusy} + > + {isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />} + </Button> + </div> + {environments.length > 0 ? ( + <div className="space-y-2 pt-2"> + <div className="text-xs font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverDetails', + 'Server details' + )} + </div> + <div className="space-y-1 rounded-lg border border-border/50 bg-card/30 p-2"> + {environments.map((environment) => { + const details = detailsByEnvironmentId[environment.id] + return ( + <div + key={environment.id} + className="grid gap-1 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)]" + > + <div className="truncate font-medium text-foreground"> + {environment.name} + </div> + <div className="min-w-0 space-y-0.5"> + <div className="truncate font-mono"> + {environment.endpoints[0]?.endpoint ?? + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', + 'No endpoint' + )} + </div> + {details?.runtimeStatus ? ( + <div className="truncate"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a', + 'Protocol {{value0}}', + { + value0: + details.runtimeStatus?.runtimeProtocolVersion ?? + details.runtimeStatus?.protocolVersion ?? + 0 + } + )} + {details.runtimeStatus.hostPlatform + ? ` · ${details.runtimeStatus.hostPlatform}` + : ''} + {' · '} + {getRuntimeCapabilitiesSummary(details.runtimeStatus)} + </div> + ) : null} + {getHostModelCapabilitySummary(details?.runtimeStatus) ? ( + <div className="truncate"> + {getHostModelCapabilitySummary(details?.runtimeStatus)} + </div> + ) : null} + </div> + </div> + ) + })} + </div> + </div> + ) : null} + </div> </div> - <div className="border-t border-border/40 px-3 py-3"> - <RuntimePairingUrlGenerator - framed={false} - showHeader={false} - showGeneratorForm={shareServerFormOpen} - /> + </div> + </div> + + {canGeneratePairingUrl ? ( + <div className="space-y-3 pt-2"> + <div className="space-y-0.5"> + <div className="text-sm font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisApp', + 'Advertise this app as a server' + )} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisAppHelp', + 'Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.' + )} + </p> + </div> + <div className="overflow-hidden rounded-lg border border-border/50 bg-card/30"> + <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2.5"> + <div className="min-w-0 space-y-0.5"> + <div className="text-sm font-medium"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55', + 'Share this Orca server' + )} + </div> + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05', + 'Create a revocable access grant so a browser or another Orca client can connect.' + )} + </p> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="gap-1.5" + onClick={() => setShareServerFormOpen((open) => !open)} + > + <Share2 /> + {shareServerFormOpen + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.54dee18f5c', + 'Hide Form' + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.3595fd1948', + 'New Link' + )} + </Button> + </div> + <div className="border-t border-border/40 px-3 py-3"> + <RuntimePairingUrlGenerator + framed={false} + showHeader={false} + showGeneratorForm={shareServerFormOpen} + /> + </div> </div> </div> ) : null} @@ -620,7 +1151,7 @@ export function RuntimeEnvironmentsPane({ <DialogDescription> {translate( 'auto.components.settings.RuntimeEnvironmentsPane.b2290ed203', - 'Orca will close remote terminals and browser tabs from the current server before loading projects from the next server.' + 'Orca will focus this host and load its projects. Existing terminals and browser tabs on other hosts stay alive.' )} </DialogDescription> </DialogHeader> @@ -692,11 +1223,11 @@ export function RuntimeEnvironmentsPane({ ? allowLocalRuntime ? translate( 'auto.components.settings.RuntimeEnvironmentsPane.9f7665a01b', - 'Removing the active server first switches Orca back to Local desktop and closes remote terminals and browser tabs for that server.' + 'Removing the active server first switches Orca back to Local desktop. Existing host sessions are left alone.' ) : translate( 'auto.components.settings.RuntimeEnvironmentsPane.b2fda48c39', - 'Removing the active server disconnects this browser and closes remote terminals and browser tabs for that server.' + 'Removing the active server disconnects this browser from that host. Existing host sessions are left alone.' ) : translate( 'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d', diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index f42c502df56..b765583c0a3 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' @@ -87,60 +86,48 @@ 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') - } + titleKey: 'auto.components.settings.Settings.23c6874fdf', + titleDefault: 'AI Capabilities' }, + { id: 'setup', titleKey: 'auto.components.settings.Settings.9abb9be3bc', titleDefault: 'Set Up' }, { id: 'workflows', - get title() { - return translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows') - } + titleKey: 'auto.components.settings.Settings.e1578cd4bc', + titleDefault: 'Workflows' }, { id: 'interface', - get title() { - return translate('auto.components.settings.Settings.8bd117d669', 'Interface') - } + titleKey: 'auto.components.settings.Settings.8bd117d669', + titleDefault: 'Interface' }, { id: 'remote', - get title() { - return translate('auto.components.settings.Settings.23931df7e8', 'Remote Access') - } + titleKey: 'auto.components.settings.Settings.23931df7e8', + titleDefault: 'Remote Hosts' + }, + { + id: 'mobile', + titleKey: 'auto.components.settings.Settings.mobile_group', + titleDefault: 'Mobile' }, { id: 'security', - get title() { - return translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security') - } + titleKey: 'auto.components.settings.Settings.084d8fac5b', + titleDefault: 'Privacy & Security' }, { id: 'advanced', - get title() { - return translate('auto.components.settings.Settings.1c87f8d024', 'Advanced') - } + titleKey: 'auto.components.settings.Settings.1c87f8d024', + titleDefault: 'Advanced' }, { id: 'experimental', - get title() { - return translate('auto.components.settings.Settings.8b017f2506', 'Experimental') - } + titleKey: 'auto.components.settings.Settings.8b017f2506', + titleDefault: 'Experimental' } ] as const @@ -168,19 +155,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[] @@ -276,21 +250,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 }) @@ -968,7 +931,8 @@ function Settings(): React.JSX.Element { const generalNavSections = visibleNavSections.filter((section) => !section.id.startsWith('repo-')) const generalNavGroups: SettingsNavGroup[] = SETTINGS_NAV_GROUPS.map((group) => ({ - ...group, + id: group.id, + title: translate(group.titleKey, group.titleDefault), sections: generalNavSections.filter((section) => section.group === group.id) })).filter((group) => group.sections.length > 0 || group.id === 'setup') const repoNavSections = visibleNavSections @@ -995,8 +959,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} @@ -1091,15 +1055,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 ? ( @@ -1116,15 +1072,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 @@ -1453,7 +1401,7 @@ function Settings(): React.JSX.Element { ) : translate( 'auto.components.settings.Settings.b5ee17826b', - 'Switch between local desktop mode and paired remote Orca runtimes.' + 'Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.' ) } searchEntries={getSectionSearchEntries('servers')} @@ -1475,7 +1423,7 @@ function Settings(): React.JSX.Element { title={translate('auto.components.settings.Settings.9b02492d1f', 'SSH Hosts')} description={translate( 'auto.components.settings.Settings.c2ee313198', - 'Remote SSH hosts for files, terminals, and git.' + 'Use existing machines over SSH for files, terminals, Git, and workspaces.' )} searchEntries={getSectionSearchEntries('ssh')} > diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 05fcb6d06b5..345612ca849 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -346,12 +346,12 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { <div className="flex items-center justify-between gap-3"> <div className="space-y-0.5"> <p className="text-sm font-medium"> - {translate('auto.components.settings.SshPane.94c5284560', 'Targets')} + {translate('auto.components.settings.SshPane.94c5284560', 'SSH hosts')} </p> <p className="text-xs text-muted-foreground"> {translate( 'auto.components.settings.SshPane.a7d28dff81', - 'Add a remote host to connect to it in Orca.' + 'Add an existing machine over SSH so projects and workspaces can run there.' )} </p> </div> diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx new file mode 100644 index 00000000000..62c365c82eb --- /dev/null +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react' +import { FolderOpen, RotateCcw } from 'lucide-react' +import type { GlobalSettings } from '../../../../shared/types' +import { + getEffectiveHostSetting, + getHostSettingOverride, + setHostSettingOverride, + clearHostSettingOverride +} from '../../../../shared/host-setting-overrides' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { + buildHostScopeChoices, + CLIENT_DEFAULT_SCOPE, + isHostScope, + type HostSettingScope +} from './host-scoped-setting-scope' +import { translate } from '@/i18n/i18n' + +type WorkspaceDirectorySettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial<GlobalSettings>) => void +} + +export function WorkspaceDirectorySetting({ + settings, + updateSettings +}: WorkspaceDirectorySettingProps): React.JSX.Element { + const { hostOptions } = useSidebarHostScopeOptions() + const [scope, setScope] = useState<HostSettingScope>(CLIENT_DEFAULT_SCOPE) + + const clientDefaultLabel = translate( + 'auto.components.settings.WorkspaceDirectorySetting.1a2b3c4d5e', + 'Client default' + ) + const choices = buildHostScopeChoices(hostOptions, clientDefaultLabel) + // Why: if the selected host disappears (removed/disconnected), fall back to the + // client default so the control never edits a stale host. + const activeScope = choices.some((c) => c.scope === scope) ? scope : CLIENT_DEFAULT_SCOPE + const editingHost = isHostScope(activeScope) + + const hostOverride = editingHost + ? getHostSettingOverride(settings, activeScope, 'defaultWorktreeLocation') + : undefined + const hasOverride = editingHost && hostOverride !== undefined + + // For a host scope, show its override or — as a hint — the inherited client + // default. For the client default scope, edit `workspaceDir` directly. + const value = editingHost + ? getEffectiveHostSetting( + settings, + activeScope, + 'defaultWorktreeLocation', + settings.workspaceDir + ) + : settings.workspaceDir + + const writeValue = (next: string): void => { + if (!editingHost) { + updateSettings({ workspaceDir: next }) + return + } + updateSettings({ + hostSettingOverrides: setHostSettingOverride( + settings, + activeScope, + 'defaultWorktreeLocation', + next + ) + }) + } + + const resetOverride = (): void => { + if (!editingHost) { + return + } + updateSettings({ + hostSettingOverrides: clearHostSettingOverride( + settings, + activeScope, + 'defaultWorktreeLocation' + ) + }) + } + + const handleBrowse = async (): Promise<void> => { + const path = await window.api.repos.pickFolder() + if (path) { + writeValue(path) + } + } + + // Why: only show the scope picker when at least one non-local host exists, + // matching the multi-host gating used elsewhere in the sidebar. + const showScopePicker = hostOptions.some((host) => host.id !== LOCAL_EXECUTION_HOST_ID) + + return ( + <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', 'host', 'override']} + className="space-y-2" + > + <div className="flex items-center justify-between gap-2"> + <Label> + {translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.0e9fc0eadc', + 'Workspace Directory' + )} + </Label> + {showScopePicker && ( + <div className="flex items-center gap-1.5"> + <span className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.WorkspaceDirectorySetting.2b3c4d5e6f', + 'Apply to' + )} + </span> + <Select + value={activeScope} + onValueChange={(next) => setScope(next as HostSettingScope)} + > + <SelectTrigger size="sm" className="h-7 w-44 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {choices.map((choice) => ( + <SelectItem key={choice.scope} value={choice.scope} className="text-xs"> + {choice.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + </div> + <div className="flex gap-2"> + <Input + value={value} + onChange={(e) => writeValue(e.target.value)} + className="flex-1 text-xs" + /> + <Button + variant="outline" + size="sm" + onClick={() => void handleBrowse()} + className="shrink-0 gap-1.5" + > + <FolderOpen className="size-3.5" /> + {translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.5567191a6e', + 'Browse' + )} + </Button> + </div> + {editingHost && ( + <div className="flex items-center justify-between gap-2"> + <p className="text-xs text-muted-foreground"> + {hasOverride + ? translate( + 'auto.components.settings.WorkspaceDirectorySetting.3c4d5e6f7a', + 'Overrides client default' + ) + : translate( + 'auto.components.settings.WorkspaceDirectorySetting.4d5e6f7a8b', + 'Inherits the client default' + )} + </p> + {hasOverride && ( + <Button + type="button" + variant="ghost" + size="sm" + className="h-7 gap-1.5 text-xs" + onClick={resetOverride} + > + <RotateCcw className="size-3.5" /> + {translate('auto.components.settings.WorkspaceDirectorySetting.5e6f7a8b9c', 'Reset')} + </Button> + )} + </div> + )} + <p className="text-xs text-muted-foreground"> + {translate( + 'auto.components.settings.GeneralWorkspaceSettingsSection.a246f5ce6f', + 'Root directory where workspace folders are created.' + )} + </p> + </SearchableSetting> + ) +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 088e015d385..c97f198da24 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -163,10 +163,10 @@ export const getStatusBarToggles = createLocalizedCatalog( }, { id: 'ssh', - title: translate('auto.components.settings.appearance.search.57fb424c56', 'SSH Status'), + title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), description: translate( 'auto.components.settings.appearance.search.f17d66d0d2', - 'Show the active SSH connection status in the status bar.' + 'Show remote host connection status in the status bar.' ), keywords: [ ...translateSearchKeyword( @@ -186,7 +186,7 @@ export const getStatusBarToggles = createLocalizedCatalog( ], toggleDescription: translate( 'settings.appearance.statusBar.sshToggleDescription', - 'Show the active SSH connection. Only visible once an SSH target is configured.' + 'Show configured SSH and remote Orca hosts when any are available.' ) }, { diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx new file mode 100644 index 00000000000..520e526235c --- /dev/null +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + GitHubIntegrationCard, + GitLabIntegrationCard +} from './cli-source-control-integration-cards' + +type StoreState = { + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void +} + +const mocks = vi.hoisted(() => ({ + store: { current: null as StoreState | null }, + preflight: { + statuses: { + ghStatus: 'connected', + glabStatus: 'connected', + bitbucketStatus: 'not-configured', + azureDevOpsStatus: 'not-configured', + giteaStatus: 'not-configured', + bitbucketAccount: null, + azureDevOpsAccount: null, + giteaAccount: null + }, + unavailable: false, + refresh: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => { + if (!mocks.store.current) { + throw new Error('Store state was not installed') + } + return selector(mocks.store.current) + } +})) + +vi.mock('./source-control-preflight-card-status', () => ({ + usePreflightCardStatuses: () => mocks.preflight +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderCard(card: React.ReactNode): Promise<HTMLDivElement> { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render(card) + }) + return container +} + +describe('CLI source-control integration card account scope', () => { + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + mocks.store.current = null + mocks.preflight.statuses.ghStatus = 'connected' + mocks.preflight.statuses.glabStatus = 'connected' + mocks.preflight.unavailable = false + mocks.preflight.refresh.mockClear() + }) + + it('shows local-client ownership for connected GitHub CLI credentials', async () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage, + openSettingsTarget + } + + const rendered = await renderCard(<GitHubIntegrationCard />) + + expect(rendered.textContent).toContain('GitHub') + expect(rendered.textContent).toContain('Connected') + 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. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + ) + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Open Remote Servers') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + sectionId: 'default-runtime' + }) + }) + + it('shows remote-server ownership for GitLab CLI credential checks', async () => { + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + mocks.preflight.statuses.glabStatus = 'not-authenticated' + + const rendered = await renderCard(<GitLabIntegrationCard />) + + expect(rendered.textContent).toContain('GitLab') + 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. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + ) + expect(rendered.textContent).toContain('glab auth login') + }) +}) diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx index 33332c451f8..0007e594d02 100644 --- a/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx @@ -1,9 +1,35 @@ import { ExternalLink, Github, Gitlab, Terminal } from 'lucide-react' import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { usePreflightCardStatuses } from './source-control-preflight-card-status' import { translate } from '@/i18n/i18n' +function ProviderAccountScopeDetails({ + children +}: { + children?: React.ReactNode +}): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const accountScope = getProviderAccountScope(settings) + + return ( + <IntegrationCardDetails> + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.settings.cli.source.control.integration.cards.account_scope_prefix', + 'Account scope' + )} + scope={accountScope} + className="text-xs" + /> + {children} + </IntegrationCardDetails> + ) +} + export function GitHubIntegrationCard(): React.JSX.Element { const { statuses, unavailable, refresh } = usePreflightCardStatuses('gh') const status = unavailable ? 'unavailable' : statuses.ghStatus @@ -43,9 +69,9 @@ export function GitHubIntegrationCard(): React.JSX.Element { : 'Not authenticated' } > - {status !== 'checking' && !connected ? ( - <IntegrationCardDetails> - {status === 'unavailable' ? ( + <ProviderAccountScopeDetails> + {status !== 'checking' && !connected ? ( + status === 'unavailable' ? ( <> <p className="text-xs text-muted-foreground"> {translate( @@ -125,9 +151,9 @@ export function GitHubIntegrationCard(): React.JSX.Element { </Button> </div> </> - )} - </IntegrationCardDetails> - ) : null} + ) + ) : null} + </ProviderAccountScopeDetails> </IntegrationCardShell> ) } @@ -171,9 +197,9 @@ export function GitLabIntegrationCard(): React.JSX.Element { : 'Not authenticated' } > - {status !== 'checking' && !connected ? ( - <IntegrationCardDetails> - {status === 'unavailable' ? ( + <ProviderAccountScopeDetails> + {status !== 'checking' && !connected ? ( + status === 'unavailable' ? ( <> <p className="text-xs text-muted-foreground"> {translate( @@ -257,9 +283,9 @@ export function GitLabIntegrationCard(): React.JSX.Element { </Button> </div> </> - )} - </IntegrationCardDetails> - ) : null} + ) + ) : null} + </ProviderAccountScopeDetails> </IntegrationCardShell> ) } diff --git a/src/renderer/src/components/settings/host-scoped-setting-scope.test.ts b/src/renderer/src/components/settings/host-scoped-setting-scope.test.ts new file mode 100644 index 00000000000..a37f62978bc --- /dev/null +++ b/src/renderer/src/components/settings/host-scoped-setting-scope.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + buildHostScopeChoices, + CLIENT_DEFAULT_SCOPE, + isHostScope +} from './host-scoped-setting-scope' +import type { SidebarHostOption } from '../sidebar/sidebar-host-options' + +function host(id: SidebarHostOption['id'], label: string): SidebarHostOption { + const kind = id === 'local' ? 'local' : id.startsWith('runtime:') ? 'runtime' : 'ssh' + return { + id, + label, + detail: '', + kind, + health: 'available', + presence: kind === 'local' ? 'local' : 'configured' + } +} + +describe('buildHostScopeChoices', () => { + it('lists the client default first, then non-local hosts', () => { + const choices = buildHostScopeChoices( + [host('local', 'Local Mac'), host('ssh:box', 'Box'), host('runtime:env', 'Server')], + 'Client default' + ) + expect(choices).toEqual([ + { scope: CLIENT_DEFAULT_SCOPE, label: 'Client default' }, + { scope: 'ssh:box', label: 'Box' }, + { scope: 'runtime:env', label: 'Server' } + ]) + }) + + it('excludes the local host', () => { + const choices = buildHostScopeChoices([host('local', 'Local Mac')], 'Client default') + expect(choices).toEqual([{ scope: CLIENT_DEFAULT_SCOPE, label: 'Client default' }]) + }) +}) + +describe('isHostScope', () => { + it('is false for the client default sentinel', () => { + expect(isHostScope(CLIENT_DEFAULT_SCOPE)).toBe(false) + }) + + it('is true for a real host id', () => { + expect(isHostScope('ssh:box')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/host-scoped-setting-scope.ts b/src/renderer/src/components/settings/host-scoped-setting-scope.ts new file mode 100644 index 00000000000..7eb33087c5e --- /dev/null +++ b/src/renderer/src/components/settings/host-scoped-setting-scope.ts @@ -0,0 +1,33 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { SidebarHostOption } from '../sidebar/sidebar-host-options' + +/** Sentinel scope for "edit the shared client default" rather than a host override. */ +export const CLIENT_DEFAULT_SCOPE = 'client-default' + +export type HostSettingScope = typeof CLIENT_DEFAULT_SCOPE | ExecutionHostId + +export type HostScopeChoice = { + scope: HostSettingScope + label: string +} + +/** Builds the "Apply to:" choices: the client default first, then every known + * non-local host. Local is excluded because its override and the client + * default address the same machine. */ +export function buildHostScopeChoices( + hosts: readonly SidebarHostOption[], + clientDefaultLabel: string +): HostScopeChoice[] { + const choices: HostScopeChoice[] = [{ scope: CLIENT_DEFAULT_SCOPE, label: clientDefaultLabel }] + for (const host of hosts) { + if (host.id !== LOCAL_EXECUTION_HOST_ID) { + choices.push({ scope: host.id, label: host.label }) + } + } + return choices +} + +/** A scope is host-specific when it targets a real host rather than the shared default. */ +export function isHostScope(scope: HostSettingScope): scope is ExecutionHostId { + return scope !== CLIENT_DEFAULT_SCOPE +} diff --git a/src/renderer/src/components/settings/jira-integration-card.test.tsx b/src/renderer/src/components/settings/jira-integration-card.test.tsx new file mode 100644 index 00000000000..50b3990748c --- /dev/null +++ b/src/renderer/src/components/settings/jira-integration-card.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { JiraIntegrationCard } from './jira-integration-card' + +type StoreState = { + jiraStatus: { + connected: boolean + sites?: { id: string; displayName: string; siteUrl: string; email?: string }[] + } + jiraStatusChecked: boolean + jiraStatusContextKey: string | null + checkJiraConnection: () => Promise<void> + disconnectJira: (siteId?: string) => Promise<void> + testJiraConnection: (siteId: string) => Promise<{ ok: boolean; error?: string }> + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void +} + +const mocks = vi.hoisted(() => ({ + store: { current: null as StoreState | null } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => { + if (!mocks.store.current) { + throw new Error('Store state was not installed') + } + return selector(mocks.store.current) + } +})) + +vi.mock('@/components/jira-connect-dialog', () => ({ + JiraConnectDialog: ({ onConnected }: { onConnected?: () => void }) => ( + <button type="button" data-testid="simulate-jira-connected" onClick={onConnected}> + Simulate Jira connected + </button> + ) +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function installStore(settings: StoreState['settings']): StoreState { + const state: StoreState = { + jiraStatus: { + connected: true, + sites: [ + { + id: 'site-1', + displayName: 'Acme Jira', + siteUrl: 'https://acme.atlassian.net', + email: 'jira@example.test' + } + ] + }, + jiraStatusChecked: true, + jiraStatusContextKey: getProviderRuntimeContextKey(settings), + checkJiraConnection: vi.fn(async () => {}), + disconnectJira: vi.fn(async () => {}), + testJiraConnection: vi.fn(async () => ({ ok: true })), + settings, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + mocks.store.current = state + return state +} + +async function renderCard(): Promise<HTMLDivElement> { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render(<JiraIntegrationCard />) + }) + return container +} + +describe('JiraIntegrationCard account scope', () => { + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + mocks.store.current = null + }) + + it('shows remote-server account ownership and opens Hosts settings', async () => { + const state = installStore({ activeRuntimeEnvironmentId: 'runtime-1' }) + + const rendered = await renderCard() + + expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') + expect(rendered.textContent).toContain('Acme Jira') + expect(rendered.textContent).toContain('https://acme.atlassian.net · jira@example.test') + + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Open Remote Servers') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(state.openSettingsPage).toHaveBeenCalledTimes(1) + expect(state.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + sectionId: 'default-runtime' + }) + }) +}) diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index bd27b1cc8ed..32b1a472467 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -10,6 +10,8 @@ import { } from '@/lib/provider-runtime-context' import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' +import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -33,6 +35,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 +109,14 @@ export function JiraIntegrationCard(): React.JSX.Element { ) : null } > + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.settings.task.tracker.integration.cards.account_scope_prefix', + 'Account scope' + )} + scope={accountScope} + className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs" + /> {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..3af51857d05 --- /dev/null +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { getProviderAccountScope, getProviderRateLimitScope } 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. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + }) + }) + + 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. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + }) + }) + + it('describes provider API budgets as host-scoped', () => { + expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: null }, 'GitHub')).toEqual({ + label: 'Local Mac', + description: + 'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.' + }) + expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: ' env-1 ' }, 'GitLab')).toEqual({ + label: 'Remote server: env-1', + description: + 'GitLab API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.' + }) + }) +}) 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..9e25f47d0af --- /dev/null +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -0,0 +1,46 @@ +import type { GlobalSettings } from '../../../../shared/types' + +export type ProviderAccountScope = { + label: string + description: string +} + +export type ProviderRateLimitScope = { + 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. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + } + } + return { + label: 'Local Mac', + description: + 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + } +} + +export function getProviderRateLimitScope( + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, + providerLabel: string +): ProviderRateLimitScope { + const runtimeId = settings?.activeRuntimeEnvironmentId?.trim() + if (runtimeId) { + return { + label: `Remote server: ${runtimeId}`, + description: `${providerLabel} API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.` + } + } + return { + label: 'Local Mac', + description: `${providerLabel} API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.` + } +} diff --git a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx new file mode 100644 index 00000000000..a06d9f08c59 --- /dev/null +++ b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx @@ -0,0 +1,57 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { GitHubRateLimitPanel } from '@/components/github/github-rate-limit-display' +import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-display' + +type StoreState = { + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void +} + +const mocks = vi.hoisted(() => ({ + store: { + current: { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } as StoreState + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.store.current) +})) + +describe('provider rate-limit panels account scope', () => { + it('shows the local host scope for GitHub API budget', () => { + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + + const markup = renderToStaticMarkup(<GitHubRateLimitPanel />) + + expect(markup).toContain('Budget scope: Local Mac') + expect(markup).toContain( + 'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.' + ) + expect(markup).toContain('Open Remote Servers') + }) + + it('shows the remote server scope for GitLab API budget', () => { + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + + const markup = renderToStaticMarkup(<GitLabRateLimitPanel />) + + expect(markup).toContain('Budget scope: Remote server: runtime-1') + expect(markup).toContain( + 'GitLab API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.' + ) + }) +}) diff --git a/src/renderer/src/components/settings/repository-host-setup-options.test.ts b/src/renderer/src/components/settings/repository-host-setup-options.test.ts new file mode 100644 index 00000000000..9a48b60b5f5 --- /dev/null +++ b/src/renderer/src/components/settings/repository-host-setup-options.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { buildSetupHostOptions } from './repository-host-setup-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const FULL_HOST_MODEL_RUNTIME_CAPABILITIES = [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +] + +function runtimeHost( + overrides: Partial<ExecutionHostRegistryEntry> = {} +): ExecutionHostRegistryEntry { + return { + id: 'runtime:env-1', + kind: 'runtime', + label: 'Remote Orca', + detail: 'Orca server', + health: 'available', + ...overrides + } as ExecutionHostRegistryEntry +} + +describe('buildSetupHostOptions', () => { + it('disables runtime hosts while capabilities are unknown', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [runtimeHost()] + })[0] + ).toMatchObject({ + isAvailable: false, + detail: 'Checking host capabilities' + }) + }) + + it('enables runtime hosts that advertise project setup and workspace run support', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [ + runtimeHost({ + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ] + })[0] + ).toMatchObject({ + isAvailable: true, + detail: 'Orca server' + }) + }) + + it('disables runtime hosts that cannot run workspaces with explicit host context', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [ + runtimeHost({ + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + }) + ] + })[0] + ).toMatchObject({ + isAvailable: false, + detail: 'Update Orca on this host to set up projects' + }) + }) +}) diff --git a/src/renderer/src/components/settings/repository-host-setup-options.ts b/src/renderer/src/components/settings/repository-host-setup-options.ts new file mode 100644 index 00000000000..b1e571a1926 --- /dev/null +++ b/src/renderer/src/components/settings/repository-host-setup-options.ts @@ -0,0 +1,103 @@ +import { getExecutionHostLabel, type ExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { ProjectHostSetup, ProjectHostSetupState } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type SetupHostOption = { + id: ExecutionHostId + label: string + detail: string + isAvailable: boolean +} + +export function getSetupStateLabel(setupState: ProjectHostSetupState): string { + switch (setupState) { + case 'ready': + return translate('auto.components.settings.RepositoryPane.hostSetupStateReady', 'Ready') + case 'not-set-up': + return translate( + 'auto.components.settings.RepositoryPane.hostSetupStateNotSetUp', + 'Not set up' + ) + case 'setting-up': + return translate( + 'auto.components.settings.RepositoryPane.hostSetupStateSettingUp', + 'Setting up' + ) + case 'error': + return translate('auto.components.settings.RepositoryPane.hostSetupStateError', 'Error') + case 'unsupported': + return translate( + 'auto.components.settings.RepositoryPane.hostSetupStateUnsupported', + 'Unsupported' + ) + } +} + +export function buildSetupHostOptions({ + projectHostSetups, + hostOptions +}: { + projectHostSetups: ProjectHostSetup[] + hostOptions: readonly ExecutionHostRegistryEntry[] +}): SetupHostOption[] { + const setupHostIds = new Set(projectHostSetups.map((setup) => setup.hostId)) + return hostOptions + .filter((host) => !setupHostIds.has(host.id)) + .map((host) => { + const availability = getHostSetupAvailability(host) + return { + id: host.id, + label: host.label || getExecutionHostLabel(host.id), + detail: availability.detail, + isAvailable: availability.isAvailable + } + }) +} + +function getHostSetupAvailability(host: ExecutionHostRegistryEntry): { + isAvailable: boolean + detail: string +} { + if (host.health === 'blocked') { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupBlockedVersion', + 'Orca server version is incompatible' + ) + } + } + if (host.kind === 'runtime') { + const capabilities = host.capabilities + if (!capabilities) { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupCheckingCapability', + 'Checking host capabilities' + ) + } + } + if ( + !capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) || + !capabilities.includes(WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY) + ) { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupMissingCapability', + 'Update Orca on this host to set up projects' + ) + } + } + } + return { + isAvailable: true, + detail: host.detail + } +} diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index 9373206c438..b000c53a6c8 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -86,6 +86,41 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ ) ] }, + { + title: translate('auto.components.settings.repository.search.b24f00294a', 'Project Icon'), + description: translate( + 'auto.components.settings.repository.search.a1f3a2bd47', + 'Project icon and color used in the sidebar and tabs.' + ), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.6438a94c63', + 'project icon' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.b2546efab5', + 'repository icon' + ), + ...translateSearchKeyword('auto.components.settings.repository.search.8d045419b1', 'color'), + ...translateSearchKeyword('auto.components.settings.repository.search.6d8de2f090', 'hex'), + ...translateSearchKeyword('auto.components.settings.repository.search.c1075178cf', 'badge'), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.cb4b4de666', + 'avatar' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.9dc60d7f6d', + 'github' + ), + ...translateSearchKeyword('auto.components.settings.repository.search.1e73e840ff', 'emoji'), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.27733eb6c1', + 'favicon' + ) + ] + }, + ...(isFolder ? [] : getRepositoryGitWorktreeSearchEntries(repo)), ...(isFolder ? [] : [...getRepositoryGitAuthorSearchEntries(repo), ...getRepositoryGitHooksSearchEntries(repo)]) diff --git a/src/renderer/src/components/settings/runtime-environments-search.ts b/src/renderer/src/components/settings/runtime-environments-search.ts index 050c812ebd5..8777c01ed81 100644 --- a/src/renderer/src/components/settings/runtime-environments-search.ts +++ b/src/renderer/src/components/settings/runtime-environments-search.ts @@ -7,11 +7,11 @@ export const getRuntimeEnvironmentsSearchEntry = createLocalizedCatalog( (): SettingsSearchEntry => ({ title: translate( 'auto.components.settings.runtime.environments.search.3517fb2ec0', - 'Active Server' + 'Remote Orca Servers' ), description: translate( 'auto.components.settings.runtime.environments.search.4575341c77', - 'Choose local desktop, add a saved remote Orca server, or generate a pairing URL.' + 'Add a saved remote Orca server, generate a pairing URL, or adjust the advanced default runtime.' ), keywords: [ ...translateSearchKeyword( @@ -66,7 +66,7 @@ export const getWebRuntimeEnvironmentsSearchEntry = createLocalizedCatalog( (): SettingsSearchEntry => ({ title: translate( 'auto.components.settings.runtime.environments.search.3517fb2ec0', - 'Active Server' + 'Remote Orca Servers' ), description: translate( 'auto.components.settings.runtime.environments.search.baec27aa8f', 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/ssh-search.ts b/src/renderer/src/components/settings/ssh-search.ts index 0985e906443..1f2fbb8433b 100644 --- a/src/renderer/src/components/settings/ssh-search.ts +++ b/src/renderer/src/components/settings/ssh-search.ts @@ -5,10 +5,7 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' export const getSshPaneSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.ssh.search.380a788da7', 'SSH Connections'), - description: translate( - 'auto.components.settings.ssh.search.74c6d90d78', - 'Manage remote SSH targets.' - ), + description: translate('auto.components.settings.ssh.search.74c6d90d78', 'Manage SSH hosts.'), keywords: [ ...translateSearchKeyword('auto.components.settings.ssh.search.7efd17e816', 'ssh'), ...translateSearchKeyword('auto.components.settings.ssh.search.d4bcd497c7', 'remote'), @@ -19,10 +16,7 @@ export const getSshPaneSearchEntries = createLocalizedCatalog(() => [ }, { title: translate('auto.components.settings.ssh.search.f5a691bb6c', 'Add SSH Target'), - description: translate( - 'auto.components.settings.ssh.search.62826efbe9', - 'Add a new remote SSH target.' - ), + description: translate('auto.components.settings.ssh.search.62826efbe9', 'Add a new SSH host.'), keywords: [ ...translateSearchKeyword('auto.components.settings.ssh.search.7efd17e816', 'ssh'), ...translateSearchKeyword('auto.components.settings.ssh.search.f7b6383aec', 'add'), 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..c32d777809f 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,16 @@ 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 } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void } 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 +35,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 +43,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 @@ -127,8 +70,12 @@ function installStore( disconnectLinearWorkspace: vi.fn(async () => {}), checkLinearConnection: vi.fn(async () => {}), testLinearConnection: vi.fn(async () => ({ ok: true })), - settings + settings, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() } + mocks.store.current = state + return state } async function renderCard(): Promise<HTMLDivElement> { @@ -141,29 +88,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 +99,71 @@ describe('LinearIntegrationCard skill setup', () => { container?.remove() container = null mocks.store.current = null - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: defaultUserAgent + }) + + it('shows local-client account ownership when Linear is disconnected', async () => { + const state = installStore(false) + + const rendered = await renderCard() + + 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. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + ) + expect(rendered.textContent).toContain('Open Remote Servers') + expect(rendered.textContent).toContain('Add access with a Personal API key') + + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Re-check') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - Reflect.deleteProperty(window, 'api') + + expect(state.checkLinearConnection).toHaveBeenCalledWith(true) }) - it('keeps Linear skill setup out of the disconnected state', async () => { - installStore(false) + it('shows remote-server account ownership and connected workspace rows', async () => { + const state = installStore(true, { activeRuntimeEnvironmentId: 'runtime-1' }) 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'] }) - ) - }) - - 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' + 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. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' ) await act(async () => { - openInstallerButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Open Remote Servers') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - - expect(mocks.ensureCli).toHaveBeenCalledTimes(1) - }) - - 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' + expect(state.openSettingsPage).toHaveBeenCalledTimes(1) + expect(state.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + sectionId: 'default-runtime' }) + expect(rendered.textContent).toContain('Acme') + expect(rendered.textContent).toContain('Acme workspace · linear@example.test') - 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("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' }) - - 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 +171,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..7c89a30584e 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,14 @@ -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 { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -45,7 +25,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 +34,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 +107,7 @@ export function LinearIntegrationCard(): React.JSX.Element { ) : null } > + <ProviderAccountScopeRow scope={accountScope} /> {connected ? ( <div className="mt-3 space-y-2"> {workspaces.map((workspace) => { @@ -234,80 +183,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 +205,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 +213,17 @@ export function LinearIntegrationCard(): React.JSX.Element { ) } +function ProviderAccountScopeRow({ scope }: { scope: ReturnType<typeof getProviderAccountScope> }) { + return ( + <ProviderHostScopeControl + labelPrefix={translate( + 'auto.components.settings.task.tracker.integration.cards.account_scope_prefix', + 'Account scope' + )} + scope={scope} + className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs" + /> + ) +} + 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 new file mode 100644 index 00000000000..19d78a6f445 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react' +import { Folder } from 'lucide-react' +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' +import { RemoteFileBrowser } from './RemoteFileBrowser' + +type CloneStepProps = { + cloneUrl: string + cloneDestination: string + cloneError: string | null + cloneProgress: { phase: string; percent: number } | null + isCloning: boolean + disableDestinationPicker?: boolean + runtimeEnvironmentId?: string | null + sshTargetId?: string | null + cloneTargetLabel?: string | null + onUrlChange: (value: string) => void + onDestChange: (value: string) => void + onPickDestination: () => void + onClone: () => void +} + +export function CloneStep({ + cloneUrl, + cloneDestination, + cloneError, + cloneProgress, + isCloning, + disableDestinationPicker = false, + runtimeEnvironmentId, + sshTargetId, + cloneTargetLabel, + onUrlChange, + onDestChange, + onPickDestination, + onClone +}: CloneStepProps): React.JSX.Element { + const [browsingDestination, setBrowsingDestination] = useState(false) + const isRemoteClone = Boolean(runtimeEnvironmentId || sshTargetId) + const canBrowseRemoteDestination = isRemoteClone + const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault() + if (canClone) { + onClone() + } + } + } + + if (browsingDestination && (runtimeEnvironmentId || sshTargetId)) { + return ( + <> + <DialogHeader> + <DialogTitle> + {translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse host filesystem' + )} + </DialogTitle> + <DialogDescription> + {translate( + 'auto.components.sidebar.AddRepoSteps.fe8e629fe3', + 'Navigate to a directory and click Select to choose it.' + )} + </DialogDescription> + </DialogHeader> + {sshTargetId ? ( + <RemoteFileBrowser + targetId={sshTargetId} + initialPath={cloneDestination || '~'} + onSelect={(path) => { + onDestChange(path) + setBrowsingDestination(false) + }} + onCancel={() => setBrowsingDestination(false)} + /> + ) : ( + <RemoteFileBrowser + runtimeEnvironmentId={runtimeEnvironmentId as string} + initialPath={cloneDestination || '~'} + onSelect={(path) => { + onDestChange(path) + setBrowsingDestination(false) + }} + onCancel={() => setBrowsingDestination(false)} + /> + )} + </> + ) + } + + return ( + <> + <DialogHeader> + <DialogTitle> + {translate('auto.components.sidebar.AddRepoSteps.c05f88a31f', 'Clone from URL')} + </DialogTitle> + <DialogDescription> + {cloneTargetLabel + ? translate( + 'auto.components.sidebar.AddRepoSteps.cloneOnHostDescription', + 'Enter the Git URL and choose where to clone it on {{value0}}.', + { value0: cloneTargetLabel } + ) + : translate( + 'auto.components.sidebar.AddRepoSteps.5b2ea674b1', + 'Enter the Git URL and choose where to clone it.' + )} + </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.AddRepoSteps.3d4acbe693', 'Git URL')} + </label> + <Input + value={cloneUrl} + onChange={(e) => onUrlChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate( + 'auto.components.sidebar.AddRepoSteps.b698a4a29d', + 'https://github.com/user/repo.git' + )} + className="h-8 text-xs" + disabled={isCloning} + autoFocus + /> + </div> + + <div className="space-y-1"> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.AddRepoSteps.cloneParentFolder', 'Parent folder')} + </label> + <div className="flex gap-2"> + <Input + value={cloneDestination} + onChange={(e) => onDestChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate( + isRemoteClone + ? 'auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder' + : 'auto.components.sidebar.AddRepoSteps.2ce3f6edf8', + isRemoteClone ? '/home/user/projects' : '/path/to/destination' + )} + className="h-8 text-xs flex-1" + disabled={isCloning} + /> + <Button + variant="outline" + size="sm" + className="h-8 px-2 shrink-0" + onClick={() => { + if (canBrowseRemoteDestination) { + setBrowsingDestination(true) + return + } + onPickDestination() + }} + disabled={isCloning || (disableDestinationPicker && !canBrowseRemoteDestination)} + title={ + canBrowseRemoteDestination + ? translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse host filesystem' + ) + : translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder') + } + aria-label={ + canBrowseRemoteDestination + ? translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse host filesystem' + ) + : translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder') + } + > + <Folder className="size-3.5" /> + </Button> + </div> + </div> + + {cloneError && <p className="text-[11px] text-destructive">{cloneError}</p>} + + <Button + onClick={onClone} + disabled={!cloneUrl.trim() || !cloneDestination.trim() || isCloning} + className="w-full" + > + {isCloning + ? translate('auto.components.sidebar.AddRepoSteps.69f5b5380d', 'Cloning...') + : translate('auto.components.sidebar.AddRepoSteps.32a7256d85', 'Clone')} + </Button> + + {/* Why: progress bar lives below the button so it doesn't push the + button down when it appears mid-clone. */} + {isCloning && cloneProgress && ( + <div className="space-y-1.5"> + <div className="flex items-center justify-between text-[11px] text-muted-foreground"> + <span>{cloneProgress.phase}</span> + <span>{cloneProgress.percent}%</span> + </div> + <div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden"> + <div + className="h-full rounded-full bg-foreground transition-[width] duration-300 ease-out" + style={{ width: `${cloneProgress.percent}%` }} + /> + </div> + </div> + )} + </div> + </> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoCreateKindCard.tsx b/src/renderer/src/components/sidebar/AddRepoCreateKindCard.tsx new file mode 100644 index 00000000000..90cbb7194d0 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoCreateKindCard.tsx @@ -0,0 +1,72 @@ +import type React from 'react' + +export type AddRepoCreateKind = 'git' | 'folder' + +type AddRepoCreateKindCardProps = { + kind: AddRepoCreateKind + selected: boolean + disabled: boolean + onSelect: () => void + onArrowNav: () => void + icon: React.ReactNode + title: string + caption: string +} + +export function AddRepoCreateKindCard({ + kind, + selected, + disabled, + onSelect, + onArrowNav, + icon, + title, + caption +}: AddRepoCreateKindCardProps): React.JSX.Element { + return ( + <button + type="button" + role="radio" + aria-checked={selected} + tabIndex={selected ? 0 : -1} + onClick={onSelect} + onKeyDown={(e) => { + // Why: WAI-ARIA radiogroup spec expects all four arrow keys to move + // selection, even if this specific layout is horizontal today. + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' + ) { + e.preventDefault() + onArrowNav() + } else if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + onSelect() + } + }} + disabled={disabled} + data-kind={kind} + className={`group relative flex cursor-pointer items-center gap-3 rounded-md border px-3.5 py-3.5 text-left text-xs transition-colors outline-none ${ + selected ? 'border-foreground/30 bg-accent' : 'border-border hover:bg-accent/50' + } focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60`} + > + <span + className={`inline-flex size-8 shrink-0 items-center justify-center rounded-md border transition-colors ${ + selected + ? 'border-foreground/20 bg-background/60 text-foreground' + : 'border-border/70 bg-background/30 text-muted-foreground group-hover:text-foreground' + }`} + > + {icon} + </span> + <span className="min-w-0"> + <span className="block text-[13px] font-medium leading-tight">{title}</span> + <span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground"> + {caption} + </span> + </span> + </button> + ) +} 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 8fd3e75d196..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 @@ -20,8 +33,10 @@ type CreateStepProps = { parentDefaultPending?: boolean manualParentEntry?: boolean runtimeEnvironmentId?: string | null + sshTargetId?: string | null onNameChange: (value: string) => void onParentChange: (value: string) => void + onKindChange: (kind: RepoKind) => void onPickParent: () => void onCreate: () => void } @@ -29,6 +44,7 @@ type CreateStepProps = { export function CreateStep({ createName, createParent, + createKind, createError, isCreating, defaultParent = '', @@ -37,18 +53,57 @@ export function CreateStep({ parentDefaultPending = false, manualParentEntry = false, runtimeEnvironmentId, + 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( @@ -57,37 +112,47 @@ export function CreateStep({ ) const missingServerLocationLabel = translate( 'auto.components.sidebar.AddRepoCreateStep.6ed14c0281', - 'server folder not selected' + 'host folder not selected' ) + const isRemoteHost = Boolean(runtimeEnvironmentId || sshTargetId) - const parentSummary = useMemo( + const summaryParent = useMemo( () => formatCreateProjectParentSummary({ parent: createParent, defaultParent, runtimeEnvironmentId, + isRemoteHost, missingLocationLabel, missingServerLocationLabel }), [ createParent, defaultParent, + isRemoteHost, missingLocationLabel, missingServerLocationLabel, runtimeEnvironmentId ] ) - const repoNamePreview = createName.trim() - const submitShortcutModifierLabel = getScreenSubmitModifierLabel() + const targetPathPreview = useMemo(() => { + const name = createName.trim() || CREATE_PROJECT_NAME_PLACEHOLDER + return createParent.trim() ? joinCreateProjectPath(createParent, name) : '' + }, [createName, createParent]) + const kindLabel = + createKind === 'git' + ? translate('auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', 'Git repository') + : translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder') const showGitFallback = gitAvailability === 'unavailable' const showGitChecking = gitAvailability === 'checking' const showRuntimeMissingParent = runtimeEnvironmentId && !createParent.trim() && runtimeParentStatus !== 'checking' - if (browsingParent && runtimeEnvironmentId) { + if (browsingParent && (runtimeEnvironmentId || sshTargetId)) { return ( <CreateProjectParentBrowser runtimeEnvironmentId={runtimeEnvironmentId} + sshTargetId={sshTargetId} createParent={createParent} onParentChange={onParentChange} onClose={() => setBrowsingParent(false)} @@ -100,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> @@ -116,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" @@ -135,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 9d2c17a12b7..58a5d89f36b 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -1,12 +1,7 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useState } from 'react' import { useAppStore } from '@/store' -import { Dialog, DialogContent } from '@/components/ui/dialog' -import { track } from '@/lib/telemetry' 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' @@ -14,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) @@ -32,13 +31,14 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const settings = useAppStore((s) => s.settings) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const completeGitRepoAdd = useCompleteGitRepoAdd({ + closeModal, + setHideDefaultBranchWorkspace + }) const [step, setStep] = useState<AddRepoDialogStep>('add') const [isAdding, setIsAdding] = useState(false) const [addProjectBusyLabel, setAddProjectBusyLabel] = useState<string | null>(null) - const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set()) const { nestedScan, nestedSelectedPaths, @@ -63,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, @@ -105,44 +93,32 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { closeModal, (repoId) => completeGitRepoAdd(repoId, 'ssh_remote_path'), scanNestedRepos, - (scan, selectedPath, connectionId, attemptId, inProgress, scanId) => { - setActiveNestedScanId(inProgress ? scanId : null) - showNestedRepoReview({ - scan, - selectedPath, - connectionId, - attemptId, - runtimeKind: 'ssh', - inProgress, - scanId - }) - }, - (scan, attemptId) => { - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'sidebar', - runtimeKind: 'ssh', - scan - }) - ) - } + showRemoteNestedRepoReview, + trackRemoteNestedScanResult ) const { createName, createParent, + createKind, createError, isCreating, setCreateName, setCreateParent, + setCreateKind, setCreateError, resetCreateState, handlePickParent, handleCreate - } = useCreateRepo(fetchWorktrees, closeModal, (repoId) => - completeGitRepoAdd(repoId, 'create_project') + } = useCreateRepo( + fetchWorktrees, + closeModal, + (repoId) => completeGitRepoAdd(repoId, 'create_project'), + { + hostId: hostSelection.selectedHostId, + runtimeEnvironmentId: selectedRuntimeEnvironmentId, + sshTargetId: hostSelection.selectedSshTargetId + } ) const { @@ -151,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 { @@ -173,7 +152,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { handleClone } = useAddRepoCloneFlow({ step, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, + sshTargetId: hostSelection.selectedSshTargetId, workspaceDir: settings?.workspaceDir, fetchWorktrees, onGitRepoReady: completeGitRepoAdd @@ -182,18 +162,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const isOpen = activeModal === 'add-repo' const droppedLocalPath = typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' - const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) - // Why: repo_added telemetry cannot reliably separate SSH from local folder adds, - // so promote remote projects from durable local SSH state instead. - const isSshLikely = - repos.some((repo) => Boolean(repo.connectionId)) || - sshTargetLabels.size > 0 || - Array.from(sshConnectionStates.values()).some((state) => state.status === 'connected') - + const isRuntimeEnvironmentActive = Boolean(selectedRuntimeEnvironmentId) + const selectedHostKind = hostSelection.selectedParsedHost?.kind const { handleBrowse, resetLocalFolderFlow } = useAddRepoLocalFolderFlow({ isOpen, droppedLocalPath, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, addRepoPath, closeModal, fetchWorktrees, @@ -232,7 +206,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { nestedConnectionId, nestedGroupName, nestedImportScanId, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, fetchWorktrees, importNestedRepos, getNestedRepoRuntimeKind, @@ -266,14 +240,29 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetCreateState ]) - // Why: reset state on close so reopening doesn't show stale step/repo. - useEffect(() => { - if (!isOpen) { - resetState() - } - }, [isOpen, resetState]) + const resetHostScopedState = useCallback(() => { + setIsAdding(false) + setAddProjectBusyLabel(null) + resetServerPathFlow() + resetCloneFlow() + resetCreateDefaultState() + resetCreateState() + resetRemoteState() + }, [ + resetCloneFlow, + resetCreateDefaultState, + resetCreateState, + resetRemoteState, + resetServerPathFlow + ]) + + useAddRepoHostChangeReset({ + isOpen, + selectedHostId: hostSelection.selectedHostId, + onResetClosed: resetState, + onResetHostScopedState: resetHostScopedState + }) - // Why: handleBack reuses resetState which already aborts clones and resets all fields. const handleBack = useCallback(() => { if (step === 'nested') { trackNestedBackAction() @@ -281,121 +270,146 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetState() }, [resetState, step, trackNestedBackAction]) - return ( - <Dialog - open={isOpen} - onOpenChange={(open) => { - if (!open) { - if (step === 'nested' && !isAdding) { - trackNestedBackAction() - } - closeModal() - resetState() + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + if (step === 'nested' && !isAdding) { + trackNestedBackAction() } - }} + closeModal() + resetState() + } + }, + [closeModal, isAdding, resetState, step, trackNestedBackAction] + ) + + return ( + <AddRepoDialogChrome + isOpen={isOpen} + step={step} + isAdding={isAdding} + 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={ + selectedHostKind === 'ssh' || selectedHostKind === 'runtime' ? selectedHostKind : 'local' + } + createDefaultParent={createDefaultParent} + createGitAvailability={createGitAvailability} + createRuntimeParentStatus={createRuntimeParentStatus} + createParentDefaultPending={createParentDefaultPending} + manualCreateParentEntry={isRuntimeEnvironmentActive || selectedHostKind === 'ssh'} + onBrowse={ + selectedHostKind === 'ssh' + ? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId) + : selectedHostKind === 'runtime' + ? () => setStep('server-path') + : 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/AddRepoDialogChrome.tsx b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx new file mode 100644 index 00000000000..5c27fd8dd22 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import type { AddRepoDialogStep } from './add-repo-dialog-types' +import { AddRepoStepIndicator } from './AddRepoStepIndicator' + +export function AddRepoDialogChrome({ + children, + isAdding, + isOpen, + onBack, + onOpenChange, + step +}: { + children: ReactNode + isAdding: boolean + isOpen: boolean + onBack: () => void + onOpenChange: (open: boolean) => void + step: AddRepoDialogStep +}) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <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={onBack} /> + {children} + </DialogContent> + </Dialog> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index b295293c100..f1e2bd0cfd4 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,6 +156,121 @@ 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', () => { + const html = renderStepContent({ + step: 'clone', + selectedSshTargetId: 'openclaw-2', + selectedHostLabel: 'openclaw 2' + }) + + 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 host filesystem"') + expect(html).not.toContain('aria-label="Choose folder"') + }) + + it('hides the SSH target chooser after a host was already selected', () => { + const html = renderStepContent({ + step: 'remote', + lockSshTargetSelection: true, + selectedTargetId: 'openclaw-2', + sshTargets: [ + { + id: 'github', + label: 'github.com', + host: 'github.com', + port: 22, + username: 'git', + state: { + targetId: 'github', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + }, + { + id: 'openclaw-2', + label: 'openclaw 2', + host: 'openclaw.example.com', + port: 22, + username: 'dev', + state: { + targetId: 'openclaw-2', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + } + ] + }) + + expect(html).toContain('Open project on SSH host') + expect(html).toContain('openclaw 2') + expect(html).toContain('Host path') + expect(html).not.toContain('SSH target') + expect(html).not.toContain('github.com') + expect(html).not.toContain('Connect') + }) + + it('shows a connect affordance for a selected disconnected SSH host', () => { + const html = renderStepContent({ + step: 'remote', + lockSshTargetSelection: true, + selectedTargetId: 'openclaw-2', + sshTargets: [ + { + id: 'openclaw-2', + label: 'openclaw 2', + host: 'openclaw.example.com', + port: 22, + username: 'dev', + state: { + targetId: 'openclaw-2', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + } + ] + }) + + expect(html).toContain('openclaw 2') + expect(html).toContain('is disconnected') + expect(html).toContain('Connect') + expect(html).not.toContain('SSH target') + expect(html).toContain('placeholder="/home/user/project"') + expect(html).toContain('disabled=""') + }) + + it('uses SSH-aware copy on the add step when an SSH host is selected', () => { + const html = renderStepContent({ + step: 'add', + browseHostKind: 'ssh' + }) + + 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') + }) + + it('uses the standard add step for remote Orca server hosts', () => { + const html = renderStepContent({ + step: 'add', + isRuntimeEnvironmentActive: true, + activeRuntimeEnvironmentId: 'env-1', + browseHostKind: 'runtime' + }) + + expect(html).toContain('Browse folder') + expect(html).toContain('Existing Git repository or folder on this host') + expect(html).toContain('Clone from URL') + expect(html).toContain('Create new project') + expect(html).not.toContain('Browse host') + expect(html).not.toContain('Create on host') + expect(html).not.toContain('Want to import many repos at once?') }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index baa39686ba7..c7d67d97bda 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -1,5 +1,5 @@ -import type { Dispatch, SetStateAction } from 'react' -import { CloneStep } from './AddRepoSteps' +import type { Dispatch, ReactNode, SetStateAction } from 'react' +import { CloneStep } from './AddRepoCloneStep' import { RemoteStep } from './AddRepoRemoteStep' import { CreateStep } from './AddRepoCreateStep' import { AddRepoLocalStartStep } from './AddRepoStartSteps' @@ -29,6 +29,9 @@ type AddRepoDialogStepContentProps = { isCloning: boolean sshTargets: (SshTarget & { state?: SshConnectionState })[] selectedTargetId: string | null + selectedSshTargetId?: string | null + selectedHostLabel?: string | null + lockSshTargetSelection?: boolean remotePath: string remoteError: string | null isAddingRemote: boolean @@ -38,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' | 'runtime' createDefaultParent: string createGitAvailability: GitAvailability createRuntimeParentStatus: 'idle' | 'checking' | 'failed' @@ -47,7 +56,7 @@ type AddRepoDialogStepContentProps = { onBrowse: () => void onOpenCloneStep: () => void onOpenCreateStep: () => void - onOpenRemoteStep: () => void + onOpenRemoteStep: (targetId?: string | null) => void onStopNestedScan: () => void onServerPathChange: (path: string) => void onAddServerPath: (kind: 'git' | 'folder') => void @@ -66,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 } @@ -89,6 +99,9 @@ export function AddRepoDialogStepContent({ isCloning, sshTargets, selectedTargetId, + selectedSshTargetId, + selectedHostLabel, + lockSshTargetSelection = false, remotePath, remoteError, isAddingRemote, @@ -98,8 +111,14 @@ export function AddRepoDialogStepContent({ nestedGroupName, createName, createParent, + createKind, createError, isCreating, + hostSelector, + showRemoteAction = true, + canCreateProject = true, + manualCreateParentEntry = isRuntimeEnvironmentActive, + browseHostKind = 'local', createDefaultParent, createGitAvailability, createRuntimeParentStatus, @@ -126,24 +145,10 @@ export function AddRepoDialogStepContent({ onImportNestedRepos, onCreateNameChange, onCreateParentChange, + onCreateKindChange, onPickCreateParent, onCreate }: AddRepoDialogStepContentProps): React.JSX.Element | null { - if (step === 'add' && isRuntimeEnvironmentActive) { - return ( - <AddRepoServerPathStartStep - serverPath={serverPath} - runtimeEnvironmentId={activeRuntimeEnvironmentId} - isAddingServerPath={isAddingServerPath} - addProjectBusyLabel={addProjectBusyLabel} - onServerPathChange={onServerPathChange} - onAddServerPath={onAddServerPath} - onOpenCloneStep={onOpenCloneStep} - onOpenCreateStep={onOpenCreateStep} - /> - ) - } - if (step === 'add') { return ( <AddRepoLocalStartStep @@ -153,6 +158,10 @@ export function AddRepoDialogStepContent({ addProjectBusyLabel={addProjectBusyLabel} nestedScanInProgress={nestedScanInProgress} nestedScanId={nestedScanId} + hostSelector={hostSelector} + showRemoteAction={showRemoteAction} + canCreateProject={canCreateProject} + browseHostKind={browseHostKind} onBrowse={onBrowse} onOpenCloneStep={onOpenCloneStep} onOpenRemoteStep={onOpenRemoteStep} @@ -162,11 +171,29 @@ export function AddRepoDialogStepContent({ ) } + if (step === 'server-path') { + return ( + <AddRepoServerPathStartStep + serverPath={serverPath} + runtimeEnvironmentId={activeRuntimeEnvironmentId} + isAddingServerPath={isAddingServerPath} + addProjectBusyLabel={addProjectBusyLabel} + hostSelector={hostSelector} + initialBrowsing + onServerPathChange={onServerPathChange} + onAddServerPath={onAddServerPath} + onOpenCloneStep={onOpenCloneStep} + onOpenCreateStep={onOpenCreateStep} + /> + ) + } + if (step === 'remote') { return ( <RemoteStep sshTargets={sshTargets} selectedTargetId={selectedTargetId} + lockSshTargetSelection={lockSshTargetSelection} remotePath={remotePath} remoteError={remoteError} isAddingRemote={isAddingRemote} @@ -191,6 +218,10 @@ export function AddRepoDialogStepContent({ isCloning={isCloning} disableDestinationPicker={isRuntimeEnvironmentActive} runtimeEnvironmentId={activeRuntimeEnvironmentId} + sshTargetId={selectedSshTargetId} + cloneTargetLabel={ + isRuntimeEnvironmentActive || selectedSshTargetId ? selectedHostLabel : null + } onUrlChange={onCloneUrlChange} onDestChange={onCloneDestinationChange} onPickDestination={onPickCloneDestination} @@ -220,16 +251,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 new file mode 100644 index 00000000000..470e81b0512 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx @@ -0,0 +1,105 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { AddRepoHostSelector } from './AddRepoHostSelector' + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, + PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div> +})) + +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + CommandItem: ({ + children, + disabled, + className + }: { + children: React.ReactNode + disabled?: boolean + className?: string + }) => ( + <div aria-disabled={disabled} className={className}> + {children} + </div> + ) +})) + +describe('AddRepoHostSelector', () => { + it('shows disconnected SSH hosts as disabled choices in Add Project', () => { + const html = renderToStaticMarkup( + <AddRepoHostSelector + hosts={[ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + }, + { + id: 'ssh:ssh-1', + label: 'Builder', + detail: 'SSH', + kind: 'ssh', + health: 'disconnected', + presence: 'configured' + } + ]} + selectedHostId="ssh:ssh-1" + open={false} + onOpenChange={vi.fn()} + onSelectHost={vi.fn()} + /> + ) + + expect(html).toContain('Builder') + expect(html).toContain('Disconnected') + 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', () => { + const html = renderToStaticMarkup( + <AddRepoHostSelector + hosts={[ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + }, + { + id: 'runtime:old-server', + label: 'Old server', + detail: 'Orca server', + kind: 'runtime', + health: 'blocked', + presence: 'active', + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: 5, + serverProtocolVersion: 1, + requiredServerProtocolVersion: 4 + } + } + ]} + selectedHostId="runtime:old-server" + open + onOpenChange={vi.fn()} + onSelectHost={vi.fn()} + /> + ) + + expect(html).toContain('Update needed') + expect(html).toContain('The selected Orca server is too old for this client.') + expect(html).toContain('Update Orca on the server.') + expect(html).toContain('aria-disabled="true"') + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx new file mode 100644 index 00000000000..3bf51c33bf7 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx @@ -0,0 +1,118 @@ +import { Check, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { SidebarHostOption } from './sidebar-host-options' +import { getSidebarHostHealthLabel, shouldShowHostScopeControls } from './sidebar-host-options' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat' +import { translate } from '@/i18n/i18n' +import { canSelectAddRepoHost } from './add-repo-host-availability' + +type AddRepoHostSelectorProps = { + hosts: SidebarHostOption[] + selectedHostId: ExecutionHostId + open: boolean + onOpenChange: (open: boolean) => void + onSelectHost: (hostId: ExecutionHostId) => void +} + +function getHostStatusDetail(host: SidebarHostOption): string { + if (host.compatibility?.kind === 'blocked') { + return describeRuntimeCompatBlock(host.compatibility) + } + return `${getSidebarHostHealthLabel(host.health)}${host.detail ? ` - ${host.detail}` : ''}` +} + +export function AddRepoHostSelector({ + hosts, + selectedHostId, + open, + onOpenChange, + onSelectHost +}: AddRepoHostSelectorProps): React.JSX.Element | null { + if (!shouldShowHostScopeControls(hosts)) { + return null + } + + const selectedHost = hosts.find((host) => host.id === selectedHostId) ?? hosts[0] + if (!selectedHost) { + return null + } + return ( + <div className="flex items-center gap-2 text-xs"> + <span className="font-medium text-muted-foreground"> + {translate('auto.components.sidebar.AddRepoHostSelector.host', 'Host')} + </span> + <Popover open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="ghost" + role="combobox" + aria-expanded={open} + className="h-7 min-w-0 max-w-[18rem] gap-1.5 rounded-md border border-border bg-muted/30 px-2 text-xs font-medium text-foreground hover:bg-accent hover:text-accent-foreground" + > + <span className="min-w-0 truncate">{selectedHost.label}</span> + {selectedHost.health !== 'local' ? ( + <span + title={getHostStatusDetail(selectedHost)} + className="shrink-0 text-[11px] font-normal text-muted-foreground" + > + {getSidebarHostHealthLabel(selectedHost.health)} + </span> + ) : null} + <ChevronsUpDown className="size-3.5 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[min(340px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" + > + <Command> + <CommandList> + {hosts.map((host) => { + const selected = host.id === selectedHostId + const disabled = !canSelectAddRepoHost(host) + return ( + <CommandItem + key={host.id} + value={`${host.label} ${host.detail}`} + disabled={disabled} + onSelect={() => { + if (disabled) { + return + } + onSelectHost(host.id) + onOpenChange(false) + }} + className={cn( + 'items-start gap-2 px-3 py-2 text-xs', + disabled && 'cursor-not-allowed opacity-55' + )} + > + <Check + className={cn( + 'mt-0.5 size-3 text-muted-foreground', + selected ? 'opacity-70' : 'opacity-0' + )} + /> + <span className="min-w-0 flex-1"> + <span className="flex min-w-0 items-center gap-2"> + <span className="truncate font-medium">{host.label}</span> + </span> + <span className="mt-0.5 block truncate text-[11px] text-muted-foreground"> + {getHostStatusDetail(host)} + </span> + </span> + </CommandItem> + ) + })} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx new file mode 100644 index 00000000000..0fe1236f5a5 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx @@ -0,0 +1,18 @@ +import { AddRepoHostSelector } from './AddRepoHostSelector' +import type { useAddRepoHostSelection } from './use-add-repo-host-selection' + +export function AddRepoHostSelectorSlot({ + hostSelection +}: { + hostSelection: ReturnType<typeof useAddRepoHostSelection> +}) { + return ( + <AddRepoHostSelector + hosts={hostSelection.hostOptions} + selectedHostId={hostSelection.selectedHostId} + open={hostSelection.hostSelectorOpen} + onOpenChange={hostSelection.setHostSelectorOpen} + onSelectHost={(hostId) => void hostSelection.handleSelectAddProjectHost(hostId)} + /> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index 47d8ecd691a..13be2021c7d 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -78,13 +78,13 @@ describe('AddRepoNestedImportStep', () => { expect(html).toContain('Import repositories from folder') expect(html).toContain('Found 3 repositories in') expect(html).toContain('/workspace/platform') - expect(html).toContain('aria-label="Group name"') + expect(html).toContain('aria-label="Monorepo name"') expect(html).not.toContain('What is a') expect(html).toContain('Is this a monorepo?') - expect(html).toContain('Import them as a group if they're a monorepo') + expect(html).toContain('Choose this if these projects belong together') expect(html).toContain('Orca will group them and let you work from the parent folder') expect(html).toContain('No, import separately') - expect(html).toContain('Import as group') + expect(html).toContain('Yes, import as monorepo') expect(html).toContain('payments/api') expect(html).toContain('billing/api') expect(html).not.toContain('disabled=""') @@ -97,9 +97,9 @@ describe('AddRepoNestedImportStep', () => { expect(html).toContain('Is this a monorepo?') expect(html).toContain('No, import separately') - expect(html).toContain('Import as group') + expect(html).toContain('Yes, import as monorepo') expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/) - expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Import as group<\/button>/) + expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/) }) it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => { @@ -130,7 +130,7 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Import as group').click() + findButton(host, 'Yes, import as monorepo').click() findButton(host, 'No, import separately').click() }) @@ -174,11 +174,13 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Import as group').click() + findButton(host, 'Yes, import as monorepo').click() }) expect(onImport).toHaveBeenCalledWith('group') - expect(findButton(host, 'Import as group').querySelector('.animate-spin')).not.toBeNull() + expect( + findButton(host, 'Yes, import as monorepo').querySelector('.animate-spin') + ).not.toBeNull() expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull() }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx index 8332415fa2a..e8e5f816769 100644 --- a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx @@ -11,6 +11,7 @@ import { translate } from '@/i18n/i18n' type RemoteStepProps = { sshTargets: (SshTarget & { state?: SshConnectionState })[] selectedTargetId: string | null + lockSshTargetSelection?: boolean remotePath: string remoteError: string | null isAddingRemote: boolean @@ -26,6 +27,7 @@ type RemoteStepProps = { export function RemoteStep({ sshTargets, selectedTargetId, + lockSshTargetSelection = false, remotePath, remoteError, isAddingRemote, @@ -38,6 +40,14 @@ export function RemoteStep({ onStopNestedScan }: RemoteStepProps): React.JSX.Element { const [browsing, setBrowsing] = useState(false) + const selectedTarget = selectedTargetId + ? sshTargets.find((target) => target.id === selectedTargetId) + : null + const selectedTargetLabel = + selectedTarget?.label || + (selectedTarget ? `${selectedTarget.username}@${selectedTarget.host}` : selectedTargetId) + const selectedTargetStatus = selectedTarget?.state?.status ?? 'disconnected' + const selectedTargetConnected = selectedTargetStatus === 'connected' if (browsing && selectedTargetId) { return ( @@ -73,60 +83,89 @@ 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 && !selectedTargetConnected ? ( + <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2"> + <p className="min-w-0 text-xs text-muted-foreground"> + {translate( + 'auto.components.sidebar.AddRepoRemoteStep.lockedDisconnected', + '{{value0}} is disconnected.', + { value0: selectedTargetLabel ?? 'This SSH host' } + )} + </p> + <Button + variant="outline" + size="xs" + className="shrink-0" + onClick={() => onConnectTarget(selectedTarget.id)} + > + {translate('auto.components.sidebar.AddRepoRemoteStep.93e0221434', 'Connect')} + </Button> + </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 @@ -145,14 +184,14 @@ export function RemoteStep({ '/home/user/project' )} className="h-8 text-xs flex-1" - disabled={isAddingRemote || !selectedTargetId} + disabled={isAddingRemote || !selectedTargetId || !selectedTargetConnected} /> <Button variant="outline" size="sm" className="h-8 px-2 shrink-0" onClick={() => setBrowsing(true)} - disabled={!selectedTargetId || isAddingRemote} + disabled={!selectedTargetId || !selectedTargetConnected || isAddingRemote} > <FolderOpen className="size-3.5" /> </Button> @@ -163,14 +202,16 @@ export function RemoteStep({ <Button onClick={onAdd} - disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote} + disabled={ + !selectedTargetId || !selectedTargetConnected || !remotePath.trim() || isAddingRemote + } className="w-full" > {isAddingRemote ? 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 98b2f01fee9..14f539e2b02 100644 --- a/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx @@ -1,4 +1,4 @@ -import { useState, type ComponentType } from 'react' +import { useState, type ComponentType, type ReactNode } from 'react' import { FolderOpen, Globe, Lightbulb, Loader2, Server } from 'lucide-react' import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -12,6 +12,8 @@ type AddRepoServerPathStartStepProps = { runtimeEnvironmentId: string | null | undefined isAddingServerPath: boolean addProjectBusyLabel: string | null + hostSelector?: ReactNode + initialBrowsing?: boolean onServerPathChange: (path: string) => void onAddServerPath: (kind: 'git' | 'folder') => void onOpenCloneStep: () => void @@ -23,13 +25,15 @@ export function AddRepoServerPathStartStep({ runtimeEnvironmentId, isAddingServerPath, addProjectBusyLabel, + hostSelector, + initialBrowsing = false, onServerPathChange, onAddServerPath, onOpenCloneStep, onOpenCreateStep }: AddRepoServerPathStartStepProps): React.JSX.Element { - const [browsing, setBrowsing] = useState(false) - const [pathEntryOpen, setPathEntryOpen] = useState(false) + const [browsing, setBrowsing] = useState(initialBrowsing) + const [pathEntryOpen, setPathEntryOpen] = useState(initialBrowsing) if (browsing && runtimeEnvironmentId) { return ( @@ -38,7 +42,7 @@ export function AddRepoServerPathStartStep({ <DialogTitle> {translate( 'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d', - 'Browse server filesystem' + 'Browse host filesystem' )} </DialogTitle> <DialogDescription> @@ -77,18 +81,19 @@ 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> <div className="space-y-3 pt-2"> + {hostSelector} <div className="grid grid-cols-3 gap-2"> <AddRepoServerStartAction icon={FolderOpen} title={translate( 'auto.components.sidebar.AddRepoServerStartStep.0adf083af7', - 'Browse server' + 'Browse host' )} description={translate( 'auto.components.sidebar.AddRepoServerStartStep.516187414c', @@ -114,7 +119,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', @@ -145,7 +150,7 @@ export function AddRepoServerPathStartStep({ > {translate( 'auto.components.sidebar.AddRepoServerStartStep.438493f214', - 'Or enter a server path manually' + 'Or enter a host path manually' )} </button> </div> @@ -159,24 +164,25 @@ 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> <div className="space-y-3 pt-2"> + {hostSelector} <div className="space-y-1"> <label 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 @@ -203,7 +209,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" /> @@ -212,7 +218,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 62cfae41daa..40fc712810a 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -120,6 +120,46 @@ function getActionTitles(isSshLikely: boolean): { } } +function getHostAwareActionModel(): { + secondary: string[] + createDisabled: boolean | undefined +} { + const { secondaryActions } = getAddRepoLocalStartActions({ + isSshLikely: true, + showRemoteAction: false, + onBrowse: vi.fn(), + onOpenCloneStep: vi.fn(), + onOpenRemoteStep: vi.fn(), + onOpenCreateStep: vi.fn() + }) + const createAction = secondaryActions.find((action) => action.kind === 'create') + + return { + secondary: secondaryActions.map((action) => action.title), + createDisabled: createAction?.disabled + } +} + +function getRuntimeHostActionModel(): { + primary: string + description: string +} { + const { primaryAction } = getAddRepoLocalStartActions({ + isSshLikely: false, + showRemoteAction: false, + browseHostKind: 'runtime', + onBrowse: vi.fn(), + onOpenCloneStep: vi.fn(), + onOpenRemoteStep: vi.fn(), + onOpenCreateStep: vi.fn() + }) + + return { + primary: primaryAction.title, + description: primaryAction.description + } +} + describe('AddRepoLocalStartStep', () => { afterEach(() => { document.body.innerHTML = '' @@ -130,8 +170,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') }) @@ -140,23 +180,45 @@ 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', () => { + const model = getHostAwareActionModel() + + expect(model.secondary).toEqual(['Clone from URL', 'Create new project']) + expect(model.createDisabled).toBe(false) + }) + + it('uses host-neutral browse copy for runtime hosts', () => { + const model = getRuntimeHostActionModel() + + expect(model.primary).toBe('Browse folder') + expect(model.description).toBe('Existing Git repository or folder on this host') }) it('focuses Browse folder when the default Add Project step opens', async () => { @@ -173,7 +235,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) @@ -187,8 +249,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) + expect(findButton(container, 'Project on SSH host').disabled).toBe(false) + expect(findButton(container, 'Create new project').disabled).toBe(false) await act(async () => { root.unmount() @@ -303,12 +365,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 20b259eaa88..a6715bdfee6 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' @@ -66,6 +66,10 @@ type AddRepoLocalStartStepProps = { addProjectBusyLabel: string | null nestedScanInProgress: boolean nestedScanId: string | null + hostSelector?: ReactNode + showRemoteAction?: boolean + canCreateProject?: boolean + browseHostKind?: 'local' | 'ssh' | 'runtime' onBrowse: () => void onOpenCloneStep: () => void onOpenRemoteStep: () => void @@ -80,6 +84,10 @@ export function AddRepoLocalStartStep({ addProjectBusyLabel, nestedScanInProgress, nestedScanId, + hostSelector, + showRemoteAction = true, + canCreateProject = true, + browseHostKind = 'local', onBrowse, onOpenCloneStep, onOpenRemoteStep, @@ -93,7 +101,10 @@ export function AddRepoLocalStartStep({ onBrowse, onOpenCloneStep, onOpenRemoteStep, - onOpenCreateStep + onOpenCreateStep, + showRemoteAction, + canCreateProject, + browseHostKind }) // The white fill + ⏎ chip is a roving selection indicator, not a fixed "primary" badge: @@ -161,6 +172,7 @@ export function AddRepoLocalStartStep({ onBlur={handleActionsBlur} onKeyDown={handleArrowNavigation} > + {hostSelector} <AddRepoPrimaryStartAction icon={primaryAction.icon} title={primaryAction.title} @@ -187,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/AddRepoStepIndicator.tsx b/src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx index 514d4f6af1a..7cb9148fb33 100644 --- a/src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx @@ -13,7 +13,12 @@ export function AddRepoStepIndicator({ isAdding, onBack }: AddRepoStepIndicatorProps): React.JSX.Element | null { - const showBack = step === 'clone' || step === 'remote' || step === 'create' || step === 'nested' + const showBack = + step === 'clone' || + step === 'remote' || + step === 'server-path' || + step === 'create' || + step === 'nested' if (!showBack) { return null diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts index c1c9cd15935..db874556e9d 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts @@ -8,11 +8,15 @@ const mocks = vi.hoisted(() => ({ stateIndex: 0, storeState: { repos: [] as Repo[], + projects: [], + projectHostSetups: [], clearOrcaHookTrustForRepo: vi.fn(), openModal: vi.fn(), cancelNestedRepoScan: vi.fn() }, addRemote: vi.fn(), + listTargets: vi.fn(), + getState: vi.fn(), onStateChanged: vi.fn(() => vi.fn()), fetchWorktrees: vi.fn(), onGitRepoReady: vi.fn() @@ -90,9 +94,18 @@ describe('useRemoteRepo default-checkout handoff', () => { mocks.stateSetters = [] mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false, null] mocks.storeState.repos = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] + mocks.listTargets.mockResolvedValue([ + { id: 'ssh-1', label: 'Builder 1' }, + { id: 'ssh-2', label: 'Builder 2' } + ]) + mocks.getState.mockResolvedValue({ status: 'connected' }) vi.stubGlobal('window', { api: { ssh: { + listTargets: mocks.listTargets, + getState: mocks.getState, onStateChanged: mocks.onStateChanged }, repos: { @@ -124,6 +137,12 @@ describe('useRemoteRepo default-checkout handoff', () => { expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { requireAuthoritative: true }) + expect(mocks.storeState.projects).toEqual( + expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) + ) + expect(mocks.storeState.projectHostSetups).toEqual( + expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) + ) expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id) }) @@ -150,4 +169,23 @@ describe('useRemoteRepo default-checkout handoff', () => { 'Could not refresh project worktrees. Try again.' ) }) + + it('preselects the preferred SSH target when opening Browse for a selected host', async () => { + mocks.stateValues = [[], null, '~/', null, false, null] + const { useRemoteRepo } = await import('./AddRepoSteps') + + const result = useRemoteRepo( + mocks.fetchWorktrees, + vi.fn(), + vi.fn(), + mocks.onGitRepoReady, + vi.fn().mockResolvedValue(null) + ) + await result.handleOpenRemoteStep('ssh-2') + + expect(mocks.listTargets).toHaveBeenCalled() + expect(mocks.getState).toHaveBeenCalledWith({ targetId: 'ssh-1' }) + expect(mocks.getState).toHaveBeenCalledWith({ targetId: 'ssh-2' }) + expect(mocks.stateSetters[1]).toHaveBeenCalledWith('ssh-2') + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index af55e2448ea..5ab96be88ca 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -1,18 +1,15 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' -import { Folder } from 'lucide-react' import { useAppStore } from '@/store' -import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { useMountedRef } from '@/hooks/useMountedRef' -import { RemoteFileBrowser } from './RemoteFileBrowser' import type { NestedRepoScanResult } from '../../../../shared/types' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry' 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: ( @@ -67,37 +64,47 @@ export function useRemoteRepo( void cancelNestedRepoScan(remoteNestedScanId) }, [cancelNestedRepoScan, remoteNestedScanId]) - const handleOpenRemoteStep = useCallback(async () => { - const gen = ++remoteGenRef.current - setStep('remote') - try { - const targets = (await window.api.ssh.listTargets()) as SshTarget[] - if (gen !== remoteGenRef.current) { - return + const handleOpenRemoteStep = useCallback( + async (preferredTargetId?: string | null) => { + const gen = ++remoteGenRef.current + setStep('remote') + try { + const targets = (await window.api.ssh.listTargets()) as SshTarget[] + if (gen !== remoteGenRef.current) { + return + } + const withState = await Promise.all( + targets.map(async (t) => { + const state = (await window.api.ssh.getState({ + targetId: t.id + })) as SshConnectionState | null + return { ...t, state: state ?? undefined } + }) + ) + if (gen !== remoteGenRef.current) { + return + } + setSshTargets(withState) + const preferred = preferredTargetId + ? withState.find((t) => t.id === preferredTargetId) + : undefined + const connected = withState.find((t) => t.state?.status === 'connected') + if (preferred) { + setSelectedTargetId(preferred.id) + return + } + if (connected) { + setSelectedTargetId(connected.id) + } + } catch { + if (gen !== remoteGenRef.current) { + return + } + setSshTargets([]) } - const withState = await Promise.all( - targets.map(async (t) => { - const state = (await window.api.ssh.getState({ - targetId: t.id - })) as SshConnectionState | null - return { ...t, state: state ?? undefined } - }) - ) - if (gen !== remoteGenRef.current) { - return - } - setSshTargets(withState) - const connected = withState.find((t) => t.state?.status === 'connected') - if (connected) { - setSelectedTargetId(connected.id) - } - } catch { - if (gen !== remoteGenRef.current) { - return - } - setSshTargets([]) - } - }, [setStep]) + }, + [setStep] + ) // Why: keep the target list's connection state in sync while the dialog is // open, so clicking the inline Connect button below updates the dot/label @@ -116,7 +123,11 @@ export function useRemoteRepo( try { await window.api.ssh.connect({ targetId }) } catch (err) { - toast.error(err instanceof Error ? err.message : translate("auto.components.sidebar.AddRepoSteps.3e64e8a70d", "Connection failed")) + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.sidebar.AddRepoSteps.3e64e8a70d', 'Connection failed') + ) } }, []) @@ -178,18 +189,15 @@ export function useRemoteRepo( if (existingIdx !== -1) { state.clearOrcaHookTrustForRepo(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) if (!mountedRef.current || gen !== remoteGenRef.current) { return } - toast.success(translate("auto.components.sidebar.AddRepoSteps.df8b0e6c22", "Remote project added"), { description: repo.displayName }) + toast.success( + 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 // non-authoritative, finish onto the project row instead of stranding the dialog. await fetchWorktrees(repo.id, { requireAuthoritative: true }) @@ -198,7 +206,7 @@ export function useRemoteRepo( } await onGitRepoReady?.(repo.id) } catch (err) { - const message = err instanceof Error ? err.message : String(err) + const message = extractIpcErrorMessage(err, String(err)) if (message.includes('Not a valid git repository')) { // Why: match the local add-project flow — show confirmation dialog so // users understand git features will be unavailable, rather than @@ -248,147 +256,3 @@ export function useRemoteRepo( stopRemoteNestedScan } } - -// ── Clone step ─────────────────────────────────────────────────────── - -type CloneStepProps = { - cloneUrl: string - cloneDestination: string - cloneError: string | null - cloneProgress: { phase: string; percent: number } | null - isCloning: boolean - disableDestinationPicker?: boolean - runtimeEnvironmentId?: string | null - onUrlChange: (value: string) => void - onDestChange: (value: string) => void - onPickDestination: () => void - onClone: () => void -} - -export function CloneStep({ - cloneUrl, - cloneDestination, - cloneError, - cloneProgress, - isCloning, - disableDestinationPicker = false, - runtimeEnvironmentId, - onUrlChange, - onDestChange, - onPickDestination, - onClone -}: CloneStepProps): React.JSX.Element { - const [browsingDestination, setBrowsingDestination] = useState(false) - const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning - const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { - e.preventDefault() - if (canClone) { - onClone() - } - } - } - - if (browsingDestination && runtimeEnvironmentId) { - return ( - <> - <DialogHeader> - <DialogTitle>{translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem")}</DialogTitle> - <DialogDescription> - {translate("auto.components.sidebar.AddRepoSteps.fe8e629fe3", "Navigate to a directory and click Select to choose it.")}</DialogDescription> - </DialogHeader> - <RemoteFileBrowser - runtimeEnvironmentId={runtimeEnvironmentId} - initialPath={cloneDestination || '~'} - onSelect={(path) => { - onDestChange(path) - setBrowsingDestination(false) - }} - onCancel={() => setBrowsingDestination(false)} - /> - </> - ) - } - - return ( - <> - <DialogHeader> - <DialogTitle>{translate("auto.components.sidebar.AddRepoSteps.c05f88a31f", "Clone from URL")}</DialogTitle> - <DialogDescription>{translate("auto.components.sidebar.AddRepoSteps.5b2ea674b1", "Enter the Git URL and choose where to clone it.")}</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.AddRepoSteps.3d4acbe693", "Git URL")}</label> - <Input - value={cloneUrl} - onChange={(e) => onUrlChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={translate("auto.components.sidebar.AddRepoSteps.b698a4a29d", "https://github.com/user/repo.git")} - className="h-8 text-xs" - disabled={isCloning} - autoFocus - /> - </div> - - <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.AddRepoSteps.04a4c4e84a", "Clone location")}</label> - <div className="flex gap-2"> - <Input - value={cloneDestination} - onChange={(e) => onDestChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={translate("auto.components.sidebar.AddRepoSteps.2ce3f6edf8", "/path/to/destination")} - className="h-8 text-xs flex-1" - disabled={isCloning} - /> - <Button - variant="outline" - size="sm" - className="h-8 px-2 shrink-0" - onClick={() => { - if (runtimeEnvironmentId) { - setBrowsingDestination(true) - return - } - onPickDestination() - }} - disabled={isCloning || (disableDestinationPicker && !runtimeEnvironmentId)} - title={runtimeEnvironmentId ? translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem") : translate("auto.components.sidebar.AddRepoSteps.569326d9cc", "Choose folder")} - aria-label={runtimeEnvironmentId ? translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem") : translate("auto.components.sidebar.AddRepoSteps.569326d9cc", "Choose folder")} - > - <Folder className="size-3.5" /> - </Button> - </div> - </div> - - {cloneError && <p className="text-[11px] text-destructive">{cloneError}</p>} - - <Button - onClick={onClone} - disabled={!cloneUrl.trim() || !cloneDestination.trim() || isCloning} - className="w-full" - > - {isCloning ? translate("auto.components.sidebar.AddRepoSteps.69f5b5380d", "Cloning...") : translate("auto.components.sidebar.AddRepoSteps.32a7256d85", "Clone")} - </Button> - - {/* Why: progress bar lives below the button so it doesn't push the - button down when it appears mid-clone. */} - {isCloning && cloneProgress && ( - <div className="space-y-1.5"> - <div className="flex items-center justify-between text-[11px] text-muted-foreground"> - <span>{cloneProgress.phase}</span> - <span>{cloneProgress.percent}%</span> - </div> - <div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden"> - <div - className="h-full rounded-full bg-foreground transition-[width] duration-300 ease-out" - style={{ width: `${cloneProgress.percent}%` }} - /> - </div> - </div> - )} - </div> - </> - ) -} diff --git a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx index 9b0ae00c3f9..adca84a5230 100644 --- a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx +++ b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx @@ -7,7 +7,8 @@ import { RemoteFileBrowser } from './RemoteFileBrowser' import { translate } from '@/i18n/i18n' type CreateProjectParentBrowserProps = { - runtimeEnvironmentId: string + runtimeEnvironmentId?: string | null + sshTargetId?: string | null createParent: string onParentChange: (value: string) => void onClose: () => void @@ -15,6 +16,7 @@ type CreateProjectParentBrowserProps = { export function CreateProjectParentBrowser({ runtimeEnvironmentId, + sshTargetId, createParent, onParentChange, onClose @@ -25,7 +27,7 @@ export function CreateProjectParentBrowser({ <DialogTitle> {translate( 'auto.components.sidebar.CreateProjectLocationField.f520f83a97', - 'Browse server filesystem' + 'Browse host filesystem' )} </DialogTitle> <DialogDescription> @@ -35,15 +37,27 @@ export function CreateProjectParentBrowser({ )} </DialogDescription> </DialogHeader> - <RemoteFileBrowser - runtimeEnvironmentId={runtimeEnvironmentId} - initialPath={createParent || '~'} - onSelect={(path) => { - onParentChange(path) - onClose() - }} - onCancel={onClose} - /> + {sshTargetId ? ( + <RemoteFileBrowser + targetId={sshTargetId} + initialPath={createParent || '~'} + onSelect={(path) => { + onParentChange(path) + onClose() + }} + onCancel={onClose} + /> + ) : ( + <RemoteFileBrowser + runtimeEnvironmentId={runtimeEnvironmentId as string} + initialPath={createParent || '~'} + onSelect={(path) => { + onParentChange(path) + onClose() + }} + onCancel={onClose} + /> + )} </> ) } @@ -53,6 +67,7 @@ type CreateProjectLocationFieldProps = { isCreating: boolean manualParentEntry: boolean runtimeEnvironmentId?: string | null + sshTargetId?: string | null onParentChange: (value: string) => void onPickParent: () => void onBrowseServer: () => void @@ -63,6 +78,7 @@ export function CreateProjectLocationField({ isCreating, manualParentEntry, runtimeEnvironmentId, + sshTargetId, onParentChange, onPickParent, onBrowseServer @@ -94,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" /> @@ -106,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/DeleteWorktreeDialog.host-context-boundary.test.ts b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts new file mode 100644 index 00000000000..30066815370 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SOURCE = readFileSync(join(__dirname, 'DeleteWorktreeDialog.tsx'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('DeleteWorktreeDialog host-context boundaries', () => { + it('preloads git status from the selected worktree owner instead of the focused host', () => { + const effect = sourceBetween( + SOURCE, + 'const statusTargets = deleteTargets.filter(', + 'return () => {' + ) + + expect(effect).toContain('getSettingsForWorktreeRuntimeOwner') + expect(effect).toContain('worktreesByRepo: useAppStore.getState().worktreesByRepo') + expect(effect).toContain('item.id') + expect(effect).not.toContain('settings,\n worktreeId: item.id') + }) +}) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 64ca7e83b9b..f24c0a5ac17 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle @@ -11,13 +10,16 @@ import { useAppStore } from '@/store' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { DeleteWorktreeLineageNotice } from './DeleteWorktreeLineageNotice' import { DeleteWorktreeSkipConfirmOption } from './DeleteWorktreeSkipConfirmOption' import { DeleteWorktreeDialogFooter } from './DeleteWorktreeDialogFooter' +import { DeleteWorktreeDialogDescription } from './DeleteWorktreeDialogDescription' import { DeleteWorktreeTargetPreview } from './DeleteWorktreeTargetPreview' import { DeleteWorktreeWarningPanels } from './DeleteWorktreeWarningPanels' +import { persistDeleteWorktreeConfirmSkipPreference } from './delete-worktree-preference-toast' import { countFolderWorkspaceDeletes, getDeleteWorktreeDialogCopy, @@ -188,7 +190,12 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { let cancelled = false for (const item of statusTargets) { void getRuntimeGitStatus({ - settings, + // Why: delete warnings inspect git state for the selected workspace; + // a later focused-host switch must not make this preload query another host. + settings: getSettingsForWorktreeRuntimeOwner( + { repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo }, + item.id + ), worktreeId: item.id, worktreePath: item.path, connectionId: getConnectionId(item.id) ?? undefined @@ -206,7 +213,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { return () => { cancelled = true } - }, [deleteTargets, gitStatusByWorktree, isOpen, repoMap, setGitStatus, settings]) + }, [deleteTargets, gitStatusByWorktree, isOpen, repoMap, repos, setGitStatus, settings]) const handleOpenChange = useCallback( (open: boolean) => { @@ -232,24 +239,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { ) const persistDontAskAgainPreference = useCallback((): void => { - void updateSettings({ skipDeleteWorktreeConfirm: true }) - // Why: the toast confirms the preference was saved and points the user at - // where to undo it. The "Open Settings" action deep-links to the General - // pane so they never have to hunt for the toggle if they change their mind. - toast.success(translate("auto.components.sidebar.DeleteWorktreeDialog.dd3a45bbbd", "We'll skip this confirmation next time."), { - description: translate("auto.components.sidebar.DeleteWorktreeDialog.2b56b35f53", "You can change this in Settings."), - duration: 8000, - action: { - label: translate("auto.components.sidebar.DeleteWorktreeDialog.5cc1a6701c", "Open Settings"), - onClick: () => { - openSettingsPage() - openSettingsTarget({ - pane: 'general', - repoId: null, - sectionId: 'general-skip-delete-worktree-confirm' - }) - } - } + persistDeleteWorktreeConfirmSkipPreference({ + updateSettings, + openSettingsPage, + openSettingsTarget }) }, [openSettingsPage, openSettingsTarget, updateSettings]) @@ -282,17 +275,29 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { deletePromise .then((result) => { if (!result.ok) { - toast.error(translate("auto.components.sidebar.DeleteWorktreeDialog.42e610d6cf", "Force delete failed"), { - description: result.error - }) + toast.error( + translate( + 'auto.components.sidebar.DeleteWorktreeDialog.42e610d6cf', + 'Force delete failed' + ), + { + description: result.error + } + ) return } onDeleted?.([worktreeId]) }) .catch((err: unknown) => { - toast.error(translate("auto.components.sidebar.DeleteWorktreeDialog.4f6750ca7b", "Failed to delete workspace"), { - description: err instanceof Error ? err.message : String(err) - }) + toast.error( + translate( + 'auto.components.sidebar.DeleteWorktreeDialog.4f6750ca7b', + 'Failed to delete workspace' + ), + { + description: err instanceof Error ? err.message : String(err) + } + ) }) } else { // Why: this modal is the destructive confirmation for the workspace @@ -365,23 +370,27 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { > <DialogHeader> <DialogTitle className="text-sm"> - {isBatchDelete ? translate("auto.components.sidebar.DeleteWorktreeDialog.86f0ae1257", "Delete Workspaces") : translate("auto.components.sidebar.DeleteWorktreeDialog.fc23c4cbdf", "Delete Workspace")} + {isBatchDelete + ? translate( + 'auto.components.sidebar.DeleteWorktreeDialog.86f0ae1257', + 'Delete Workspaces' + ) + : translate( + 'auto.components.sidebar.DeleteWorktreeDialog.fc23c4cbdf', + 'Delete Workspace' + )} </DialogTitle> - <DialogDescription className="text-xs"> - {translate("auto.components.sidebar.DeleteWorktreeDialog.91492c9ad6", "Remove")}<span className={deleteCopy.targetClassName}>{deleteCopy.targetLabel}</span> - {canDeleteAllLineage ? ( - <> - {' '} - {translate("auto.components.sidebar.DeleteWorktreeDialog.ff2a74ac0e", "and")}{' '} - <span className="font-medium text-foreground"> - {lineageDeleteCopy.childTargetLabel} - </span>{' '} - {lineageDeleteCopy.descriptionSuffix} - </> - ) : ( - <> {deleteCopy.descriptionSuffix}</> - )} - </DialogDescription> + <DeleteWorktreeDialogDescription + targetClassName={deleteCopy.targetClassName} + targetLabel={deleteCopy.targetLabel} + canDeleteAllLineage={canDeleteAllLineage} + childTargetLabel={lineageDeleteCopy.childTargetLabel} + descriptionSuffix={ + canDeleteAllLineage + ? lineageDeleteCopy.descriptionSuffix + : deleteCopy.descriptionSuffix + } + /> </DialogHeader> <DeleteWorktreeTargetPreview diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx new file mode 100644 index 00000000000..0d960e16844 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialogDescription.tsx @@ -0,0 +1,33 @@ +import { DialogDescription } from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +export function DeleteWorktreeDialogDescription({ + targetClassName, + targetLabel, + canDeleteAllLineage, + childTargetLabel, + descriptionSuffix +}: { + targetClassName: string + targetLabel: string | undefined + canDeleteAllLineage: boolean + childTargetLabel: string + descriptionSuffix: string +}): React.JSX.Element { + return ( + <DialogDescription className="text-xs"> + {translate('auto.components.sidebar.DeleteWorktreeDialog.91492c9ad6', 'Remove')} + <span className={targetClassName}>{targetLabel}</span> + {canDeleteAllLineage ? ( + <> + {' '} + {translate('auto.components.sidebar.DeleteWorktreeDialog.ff2a74ac0e', 'and')}{' '} + <span className="font-medium text-foreground">{childTargetLabel}</span>{' '} + {descriptionSuffix} + </> + ) : ( + <> {descriptionSuffix}</> + )} + </DialogDescription> + ) +} 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/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx new file mode 100644 index 00000000000..97592d73fe0 --- /dev/null +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react' +import { Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { removeSshTargetWithBestEffortCleanup } from '../settings/ssh-target-remove' +import { clearHostRename } from './host-rename-remove' +import type { HostRemovalTarget } from './host-rename-remove' + +type HostRemoveDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + hostId: ExecutionHostId + label: string + target: NonNullable<HostRemovalTarget> +} + +export function HostRemoveDialog({ + open, + onOpenChange, + hostId, + label, + target +}: HostRemoveDialogProps): React.JSX.Element { + const [busy, setBusy] = useState(false) + const mountedRef = useMountedRef() + + // Why: dropping a host should also drop its now-orphaned label override so a + // future host reusing the same id doesn't inherit a stale rename. + const dropOverridesForHost = (): void => { + const state = useAppStore.getState() + void state.updateSettings({ + hostSettingOverrides: clearHostRename(state.settings, hostId) + }) + } + + const handleRemoveSsh = async (targetId: string): Promise<void> => { + await removeSshTargetWithBestEffortCleanup(window.api.ssh, targetId) + // Why: clear deferred reconnect metadata so focused SSH tabs stop retrying + // the deleted target — mirrors the SSH settings pane removal flow. + useAppStore.getState().clearRemovedSshTargetState(targetId) + dropOverridesForHost() + } + + // Why: runtime-environment removal needs active-environment switching and + // error context owned by the Orca servers settings pane, so we deep-link + // there with the host pre-selected instead of duplicating that flow. + const handleRemoveRuntime = (environmentId: string): void => { + const state = useAppStore.getState() + state.openSettingsTarget({ pane: 'servers', repoId: null, sectionId: environmentId }) + state.openSettingsPage() + onOpenChange(false) + } + + const confirm = async (): Promise<void> => { + if (target.kind === 'runtime') { + handleRemoveRuntime(target.environmentId) + return + } + setBusy(true) + try { + await handleRemoveSsh(target.targetId) + if (mountedRef.current) { + onOpenChange(false) + } + toast.success( + translate('auto.components.sidebar.HostRemoveDialog.1a2b3c4d5e', 'Removed {{value0}}', { + value0: label + }) + ) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.sidebar.HostRemoveDialog.2b3c4d5e6f', + 'Failed to remove host' + ) + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + } + + const isRuntime = parseExecutionHostId(hostId)?.kind === 'runtime' + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {translate( + 'auto.components.sidebar.HostRemoveDialog.3c4d5e6f7a', + 'Remove {{value0}}?', + { + value0: label + } + )} + </DialogTitle> + <DialogDescription> + {isRuntime + ? translate( + 'auto.components.sidebar.HostRemoveDialog.4d5e6f7a8b', + 'This opens the Orca servers settings where you can remove this server.' + ) + : translate( + 'auto.components.sidebar.HostRemoveDialog.5e6f7a8b9c', + 'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.' + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + {translate('auto.components.sidebar.HostRemoveDialog.6f7a8b9c0d', 'Cancel')} + </Button> + <Button + type="button" + variant="destructive" + disabled={busy} + onClick={() => void confirm()} + > + {busy ? <Loader2 className="size-3.5 animate-spin" /> : null} + {isRuntime + ? translate('auto.components.sidebar.HostRemoveDialog.7a8b9c0d1e', 'Open settings') + : translate('auto.components.sidebar.HostRemoveDialog.8b9c0d1e2f', 'Remove host')} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/src/renderer/src/components/sidebar/HostRenameDialog.tsx b/src/renderer/src/components/sidebar/HostRenameDialog.tsx new file mode 100644 index 00000000000..d9089665f43 --- /dev/null +++ b/src/renderer/src/components/sidebar/HostRenameDialog.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { applyHostRename, getHostDisplayLabelOverride } from './host-rename-remove' + +type HostRenameDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + hostId: ExecutionHostId + /** The label the host shows by default, used as the placeholder and reset target. */ + derivedLabel: string +} + +export function HostRenameDialog({ + open, + onOpenChange, + hostId, + derivedLabel +}: HostRenameDialogProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const currentOverride = getHostDisplayLabelOverride(settings, hostId) + const [value, setValue] = useState(currentOverride ?? '') + + // Why: reseed the field from the persisted override each time the dialog opens + // so a prior cancelled edit doesn't leak into the next open. + useEffect(() => { + if (open) { + setValue(currentOverride ?? '') + } + }, [open, currentOverride]) + + const submit = (): void => { + void updateSettings({ hostSettingOverrides: applyHostRename(settings, hostId, value) }) + onOpenChange(false) + } + + const reset = (): void => { + setValue('') + void updateSettings({ hostSettingOverrides: applyHostRename(settings, hostId, '') }) + onOpenChange(false) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {translate('auto.components.sidebar.HostRenameDialog.1a2b3c4d5e', 'Rename host')} + </DialogTitle> + <DialogDescription> + {translate( + 'auto.components.sidebar.HostRenameDialog.2b3c4d5e6f', + 'This label is shown only on this computer. Leave it blank to use the default name.' + )} + </DialogDescription> + </DialogHeader> + <div className="space-y-2"> + <Label htmlFor="host-rename-input"> + {translate('auto.components.sidebar.HostRenameDialog.3c4d5e6f7a', 'Display name')} + </Label> + <Input + id="host-rename-input" + autoFocus + value={value} + placeholder={derivedLabel} + onChange={(e) => setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submit() + } + }} + /> + </div> + <DialogFooter className="sm:justify-between"> + <Button type="button" variant="ghost" disabled={!currentOverride} onClick={reset}> + {translate('auto.components.sidebar.HostRenameDialog.4d5e6f7a8b', 'Reset to default')} + </Button> + <div className="flex gap-2"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + {translate('auto.components.sidebar.HostRenameDialog.5e6f7a8b9c', 'Cancel')} + </Button> + <Button type="button" onClick={submit}> + {translate('auto.components.sidebar.HostRenameDialog.6f7a8b9c0d', 'Save')} + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx new file mode 100644 index 00000000000..fddb48137b5 --- /dev/null +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -0,0 +1,301 @@ +import React, { useCallback, useState } from 'react' +import { + AlertTriangle, + Ellipsis, + Loader2, + Pencil, + Plug, + PlugZap, + RefreshCw, + Settings2, + Trash2 +} from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat' +import { + clearRuntimeCompatibilityCache, + unwrapRuntimeRpcResult +} from '@/runtime/runtime-rpc-client' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { HostHeaderRow } from './host-section-rows' +import { buildHostHeaderMenuModel } from './host-header-menu-items' +import { HostRenameDialog } from './HostRenameDialog' +import { HostRemoveDialog } from './HostRemoveDialog' +import { resolveHostRemoval } from './host-rename-remove' + +function blockedTitle(reason: 'client-too-old' | 'server-too-old'): string { + return reason === 'server-too-old' + ? translate( + 'auto.components.sidebar.HostSectionHeaderMenu.5b8b4b6a01', + 'Update server required' + ) + : translate( + 'auto.components.sidebar.HostSectionHeaderMenu.9b3c1d2e44', + 'Update client required' + ) +} + +// Why: SSH and paired runtime hosts share the sidebar model, but Settings keeps +// their management pages separate so each connection type can explain itself. +function openManageHost(row: HostHeaderRow): void { + const state = useAppStore.getState() + if (row.kind === 'runtime') { + const parsed = parseExecutionHostId(row.hostId) + state.openSettingsTarget({ + pane: 'servers', + repoId: null, + sectionId: parsed?.kind === 'runtime' ? parsed.environmentId : undefined + }) + } else if (row.kind === 'ssh') { + state.openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + } else { + state.openSettingsTarget({ pane: 'general', repoId: null }) + } + state.openSettingsPage() +} + +export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JSX.Element { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const [removeOpen, setRemoveOpen] = useState(false) + const mountedRef = useMountedRef() + const sshConnected = useAppStore((s) => { + const parsed = parseExecutionHostId(row.hostId) + if (parsed?.kind !== 'ssh') { + return false + } + return s.sshConnectionStates.get(parsed.targetId)?.status === 'connected' + }) + + const model = buildHostHeaderMenuModel({ + kind: row.kind, + health: row.health, + sshConnected, + compatibility: row.compatibility + }) + const removalTarget = resolveHostRemoval(row.hostId) + + const handleManage = useCallback(() => { + openManageHost(row) + }, [row]) + + const runSshAction = useCallback( + async (action: 'connect' | 'disconnect') => { + const parsed = parseExecutionHostId(row.hostId) + if (parsed?.kind !== 'ssh') { + return + } + setBusy(true) + try { + await window.api.ssh[action]({ targetId: parsed.targetId }) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : action === 'connect' + ? translate( + 'auto.components.sidebar.HostSectionHeaderMenu.2c29e2de68', + 'Connection failed' + ) + : translate( + 'auto.components.sidebar.HostSectionHeaderMenu.bf07aee59e', + 'Disconnect failed' + ) + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, + [mountedRef, row.hostId] + ) + + const handleCheckConnection = useCallback(async () => { + const parsed = parseExecutionHostId(row.hostId) + if (parsed?.kind !== 'runtime') { + return + } + setBusy(true) + // Why: drop any cached "compatible" verdict so the re-probe re-evaluates + // version skew instead of trusting the prior pass. + clearRuntimeCompatibilityCache(parsed.environmentId) + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: parsed.environmentId, + timeoutMs: 10_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response) + // Why: feed the probe result into the shared store so the host header and + // other host pickers reflect this check without a separate fetch. + useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { + status: runtimeStatus, + checkedAt: Date.now() + }) + toast.success( + translate( + 'auto.components.sidebar.HostSectionHeaderMenu.7f1a2b3c4d', + '{{value0}} is reachable', + { + value0: row.label + } + ) + ) + } catch (err) { + // Why: record the failed probe so the host registry can drop a previously + // healthy verdict instead of showing stale "compatible" state. + useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { + status: null, + checkedAt: Date.now() + }) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.sidebar.HostSectionHeaderMenu.2c29e2de68', + 'Connection failed' + ) + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, row.hostId, row.label]) + + return ( + <DropdownMenu modal={false} open={open} onOpenChange={setOpen}> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon-xs" + type="button" + className="size-5 shrink-0 text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/host-header:opacity-100 data-[state=open]:opacity-100" + aria-label={translate( + 'auto.components.sidebar.HostSectionHeaderMenu.4f2c8a9b10', + 'Host actions for {{value0}}', + { value0: row.label } + )} + // Why: the host header row itself toggles collapse on click; + // opening the menu must not also fold the section. + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {busy ? ( + <Loader2 className="size-3.5 animate-spin" /> + ) : ( + <Ellipsis className="size-3.5" /> + )} + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom" sideOffset={6}> + {translate('auto.components.sidebar.HostSectionHeaderMenu.6b7c8d9e10', 'Host actions')} + </TooltipContent> + </Tooltip> + <DropdownMenuContent side="right" align="start" sideOffset={8} className="w-56"> + {model.blocked && ( + <> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuItem + className="text-destructive focus:text-destructive" + onSelect={() => openManageHost(row)} + > + <AlertTriangle className="size-3.5" /> + {blockedTitle(model.blocked.reason)} + </DropdownMenuItem> + </TooltipTrigger> + <TooltipContent side="right" sideOffset={6} className="max-w-72"> + {row.compatibility ? describeRuntimeCompatBlock(row.compatibility) : null} + </TooltipContent> + </Tooltip> + <DropdownMenuSeparator /> + </> + )} + <DropdownMenuLabel className="truncate text-[11px] font-medium text-muted-foreground"> + {row.label} + </DropdownMenuLabel> + {model.actions.includes('rename') && ( + <DropdownMenuItem onSelect={() => setRenameOpen(true)}> + <Pencil className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.8d1e2f3a4b', 'Rename…')} + </DropdownMenuItem> + )} + {model.actions.includes('ssh-reconnect') && ( + <DropdownMenuItem onSelect={() => void runSshAction('connect')}> + <Plug className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.63f36455cc', 'Reconnect')} + </DropdownMenuItem> + )} + {model.actions.includes('ssh-disconnect') && ( + <DropdownMenuItem onSelect={() => void runSshAction('disconnect')}> + <PlugZap className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.59b553e2aa', 'Disconnect')} + </DropdownMenuItem> + )} + {model.actions.includes('runtime-check-connection') && ( + <DropdownMenuItem onSelect={() => void handleCheckConnection()}> + <RefreshCw className="size-3.5" /> + {translate( + 'auto.components.sidebar.HostSectionHeaderMenu.2d3e4f5a6b', + 'Check connection' + )} + </DropdownMenuItem> + )} + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={handleManage}> + <Settings2 className="size-3.5" /> + {translate('auto.components.sidebar.HostSectionHeaderMenu.3c4d5e6f7a', 'Manage host…')} + </DropdownMenuItem> + {model.actions.includes('remove') && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + className="text-destructive focus:text-destructive" + onSelect={() => setRemoveOpen(true)} + > + <Trash2 className="size-3.5" /> + {translate( + 'auto.components.sidebar.HostSectionHeaderMenu.6e7f8a9b0c', + 'Remove host…' + )} + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + <HostRenameDialog + open={renameOpen} + onOpenChange={setRenameOpen} + hostId={row.hostId} + derivedLabel={row.label} + /> + {removalTarget && ( + <HostRemoveDialog + open={removeOpen} + onOpenChange={setRemoveOpen} + hostId={row.hostId} + label={row.label} + target={removalTarget} + /> + )} + </DropdownMenu> + ) +} diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 621f89c0d67..6eeb461be7a 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -81,6 +81,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', @@ -177,6 +190,7 @@ describe('LinearAgentSkillSetupPrompt', () => { mocks.ensureCli.mockClear() mocks.ensureWslCli.mockClear() mocks.panelProps.length = 0 + installLocalStorageShim() window.localStorage.clear() _linearAgentSkillSetupPromptInternalsForTests.resetSessionReminders() Object.defineProperty(window, 'api', { diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 1e9ef329da3..d65cd747604 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -73,7 +73,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { ? err.message : translate( 'auto.components.sidebar.NonGitFolderDialog.c49fb13492', - 'Failed to add remote folder' + 'Failed to add folder on this host' ) ) } 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/ScrollToCurrentWorkspaceToolbarButton.tsx b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx index 0ac1b3241b4..bddeeb43843 100644 --- a/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx +++ b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx @@ -20,7 +20,7 @@ export function ScrollToCurrentWorkspaceToolbarButton(): React.JSX.Element { onClick={requestScrollToCurrentWorkspaceReveal} className="text-muted-foreground" > - <Crosshair className="size-3.5" /> + <Crosshair className="size-4" /> </Button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> diff --git a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx index 0a9c63dfa73..9ccce4fbe65 100644 --- a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx +++ b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx @@ -1,6 +1,10 @@ +// @vitest-environment happy-dom + import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureWallSetupProgress } from '../feature-wall/feature-wall-setup-progress' import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry' @@ -92,7 +96,35 @@ function makeOnlyBrowserIncompleteProgress(): FeatureWallSetupProgress { }) } +const mountedRoots: Root[] = [] + +async function renderSetupGuideSidebarEntry(): Promise<{ + container: HTMLDivElement + rerender: () => Promise<void> +}> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + const rerender = async (): Promise<void> => { + await act(async () => { + root.render(<SetupGuideSidebarEntry />) + }) + } + await rerender() + return { container, rerender } +} + describe('SetupGuideSidebarEntry', () => { + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + }) + beforeEach(() => { persistedUIReady = true activeModal = 'none' @@ -151,4 +183,20 @@ describe('SetupGuideSidebarEntry', () => { it('renders after persisted UI and setup progress are ready when setup is incomplete', () => { expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).toContain('Onboarding checklist') }) + + it('keeps the visible entry mounted during transient setup progress refreshes', async () => { + const { container, rerender } = await renderSetupGuideSidebarEntry() + + expect(container.textContent).toContain('Onboarding checklist') + + mocks.useSetupGuideProgress.mockReturnValue(makeProgress({ ready: false })) + await rerender() + + expect(container.textContent).toContain('Onboarding checklist') + + mocks.useSetupGuideProgress.mockReturnValue(makeAllDoneProgress()) + await rerender() + + expect(container.textContent).not.toContain('Onboarding checklist') + }) }) diff --git a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx index 7cbaf215812..08e1d2ae95c 100644 --- a/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx +++ b/src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx @@ -49,22 +49,31 @@ export function SetupGuideSidebarEntry(): React.JSX.Element | null { const setupProgress = useSetupGuideProgress(true, false, false) const setupComplete = isSetupGuideSidebarComplete(setupProgress) const setupActive = activeModal === 'setup-guide' - const firstUnfinishedSetupStepId = React.useMemo<FeatureWallSetupStepId>( - () => getFirstIncompleteFeatureWallSetupStepId(setupProgress.stepDone), - [setupProgress.stepDone] - ) const showSetupGuideEntry = shouldShowSetupGuideEntry({ ready: getSetupGuideSidebarEntryReady(persistedUIReady, setupProgress.ready), setupComplete, dismissed: setupGuideSidebarDismissed }) + const lastVisibleProgressRef = React.useRef<FeatureWallSetupProgress | null>(null) + if (showSetupGuideEntry) { + lastVisibleProgressRef.current = setupProgress + } + // Why: host/workspace switches can briefly refresh setup probes. Once the + // checklist is visibly available, keep that stable row through the refresh. + const renderedProgress = showSetupGuideEntry + ? setupProgress + : !setupProgress.ready && !setupGuideSidebarDismissed + ? lastVisibleProgressRef.current + : null const handleHideSetupGuide = React.useCallback(() => { setSetupGuideSidebarDismissed(true) }, [setSetupGuideSidebarDismissed]) - if (!showSetupGuideEntry) { + if (!renderedProgress) { return null } + const firstUnfinishedSetupStepId: FeatureWallSetupStepId = + getFirstIncompleteFeatureWallSetupStepId(renderedProgress.stepDone) return ( <ContextMenu> @@ -87,8 +96,8 @@ export function SetupGuideSidebarEntry(): React.JSX.Element | null { )} > <SetupGuideProgressRing - done={setupProgress.coreDoneCount} - total={setupProgress.coreTotal} + done={renderedProgress.coreDoneCount} + total={renderedProgress.coreTotal} sizeClassName="size-4" /> <span className="flex min-w-0 flex-1 flex-col"> diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts new file mode 100644 index 00000000000..35d8380ac45 --- /dev/null +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { getRenderedSetupScriptPromptState } from './setup-script-prompt-render-state' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' + +function prompt(repoId: string): SetupScriptPromptInspection { + return { + status: 'ok', + repoId, + hasEffectiveSetup: false, + hasSharedHooks: false, + candidate: null + } +} + +describe('getRenderedSetupScriptPromptState', () => { + it('uses the current inspection when it belongs to the active repo', () => { + const current = prompt('repo-local') + + expect( + getRenderedSetupScriptPromptState({ + promptState: current, + activeRepoId: 'repo-local', + activeProjectId: 'github:stablyai/orca', + lastVisiblePrompt: { state: prompt('repo-ssh'), projectId: 'github:stablyai/orca' } + }) + ).toBe(current) + }) + + it('keeps the previous visible prompt during same-project host inspection refresh', () => { + const previous = prompt('repo-local') + + expect( + getRenderedSetupScriptPromptState({ + promptState: null, + activeRepoId: 'repo-ssh', + activeProjectId: 'github:stablyai/orca', + lastVisiblePrompt: { state: previous, projectId: 'github:stablyai/orca' } + }) + ).toBe(previous) + }) + + it('does not keep a stale prompt when switching to a different project', () => { + expect( + getRenderedSetupScriptPromptState({ + promptState: null, + activeRepoId: 'repo-other', + activeProjectId: 'github:stablyai/other', + lastVisiblePrompt: { state: prompt('repo-local'), projectId: 'github:stablyai/orca' } + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index f1a4604d130..cfec68165f5 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { track } from '@/lib/telemetry' -import { getRepositoryLocalCommandsSectionId } from '@/components/settings/repository-settings-targets' -import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { useMountedRef } from '@/hooks/useMountedRef' import { buildImportedHookSettings, @@ -17,57 +15,25 @@ import { import { checkRuntimeHooks, inspectRuntimeSetupScriptImports } from '@/runtime/runtime-hooks-client' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { SetupScriptImportCandidate } from '../../../../shared/setup-script-imports' +import { buildSetupScriptPromptActionTelemetry } from '../../../../shared/setup-script-telemetry' +import { SetupScriptPromptCardShell } from './SetupScriptPromptCardShell' +import { showSavedInProjectSettingsToast } from './SetupScriptPromptToast' +import { openSetupScriptSettings } from './open-setup-script-settings' +import { trackSetupScriptPromptExposure } from './setup-script-prompt-exposure-telemetry' import { - buildSetupScriptPromptActionTelemetry, - buildSetupScriptPromptTelemetry -} from '../../../../shared/setup-script-telemetry' -import { - ConfigureOnlyAction, - DetectedSetupPreview, - DismissButton, - InspectionErrorActions, - PackageManagerActions, - SaveLocalSetupAction, - SetupScriptPromptBody -} from './SetupScriptPromptCardViews' + getRenderedSetupScriptPromptState, + getRepoProjectId, + type LastVisibleSetupScriptPrompt, + useSetupScriptPromptProjectContext +} from './setup-script-prompt-render-state' import { translate } from '@/i18n/i18n' type PromptState = SetupScriptPromptInspection -type SavedInProjectSettingsToastProps = { - onOpenSettings: () => void -} - -function SavedInProjectSettingsToast({ - onOpenSettings -}: SavedInProjectSettingsToastProps): React.JSX.Element { - return ( - <span> - {translate("auto.components.sidebar.SetupScriptPromptCard.a5bb8c5135", "Saved in this")}{' '} - <button - type="button" - className="rounded-sm font-medium underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - onClick={onOpenSettings} - > - {translate("auto.components.sidebar.SetupScriptPromptCard.d9f2db2738", "project's settings")}</button> - </span> - ) -} - -function showSavedInProjectSettingsToast(input: { - onOpenSettings: () => void - description?: React.ReactNode -}): void { - // Why: the save confirmation is also the fastest path back to the exact - // local setup editor the user just changed. - toast.success(<SavedInProjectSettingsToast onOpenSettings={input.onOpenSettings} />, { - description: input.description - }) -} - function SetupScriptPromptCard(): React.JSX.Element | null { const sidebarOpen = useAppStore((s) => s.sidebarOpen) const repos = useAppStore((s) => s.repos) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) const activeRepoId = useAppStore((s) => s.activeRepoId) const settings = useAppStore((s) => s.settings) const updateRepo = useAppStore((s) => s.updateRepo) @@ -87,9 +53,15 @@ function SetupScriptPromptCard(): React.JSX.Element | null { () => repos.find((repo) => repo.id === activeRepoId) ?? null, [activeRepoId, repos] ) + const { activeProjectId, setupByRepoId } = useSetupScriptPromptProjectContext( + activeRepo, + repos, + projectHostSetups + ) const isDismissed = activeRepo ? isSetupScriptPromptDismissed(activeRepo.id, dismissedRepoIds) : false + const lastVisiblePromptRef = useRef<LastVisibleSetupScriptPrompt | null>(null) useEffect(() => { if (!sidebarOpen || !activeRepo || !isGitRepoKind(activeRepo) || isDismissed) { @@ -127,15 +99,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { const openLocalCommandSettings = useCallback( (repoId: string) => { - // Why: imported setup commands are local repo settings; a stale Settings - // search should not hide the exact editor this action opens. - setSettingsSearchQuery('') - openSettingsTarget({ - pane: 'repo', + openSetupScriptSettings({ repoId, - sectionId: getRepositoryLocalCommandsSectionId(repoId) + setSettingsSearchQuery, + openSettingsTarget, + openSettingsPage }) - openSettingsPage() }, [openSettingsPage, openSettingsTarget, setSettingsSearchQuery] ) @@ -157,26 +126,11 @@ function SetupScriptPromptCard(): React.JSX.Element | null { return } - const telemetry = buildSetupScriptPromptTelemetry({ - candidate: promptState.candidate, - hasSharedHooks: promptState.hasSharedHooks + trackSetupScriptPromptExposure({ + repoId: activeRepo.id, + promptState, + trackedPromptKeys: trackedPromptKeysRef.current }) - // Why: React may re-render the sidebar often; this event should represent - // a distinct prompt exposure for this repo/source, not render churn. - const promptKey = [ - activeRepo.id, - telemetry.mode, - telemetry.provider ?? 'none', - telemetry.file_count_bucket, - telemetry.unsupported_field_count_bucket, - String(telemetry.has_shared_hooks) - ].join(':') - if (trackedPromptKeysRef.current.has(promptKey)) { - return - } - - trackedPromptKeysRef.current.add(promptKey) - track('setup_script_prompt_shown', telemetry) }, [activeRepo, isDismissed, promptState, sidebarOpen]) const handleConfigure = useCallback(() => { @@ -250,7 +204,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }) ) if (mountedRef.current) { - toast.error(translate("auto.components.sidebar.SetupScriptPromptCard.888b83bf78", "Failed to save setup script")) + toast.error( + translate( + 'auto.components.sidebar.SetupScriptPromptCard.888b83bf78', + 'Failed to save setup script' + ) + ) } return } @@ -267,9 +226,6 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }) ) if (actionPrefix === 'save_detected_setup') { - // Why: the user has already reviewed the detected script in the - // card; after saving, close the prompt instead of showing a second - // confirmation panel. if (mountedRef.current) { setPromptState((current) => current?.repoId === activeRepo.id && current.status === 'ok' @@ -278,7 +234,10 @@ function SetupScriptPromptCard(): React.JSX.Element | null { ) showSavedInProjectSettingsToast({ onOpenSettings: () => openLocalCommandSettings(importedRepoId), - description: translate("auto.components.sidebar.SetupScriptPromptCard.a49196d538", "Runs when Orca creates a new worktree.") + description: translate( + 'auto.components.sidebar.SetupScriptPromptCard.a49196d538', + 'Runs when Orca creates a new worktree.' + ) }) } return @@ -313,7 +272,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { ) console.warn('[setup-script-prompt] Failed to save setup script:', error) if (mountedRef.current) { - toast.error(translate("auto.components.sidebar.SetupScriptPromptCard.888b83bf78", "Failed to save setup script")) + toast.error( + translate( + 'auto.components.sidebar.SetupScriptPromptCard.888b83bf78', + 'Failed to save setup script' + ) + ) } } finally { if (mountedRef.current) { @@ -339,7 +303,12 @@ function SetupScriptPromptCard(): React.JSX.Element | null { } : promptState.candidate if (!candidate.setup) { - toast.error(translate("auto.components.sidebar.SetupScriptPromptCard.70715947fb", "Setup script cannot be empty")) + toast.error( + translate( + 'auto.components.sidebar.SetupScriptPromptCard.70715947fb', + 'Setup script cannot be empty' + ) + ) return } if (actionPrefix === 'save_detected_setup') { @@ -362,75 +331,72 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }, [activeRepo, detectedSetupDraft, promptState, saveSetupCandidate]) if (!sidebarOpen || !activeRepo || !isGitRepoKind(activeRepo) || isDismissed) { + lastVisiblePromptRef.current = null + return null + } + + const promptProjectId = promptState?.repoId + ? getRepoProjectId(promptState.repoId, repos, projectHostSetups, setupByRepoId) + : null + const renderedPromptState = + activeRepo && + getRenderedSetupScriptPromptState({ + promptState, + activeRepoId: activeRepo.id, + activeProjectId, + lastVisiblePrompt: lastVisiblePromptRef.current + }) + + if ( + !renderedPromptState || + (renderedPromptState.status === 'ok' && renderedPromptState.hasEffectiveSetup) + ) { + if (renderedPromptState?.status === 'ok' && renderedPromptState.hasEffectiveSetup) { + lastVisiblePromptRef.current = null + } return null } if ( - promptState?.repoId !== activeRepo.id || - (promptState.status === 'ok' && promptState.hasEffectiveSetup) + renderedPromptState.status === 'ok' && + !renderedPromptState.hasEffectiveSetup && + (renderedPromptState.repoId === activeRepo.id || promptProjectId === activeProjectId) ) { - return null + lastVisiblePromptRef.current = { + state: renderedPromptState, + projectId: activeProjectId + } } - const isInspectionError = promptState.status === 'error' - const candidate = promptState.status === 'ok' ? promptState.candidate : null + const isInspectionError = renderedPromptState.status === 'error' + const candidate = renderedPromptState.status === 'ok' ? renderedPromptState.candidate : null const isPackageManagerSuggestion = candidate?.provider === 'package-manager' const sharedSetupIgnored = - promptState.status === 'ok' && candidate === null && ignoresSharedSetupScripts(activeRepo) + renderedPromptState.status === 'ok' && + candidate === null && + ignoresSharedSetupScripts(activeRepo) const candidateSource = candidate ? formatCandidateSource(candidate) : null const candidateProvenance = candidate ? formatCandidateProvenance(candidate) : null return ( - // Why: shrink-0 keeps the card from being squeezed by a long worktree list - // in the overflow-hidden sidebar column, which clipped its top edge. - <div className="shrink-0 px-3 pb-2"> - <div className="setup-script-prompt-card rounded-lg border border-worktree-sidebar-border p-3 text-worktree-sidebar-accent-foreground shadow-xs"> - <div className="flex items-center justify-between gap-2"> - <p className="text-sm font-semibold leading-snug">{translate("auto.components.sidebar.SetupScriptPromptCard.ff1e819a11", "Add a setup script")}</p> - <DismissButton onDismiss={handleDismiss} /> - </div> - - {/* Why: name the repo on its own line so the prompt's project is clear in - every body variant, not just the default one. */} - <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> - <RepoBadgeMark color={activeRepo.badgeColor} /> - <span className="truncate font-medium text-foreground">{activeRepo.displayName}</span> - </p> - - <p className="mt-1 text-xs leading-snug text-muted-foreground"> - <SetupScriptPromptBody - isInspectionError={isInspectionError} - sharedSetupIgnored={sharedSetupIgnored} - isPackageManagerSuggestion={Boolean(isPackageManagerSuggestion && candidate)} - candidateSource={candidateSource} - /> - </p> - - {!isInspectionError && !sharedSetupIgnored && candidate && isPackageManagerSuggestion ? ( - <DetectedSetupPreview - setup={detectedSetupDraft} - onSetupChange={setDetectedSetupDraft} - provenance={candidateProvenance} - /> - ) : null} - - {isInspectionError ? ( - <InspectionErrorActions onRetry={handleRetryInspection} onConfigure={handleConfigure} /> - ) : sharedSetupIgnored ? ( - <ConfigureOnlyAction onConfigure={handleConfigure} /> - ) : candidate && isPackageManagerSuggestion ? ( - <PackageManagerActions - isSaving={isImporting} - onSave={() => void handleImport()} - onConfigure={handleConfigure} - /> - ) : candidate ? ( - <SaveLocalSetupAction isSaving={isImporting} onSave={() => void handleImport()} /> - ) : promptState.status === "ok" ? ( - <ConfigureOnlyAction onConfigure={handleConfigure} /> - ) : null} - </div> - </div> + <SetupScriptPromptCardShell + repoBadgeColor={activeRepo.badgeColor} + repoDisplayName={activeRepo.displayName} + isInspectionError={isInspectionError} + sharedSetupIgnored={sharedSetupIgnored} + isPackageManagerSuggestion={Boolean(isPackageManagerSuggestion && candidate)} + hasCandidate={Boolean(candidate)} + candidateSource={candidateSource} + candidateProvenance={candidateProvenance} + detectedSetupDraft={detectedSetupDraft} + isImporting={isImporting} + renderedStateOk={renderedPromptState.status === 'ok'} + onDismiss={handleDismiss} + onRetryInspection={handleRetryInspection} + onConfigure={handleConfigure} + onImport={() => void handleImport()} + onSetupDraftChange={setDetectedSetupDraft} + /> ) } diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCardShell.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCardShell.tsx new file mode 100644 index 00000000000..062730e7f4e --- /dev/null +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCardShell.tsx @@ -0,0 +1,103 @@ +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { + ConfigureOnlyAction, + DetectedSetupPreview, + DismissButton, + InspectionErrorActions, + PackageManagerActions, + SaveLocalSetupAction, + SetupScriptPromptBody +} from './SetupScriptPromptCardViews' +import { translate } from '@/i18n/i18n' + +type SetupScriptPromptCardShellProps = { + repoBadgeColor: string + repoDisplayName: string + isInspectionError: boolean + sharedSetupIgnored: boolean + isPackageManagerSuggestion: boolean + hasCandidate: boolean + candidateSource: string | null + candidateProvenance: string | null + detectedSetupDraft: string + isImporting: boolean + renderedStateOk: boolean + onDismiss: () => void + onRetryInspection: () => void + onConfigure: () => void + onImport: () => void + onSetupDraftChange: (value: string) => void +} + +export function SetupScriptPromptCardShell({ + repoBadgeColor, + repoDisplayName, + isInspectionError, + sharedSetupIgnored, + isPackageManagerSuggestion, + hasCandidate, + candidateSource, + candidateProvenance, + detectedSetupDraft, + isImporting, + renderedStateOk, + onDismiss, + onRetryInspection, + onConfigure, + onImport, + onSetupDraftChange +}: SetupScriptPromptCardShellProps): React.JSX.Element { + return ( + <div className="shrink-0 px-3 pb-2"> + <div className="setup-script-prompt-card rounded-lg border border-worktree-sidebar-border p-3 text-worktree-sidebar-accent-foreground shadow-xs"> + <div className="flex items-center justify-between gap-2"> + <p className="text-sm font-semibold leading-snug"> + {translate( + 'auto.components.sidebar.SetupScriptPromptCard.ff1e819a11', + 'Add a setup script' + )} + </p> + <DismissButton onDismiss={onDismiss} /> + </div> + + <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> + <RepoBadgeMark color={repoBadgeColor} /> + <span className="truncate font-medium text-foreground">{repoDisplayName}</span> + </p> + + <p className="mt-1 text-xs leading-snug text-muted-foreground"> + <SetupScriptPromptBody + isInspectionError={isInspectionError} + sharedSetupIgnored={sharedSetupIgnored} + isPackageManagerSuggestion={isPackageManagerSuggestion} + candidateSource={candidateSource} + /> + </p> + + {!isInspectionError && !sharedSetupIgnored && hasCandidate && isPackageManagerSuggestion ? ( + <DetectedSetupPreview + setup={detectedSetupDraft} + onSetupChange={onSetupDraftChange} + provenance={candidateProvenance} + /> + ) : null} + + {isInspectionError ? ( + <InspectionErrorActions onRetry={onRetryInspection} onConfigure={onConfigure} /> + ) : sharedSetupIgnored ? ( + <ConfigureOnlyAction onConfigure={onConfigure} /> + ) : hasCandidate && isPackageManagerSuggestion ? ( + <PackageManagerActions + isSaving={isImporting} + onSave={onImport} + onConfigure={onConfigure} + /> + ) : hasCandidate ? ( + <SaveLocalSetupAction isSaving={isImporting} onSave={onImport} /> + ) : renderedStateOk ? ( + <ConfigureOnlyAction onConfigure={onConfigure} /> + ) : null} + </div> + </div> + ) +} diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptToast.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptToast.tsx new file mode 100644 index 00000000000..2cbf7bd5e12 --- /dev/null +++ b/src/renderer/src/components/sidebar/SetupScriptPromptToast.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +type SavedInProjectSettingsToastProps = { + onOpenSettings: () => void +} + +function SavedInProjectSettingsToast({ + onOpenSettings +}: SavedInProjectSettingsToastProps): React.JSX.Element { + return ( + <span> + {translate('auto.components.sidebar.SetupScriptPromptCard.a5bb8c5135', 'Saved in this')}{' '} + <button + type="button" + className="rounded-sm font-medium underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + onClick={onOpenSettings} + > + {translate( + 'auto.components.sidebar.SetupScriptPromptCard.d9f2db2738', + "project's settings" + )} + </button> + </span> + ) +} + +export function showSavedInProjectSettingsToast(input: { + onOpenSettings: () => void + description?: React.ReactNode +}): void { + // Why: the save confirmation is also the fastest path back to the exact + // local setup editor the user just changed. + toast.success(<SavedInProjectSettingsToast onOpenSettings={input.onOpenSettings} />, { + description: input.description + }) +} diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx new file mode 100644 index 00000000000..657a2c91df0 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx @@ -0,0 +1,155 @@ +import type React from 'react' +import { + DropdownMenuCheckboxItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { ALL_EXECUTION_HOSTS_SCOPE, type ExecutionHostId } from '../../../../shared/execution-host' +import type { VisibleWorkspaceHostIds, WorkspaceHostScope } from '../../../../shared/types' +import { getSidebarHostHealthLabel, type SidebarHostOption } from './sidebar-host-options' +import { translate } from '@/i18n/i18n' + +type SidebarHostScopeMenuSectionProps = { + hostOptionsCount: number + hostVisibilityLabel: string + hostOptions: readonly SidebarHostOption[] + preserveWorkspaceBoardOpen: boolean + setWorkspaceHostScope: (scope: WorkspaceHostScope) => void + visibleWorkspaceHostIds: VisibleWorkspaceHostIds + setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void +} + +function getHostMetadata(host: SidebarHostOption): string { + const healthLabel = getSidebarHostHealthLabel(host.health) + if (host.kind === 'local') { + return host.detail + } + if (host.kind === 'ssh') { + const presenceLabel = + host.presence === 'configured' + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.configuredSshHost', + 'Configured SSH' + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.projectSshHost', + 'Project SSH' + ) + return `${presenceLabel} · ${healthLabel}` + } + const presenceLabel = + host.presence === 'active' + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.activeRuntimeHost', + 'Active server' + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.projectRuntimeHost', + 'Project server' + ) + return `${presenceLabel} · ${healthLabel}` +} + +export function SidebarHostScopeMenuSection({ + hostOptionsCount, + hostVisibilityLabel, + hostOptions, + preserveWorkspaceBoardOpen, + setWorkspaceHostScope, + visibleWorkspaceHostIds, + setVisibleWorkspaceHostIds +}: SidebarHostScopeMenuSectionProps): React.JSX.Element { + const allVisible = !visibleWorkspaceHostIds + const visibleHostIdSet = new Set(visibleWorkspaceHostIds ?? []) + + const toggleAllHosts = (): void => { + if (!allVisible) { + setWorkspaceHostScope(ALL_EXECUTION_HOSTS_SCOPE) + return + } + const firstHost = hostOptions[0] + if (firstHost) { + setVisibleWorkspaceHostIds([firstHost.id]) + } + } + + const toggleHost = (hostId: ExecutionHostId): void => { + if (allVisible) { + setVisibleWorkspaceHostIds([hostId]) + return + } + const next = new Set(visibleHostIdSet) + if (next.has(hostId)) { + if (next.size <= 1) { + return + } + next.delete(hostId) + } else { + next.add(hostId) + } + setVisibleWorkspaceHostIds(next.size === hostOptions.length ? null : [...next]) + } + + return ( + <> + <DropdownMenuLabel> + {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.hosts', 'Hosts')} + </DropdownMenuLabel> + <DropdownMenuSub> + <DropdownMenuSubTrigger> + <span className="flex flex-1 items-center justify-between gap-3"> + <span className="min-w-0 truncate">{hostVisibilityLabel}</span> + <span className="text-[11px] font-medium text-muted-foreground"> + {hostOptionsCount} + </span> + </span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent + className="w-56" + data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined} + > + <DropdownMenuCheckboxItem + checked={allVisible} + onCheckedChange={toggleAllHosts} + onSelect={(e) => e.preventDefault()} + className="min-h-11 items-start py-1.5" + > + <span className="flex min-w-0 flex-col gap-0.5"> + <span className="truncate"> + {translate('auto.components.sidebar.sidebarHostOptions.3e102f111c', 'All hosts')} + </span> + <span className="truncate text-[11px] font-normal text-muted-foreground"> + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.allHostsDetail', + 'Show every host' + )} + </span> + </span> + </DropdownMenuCheckboxItem> + {hostOptions.map((host) => ( + <DropdownMenuCheckboxItem + key={host.id} + checked={visibleHostIdSet.has(host.id)} + disabled={!allVisible && visibleHostIdSet.has(host.id) && visibleHostIdSet.size <= 1} + onCheckedChange={() => toggleHost(host.id)} + onSelect={(e) => e.preventDefault()} + className="min-h-11 items-start py-1.5" + > + <span className="flex min-w-0 flex-col gap-0.5"> + <span className="truncate">{host.label}</span> + <span className="text-[11px] font-normal text-muted-foreground"> + {getHostMetadata(host)} + </span> + </span> + </DropdownMenuCheckboxItem> + ))} + </DropdownMenuSubContent> + </DropdownMenuSub> + + <DropdownMenuSeparator /> + </> + ) +} diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx new file mode 100644 index 00000000000..82e81f48c65 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { AlertTriangle, Loader2, X } from 'lucide-react' +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { + getSidebarHostVisibilityLabel, + shouldShowHostScopeControls, + type SidebarHostScopeOption +} from './sidebar-host-options' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' +import { translate } from '@/i18n/i18n' + +function HostScopeWarningIcon({ health }: { health: SidebarHostScopeOption['health'] }) { + // Why: the banner stays quiet unless the scoped host needs attention. + 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 +} + +/** Shown only when the sidebar is scoped to a single host: names the scope and + * offers the way back. In All-hosts view the host section headers tell the + * story, so no persistent strip renders; scope switching lives in the + * workspace options menu. */ +const SidebarHostScopeStrip = React.memo(function SidebarHostScopeStrip() { + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) + const { hostOptions, hostScopeOptions } = useSidebarHostScopeOptions() + + if (!visibleWorkspaceHostIds) { + return null + } + if (!shouldShowHostScopeControls(hostOptions)) { + return null + } + + const label = getSidebarHostVisibilityLabel(visibleWorkspaceHostIds, hostOptions) + const selectedScope = + visibleWorkspaceHostIds.length === 1 + ? hostScopeOptions.find((option) => option.id === visibleWorkspaceHostIds[0]) + : undefined + + return ( + <div className="px-2 pb-1"> + <div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-sidebar-border/70 bg-sidebar-accent/35 pl-2 pr-1"> + <span className="flex min-w-0 items-center gap-1.5"> + <HostScopeWarningIcon health={selectedScope?.health ?? 'available'} /> + <span className="truncate text-xs font-medium text-sidebar-foreground"> + {translate( + 'auto.components.sidebar.SidebarHostScopeStrip.scopedTo', + '{{value0}} visible', + { + value0: label + } + )} + </span> + </span> + <Button + variant="ghost" + size="sm" + className="h-5 shrink-0 gap-1 rounded px-1.5 text-[11px] font-normal text-muted-foreground hover:text-foreground" + onClick={() => setVisibleWorkspaceHostIds(null)} + > + <X className="size-3" /> + {translate('auto.components.sidebar.SidebarHostScopeStrip.backToAll', 'All hosts')} + </Button> + </div> + </div> + ) +}) + +export default SidebarHostScopeStrip diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 208a747ebe2..18027cbbcae 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -21,13 +21,9 @@ import type { AgentActivityDisplayMode } from '../../../../shared/types' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection' import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection' -import { translate } from '@/i18n/i18n' - -type SidebarWorkspaceOptionsMenuProps = { - preserveWorkspaceBoardOpen?: boolean - onMenuOpenChange?: (open: boolean) => void -} - +import { getSidebarHostVisibilityLabel, shouldShowHostScopeControls } from './sidebar-host-options' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' +import { SidebarHostScopeMenuSection } from './SidebarHostScopeMenuSection' import { AGENT_ACTIVITY_DISPLAY_OPTIONS, CARD_LAYOUT_OPTIONS, @@ -35,7 +31,13 @@ import { PROJECT_ORDER_OPTIONS, PROPERTY_OPTIONS, SORT_OPTIONS -} from './sidebar-workspace-options-menu-options' +} from './sidebar-workspace-option-items' +import { translate } from '@/i18n/i18n' + +type SidebarWorkspaceOptionsMenuProps = { + preserveWorkspaceBoardOpen?: boolean + onMenuOpenChange?: (open: boolean) => void +} const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({ preserveWorkspaceBoardOpen = false, @@ -49,6 +51,9 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty) const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) + const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode) const setAgentActivityDisplayMode = useAppStore((s) => s.setAgentActivityDisplayMode) const sortBy = useAppStore((s) => s.sortBy) @@ -59,6 +64,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const setProjectOrderBy = useAppStore((s) => s.setProjectOrderBy) const [open, setOpen] = useState(false) + const { hostOptions } = useSidebarHostScopeOptions() + const showHostScopeControls = shouldShowHostScopeControls(hostOptions) const handleOpenChange = useCallback( (next: boolean) => { @@ -81,13 +88,19 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM }, [repos, filterRepoIds]) const hasRepoFilter = selectedCount > 0 const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES - const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter + const hasHostVisibilityFilter = visibleWorkspaceHostIds !== null + const hasAnyFilter = + hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter || hasHostVisibilityFilter const activeFilterCount = - (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + (hasSleepingFilter ? 1 : 0) + + (hideDefaultBranchWorkspace ? 1 : 0) + + (hasHostVisibilityFilter ? 1 : 0) + + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort' const projectOrderLabel = PROJECT_ORDER_OPTIONS.find((opt) => opt.id === projectOrderBy)?.label ?? 'Manual' + const hostVisibilityLabel = getSidebarHostVisibilityLabel(visibleWorkspaceHostIds, hostOptions) const cardLayout = settings?.compactWorktreeCards ? 'compact' : 'detailed' const cardLayoutLabel = CARD_LAYOUT_OPTIONS.find((opt) => opt.id === cardLayout)?.label ?? 'Detailed' @@ -153,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/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx index 21fa58d026d..fd4a460e29a 100644 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx @@ -27,7 +27,7 @@ const STATUS_MESSAGES: Partial<Record<SshConnectionStatus, string>> = { get disconnected() { return translate( 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This remote repository is not connected.' + 'This SSH host is not connected.' ) }, get reconnecting() { @@ -108,7 +108,7 @@ export function SshDisconnectedDialog({ STATUS_MESSAGES.disconnected ?? translate( 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This remote repository is not connected.' + 'This SSH host is not connected.' ) const message = isConnecting ? reconnectingMessage 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.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index f2a37dd13d0..d3568375d4e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -10,7 +10,6 @@ const fetchIssue = vi.fn() const fetchLinearIssue = vi.fn() const openModal = vi.fn() const updateWorktreeMeta = vi.fn() -const linearPromptProps: { remote?: boolean; settings?: unknown }[] = [] let worktreeCardProperties: WorktreeCardProperty[] = ['pr'] let hostedReviewCache: Record<string, unknown> = {} @@ -66,13 +65,6 @@ vi.mock('./SshDisconnectedDialog', () => ({ SshDisconnectedDialog: () => null })) -vi.mock('./LinearAgentSkillSetupPrompt', () => ({ - LinearAgentSkillSetupPrompt: (props: { remote?: boolean; settings?: unknown }) => { - linearPromptProps.push(props) - return null - } -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}</>, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', @@ -136,7 +128,6 @@ describe('WorktreeCard linked PR display', () => { vi.clearAllMocks() worktreeCardProperties = ['pr'] hostedReviewCache = {} - linearPromptProps.length = 0 workspacePortScan = null settings = null }) @@ -150,7 +141,7 @@ describe('WorktreeCard linked PR display', () => { expect(markup).toContain('Linked PR #456') expect(markup).not.toContain('Loading PR') - }) + }, 10_000) it('does not show cached branch PR details when the worktree has no linked PR', async () => { hostedReviewCache = { @@ -226,27 +217,6 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('Reviewer handoff note') }) - it('treats active runtime environment Linear prompts as remote setup', async () => { - settings = { activeRuntimeEnvironmentId: 'env-1' } - worktreeCardProperties = ['linear-issue'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - renderWorktreeCardMarkup( - <WorktreeCard - worktree={makeWorktree({ linkedLinearIssue: 'ENG-123' })} - repo={makeRepo()} - isActive - /> - ) - - expect(linearPromptProps.at(-1)).toEqual( - expect.objectContaining({ - remote: true, - settings - }) - ) - }) - it('keeps issue, Linear issue, PR, and notes metadata out of compact cards', async () => { settings = { compactWorktreeCards: true } worktreeCardProperties = ['issue', 'linear-issue', 'pr', 'comment'] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 3c30d49972b..f5cd15563ec 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useState } from 'react' import { useAppStore } from '@/store' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -22,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' @@ -77,6 +77,7 @@ type WorktreeCardProps = { revealHighlightTone?: 'default' | 'ai' selectedWorktrees?: readonly Worktree[] hideRepoBadge?: boolean + hostContextLabel?: string inPinnedSection?: boolean contentIndent?: number flushSurface?: boolean @@ -164,6 +165,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ onCardDragEnd, nativeDragEnabled = true, hideRepoBadge, + hostContextLabel, inPinnedSection = false, contentIndent = 0, flushSurface = false, @@ -265,9 +267,26 @@ const WorktreeCard = React.memo(function WorktreeCard({ const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null const hostedReviewCacheKey = repo && branch - ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId) + ? getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId + ) + : '' + const issueCacheKey = + repo && worktree.linkedIssue + ? getIssueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + settings, + repo.connectionId, + repo.executionHostId + ) : '' - const issueCacheKey = repo && worktree.linkedIssue ? `${repo.id}::${worktree.linkedIssue}` : '' // Why: use 'all' to fetch from all Linear workspaces. The issue might belong // to a different workspace than the currently selected one. const linearIssueCacheKey = worktree.linkedLinearIssue ? `all::${worktree.linkedLinearIssue}` : '' @@ -780,6 +799,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showInlineRepoBadge = compactCards && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon const showRepoBadgeInMetaRow = !compactCards && !!repo && !hideRepoBadge && !showPinnedRepoIcon + const showHostContextBadge = !compactCards && !!hostContextLabel const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null const showBranch = !isFolder && branch.length > 0 && (!compactCards || branch !== worktree.displayName) @@ -801,6 +821,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ // metadata lane unless branch or detached-head identity has content. const hasDetailedMetaRowContent = Boolean( (showRepoBadgeInMetaRow && repo) || + showHostContextBadge || isFolder || showBranch || showDetachedHeadInMetaRow || @@ -991,7 +1012,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) : translate( 'auto.components.sidebar.WorktreeCard.ca74db7550', - 'Remote project via SSH' + 'Project on SSH host' )} </TooltipContent> </Tooltip> @@ -1219,6 +1240,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" @@ -1278,15 +1308,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/WorktreeCardPorts.tsx b/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx index 3545454a918..ba30c1f2791 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardPorts.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { canStopWorkspacePort, goToWorkspacePortOwner, @@ -98,12 +99,19 @@ function PortAction({ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const runtimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, port.kind === 'workspace' ? port.owner.worktreeId : null) + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const runtimeTarget = useMemo( + () => getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }), + [runtimeEnvironmentId, settings] + ) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const address = addressForPort(port) const canStop = canStopWorkspacePort(port) @@ -182,6 +190,8 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) if (!refreshResult.ok) { @@ -203,6 +213,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element { recordFeatureInteraction, runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, setWorkspacePortScanRefreshing ] ) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 10669bf79f1..8d544d5daa8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -201,6 +201,8 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ React.createElement(React.Fragment, null, children), DropdownMenuItem: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), DropdownMenuSeparator: () => React.createElement('hr'), DropdownMenuSub: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -376,6 +378,9 @@ function setLineageFixtureState( toggleCollapsedGroup: vi.fn(), updateWorktreeMeta: vi.fn(), updateWorktreesMeta: vi.fn(), + // Why: multi-host added a host scope filter; 'all' (the store default) + // bypasses it so the fixture's worktrees aren't dropped before rendering. + workspaceHostScope: 'all', workspaceStatuses: [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: { @@ -452,6 +457,7 @@ function setProjectGroupWithoutWorktreeRowsState(filterRepoIds: string[] = []): toggleCollapsedGroup: vi.fn(), updateWorktreeMeta: vi.fn(), updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', workspaceStatuses: [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: {}, @@ -513,6 +519,7 @@ function setEmptyUngroupedProjectState(filterRepoIds: string[] = []): void { toggleCollapsedGroup: vi.fn(), updateWorktreeMeta: vi.fn(), updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', workspaceStatuses: [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: {}, diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 26acdb6fd7f..9100b85e459 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -311,6 +311,7 @@ function setLineageState(options: { deletingChild?: boolean } = {}): void { updateRepo: vi.fn(), updateWorktreeMeta: mockStore.updateWorktreeMeta, updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', workspacePortScan: null, workspaceStatuses: [], worktreeCardProperties: [ diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index beac56c2d08..ddcee020067 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -6,6 +6,7 @@ import { } from '@tanstack/react-virtual' import type { Range } from '@tanstack/react-virtual' import { + AlertTriangle, ChevronDown, CircleX, Ellipsis, @@ -13,7 +14,10 @@ import { FolderInput, FolderPlus, FolderX, + Loader2, Plus, + Server, + ServerOff, Shapes, SlidersHorizontal, Trash2 @@ -69,6 +73,7 @@ import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { type Row, + type ProjectGroupingModel, type WorktreeGroupBy, ALL_GROUP_KEY, PINNED_GROUP_KEY, @@ -79,7 +84,7 @@ import { import { estimateRenderRowSize, extractWorktreeVirtualRowIndexes, - getActiveStickyHeaderIndexForScroll, + getActiveStickyIndexesForScroll, getStickyHeaderIndexes, getVirtualRowTransform, shouldUseHeaderTopSpacing, @@ -124,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, @@ -177,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' @@ -205,9 +217,15 @@ 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' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, type FolderWorkspacePathStatus @@ -377,7 +395,7 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string { const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 type VirtualizedWorktreeViewportProps = { - rows: Row[] + rows: HostSectionRow[] activeWorktreeId: string | null currentWorktreeId: string | null groupBy: WorktreeGroupBy @@ -420,8 +438,11 @@ type VirtualizedWorktreeViewportProps = { // (filtered out / collapsed-only). Visible-only ids would silently drop the // hidden repos on reorder. allRepoIds: string[] + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void prCache: Record<string, unknown> | null workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGrouping?: ProjectGroupingModel projectGroups?: readonly ProjectGroup[] onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void @@ -456,8 +477,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'}` @@ -485,6 +506,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 }: { @@ -641,7 +797,7 @@ function shouldPreferSidebarStatusDropTarget(args: { return sourceStatus !== null && args.target.status !== sourceStatus } -function isWorktreeItemRow(row: Row): row is WorktreeItemRow { +function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { return row.type === 'item' } @@ -658,7 +814,7 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n return row.type === 'item' && row.worktree.id === worktreeId } -function buildRenderableRows(rows: Row[]): RenderRow[] { +function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { const renderRows: RenderRow[] = [] for (let index = 0; index < rows.length; index++) { const row = rows[index] @@ -694,6 +850,9 @@ function buildRenderableRows(rows: Row[]): RenderRow[] { } export function getRenderRowKey(row: RenderRow): string { + if (row.type === 'host-header') { + return `host:${row.hostId}` + } if (row.type === 'header') { return `hdr:${row.key}` } @@ -712,7 +871,7 @@ export function getRenderRowKey(row: RenderRow): string { return `wt:${row.worktree.id}` } -export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { +export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] let current: { key: string; ids: string[] } | null = null @@ -723,6 +882,7 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { continue } if ( + row.type === 'host-header' || row.type === 'imported-worktrees-card' || row.type === 'pending-creation' || row.type === 'folder-workspace' @@ -807,8 +967,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeLineageById, repoOrder, allRepoIds, + onReorderHostSections, + onHostDragActiveChange, prCache, workspaceStatuses, + projectGrouping, projectGroups = EMPTY_PROJECT_GROUPS, onMoveWorktreeToStatus, onMoveWorktreesToStatus, @@ -891,8 +1054,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 @@ -935,6 +1098,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( @@ -1044,47 +1230,8 @@ 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'), + () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), [renderRows] ) const firstHeaderIndexRef = useRef(firstHeaderIndex) @@ -1093,6 +1240,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes) stickyHeaderIndexesRef.current = stickyHeaderIndexes const activeStickyHeaderIndexRef = useRef<number | null>(null) + const activeStickyHostIndexRef = useRef<number | null>(null) const stickyRangeStartIndexRef = useRef(0) const activeWorktreeRowIndex = useMemo( () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), @@ -1227,7 +1375,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) } const index = getVirtualRowIndex(element) - if (index !== null && renderRowsRef.current[index]?.type === 'header') { + if ( + index !== null && + (renderRowsRef.current[index]?.type === 'header' || + renderRowsRef.current[index]?.type === 'host-header') + ) { return estimateRenderRowSize( renderRowsRef.current, index, @@ -1276,7 +1428,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp rangeExtractor: useCallback( (range: Range) => { stickyRangeStartIndexRef.current = range.startIndex - return extractWorktreeVirtualRowIndexes({ range, stickyHeaderIndexes }) + return extractWorktreeVirtualRowIndexes({ + range, + stickyHeaderIndexes, + rows: renderRowsRef.current + }) }, [stickyHeaderIndexes] ), @@ -1512,13 +1668,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() - const activeStickyHeaderIndex = getActiveStickyHeaderIndexForScroll({ + const activeStickyIndexes = getActiveStickyIndexesForScroll({ + rows: renderRows, rangeStartIndex: stickyRangeStartIndexRef.current, scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, stickyHeaderIndexes, virtualItems }) - activeStickyHeaderIndexRef.current = activeStickyHeaderIndex + activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex + activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex const measureMountedRows = useCallback(() => { virtualizer.elementsCache.forEach((element) => { @@ -1597,7 +1755,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeMap, true, settings, - projectGroups + projectGroups, + new Set(), + new Map(), + [], + projectGrouping ).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item') if (worktreeRows.length === 0) { return @@ -1644,7 +1806,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeLineageById, worktreeMap, settings, - projectGroups + projectGroups, + projectGrouping ] ) @@ -2634,7 +2797,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const visibleRows = virtualItems .filter((item) => item.start < viewportBottom && item.end > viewportTop) .map((item) => renderRows[item.index]) - .filter((row): row is Extract<Row, { type: 'item' }> => row?.type === 'item') + .filter((row): row is WorktreeItemRow => row?.type === 'item') .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) if ( @@ -2918,6 +3081,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp style={{ top: `${repoDrag.state.dropIndicatorY}px` }} /> ) : null} + {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? ( + <div + role="presentation" + className="pointer-events-none absolute left-3 right-2 z-40 flex h-3 -translate-y-1/2 items-center" + style={{ top: `${hostDrag.state.dropIndicatorY}px` }} + > + <span className="size-1.5 shrink-0 rounded-full bg-worktree-sidebar-ring shadow-[0_0_0_2px_var(--worktree-sidebar)]" /> + <span className="h-0.5 flex-1 rounded-full bg-worktree-sidebar-ring shadow-[0_0_0_2px_var(--worktree-sidebar)]" /> + <span className="size-1.5 shrink-0 rounded-full bg-worktree-sidebar-ring shadow-[0_0_0_2px_var(--worktree-sidebar)]" /> + </div> + ) : null} {worktreeDragState.draggingWorktreeId !== null && worktreeDragState.dropIndicatorY !== null ? ( <div @@ -2936,8 +3110,58 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return null } + if (row.type === 'host-header') { + // Why: the host card is the outer hierarchy tier — it pins above + // group headers (z-30 vs z-20) and stays put while they hand off. + const isActiveStickyHost = activeStickyHostIndexRef.current === vItem.index + const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ + rows: renderRows, + index: vItem.index, + firstHeaderIndex + }) + return ( + <div + key={vItem.key} + role="presentation" + data-worktree-virtual-row + data-worktree-virtual-row-key={String(vItem.key)} + data-worktree-sticky-header="" + data-worktree-sticky-header-active={isActiveStickyHost ? '' : undefined} + data-index={vItem.index} + ref={measureVirtualRowElement} + className={cn( + 'left-0 right-0', + hasHeaderTopSpacing && !isActiveStickyHost && 'pt-1', + isActiveStickyHost + ? 'sticky -top-px z-30 bg-worktree-sidebar' + : 'absolute top-0' + )} + style={ + isActiveStickyHost + ? undefined + : { transform: getVirtualRowTransform(vItem.start) } + } + > + <HostSectionHeader + row={row} + onToggle={() => toggleGroupWithScrollAnchor(row.key)} + onDragPointerDown={ + orderedHostIds.length > 1 + ? (e) => hostDrag.onHandlePointerDown(e, row.hostId) + : undefined + } + dragging={hostDrag.state.draggingHostId === row.hostId} + /> + </div> + ) + } + if (row.type === 'header') { const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index + // Why: when a host card is pinned, the group tier pins flush + // beneath it instead of at the viewport top. + const stickyTopClass = + activeStickyHostIndexRef.current !== null ? 'top-[35px]' : '-top-px' const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ rows: renderRows, index: vItem.index, @@ -2946,21 +3170,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 && @@ -3014,7 +3223,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( @@ -3026,7 +3234,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // so the previous repo no longer stays pinned over it. hasHeaderTopSpacing && !isActiveStickyHeader && 'pt-1', isActiveStickyHeader - ? 'sticky -top-px z-20 bg-worktree-sidebar' + ? cn('sticky z-20 bg-worktree-sidebar', stickyTopClass) : 'absolute top-0' )} style={ @@ -3039,8 +3247,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} @@ -3058,6 +3264,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 @@ -3115,18 +3331,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} /> @@ -3587,6 +3792,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCardDragStart={handleWorktreeCardDragStart} onCardDragEnd={clearWorktreeDrag} hideRepoBadge={groupBy === 'repo'} + hostContextLabel={itemRow.hostContextLabel} // Why: pinned worktrees only render in the Pinned group, so // isPinned marks the mixed-repo pinned section that needs icons. inPinnedSection={itemRow.worktree.isPinned} @@ -3635,7 +3841,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp childNodes.push(renderWorktreeRow(child, true, childLineageChildren)) cursor = nextSiblingIndex } - return childNodes.length > 0 ? childNodes : undefined } @@ -3865,6 +4070,10 @@ const WorktreeList = React.memo(function WorktreeList({ const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const currentSidebarWorktreeId = activeWorktreeId const groupBy = useAppStore((s) => s.groupBy) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) + const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) const setSortBy = useAppStore((s) => s.setSortBy) @@ -3944,6 +4153,10 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy === 'pr-status' || cardProps.includes('pr') ? s.prCache : null ) const settings = useAppStore((s) => s.settings) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const sortEpoch = useAppStore((s) => s.sortEpoch) @@ -4173,15 +4386,24 @@ const WorktreeList = React.memo(function WorktreeList({ if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadPty.current) { return } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) - void (target.kind === 'environment' - ? callRuntimeRpc( - target, - 'worktree.persistSortOrder', - { orderedIds: sortedIds }, - { timeoutMs: 15_000 } - ) - : window.api.worktrees.persistSortOrder({ orderedIds: sortedIds })) + // Why: sortOrder is persisted in each host's worktreeMeta and enriched from + // the owner host, so persist each host's ids on that host. + const state = useAppStore.getState() + for (const group of splitWorktreeSortOrderByHost(state, sortedIds)) { + const parsed = parseExecutionHostId(group.hostId) + const target = + parsed?.kind === 'runtime' + ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) + : ({ kind: 'local' } as const) + void (target.kind === 'environment' + ? callRuntimeRpc( + target, + 'worktree.persistSortOrder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + : window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds })) + } }, [sortedIds, sortBy]) // Flatten, filter, and apply stable sort order via the shared utility so @@ -4195,6 +4417,9 @@ const WorktreeList = React.memo(function WorktreeList({ browserTabsByWorktree, hideDefaultBranchWorkspace, repoMap, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(settings), worktreeLineageById }) if ( @@ -4213,6 +4438,9 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, showSleepingWorkspaces, hideDefaultBranchWorkspace, + workspaceHostScope, + visibleWorkspaceHostIds, + settings, repoMap, tabsByWorktree, ptyIdsByTabId, @@ -4230,6 +4458,12 @@ const WorktreeList = React.memo(function WorktreeList({ // Why: manual repo header order is bound to state.repos. Recent/Smart derive // header order from the sorted visible worktree stream instead. const repos = useAppStore((s) => s.repos) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projectGrouping = useMemo( + () => ({ projects, projectHostSetups }), + [projectHostSetups, projects] + ) const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const effectiveCollapsedGroups = useMemo(() => { @@ -4251,7 +4485,8 @@ const WorktreeList = React.memo(function WorktreeList({ prCache, workspaceStatuses, settings, - projectGroups + projectGroups, + projectGrouping )) { next.delete(groupKey) } @@ -4281,12 +4516,57 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, prCache, projectGroups, + projectGrouping, repoMap, settings, workspaceStatuses, worktreeLineageById, worktreeMap ]) + const defaultHostId = getSettingsFocusedExecutionHostId(settings) + const visibleHostIdSet = useMemo(() => { + const visibleHostIds = + visibleWorkspaceHostIds ?? + (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope]) + return visibleHostIds ? new Set<ExecutionHostId>(visibleHostIds) : null + }, [visibleWorkspaceHostIds, workspaceHostScope]) + const visibleReposForRows = useMemo(() => { + if (!visibleHostIdSet) { + return repos + } + return repos.filter((repo) => { + const hostId = + repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, repos, visibleHostIdSet]) + const visibleProjectGroupsForRows = useMemo(() => { + if (!visibleHostIdSet) { + return projectGroups + } + return projectGroups.filter((group) => { + const hostId = group.connectionId + ? (`ssh:${encodeURIComponent(group.connectionId)}` as ExecutionHostId) + : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, projectGroups, visibleHostIdSet]) + const visibleFolderWorkspacesForRows = useMemo(() => { + if (!visibleHostIdSet) { + return folderWorkspaces + } + const projectGroupById = new Map(projectGroups.map((group) => [group.id, group])) + return folderWorkspaces.filter((folderWorkspace) => { + const connectionId = + folderWorkspace.connectionId ?? + projectGroupById.get(folderWorkspace.projectGroupId)?.connectionId ?? + null + const hostId = connectionId + ? (`ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId) + : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) const repoOrder = useMemo(() => { const map = new Map<string, number>() repos.forEach((r, i) => map.set(r.id, i)) @@ -4302,15 +4582,20 @@ const WorktreeList = React.memo(function WorktreeList({ .map(([repoId]) => repoId) ) return buildImportedWorktreesCardCandidates({ - repos, + repos: visibleReposForRows, detectedWorktreesByRepo, filterRepoIds, forceVisibleRepoIds }) - }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, repos]) + }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows]) const placeholderRepoIds = useMemo(() => { - return getEmptyProjectPlaceholderRepoIds({ groupBy, repos, worktreesByRepo, filterRepoIds }) - }, [filterRepoIds, groupBy, repos, worktreesByRepo]) + return getEmptyProjectPlaceholderRepoIds({ + groupBy, + repos: visibleReposForRows, + worktreesByRepo, + filterRepoIds + }) + }, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) // Why: buildRows only needs which creates exist and their repo. Subscribe on a @@ -4333,6 +4618,32 @@ const WorktreeList = React.memo(function WorktreeList({ }), [pendingCreationKeys] ) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const hostLabelById = useMemo( + () => new Map(hostOptions.map((host) => [host.id, host.label])), + [hostOptions] + ) // Build flat row list for rendering const rows: Row[] = useMemo( @@ -4350,11 +4661,13 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeMap, true, settings, - projectGroups, + visibleProjectGroupsForRows, placeholderRepoIds, importedWorktreesByRepo, pendingCreations, - folderWorkspaces + projectGrouping, + visibleFolderWorkspacesForRows, + hostLabelById ), [ groupBy, @@ -4368,16 +4681,69 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeLineageById, worktreeMap, settings, - projectGroups, + projectGrouping, + visibleProjectGroupsForRows, + visibleFolderWorkspacesForRows, placeholderRepoIds, importedWorktreesByRepo, pendingCreations, - folderWorkspaces + hostLabelById + ] + ) + const orderedHostOptions = useMemo( + () => orderHostSectionOptions(hostOptions, workspaceHostOrder), + [hostOptions, workspaceHostOrder] + ) + const [hostDragActive, setHostDragActive] = useState(false) + const handleReorderHostSections = useCallback( + (orderedVisibleHostIds: ExecutionHostId[]) => { + const visibleHostIds = new Set(orderedVisibleHostIds) + const hostOptionIds = orderedHostOptions.map((host) => host.id) + const knownHostIds = new Set(hostOptionIds) + const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] + const seen = new Set(nextOrder) + // Why: dragging only covers rendered host sections. Keep non-rendered + // SSH/runtime hosts in the saved preference so they return in the same + // place when their workspaces become visible again. + for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { + if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { + continue + } + nextOrder.push(hostId) + seen.add(hostId) + } + setWorkspaceHostOrder(nextOrder) + }, + [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] + ) + const sectionRows = useMemo( + () => + addHostSectionRows({ + rows, + hostOptions: orderedHostOptions, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId, + collapsedHostKeys: effectiveCollapsedGroups, + forceCollapseHosts: hostDragActive, + // Why: projects/workspaces are now the primary sidebar object in every + // grouping mode; host sections are only an explicit host-filter view. + preferProjectGrouping: true + }), + [ + defaultHostId, + effectiveCollapsedGroups, + hostDragActive, + orderedHostOptions, + rows, + visibleWorkspaceHostIds, + workspaceHostScope ] ) // Why: status headers change during wake (inactive -> active). Key only on // the grouping mode so row identity survives those ordinary status moves. - const viewportResetKey = `group:${groupBy}:lineage` + const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' + const viewportResetKey = `group:${groupBy}:host:${visibleHostResetKey}:lineage` // Why: derive the rendered item order from the post-buildRows() row list, // not the flat `worktrees` array, because grouping (groupBy: 'repo' or @@ -4386,7 +4752,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] } @@ -4395,7 +4761,7 @@ const WorktreeList = React.memo(function WorktreeList({ } return [] }), - [rows] + [sectionRows] ) const renderedWorktreeIds = useMemo( () => renderedWorktrees.map((worktree) => worktree.id), @@ -4897,13 +5263,19 @@ const WorktreeList = React.memo(function WorktreeList({ // worktree is a default-branch row and who just toggled hide on would see // "No workspaces found" with no way back short of reopening the filter menu. const filterState = useMemo( - () => ({ showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace }), - [showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace] + () => ({ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + visibleWorkspaceHostIds + }), + [showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, visibleWorkspaceHostIds] ) const hasFilters = sidebarHasActiveFilters(filterState) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) const clearFilters = useCallback(() => { const actions = computeClearFilterActions(filterState) @@ -4916,7 +5288,16 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDefaultBranchWorkspace) { setHideDefaultBranchWorkspace(false) } - }, [setShowSleepingWorkspaces, setFilterRepoIds, setHideDefaultBranchWorkspace, filterState]) + if (actions.resetVisibleWorkspaceHostIds) { + setVisibleWorkspaceHostIds(null) + } + }, [ + setShowSleepingWorkspaces, + setFilterRepoIds, + setHideDefaultBranchWorkspace, + setVisibleWorkspaceHostIds, + filterState + ]) const handleRevealCurrentWorkspaceRequest = useCallback( (event: Event) => { @@ -5067,7 +5448,7 @@ const WorktreeList = React.memo(function WorktreeList({ /> <VirtualizedWorktreeViewport key={viewportResetKey} - rows={rows} + rows={sectionRows} activeWorktreeId={selectedSidebarWorktreeId} currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} @@ -5103,8 +5484,11 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeLineageById={worktreeLineageById} repoOrder={repoOrder} allRepoIds={allRepoIds} + onReorderHostSections={handleReorderHostSections} + onHostDragActiveChange={setHostDragActive} prCache={prCache} workspaceStatuses={workspaceStatuses} + projectGrouping={projectGrouping} projectGroups={projectGroups} onMoveWorktreeToStatus={moveWorktreeToStatus} onMoveWorktreesToStatus={moveWorktreesToStatus} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 9157f3fec31..217d2fd6735 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -11,28 +11,14 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { buildWorktreeMetaUpdates, type WorktreeMetaSavedPayload } from './worktree-meta-updates' +import { useWorktreeIssueLink } from './use-worktree-issue-link' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { ExternalLink, LoaderCircle } from 'lucide-react' -import type { WorktreeMeta } from '../../../../shared/types' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -type WorktreeMetaSavedPayload = { - worktreeId: string - updates: Partial<WorktreeMeta> -} - -function parseExplicitGitHubIssueUrl(input: string): string | null { - const trimmed = input.trim() - const link = parseGitHubIssueOrPRLink(trimmed) - if (!link || link.type !== 'issue') { - return null - } - - return trimmed -} - function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` @@ -43,7 +29,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const modalData = useAppStore((s) => s.modalData) const closeModal = useAppStore((s) => s.closeModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const fetchIssue = useAppStore((s) => s.fetchIssue) const submitShortcutLabel = getScreenSubmitShortcutLabel() const isEditMeta = activeModal === 'edit-meta' @@ -68,7 +53,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const [prInput, setPrInput] = useState('') const [commentInput, setCommentInput] = useState('') const [saving, setSaving] = useState(false) - const [openingIssue, setOpeningIssue] = useState(false) + const { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({ + worktreeId, + issueInput + }) const issueInputRef = useRef<HTMLInputElement>(null) const prInputRef = useRef<HTMLInputElement>(null) @@ -81,35 +69,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { setIssueInput(currentIssue) setPrInput(currentPR) setCommentInput(currentComment) - setOpeningIssue(false) + resetOpeningIssue() } prevIsOpenRef.current = isOpen - const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput]) - const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput]) - const issueInputLooksLikeUrl = useMemo( - () => /^https?:\/\//i.test(issueInput.trim()), - [issueInput] - ) - const issueRepo = useAppStore((s) => { - const worktree = Object.values(s.worktreesByRepo) - .flat() - .find((item) => item.id === worktreeId) - if (!worktree) { - return undefined - } - return s.repos.find((repo) => repo.id === worktree.repoId) - }) - const cachedIssueUrl = useAppStore((s) => { - if (!issueRepo || issueNumber === null) { - return null - } - return s.issueCache[`${issueRepo.id}::${issueNumber}`]?.data?.url ?? null - }) - const canOpenIssue = issueInputLooksLikeUrl - ? Boolean(issueUrlFromInput) - : Boolean(cachedIssueUrl || (issueRepo && issueNumber)) - const setCommentTextareaRef = useCallback( (textarea: HTMLTextAreaElement | null) => { textareaRef.current = textarea @@ -152,28 +115,13 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { } setSaving(true) try { - const trimmedIssue = issueInput.trim() - const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue) - const finalLinkedIssue = - trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined - const trimmedPR = prInput.trim() - const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR) - const finalLinkedPR = - trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined - - const trimmedDisplayName = displayNameInput.trim() - const updates: Partial<WorktreeMeta> = { - comment: commentInput.trim(), - ...(trimmedDisplayName !== currentDisplayName && { - displayName: trimmedDisplayName || undefined - }) - } - if (finalLinkedIssue !== undefined) { - updates.linkedIssue = finalLinkedIssue - } - if (finalLinkedPR !== undefined) { - updates.linkedPR = finalLinkedPR - } + const updates = buildWorktreeMetaUpdates({ + displayNameInput, + currentDisplayName, + issueInput, + prInput, + commentInput + }) await updateWorktreeMeta(worktreeId, updates) closeModal() @@ -225,51 +173,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { [handleSave] ) - const handleOpenIssue = useCallback(async () => { - if (openingIssue) { - return - } - - if (issueUrlFromInput) { - void window.api.shell.openUrl(issueUrlFromInput) - return - } - - if (issueInputLooksLikeUrl) { - return - } - - if (cachedIssueUrl) { - void window.api.shell.openUrl(cachedIssueUrl) - return - } - - if (!issueRepo || issueNumber === null) { - return - } - - setOpeningIssue(true) - try { - const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id }) - if (issue?.url) { - void window.api.shell.openUrl(issue.url) - } - } finally { - if (mountedRef.current) { - setOpeningIssue(false) - } - } - }, [ - cachedIssueUrl, - fetchIssue, - issueInputLooksLikeUrl, - issueNumber, - issueRepo, - issueUrlFromInput, - mountedRef, - openingIssue - ]) - return ( <Dialog open={isOpen} onOpenChange={handleOpenChange}> <DialogContent @@ -288,35 +191,58 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { }} > <DialogHeader> - <DialogTitle className="text-sm">{translate("auto.components.sidebar.WorktreeMetaDialog.382fd11a3e", "Edit Worktree Details")}</DialogTitle> + <DialogTitle className="text-sm"> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.382fd11a3e', + 'Edit Worktree Details' + )} + </DialogTitle> <DialogDescription className="text-xs"> - {translate("auto.components.sidebar.WorktreeMetaDialog.65770ad0f0", "Edit GitHub links and notes for this workspace.")}</DialogDescription> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.65770ad0f0', + 'Edit GitHub links and notes for this workspace.' + )} + </DialogDescription> </DialogHeader> <div className="space-y-4"> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.ad5e4e514f", "Display Name")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.ad5e4e514f', 'Display Name')} + </label> <Input ref={displayNameInputRef} value={displayNameInput} onChange={(e) => setDisplayNameInput(e.target.value)} onKeyDown={handleIssueKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.7f21e0464f", "Custom display name...")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', + 'Custom display name...' + )} className="h-8 text-xs" /> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.459ad7f650", "Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', + 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' + )} + </p> </div> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.645fa4a0fd", "GH Issue")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.645fa4a0fd', 'GH Issue')} + </label> <div className="relative"> <Input ref={issueInputRef} value={issueInput} onChange={(e) => setIssueInput(e.target.value)} onKeyDown={handleIssueKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.741279e7b7", "Issue # or GitHub URL")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.741279e7b7', + 'Issue # or GitHub URL' + )} className="h-8 pr-9 text-xs" /> <Tooltip> @@ -325,7 +251,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { type="button" variant="ghost" size="icon-xs" - aria-label={translate("auto.components.sidebar.WorktreeMetaDialog.029ea5ec57", "Open GitHub issue")} + aria-label={translate( + 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57', + 'Open GitHub issue' + )} disabled={!canOpenIssue || openingIssue} onClick={handleOpenIssue} className="absolute right-1 top-1 text-muted-foreground" @@ -338,41 +267,71 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { </Button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> - {translate("auto.components.sidebar.WorktreeMetaDialog.029ea5ec57", "Open GitHub issue")}</TooltipContent> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57', + 'Open GitHub issue' + )} + </TooltipContent> </Tooltip> </div> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.7c454be4c5", "Paste an issue URL, or enter a number. Leave blank to remove the link.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.7c454be4c5', + 'Paste an issue URL, or enter a number. Leave blank to remove the link.' + )} + </p> </div> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.1b91db7e14", "GH PR")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.1b91db7e14', 'GH PR')} + </label> <Input ref={prInputRef} value={prInput} onChange={(e) => setPrInput(e.target.value)} onKeyDown={handleIssueKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.077a4f7b5c", "PR # or GitHub URL")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.077a4f7b5c', + 'PR # or GitHub URL' + )} className="h-8 text-xs" /> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.5ae06f40fd", "Paste a pull request URL, or enter a number. Leave blank to remove the link.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.5ae06f40fd', + 'Paste a pull request URL, or enter a number. Leave blank to remove the link.' + )} + </p> </div> <div className="space-y-1"> - <label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.WorktreeMetaDialog.9c1d1e9b71", "Comment")}</label> + <label className="text-[11px] font-medium text-muted-foreground"> + {translate('auto.components.sidebar.WorktreeMetaDialog.9c1d1e9b71', 'Comment')} + </label> <textarea ref={setCommentTextareaRef} value={commentInput} onChange={handleCommentChange} onKeyDown={handleCommentKeyDown} - placeholder={translate("auto.components.sidebar.WorktreeMetaDialog.030d484fc0", "Notes about this worktree...")} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.030d484fc0', + 'Notes about this worktree...' + )} rows={3} className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto scrollbar-sleek" /> <p className="text-[10px] text-muted-foreground"> - {translate("auto.components.sidebar.WorktreeMetaDialog.7f0be5e9a6", "Supports **markdown** — bold, lists, `code`, links. Press Enter or")}{' '} - {submitShortcutLabel} {translate("auto.components.sidebar.WorktreeMetaDialog.b48c271d39", "to save, Shift+Enter for a new line.")}</p> + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.7f0be5e9a6', + 'Supports **markdown** — bold, lists, `code`, links. Press Enter or' + )}{' '} + {submitShortcutLabel}{' '} + {translate( + 'auto.components.sidebar.WorktreeMetaDialog.b48c271d39', + 'to save, Shift+Enter for a new line.' + )} + </p> </div> </div> @@ -383,9 +342,12 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { onClick={() => handleOpenChange(false)} className="text-xs" > - {translate("auto.components.sidebar.WorktreeMetaDialog.3db0a2a593", "Cancel")}</Button> + {translate('auto.components.sidebar.WorktreeMetaDialog.3db0a2a593', 'Cancel')} + </Button> <Button size="sm" onClick={handleSave} disabled={!canSave || saving} className="text-xs"> - {saving ? translate("auto.components.sidebar.WorktreeMetaDialog.61d6f612cf", "Saving...") : translate("auto.components.sidebar.WorktreeMetaDialog.2174f17011", "Save")} + {saving + ? translate('auto.components.sidebar.WorktreeMetaDialog.61d6f612cf', 'Saving...') + : translate('auto.components.sidebar.WorktreeMetaDialog.2174f17011', 'Save')} </Button> </DialogFooter> </DialogContent> diff --git a/src/renderer/src/components/sidebar/add-repo-dialog-types.ts b/src/renderer/src/components/sidebar/add-repo-dialog-types.ts index 0f4b6e94425..e756aa29da8 100644 --- a/src/renderer/src/components/sidebar/add-repo-dialog-types.ts +++ b/src/renderer/src/components/sidebar/add-repo-dialog-types.ts @@ -1,4 +1,4 @@ -export type AddRepoDialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' +export type AddRepoDialogStep = 'add' | 'clone' | 'remote' | 'server-path' | 'create' | 'nested' export function defaultProjectGroupNameForPath(path: string): string { return ( diff --git a/src/renderer/src/components/sidebar/add-repo-host-availability.ts b/src/renderer/src/components/sidebar/add-repo-host-availability.ts new file mode 100644 index 00000000000..63019f298c3 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-host-availability.ts @@ -0,0 +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' +} 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 0572b1dc236..fbe525470da 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 @@ -7,6 +7,9 @@ export type AddRepoLocalStartActionHandlers = { onOpenCloneStep: () => void onOpenRemoteStep: () => void onOpenCreateStep: () => void + showRemoteAction?: boolean + canCreateProject?: boolean + browseHostKind?: 'local' | 'ssh' | 'runtime' } export type AddRepoLocalStartAction = { @@ -14,6 +17,7 @@ export type AddRepoLocalStartAction = { icon: ComponentType<{ className?: string }> title: string description: string + disabled?: boolean onClick: () => void } @@ -22,7 +26,10 @@ export function getAddRepoLocalStartActions({ onBrowse, onOpenCloneStep, onOpenRemoteStep, - onOpenCreateStep + onOpenCreateStep, + showRemoteAction = true, + canCreateProject = true, + browseHostKind = 'local' }: { isSshLikely: boolean } & AddRepoLocalStartActionHandlers): { primaryAction: AddRepoLocalStartAction secondaryActions: AddRepoLocalStartAction[] @@ -30,14 +37,31 @@ 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' + ) + : browseHostKind === 'runtime' + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription', + 'Existing Git repository or folder on this host' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', + 'Local project, Git repo, or folder with many repos' + ), onClick: onBrowse } @@ -46,11 +70,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 } @@ -71,18 +95,27 @@ 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 } - // SSH-likely users reach for remote targets first, so surface that row ahead of clone. - const secondaryActions = isSshLikely ? [remote, clone, create] : [clone, remote, create] + const secondaryActions = showRemoteAction + ? isSshLikely + ? [remote, clone, create] + : [clone, remote, create] + : [clone, create] return { primaryAction, secondaryActions } } diff --git a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts new file mode 100644 index 00000000000..664d5e3e93e --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts @@ -0,0 +1,19 @@ +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import type { Repo } from '../../../../shared/types' +import { useAppStore } from '@/store' + +export function upsertAddedRepoWithProjectHostSetup(repo: Repo): void { + const state = useAppStore.getState() + const repos = state.repos.some((entry) => entry.id === repo.id) + ? state.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + : [...state.repos, repo] + const projection = projectHostSetupProjectionFromRepos(repos) + + // Why: these Add Project flows call IPC directly, bypassing the repo slice + // action that normally keeps the project-first compatibility model synced. + useAppStore.setState({ + repos, + projects: projection.projects, + projectHostSetups: projection.setups + }) +} diff --git a/src/renderer/src/components/sidebar/clone-defaults.test.ts b/src/renderer/src/components/sidebar/clone-defaults.test.ts index 9ec5f6ea3c4..ec61ffdb566 100644 --- a/src/renderer/src/components/sidebar/clone-defaults.test.ts +++ b/src/renderer/src/components/sidebar/clone-defaults.test.ts @@ -104,4 +104,17 @@ describe('getCloneDestinationAutoFill', () => { }) ).toBeNull() }) + + it('does not fill SSH clone destinations from the local workspace directory', () => { + expect( + getCloneDestinationAutoFill({ + step: 'clone', + cloneDestination: '', + activeRuntimeEnvironmentId: null, + sshTargetId: 'openclaw-2', + workspaceDir: '/Users/mvanhorn/orca/workspaces', + cloneStepAutoFilled: false + }) + ).toBeNull() + }) }) diff --git a/src/renderer/src/components/sidebar/clone-defaults.ts b/src/renderer/src/components/sidebar/clone-defaults.ts index 6a720a33a6c..8352cb023ef 100644 --- a/src/renderer/src/components/sidebar/clone-defaults.ts +++ b/src/renderer/src/components/sidebar/clone-defaults.ts @@ -30,19 +30,21 @@ export function getCloneDestinationAutoFill({ step, cloneDestination, activeRuntimeEnvironmentId, + sshTargetId, workspaceDir, cloneStepAutoFilled }: { step: string cloneDestination: string activeRuntimeEnvironmentId: string | null | undefined + sshTargetId?: string | null | undefined workspaceDir: string | null | undefined cloneStepAutoFilled: boolean }): { destination: string } | null { if (step !== 'clone' || cloneStepAutoFilled || cloneDestination) { return null } - if (activeRuntimeEnvironmentId?.trim() || !workspaceDir) { + if (activeRuntimeEnvironmentId?.trim() || sshTargetId?.trim() || !workspaceDir) { return null } return { destination: getDefaultCloneParent(workspaceDir) } 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/delete-worktree-preference-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts new file mode 100644 index 00000000000..188e1bdbbaf --- /dev/null +++ b/src/renderer/src/components/sidebar/delete-worktree-preference-toast.ts @@ -0,0 +1,50 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { SettingsNavTarget } from '@/lib/settings-navigation-types' +import type { GlobalSettings } from '../../../../shared/types' + +export function persistDeleteWorktreeConfirmSkipPreference({ + updateSettings, + openSettingsPage, + openSettingsTarget +}: { + updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> + openSettingsPage: () => void + openSettingsTarget: (target: { + pane: SettingsNavTarget + repoId: string | null + sectionId?: string + intent?: 'add-quick-command' + }) => void +}): void { + void updateSettings({ skipDeleteWorktreeConfirm: true }) + // Why: the toast confirms the preference was saved and deep-links to the + // exact toggle so users can undo a skipped destructive confirmation quickly. + toast.success( + translate( + 'auto.components.sidebar.DeleteWorktreeDialog.dd3a45bbbd', + "We'll skip this confirmation next time." + ), + { + description: translate( + 'auto.components.sidebar.DeleteWorktreeDialog.2b56b35f53', + 'You can change this in Settings.' + ), + duration: 8000, + action: { + label: translate( + 'auto.components.sidebar.DeleteWorktreeDialog.5cc1a6701c', + 'Open Settings' + ), + onClick: () => { + openSettingsPage() + openSettingsTarget({ + pane: 'general', + repoId: null, + sectionId: 'general-skip-delete-worktree-confirm' + }) + } + } + } + ) +} 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/folder-workspace-composer-keyboard.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts new file mode 100644 index 00000000000..b48c5ed7f01 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react' +import type { RefObject } from 'react' +import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' +import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' + +type UseFolderWorkspaceComposerKeyboardInput = { + open: boolean + submitting: boolean + composerRef: RefObject<HTMLDivElement | null> + onOpenChange: (open: boolean) => void + onCreate: () => void +} + +export function useFolderWorkspaceComposerKeyboard({ + open, + submitting, + composerRef, + onOpenChange, + onCreate +}: UseFolderWorkspaceComposerKeyboardInput): void { + 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() + onCreate() + } + window.addEventListener('keydown', onKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [composerRef, onCreate, onOpenChange, open, submitting]) +} diff --git a/src/renderer/src/components/sidebar/host-header-drag-dom.ts b/src/renderer/src/components/sidebar/host-header-drag-dom.ts new file mode 100644 index 00000000000..050427563b0 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-drag-dom.ts @@ -0,0 +1,40 @@ +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' + +export type HostHeaderRect = { + hostId: ExecutionHostId + top: number + bottom: number +} + +const HOST_HEADER_ACTION_SELECTOR = + '[data-host-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isHostHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return currentTarget.contains(target) && target.closest(HOST_HEADER_ACTION_SELECTOR) !== null +} + +export function readHostHeaderRects(container: HTMLElement): HostHeaderRect[] { + const containerRect = container.getBoundingClientRect() + const headerRects: HostHeaderRect[] = [] + for (const header of Array.from( + container.querySelectorAll<HTMLElement>('[data-host-header-drag-id]') + )) { + const hostId = normalizeExecutionHostId(header.dataset.hostHeaderDragId) + if (!hostId) { + continue + } + const rect = header.getBoundingClientRect() + headerRects.push({ + hostId, + top: rect.top - containerRect.top + container.scrollTop, + bottom: rect.bottom - containerRect.top + container.scrollTop + }) + } + return headerRects +} diff --git a/src/renderer/src/components/sidebar/host-header-drag.ts b/src/renderer/src/components/sidebar/host-header-drag.ts new file mode 100644 index 00000000000..8b6ea676a46 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-drag.ts @@ -0,0 +1,314 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent +} from 'react' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { + createSidebarDragPreview, + setSidebarPointerDragDocumentStyles, + updateSidebarDragPreviewPosition +} from './worktree-sidebar-pointer-drag-dom' +import { + isHostHeaderActionTarget, + readHostHeaderRects, + type HostHeaderRect +} from './host-header-drag-dom' + +export type HostDragState = { + draggingHostId: ExecutionHostId | null + dropIndex: number | null + dropIndicatorY: number | null +} + +const INITIAL_STATE: HostDragState = { + draggingHostId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseHostHeaderDragArgs = { + orderedHostIds: readonly ExecutionHostId[] + onCommit: (orderedIds: ExecutionHostId[]) => void + getScrollContainer: () => HTMLElement | null +} + +export type HostHeaderDragController = { + state: HostDragState + onHandlePointerDown: (event: ReactPointerEvent<HTMLElement>, hostId: ExecutionHostId) => void +} + +const DRAG_THRESHOLD_PX = 4 + +export function useHostHeaderDrag({ + orderedHostIds, + onCommit, + getScrollContainer +}: UseHostHeaderDragArgs): HostHeaderDragController { + const [state, setState] = useState<HostDragState>(INITIAL_STATE) + const [sessionArmed, setSessionArmed] = useState(false) + const latestDropIndexRef = useRef<number | null>(null) + latestDropIndexRef.current = state.dropIndex + const orderedIdsRef = useRef(orderedHostIds) + orderedIdsRef.current = orderedHostIds + const onCommitRef = useRef(onCommit) + onCommitRef.current = onCommit + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + + const dragSessionRef = useRef<{ + hostId: ExecutionHostId + pointerId: number + headerRects: HostHeaderRect[] + handleEl: HTMLElement + startX: number + startY: number + promoted: boolean + preview: HTMLElement | null + previewOffsetX: number + previewOffsetY: number + } | null>(null) + const deferredComputeFrameRef = useRef<number | null>(null) + + const clearDeferredComputeFrame = useCallback(() => { + if (deferredComputeFrameRef.current !== null) { + window.cancelAnimationFrame(deferredComputeFrameRef.current) + deferredComputeFrameRef.current = null + } + }, []) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + // Why: dragging host headers temporarily collapses their sections, so + // live rects are the source of truth after the first promoted move. + const rects = readHostHeaderRects(container) + if (rects.length === 0 || rects.length < orderedIdsRef.current.length) { + return null + } + session.headerRects = rects + const containerRect = container.getBoundingClientRect() + const localY = pointerY - containerRect.top + container.scrollTop + 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 + } + } + 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) + return { + dropIndex: insertBefore, + dropIndicatorY: Math.max(container.scrollTop, rawIndicatorY) + } + }, + [] + ) + + const applyDrop = useCallback((drop: { dropIndex: number; dropIndicatorY: number } | null) => { + if (!drop) { + return + } + latestDropIndexRef.current = drop.dropIndex + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingHostId: dragSessionRef.current?.hostId ?? prev.draggingHostId, ...drop } + ) + }, []) + + const scheduleDeferredDropCompute = useCallback( + (pointerY: number) => { + clearDeferredComputeFrame() + deferredComputeFrameRef.current = window.requestAnimationFrame(() => { + deferredComputeFrameRef.current = null + applyDrop(computeDrop(pointerY)) + }) + }, + [applyDrop, clearDeferredComputeFrame, computeDrop] + ) + + const endDrag = useCallback( + (commit: boolean, pointerY?: number) => { + const session = dragSessionRef.current + if (!session) { + clearDeferredComputeFrame() + setState(INITIAL_STATE) + setSessionArmed(false) + return + } + clearDeferredComputeFrame() + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // Pointer capture may already be gone if the element unmounted. + } + session.preview?.remove() + setSidebarPointerDragDocumentStyles(false) + 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 finalIndex = + commit && session.promoted + ? (latestDropIndexRef.current ?? + (pointerY === undefined ? null : (computeDrop(pointerY)?.dropIndex ?? null))) + : null + dragSessionRef.current = null + setState(INITIAL_STATE) + setSessionArmed(false) + if (finalIndex === null) { + return + } + const ids = orderedIdsRef.current + const fromIndex = ids.indexOf(session.hostId) + if (fromIndex === -1) { + return + } + const next = ids.slice() + next.splice(fromIndex, 1) + const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex + if (insertAt === fromIndex) { + return + } + next.splice(insertAt, 0, session.hostId) + onCommitRef.current(next) + }, + [clearDeferredComputeFrame, computeDrop] + ) + + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || e.pointerId !== session.pointerId) { + return + } + if (!session.promoted) { + const dx = e.clientX - session.startX + const dy = e.clientY - session.startY + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) { + return + } + session.promoted = true + const { preview, offsetX, offsetY } = createSidebarDragPreview({ + sourceRow: session.handleEl, + pointerX: e.clientX, + pointerY: e.clientY, + draggedCount: 1 + }) + session.preview = preview + session.previewOffsetX = offsetX + session.previewOffsetY = offsetY + setSidebarPointerDragDocumentStyles(true) + setState({ draggingHostId: session.hostId, dropIndex: null, dropIndicatorY: null }) + } + if (session.preview) { + updateSidebarDragPreviewPosition({ + preview: session.preview, + pointerX: e.clientX, + pointerY: e.clientY, + offsetX: session.previewOffsetX, + offsetY: session.previewOffsetY + }) + } + const drop = computeDrop(e.clientY) + if (!drop) { + scheduleDeferredDropCompute(e.clientY) + return + } + applyDrop(drop) + } + const onPointerUp = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (session && e.pointerId === session.pointerId) { + endDrag(true, e.clientY) + } + } + const onPointerCancel = (e: PointerEvent): void => { + const session = dragSessionRef.current + if (session && e.pointerId === session.pointerId) { + endDrag(false) + } + } + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + } + }, [applyDrop, computeDrop, endDrag, scheduleDeferredDropCompute, sessionArmed]) + + const onHandlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLElement>, hostId: ExecutionHostId) => { + if (event.button !== 0 || isHostHeaderActionTarget(event.target, event.currentTarget)) { + return + } + const container = getContainerRef.current() + if (!container || orderedIdsRef.current.length <= 1) { + return + } + const headerRects = readHostHeaderRects(container) + dragSessionRef.current = { + hostId, + pointerId: event.pointerId, + headerRects, + handleEl: event.currentTarget, + startX: event.clientX, + startY: event.clientY, + promoted: false, + preview: null, + previewOffsetX: 0, + previewOffsetY: 0 + } + event.currentTarget.setPointerCapture(event.pointerId) + setSessionArmed(true) + }, + [] + ) + + useEffect(() => { + return () => { + clearDeferredComputeFrame() + dragSessionRef.current?.preview?.remove() + setSidebarPointerDragDocumentStyles(false) + } + }, [clearDeferredComputeFrame]) + + return { state, onHandlePointerDown } +} diff --git a/src/renderer/src/components/sidebar/host-header-menu-items.test.ts b/src/renderer/src/components/sidebar/host-header-menu-items.test.ts new file mode 100644 index 00000000000..b9c45800faa --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-menu-items.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { buildHostHeaderMenuModel } from './host-header-menu-items' + +describe('buildHostHeaderMenuModel', () => { + it('offers Focus + Rename + Manage for the local host (no Remove)', () => { + const model = buildHostHeaderMenuModel({ kind: 'local', health: 'local' }) + expect(model.actions).toEqual(['rename', 'manage']) + expect(model.actions).not.toContain('remove') + expect(model.blocked).toBeNull() + }) + + it('offers Reconnect + Remove for a disconnected SSH host', () => { + const model = buildHostHeaderMenuModel({ + kind: 'ssh', + health: 'disconnected', + sshConnected: false + }) + expect(model.actions).toEqual(['rename', 'ssh-reconnect', 'manage', 'remove']) + }) + + it('offers Disconnect + Remove for a connected SSH host', () => { + const model = buildHostHeaderMenuModel({ + kind: 'ssh', + health: 'available', + sshConnected: true + }) + expect(model.actions).toEqual(['rename', 'ssh-disconnect', 'manage', 'remove']) + }) + + it('offers Check connection + Remove for a runtime host', () => { + const model = buildHostHeaderMenuModel({ kind: 'runtime', health: 'available' }) + expect(model.actions).toEqual(['rename', 'runtime-check-connection', 'manage', 'remove']) + }) + + it('offers Rename for every host kind', () => { + for (const kind of ['local', 'ssh', 'runtime'] as const) { + expect(buildHostHeaderMenuModel({ kind, health: 'available' }).actions).toContain('rename') + } + }) + + it('offers Remove only for ssh and runtime hosts', () => { + expect(buildHostHeaderMenuModel({ kind: 'ssh', health: 'available' }).actions).toContain( + 'remove' + ) + expect(buildHostHeaderMenuModel({ kind: 'runtime', health: 'available' }).actions).toContain( + 'remove' + ) + expect(buildHostHeaderMenuModel({ kind: 'local', health: 'local' }).actions).not.toContain( + 'remove' + ) + }) + + it('surfaces a server-too-old block for a blocked runtime host', () => { + const model = buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'blocked', + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: 5, + serverProtocolVersion: 1, + requiredServerProtocolVersion: 4 + } + }) + expect(model.blocked).toEqual({ reason: 'server-too-old' }) + expect(model.actions).toContain('runtime-check-connection') + }) + + it('surfaces a client-too-old block per verdict reason', () => { + const model = buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'blocked', + compatibility: { + kind: 'blocked', + reason: 'client-too-old', + clientProtocolVersion: 1, + serverProtocolVersion: 5, + requiredClientProtocolVersion: 4 + } + }) + expect(model.blocked).toEqual({ reason: 'client-too-old' }) + }) + + it('does not surface a block when health is not blocked', () => { + const model = buildHostHeaderMenuModel({ + kind: 'runtime', + health: 'available', + compatibility: { kind: 'ok', clientProtocolVersion: 5, serverProtocolVersion: 5 } + }) + expect(model.blocked).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/host-header-menu-items.ts b/src/renderer/src/components/sidebar/host-header-menu-items.ts new file mode 100644 index 00000000000..2117d43fc24 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-menu-items.ts @@ -0,0 +1,73 @@ +import type { ExecutionHostKind } from '../../../../shared/execution-host' +import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry' +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' + +// Why: the host-header dropdown shows different lifecycle actions per host kind. +// Keeping the availability rules in a pure function makes them unit-testable +// without rendering the sidebar. +// Why: no 'focus' action here — the host scope strip is the single scoping +// control (the design doc forbids a separate focused-host toggle), and +// decluttering is served by collapsing the section. +export type HostHeaderMenuAction = + | 'rename' + | 'manage' + | 'ssh-reconnect' + | 'ssh-disconnect' + | 'runtime-check-connection' + | 'remove' + +export type HostHeaderMenuModel = { + /** Lifecycle/navigation actions, in display order. */ + actions: HostHeaderMenuAction[] + /** Present only when the host is blocked on a compatibility verdict. */ + blocked: { + reason: 'client-too-old' | 'server-too-old' + } | null +} + +export type HostHeaderMenuInput = { + kind: ExecutionHostKind + health: ExecutionHostHealth + /** SSH connection status drives Reconnect vs Disconnect. */ + sshConnected?: boolean + compatibility?: RuntimeCompatVerdict +} + +function sshActions(connected: boolean): HostHeaderMenuAction[] { + // Why: only offer the action that changes state — Disconnect when up, + // Reconnect otherwise — to avoid a dead menu item. + return connected ? ['ssh-disconnect'] : ['ssh-reconnect'] +} + +export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeaderMenuModel { + // Why: Rename edits only the client-side display label, so it's offered for + // every host kind including local. + const actions: HostHeaderMenuAction[] = ['rename'] + + switch (input.kind) { + case 'ssh': + actions.push(...sshActions(input.sshConnected ?? false)) + break + case 'runtime': + actions.push('runtime-check-connection') + break + case 'local': + break + } + + // Manage host… always closes out the list as the catch-all deep link. + actions.push('manage') + + // Why: removing a host deletes the underlying SSH target / runtime + // environment, which only exists for those kinds — local can't be removed. + if (input.kind === 'ssh' || input.kind === 'runtime') { + actions.push('remove') + } + + const blocked = + input.health === 'blocked' && input.compatibility?.kind === 'blocked' + ? { reason: input.compatibility.reason } + : null + + return { actions, blocked } +} diff --git a/src/renderer/src/components/sidebar/host-rename-remove.test.ts b/src/renderer/src/components/sidebar/host-rename-remove.test.ts new file mode 100644 index 00000000000..96cc59210c6 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-rename-remove.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + applyHostRename, + clearHostRename, + getHostDisplayLabelOverride, + resolveHostRemoval +} from './host-rename-remove' + +describe('host rename helpers', () => { + it('reads the current display-label override', () => { + const settings = { hostSettingOverrides: { 'ssh:box': { displayLabel: 'Box' } } } + expect(getHostDisplayLabelOverride(settings, 'ssh:box')).toBe('Box') + expect(getHostDisplayLabelOverride(settings, 'ssh:other')).toBeUndefined() + }) + + it('applies a rename', () => { + expect(applyHostRename({ hostSettingOverrides: {} }, 'ssh:box', 'Renamed')).toEqual({ + 'ssh:box': { displayLabel: 'Renamed' } + }) + }) + + it('clears the override when renamed to blank', () => { + const settings = { hostSettingOverrides: { 'ssh:box': { displayLabel: 'Box' } } } + expect(applyHostRename(settings, 'ssh:box', ' ')).toEqual({}) + }) + + it('resets a rename to the derived label', () => { + const settings = { + hostSettingOverrides: { + 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } + } + } + expect(clearHostRename(settings, 'ssh:box')).toEqual({ + 'ssh:box': { defaultWorktreeLocation: '/w' } + }) + }) +}) + +describe('resolveHostRemoval', () => { + it('resolves an ssh host to its target id', () => { + expect(resolveHostRemoval('ssh:box')).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + it('resolves a runtime host to its environment id', () => { + expect(resolveHostRemoval('runtime:env-1')).toEqual({ + kind: 'runtime', + environmentId: 'env-1' + }) + }) + + it('returns null for the local host', () => { + expect(resolveHostRemoval('local')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/host-rename-remove.ts b/src/renderer/src/components/sidebar/host-rename-remove.ts new file mode 100644 index 00000000000..dc1dfec6c84 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-rename-remove.ts @@ -0,0 +1,56 @@ +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + clearHostSettingOverride, + getHostSettingOverride, + setHostSettingOverride +} from '../../../../shared/host-setting-overrides' +import type { GlobalSettings, HostSettingOverrides } from '../../../../shared/types' + +type OverridesSlice = Pick<GlobalSettings, 'hostSettingOverrides'> +type OverridesMap = Partial<Record<ExecutionHostId, HostSettingOverrides>> + +/** The current user-chosen display-label override for a host, or undefined when + * the host still uses its derived label. */ +export function getHostDisplayLabelOverride( + settings: OverridesSlice | null | undefined, + hostId: ExecutionHostId +): string | undefined { + return getHostSettingOverride(settings, hostId, 'displayLabel') +} + +/** Computes the next `hostSettingOverrides` after a rename. A blank label clears + * the override so the host reverts to its derived label. */ +export function applyHostRename( + settings: OverridesSlice | null | undefined, + hostId: ExecutionHostId, + nextLabel: string +): OverridesMap { + return setHostSettingOverride(settings, hostId, 'displayLabel', nextLabel) +} + +/** Computes the next `hostSettingOverrides` after resetting a host's label. */ +export function clearHostRename( + settings: OverridesSlice | null | undefined, + hostId: ExecutionHostId +): OverridesMap { + return clearHostSettingOverride(settings, hostId, 'displayLabel') +} + +export type HostRemovalTarget = + | { kind: 'ssh'; targetId: string } + | { kind: 'runtime'; environmentId: string } + | null + +/** Resolves how a host should be removed. SSH targets are removed inline via the + * ssh API; runtime environments deep-link into the Orca servers pane because + * their removal needs active-environment/error context that lives there. */ +export function resolveHostRemoval(hostId: ExecutionHostId): HostRemovalTarget { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'ssh') { + return { kind: 'ssh', targetId: parsed.targetId } + } + if (parsed?.kind === 'runtime') { + return { kind: 'runtime', environmentId: parsed.environmentId } + } + return null +} diff --git a/src/renderer/src/components/sidebar/host-section-order.test.ts b/src/renderer/src/components/sidebar/host-section-order.test.ts new file mode 100644 index 00000000000..9062ab1cca4 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-order.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { orderHostSectionOptions } from './host-section-order' +import type { HostSectionOption } from './host-section-rows' + +const host = (id: HostSectionOption['id'], label = id): HostSectionOption => ({ + id, + kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime', + label, + detail: 'Host', + health: id === 'local' ? 'local' : 'available' +}) + +describe('orderHostSectionOptions', () => { + it('applies persisted host order and appends newly discovered hosts', () => { + expect( + orderHostSectionOptions( + [host('local'), host('ssh:ssh-1'), host('runtime:env-1')], + ['ssh:ssh-1', 'local'] + ).map((option) => option.id) + ).toEqual(['ssh:ssh-1', 'local', 'runtime:env-1']) + }) + + it('ignores stale host ids in the persisted order', () => { + expect( + orderHostSectionOptions( + [host('local'), host('ssh:ssh-1')], + ['runtime:deleted', 'ssh:ssh-1'] + ).map((option) => option.id) + ).toEqual(['ssh:ssh-1', 'local']) + }) +}) diff --git a/src/renderer/src/components/sidebar/host-section-order.ts b/src/renderer/src/components/sidebar/host-section-order.ts new file mode 100644 index 00000000000..e8cc1ad38a2 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-order.ts @@ -0,0 +1,32 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { HostSectionOption } from './host-section-rows' + +export function orderHostSectionOptions( + hostOptions: readonly HostSectionOption[], + workspaceHostOrder: readonly ExecutionHostId[] = [] +): HostSectionOption[] { + if (workspaceHostOrder.length === 0 || hostOptions.length <= 1) { + return [...hostOptions] + } + const hostById = new Map(hostOptions.map((host) => [host.id, host])) + const ordered: HostSectionOption[] = [] + const seen = new Set<ExecutionHostId>() + for (const hostId of workspaceHostOrder) { + const host = hostById.get(hostId) + if (!host || seen.has(host.id)) { + continue + } + ordered.push(host) + seen.add(host.id) + } + // Why: persisted order is only a preference for hosts the user has seen; + // newly-discovered SSH/runtime hosts should still appear without needing a + // migration or explicit reset. + for (const host of hostOptions) { + if (seen.has(host.id)) { + continue + } + ordered.push(host) + } + return ordered +} diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts new file mode 100644 index 00000000000..cb56dcd59cc --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -0,0 +1,613 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { Row } from './worktree-list-groups' +import { addHostSectionRows, type HostSectionRow } from './host-section-rows' + +function repo(id: string, connectionId?: string | null): Repo { + return { + id, + path: `/${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 0, + connectionId + } +} + +function worktree(id: string, repoId: string): Worktree { + return { + id, + repoId, + path: `/${repoId}/${id}`, + branch: `refs/heads/${id}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + comment: '', + isUnread: false, + isPinned: false, + displayName: id, + sortOrder: 0, + lastActivityAt: 0 + } +} + +function header(key: string, label = key): Extract<Row, { type: 'header' }> { + return { + type: 'header', + key, + label, + count: 1, + tone: 'text-foreground' + } +} + +function repoHeader(project: Repo): Extract<Row, { type: 'header' }> { + return { + ...header(`repo:${project.id}`, project.displayName), + repo: project + } +} + +function item(id: string, project: Repo): Extract<Row, { type: 'item' }> { + return { + type: 'item', + worktree: worktree(id, project.id), + repo: project, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: true, + lineageChildCount: 0 + } +} + +function folderWorkspaceRow( + connectionId: string | null +): Extract<Row, { type: 'folder-workspace' }> { + const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Remote folder', + parentPath: '/srv/project', + connectionId, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const folderWorkspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: projectGroup.id, + name: 'Folder workspace', + folderPath: '/srv/project', + connectionId, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + return { + type: 'folder-workspace', + key: 'folder-workspace:folder-1', + folderWorkspace, + projectGroup, + depth: 0, + groupDepth: 0 + } +} + +function rowKey(row: HostSectionRow): string { + return row.type === 'item' ? row.worktree.id : row.key +} + +describe('addHostSectionRows', () => { + it('does not add host headers for a specific host scope', () => { + const local = repo('local') + const rows = [repoHeader(local), item('local-wt', local)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'local', + defaultHostId: 'local' + }) + + expect(sectioned).toHaveLength(2) + expect(sectioned).toEqual(rows) + }) + + it('does not add host headers when only the local host exists', () => { + const local = repo('local') + const rows = [repoHeader(local), item('local-wt', local)] + + expect( + addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + ).toEqual(rows) + }) + + it('groups rows under host headers in all-host scope', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + 'repo:ssh', + 'ssh-wt' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { label: 'Local Mac', count: 1 }, + { label: 'Builder', count: 1 } + ]) + }) + + it('keeps project grouping outermost in the default Projects view', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + preferProjectGrouping: true + }) + + expect(sectioned).toEqual(rows) + }) + + it('keeps host headers for a custom multi-host visibility filter', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local', 'ssh:ssh-1'], + defaultHostId: 'local', + preferProjectGrouping: true + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + 'repo:ssh', + 'ssh-wt' + ]) + }) + + it('keeps non-repo group headers with the following host-owned rows', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [header('all'), item('local-wt', local), header('done'), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'all', + 'local-wt', + 'host:ssh:ssh-1', + 'done', + 'ssh-wt' + ]) + }) + + it('groups explicitly runtime-owned repos under their owner host, not the focused host', () => { + const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } + const runtimeOwned: Repo = { ...repo('remote-project'), executionHostId: 'runtime:env-2' } + const rows = [ + repoHeader(localOwned), + item('local-wt', localOwned), + repoHeader(runtimeOwned), + item('remote-wt', runtimeOwned) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'available' + }, + { + id: 'runtime:env-2', + kind: 'runtime', + label: 'env-2', + detail: 'Orca server', + health: 'available' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'runtime:env-1' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local-project', + 'local-wt', + 'host:runtime:env-2', + 'repo:remote-project', + 'remote-wt' + ]) + }) + + it('groups SSH folder workspace rows under their connection host', () => { + const local = repo('local') + const rows: Row[] = [repoHeader(local), item('local-wt', local), folderWorkspaceRow('ssh-1')] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + 'folder-workspace:folder-1' + ]) + }) + + it('carries the SSH connection status through to the host header row', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'ssh:ssh-1', + kind: 'ssh', + label: 'Builder', + detail: 'SSH', + health: 'error', + connectionStatus: 'auth-failed' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'ssh:ssh-1') + ).toMatchObject({ + health: 'error', + connectionStatus: 'auth-failed', + collapsed: false + }) + }) + + it('uses the focused runtime as the owner for non-SSH repos', () => { + const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } + const project = repo('runtime-project') + const rows = [ + repoHeader(localOwned), + item('local-wt', localOwned), + repoHeader(project), + item('runtime-wt', project) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'available' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'runtime:env-1' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'runtime:env-1') + ).toMatchObject({ + key: 'host:runtime:env-1', + label: 'env-1' + }) + }) + + it('passes host kind and blocked compatibility through to the header row', () => { + const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } + const project = repo('runtime-project') + const rows = [ + repoHeader(localOwned), + item('local-wt', localOwned), + repoHeader(project), + item('runtime-wt', project) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'blocked', + compatibility: { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: 5, + serverProtocolVersion: 1, + requiredServerProtocolVersion: 4 + } + } + ], + workspaceHostScope: 'all', + defaultHostId: 'runtime:env-1' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'runtime:env-1') + ).toMatchObject({ + kind: 'runtime', + health: 'blocked', + compatibility: { kind: 'blocked', reason: 'server-too-old' } + }) + }) + + it('suppresses host headers when only one host has visible workspaces', () => { + const local = repo('local') + const rows = [repoHeader(local), item('local-wt', local)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'disconnected' }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'env-1', + detail: 'Orca server', + health: 'available' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned).toEqual(rows) + }) + + it('counts a collapsed repo group via its header count instead of zero', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + // The ssh repo group is collapsed: its header is present, items are not. + const collapsedSshHeader = { ...repoHeader(ssh), count: 9 } + const rows = [repoHeader(local), item('local-wt', local), collapsedSshHeader] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'ssh:ssh-1') + ).toMatchObject({ count: 9 }) + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local') + ).toMatchObject({ count: 1 }) + }) + + it('keeps a collapsed host header but hides its rows', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + collapsedHostKeys: new Set(['host:ssh:ssh-1']) + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', collapsed: false }, + { hostId: 'ssh:ssh-1', collapsed: true, count: 1 } + ]) + }) + + it('can temporarily collapse every host without mutating persisted collapse keys', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + collapsedHostKeys: new Set(), + forceCollapseHosts: true + }) + + expect(sectioned.map(rowKey)).toEqual(['host:local', 'host:ssh:ssh-1']) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', collapsed: true, count: 1 }, + { hostId: 'ssh:ssh-1', collapsed: true, count: 1 } + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts new file mode 100644 index 00000000000..fecd7ea5d86 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -0,0 +1,224 @@ +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + type ExecutionHostId, + type ExecutionHostKind, + type ExecutionHostScope +} from '../../../../shared/execution-host' +import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry' +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' +import type { Row } from './worktree-list-groups' + +export type HostHeaderRow = { + type: 'host-header' + key: string + hostId: ExecutionHostId + kind: ExecutionHostKind + label: string + detail: string + health: ExecutionHostHealth + // Why: blocked-host guidance in the header menu needs the verdict reason so + // it can deep-link an "Update server/client required" row per skew direction. + compatibility?: RuntimeCompatVerdict + connectionStatus?: SshConnectionStatus + collapsed: boolean + count: number +} + +export type HostSectionRow = Row | HostHeaderRow + +export type HostSectionOption = { + id: ExecutionHostId + kind: ExecutionHostKind + label: string + detail: string + health: ExecutionHostHealth + compatibility?: RuntimeCompatVerdict + connectionStatus?: SshConnectionStatus +} + +function getRepoHostId( + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined, + defaultHostId: ExecutionHostId +): ExecutionHostId { + // Why: explicit executionHostId must win over the focused/default host, or + // runtime-owned repos group under whichever host happens to be focused. + if (repo?.connectionId || repo?.executionHostId) { + return getRepoExecutionHostId(repo) + } + return defaultHostId +} + +function getSshHostId(connectionId: string): ExecutionHostId { + return `ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId +} + +function getFolderWorkspaceHostId( + folderWorkspace: Pick<FolderWorkspace, 'connectionId'>, + projectGroup: Pick<ProjectGroup, 'connectionId'>, + defaultHostId: ExecutionHostId +): ExecutionHostId { + const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId + return connectionId ? getSshHostId(connectionId) : defaultHostId +} + +function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { + switch (row.type) { + case 'item': + return getRepoHostId(row.repo, defaultHostId) + case 'pending-creation': + case 'imported-worktrees-card': + return getRepoHostId(row.repo, defaultHostId) + case 'folder-workspace': + return getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId) + case 'header': + return row.repo ? getRepoHostId(row.repo, defaultHostId) : null + } +} + +function getFallbackHost(hostId: ExecutionHostId): HostSectionOption { + const isLocal = hostId === LOCAL_EXECUTION_HOST_ID + return { + id: hostId, + kind: isLocal ? 'local' : hostId.startsWith('ssh:') ? 'ssh' : 'runtime', + label: isLocal ? 'Local Mac' : hostId, + detail: isLocal ? 'This computer' : 'Host', + health: isLocal ? 'local' : 'available' + } +} + +function countWorktreeRows(rows: readonly Row[]): number { + // Why: a collapsed repo group contributes a header row but no item rows; + // fall back to the header's own count so the host badge doesn't read 0 + // while a visibly populated project sits right under it. + let count = 0 + let pendingHeaderCount: number | null = null + let pendingHeaderHadItems = false + const flushHeader = (): void => { + if (pendingHeaderCount !== null && !pendingHeaderHadItems) { + count += pendingHeaderCount + } + pendingHeaderCount = null + pendingHeaderHadItems = false + } + for (const row of rows) { + if (row.type === 'header') { + flushHeader() + pendingHeaderCount = row.count + continue + } + if (row.type === 'item') { + count += 1 + pendingHeaderHadItems = pendingHeaderCount !== null + } + } + flushHeader() + return count +} + +export function addHostSectionRows(args: { + rows: readonly Row[] + hostOptions: readonly HostSectionOption[] + workspaceHostScope: ExecutionHostScope + visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + defaultHostId: ExecutionHostId + // Why: host sections reuse the sidebar's persisted collapsed-group keys + // (`host:<hostId>`) so collapse state survives restarts like other groups. + collapsedHostKeys?: ReadonlySet<string> + forceCollapseHosts?: boolean + // Why: in the default Projects view, project is the user's primary object + // and host is context inside it. Explicit host filters still keep host + // headers as an operational/troubleshooting view. + preferProjectGrouping?: boolean +}): HostSectionRow[] { + const visibleHostIds = + args.visibleWorkspaceHostIds ?? + (args.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [args.workspaceHostScope]) + if ( + args.preferProjectGrouping && + args.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE && + !args.visibleWorkspaceHostIds + ) { + return [...args.rows] + } + if ((visibleHostIds && visibleHostIds.length <= 1) || args.hostOptions.length <= 1) { + return [...args.rows] + } + + const hostOptionsById = new Map(args.hostOptions.map((host) => [host.id, host])) + const rowsByHostId = new Map<ExecutionHostId, Row[]>() + const globalRows: Row[] = [] + let pendingRows: Row[] = [] + + for (const row of args.rows) { + const rowHostId = getRowHostId(row, args.defaultHostId) + if (rowHostId) { + const hostRows = rowsByHostId.get(rowHostId) ?? [] + if (pendingRows.length > 0) { + hostRows.push(...pendingRows) + pendingRows = [] + } + hostRows.push(row) + rowsByHostId.set(rowHostId, hostRows) + continue + } + // Why: status/"All" headers describe the rows that follow. Buffer them + // until the next host-owned row so host remains above the existing grouping. + pendingRows.push(row) + } + + if (pendingRows.length > 0) { + globalRows.push(...pendingRows) + } + + const hostOrder: ExecutionHostId[] = [] + for (const host of args.hostOptions) { + if (rowsByHostId.has(host.id)) { + hostOrder.push(host.id) + } + } + for (const hostId of rowsByHostId.keys()) { + if (!hostOptionsById.has(hostId)) { + hostOrder.push(hostId) + } + } + + // Why: a lone host section is pure noise — the grouping only earns its keep + // when there are at least two host sections to tell apart. Registered-but- + // empty hosts stay visible in the scope picker, not as headers. + if (rowsByHostId.size <= 1) { + return [...args.rows] + } + + const result: HostSectionRow[] = [...globalRows] + for (const hostId of hostOrder) { + const hostRows = rowsByHostId.get(hostId) + if (!hostRows || hostRows.length === 0) { + continue + } + const host = hostOptionsById.get(hostId) ?? getFallbackHost(hostId) + const collapsed = + args.forceCollapseHosts || (args.collapsedHostKeys?.has(`host:${host.id}`) ?? false) + result.push({ + type: 'host-header', + key: `host:${host.id}`, + hostId: host.id, + kind: host.kind, + label: host.label, + detail: host.detail, + health: host.health, + compatibility: host.compatibility, + connectionStatus: host.connectionStatus, + collapsed, + count: countWorktreeRows(hostRows) + }) + if (!collapsed) { + result.push(...hostRows) + } + } + + return result +} 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/open-setup-script-settings.ts b/src/renderer/src/components/sidebar/open-setup-script-settings.ts new file mode 100644 index 00000000000..e9918b502dc --- /dev/null +++ b/src/renderer/src/components/sidebar/open-setup-script-settings.ts @@ -0,0 +1,19 @@ +import { getRepositoryLocalCommandsSectionId } from '@/components/settings/repository-settings-targets' + +export function openSetupScriptSettings(input: { + repoId: string + setSettingsSearchQuery: (query: string) => void + openSettingsTarget: (target: { pane: 'repo'; repoId: string; sectionId: string }) => void + openSettingsPage: () => void +}): void { + const { openSettingsPage, openSettingsTarget, repoId, setSettingsSearchQuery } = input + // Why: imported setup commands are local repo settings; a stale Settings + // search should not hide the exact editor this action opens. + setSettingsSearchQuery('') + openSettingsTarget({ + pane: 'repo', + repoId, + sectionId: getRepositoryLocalCommandsSectionId(repoId) + }) + openSettingsPage() +} 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/setup-script-prompt-exposure-telemetry.ts b/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts new file mode 100644 index 00000000000..1f62941d592 --- /dev/null +++ b/src/renderer/src/components/sidebar/setup-script-prompt-exposure-telemetry.ts @@ -0,0 +1,39 @@ +import { track } from '@/lib/telemetry' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' +import { buildSetupScriptPromptTelemetry } from '../../../../shared/setup-script-telemetry' + +export function trackSetupScriptPromptExposure(input: { + repoId: string + promptState: SetupScriptPromptInspection | null + trackedPromptKeys: Set<string> +}): void { + const { promptState, repoId, trackedPromptKeys } = input + if ( + promptState?.repoId !== repoId || + promptState.status !== 'ok' || + promptState.hasEffectiveSetup + ) { + return + } + + const telemetry = buildSetupScriptPromptTelemetry({ + candidate: promptState.candidate, + hasSharedHooks: promptState.hasSharedHooks + }) + // Why: React may re-render the sidebar often; this event should represent + // a distinct prompt exposure for this repo/source, not render churn. + const promptKey = [ + repoId, + telemetry.mode, + telemetry.provider ?? 'none', + telemetry.file_count_bucket, + telemetry.unsupported_field_count_bucket, + String(telemetry.has_shared_hooks) + ].join(':') + if (trackedPromptKeys.has(promptKey)) { + return + } + + trackedPromptKeys.add(promptKey) + track('setup_script_prompt_shown', telemetry) +} diff --git a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts new file mode 100644 index 00000000000..4d7f67e068e --- /dev/null +++ b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts @@ -0,0 +1,61 @@ +import { useMemo } from 'react' +import { getProjectHostSetupForRepo } from '../../../../shared/project-host-setup-projection' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' + +export type SetupScriptPromptState = SetupScriptPromptInspection + +export type LastVisibleSetupScriptPrompt = { + state: SetupScriptPromptState + projectId: string | null +} + +export function getRepoProjectId( + repoId: string, + repos: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[], + setupByRepoId: Map<string, { projectId: string }> +): string | null { + const setup = setupByRepoId.get(repoId) + if (setup) { + return setup.projectId + } + const repo = repos.find((candidate) => candidate.id === repoId) + return repo ? getProjectHostSetupForRepo(projectHostSetups, repo).projectId : null +} + +export function getRenderedSetupScriptPromptState(input: { + promptState: SetupScriptPromptState | null + activeRepoId: string + activeProjectId: string | null + lastVisiblePrompt: LastVisibleSetupScriptPrompt | null +}): SetupScriptPromptState | null { + const { activeProjectId, activeRepoId, lastVisiblePrompt, promptState } = input + if (promptState?.repoId === activeRepoId) { + return promptState + } + return !promptState && lastVisiblePrompt?.projectId === activeProjectId + ? lastVisiblePrompt.state + : null +} + +export function useSetupScriptPromptProjectContext( + activeRepo: Repo | null, + repos: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[] +): { + activeProjectId: string | null + setupByRepoId: Map<string, { projectId: string }> +} { + const setupByRepoId = useMemo( + () => new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), + [projectHostSetups] + ) + const activeProjectId = useMemo(() => { + if (!activeRepo) { + return null + } + return getRepoProjectId(activeRepo.id, repos, projectHostSetups, setupByRepoId) + }, [activeRepo, projectHostSetups, repos, setupByRepoId]) + return { activeProjectId, setupByRepoId } +} diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts new file mode 100644 index 00000000000..46b9671984e --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + buildSidebarHostOptions, + buildSidebarHostScopeOptions, + getSidebarHostVisibilityLabel, + getSidebarHostHealthLabel, + shouldShowHostScopeControls +} from './sidebar-host-options' + +describe('sidebar host options', () => { + it('hides host controls for local-only workspaces', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: null }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts).toEqual([ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + } + ]) + expect(shouldShowHostScopeControls(hosts)).toBe(false) + }) + + it('includes SSH hosts from labels and repos', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-from-repo' }], + sshTargetLabels: new Map([['ssh-saved', 'Saved SSH']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts.map((host) => host.id)).toEqual(['local', 'ssh:ssh-saved', 'ssh:ssh-from-repo']) + expect(hosts.map((host) => host.health)).toEqual(['local', 'disconnected', 'disconnected']) + expect(hosts.find((host) => host.id === 'ssh:ssh-saved')?.presence).toBe('configured') + expect(hosts.find((host) => host.id === 'ssh:ssh-from-repo')?.presence).toBe('project') + expect(shouldShowHostScopeControls(hosts)).toBe(true) + }) + + it('includes SSH health in options', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts.find((host) => host.id === 'ssh:ssh-1')).toMatchObject({ + label: 'Builder', + health: 'available' + }) + }) + + it('includes the focused runtime compatibility host', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'runtime-1' } + }) + + expect(hosts.map((host) => host.id)).toEqual(['local', 'runtime:runtime-1']) + expect(hosts.find((host) => host.id === 'runtime:runtime-1')).toMatchObject({ + detail: 'Orca server', + health: 'available' + }) + }) + + it('uses saved runtime environment names for runtime host labels', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: '03ef704c-b180-4b10-998d-e28fbd5de9a3' }, + runtimeEnvironments: [ + { + id: '03ef704c-b180-4b10-998d-e28fbd5de9a3', + name: 'dev box' + } + ] + }) + + expect(hosts.find((host) => host.id.startsWith('runtime:'))).toMatchObject({ + label: 'dev box', + detail: 'Orca server' + }) + }) + + it('marks a runtime host blocked when its live status fails compat', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + // Why: protocol 0 is below the minimum compatible server version, so the + // registry must surface a 'server-too-old' blocked verdict + health when + // the live status map is passed. + runtimeStatusByEnvironmentId: new Map([ + [ + 'runtime-1', + { + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 0, + minCompatibleRuntimeClientVersion: 0 + } + } + ] + ]) + }) + + const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1') + expect(runtimeHost?.health).toBe('blocked') + expect(runtimeHost?.compatibility).toMatchObject({ + kind: 'blocked', + reason: 'server-too-old' + }) + }) + + it('leaves a runtime host available when its live status is compatible', () => { + const hosts = buildSidebarHostOptions({ + repos: [], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + runtimeStatusByEnvironmentId: new Map([ + [ + 'runtime-1', + { + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3 + } + } + ] + ]) + }) + + const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1') + expect(runtimeHost?.health).toBe('available') + expect(runtimeHost?.compatibility?.kind).toBe('ok') + }) + + it('builds all-host plus focused-host scope options', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(buildSidebarHostScopeOptions(hosts)).toMatchObject([ + { id: 'all', label: 'All hosts', detail: 'Local Mac, Builder', health: 'mixed' }, + { id: 'local', label: 'Local Mac', health: 'local' }, + { id: 'ssh:ssh-1', label: 'Builder', health: 'disconnected' } + ]) + }) + + it('labels visible host selections for the workspace options menu', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(getSidebarHostVisibilityLabel(null, hosts)).toBe('All hosts') + expect(getSidebarHostVisibilityLabel(['ssh:ssh-1'], hosts)).toBe('Builder') + expect(getSidebarHostVisibilityLabel(['local', 'ssh:ssh-1'], hosts)).toBe('All hosts') + }) + + it('carries host kind so the header menu can pick lifecycle actions', () => { + const hosts = buildSidebarHostOptions({ + repos: [{ connectionId: 'ssh-1' }], + sshTargetLabels: new Map([['ssh-1', 'Builder']]), + settings: { activeRuntimeEnvironmentId: 'runtime-1' } + }) + + expect(hosts.find((host) => host.id === 'local')?.kind).toBe('local') + expect(hosts.find((host) => host.id === 'ssh:ssh-1')?.kind).toBe('ssh') + expect(hosts.find((host) => host.id === 'runtime:runtime-1')?.kind).toBe('runtime') + }) + + it('labels host health for compact sidebar UI', () => { + expect(getSidebarHostHealthLabel('available')).toBe('Connected') + expect(getSidebarHostHealthLabel('connecting')).toBe('Connecting') + expect(getSidebarHostHealthLabel('blocked')).toBe('Update needed') + expect(getSidebarHostHealthLabel('error')).toBe('Needs attention') + }) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts new file mode 100644 index 00000000000..248bd98000d --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -0,0 +1,162 @@ +import type { GlobalSettings, Repo, WorkspaceHostScope } from '../../../../shared/types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { + buildExecutionHostRegistry, + type ExecutionHostHealth +} from '../../../../shared/execution-host-registry' +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' +import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { translate } from '@/i18n/i18n' + +export type SidebarHostOption = { + id: ExecutionHostId + label: string + detail: string + kind: 'local' | 'ssh' | 'runtime' + health: ExecutionHostHealth + presence: 'local' | 'configured' | 'project' | 'active' + // Why: surfaced to the sidebar host-header menu so it can warn on version skew. + compatibility?: RuntimeCompatVerdict + // Why: lets host headers spell out auth-needed SSH states, not just an icon. + connectionStatus?: SshConnectionStatus +} + +export type SidebarHostScopeOption = { + id: WorkspaceHostScope + label: string + detail: string + health: ExecutionHostHealth | 'mixed' +} + +export function buildSidebarHostOptions(args: { + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] + sshTargetLabels: ReadonlyMap<string, string> + sshConnectionStates?: ReadonlyMap<string, SshConnectionState> + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined + // Why: live per-environment runtime status lets the registry surface compat + // verdicts and blocked health in the sidebar without re-probing servers. + runtimeStatusByEnvironmentId?: ReadonlyMap< + string, + { status?: RuntimeStatus | null; appVersion?: string | null } + > + runtimeEnvironments?: readonly Pick<PublicKnownRuntimeEnvironment, 'id' | 'name'>[] + // Why: per-host display-label overrides rename hosts everywhere the sidebar + // options feed (host headers, scope picker, focus menu). + hostLabelOverrides?: ReadonlyMap<ExecutionHostId, string> +}): SidebarHostOption[] { + const configuredSshTargetIds = new Set(args.sshTargetLabels.keys()) + const projectSshTargetIds = new Set<string>() + for (const repo of args.repos) { + if (repo.connectionId?.trim()) { + projectSshTargetIds.add(repo.connectionId.trim()) + } + if (repo.executionHostId?.startsWith('ssh:')) { + projectSshTargetIds.add(decodeURIComponent(repo.executionHostId.slice('ssh:'.length))) + } + } + const activeRuntimeHostId = args.settings?.activeRuntimeEnvironmentId?.trim() + ? (`runtime:${encodeURIComponent(args.settings.activeRuntimeEnvironmentId.trim())}` as const) + : null + return buildExecutionHostRegistry({ + repos: args.repos, + settings: args.settings, + sshTargetLabels: args.sshTargetLabels, + sshConnectionStates: args.sshConnectionStates, + runtimeEnvironments: args.runtimeEnvironments, + runtimeStatusByEnvironmentId: args.runtimeStatusByEnvironmentId, + hostLabelOverrides: args.hostLabelOverrides + }).map((host) => { + if (host.kind === 'local') { + return { ...host, presence: 'local' } + } + if (host.kind === 'ssh') { + const targetId = decodeURIComponent(host.id.slice('ssh:'.length)) + // Why: configured hosts explain why a disconnected target remains + // visible; project-only hosts remain because workspaces still point at it. + return { + ...host, + presence: configuredSshTargetIds.has(targetId) + ? 'configured' + : projectSshTargetIds.has(targetId) + ? 'project' + : 'active' + } + } + return { + ...host, + presence: host.id === activeRuntimeHostId ? 'active' : 'project' + } + }) +} + +export function shouldShowHostScopeControls(hosts: readonly SidebarHostOption[]): boolean { + return hosts.some((host) => host.id !== LOCAL_EXECUTION_HOST_ID) +} + +export function buildSidebarHostScopeOptions( + hosts: readonly SidebarHostOption[] +): SidebarHostScopeOption[] { + return [ + { + id: ALL_EXECUTION_HOSTS_SCOPE, + label: translate('auto.components.sidebar.sidebarHostOptions.3e102f111c', 'All hosts'), + detail: hosts.map((host) => host.label).join(', '), + health: 'mixed' + }, + ...hosts.map((host) => ({ + id: host.id, + label: host.label, + detail: host.detail, + health: host.health + })) + ] +} + +export function getSidebarHostScopeLabel( + scope: WorkspaceHostScope, + options: readonly SidebarHostScopeOption[] +): string { + return options.find((option) => option.id === scope)?.label ?? 'All hosts' +} + +export function getSidebarHostVisibilityLabel( + visibleHostIds: readonly ExecutionHostId[] | null | undefined, + hosts: readonly SidebarHostOption[] +): string { + if (!visibleHostIds || visibleHostIds.length === hosts.length) { + return translate('auto.components.sidebar.sidebarHostOptions.3e102f111c', 'All hosts') + } + if (visibleHostIds.length === 1) { + return hosts.find((host) => host.id === visibleHostIds[0])?.label ?? 'Hosts' + } + return translate( + 'auto.components.sidebar.sidebarHostOptions.visibleHostsCount', + '{{value0}} hosts', + { value0: visibleHostIds.length } + ) +} + +export function getSidebarHostHealthLabel(health: SidebarHostScopeOption['health']): string { + switch (health) { + case 'local': + return 'Local' + case 'available': + return 'Connected' + case 'connecting': + return 'Connecting' + case 'blocked': + return 'Update needed' + case 'disconnected': + return 'Disconnected' + case 'error': + return 'Needs attention' + case 'mixed': + return 'Mixed' + } +} 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/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts new file mode 100644 index 00000000000..69ddc54ecd8 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -0,0 +1,194 @@ +import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export const GROUP_BY_OPTIONS = [ + { + id: 'none', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda', 'None') + } + }, + { + id: 'workspace-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775', 'Status') + } + }, + { + id: 'pr-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31', 'PR') + } + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + } + } +] as const + +export const CARD_LAYOUT_OPTIONS = [ + { + id: 'detailed', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b', 'Detailed') + } + }, + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + } +] as const + +export const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ + { + id: 'issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8', + 'GitHub ticket' + ) + } + }, + { + id: 'linear-issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e', + 'Linear issue' + ) + } + }, + { + id: 'pr', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321', + 'PR/MR link' + ) + } + }, + { + id: 'comment', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c', 'Notes') + } + }, + { + id: 'ports', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0', 'Ports') + } + }, + { + id: 'inline-agents', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8', + 'Agent activity' + ) + } + } +] + +export const AGENT_ACTIVITY_DISPLAY_OPTIONS: { + id: AgentActivityDisplayMode + label: string +}[] = [ + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + }, + { + id: 'full', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366', + 'Full list' + ) + } + } +] + +export const SORT_OPTIONS = [ + { + id: 'name', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd', 'Name') + }, + description: null + }, + { + id: 'smart', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4', + 'Agent Activity' + ) + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee', + 'Agents that need attention, then most recent activity.' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + description: null + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + }, + description: null + }, + { + id: 'manual', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485', + 'Drag workspaces to arrange them within each group.' + ) + } + } +] as const + +export const PROJECT_ORDER_OPTIONS = [ + { + id: 'manual', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b', + 'Drag projects to arrange them' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505', + 'Most recent workspace activity' + ) + } + } +] as const diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts new file mode 100644 index 00000000000..effec4b5192 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react' + +export function useAddRepoHostChangeReset({ + isOpen, + selectedHostId, + onResetClosed, + onResetHostScopedState +}: { + isOpen: boolean + selectedHostId: string + onResetClosed: () => void + onResetHostScopedState: () => void +}) { + const previousSelectedHostIdRef = useRef(selectedHostId) + + useEffect(() => { + if (!isOpen) { + previousSelectedHostIdRef.current = selectedHostId + onResetClosed() + } + }, [isOpen, onResetClosed, selectedHostId]) + + useEffect(() => { + if (!isOpen || previousSelectedHostIdRef.current === selectedHostId) { + return + } + // Why: Add Project form fields are host-path scoped, so switching hosts must + // clear typed paths and pending defaults before they can be submitted. + previousSelectedHostIdRef.current = selectedHostId + onResetHostScopedState() + }, [isOpen, onResetHostScopedState, selectedHostId]) +} diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts new file mode 100644 index 00000000000..7bb90f4155a --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import type { SidebarHostOption } from './sidebar-host-options' + +const mocks = vi.hoisted(() => ({ + stateValues: [] as unknown[], + stateSetters: [] as ReturnType<typeof vi.fn>[], + stateIndex: 0, + refValues: [] as unknown[], + refIndex: 0, + hostOptions: [] as SidebarHostOption[], + storeState: { + settings: { activeRuntimeEnvironmentId: null as string | null }, + switchRuntimeEnvironment: vi.fn() + } +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal<typeof ReactModule>() + return { + ...actual, + useCallback: <T extends (...args: never[]) => unknown>(fn: T) => fn, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useRef: <T>(value: T) => { + const index = mocks.refIndex++ + return { + current: index in mocks.refValues ? (mocks.refValues[index] as T) : value + } + }, + useState: <T>(initial: T | (() => T)) => { + const index = mocks.stateIndex++ + const value = + index in mocks.stateValues + ? mocks.stateValues[index] + : typeof initial === 'function' + ? (initial as () => T)() + : initial + const setter = vi.fn() + mocks.stateSetters[index] = setter + return [value as T, setter] + } + } +}) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState) +})) + +vi.mock('./use-sidebar-host-scope-options', () => ({ + useSidebarHostScopeOptions: () => ({ hostOptions: mocks.hostOptions }) +})) + +describe('useAddRepoHostSelection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.stateIndex = 0 + mocks.stateSetters = [] + mocks.refIndex = 0 + mocks.refValues = [] + mocks.hostOptions = [ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + }, + { + id: 'ssh:ssh-1', + label: 'Builder', + detail: 'SSH', + kind: 'ssh', + health: 'available', + presence: 'configured' + }, + { + id: 'runtime:env-1', + label: 'Server', + detail: 'Runtime', + kind: 'runtime', + health: 'available', + presence: 'active' + } + ] + mocks.storeState.settings = { activeRuntimeEnvironmentId: null } + mocks.storeState.switchRuntimeEnvironment.mockResolvedValue(true) + }) + + it('exposes the selected SSH target id', async () => { + mocks.stateValues = ['ssh:ssh-1', false] + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) + + expect(result.selectedHostId).toBe('ssh:ssh-1') + expect(result.selectedParsedHost).toMatchObject({ kind: 'ssh', targetId: 'ssh-1' }) + expect(result.selectedSshTargetId).toBe('ssh-1') + }) + + it('switches runtime before selecting a runtime host', async () => { + mocks.stateValues = ['local', false] + const setStep = vi.fn() + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep }) + await result.handleSelectAddProjectHost('runtime:env-1') + + expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith('env-1') + expect(mocks.stateSetters[0]).toHaveBeenCalledWith('runtime:env-1') + expect(setStep).toHaveBeenCalledWith('add') + }) + + it('clears the active runtime before selecting a local or SSH host', async () => { + mocks.stateValues = ['runtime:env-1', false] + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + const setStep = vi.fn() + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep }) + await result.handleSelectAddProjectHost('ssh:ssh-1') + + expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith(null) + expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1') + expect(setStep).toHaveBeenCalledWith('add') + }) + + it('falls back from a disconnected selected SSH host to Local Mac', async () => { + mocks.stateValues = ['ssh:ssh-1', false] + mocks.hostOptions[1] = { + ...mocks.hostOptions[1], + health: 'disconnected' + } + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) + + expect(result.selectedHostId).toBe('local') + expect(result.selectedSshTargetId).toBeNull() + }) + + it('does not select a disconnected SSH host', async () => { + mocks.stateValues = ['local', false] + mocks.hostOptions[1] = { + ...mocks.hostOptions[1], + health: 'disconnected' + } + const setStep = vi.fn() + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + const result = useAddRepoHostSelection({ isOpen: true, setStep }) + await result.handleSelectAddProjectHost('ssh:ssh-1') + + expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalled() + expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1') + expect(setStep).not.toHaveBeenCalled() + }) + + it('does not auto-select the active runtime host while it is unavailable', async () => { + mocks.stateValues = ['local', false] + mocks.hostOptions[2] = { + ...mocks.hostOptions[2], + health: 'blocked' + } + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection') + + useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }) + + expect(mocks.stateSetters[0]).toHaveBeenCalledWith('local') + }) +}) diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts new file mode 100644 index 00000000000..56e174f97f8 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { AddRepoDialogStep } from './add-repo-dialog-types' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' +import { canSelectAddRepoHost } from './add-repo-host-availability' + +export function useAddRepoHostSelection({ + isOpen, + setStep +}: { + isOpen: boolean + setStep: (step: AddRepoDialogStep) => void +}): { + hostOptions: ReturnType<typeof useSidebarHostScopeOptions>['hostOptions'] + selectedHostId: ExecutionHostId + selectedParsedHost: ReturnType<typeof parseExecutionHostId> + selectedSshTargetId: string | null + hostSelectorOpen: boolean + setHostSelectorOpen: (open: boolean) => void + handleSelectAddProjectHost: (hostId: ExecutionHostId) => Promise<void> +} { + const settings = useAppStore((s) => s.settings) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) + const { hostOptions } = useSidebarHostScopeOptions() + const [selectedAddProjectHostId, setSelectedAddProjectHostId] = + useState<ExecutionHostId>(LOCAL_EXECUTION_HOST_ID) + const [hostSelectorOpen, setHostSelectorOpen] = useState(false) + const previousOpenRef = useRef(false) + + const selectedHost = + hostOptions.find( + (host) => host.id === selectedAddProjectHostId && canSelectAddRepoHost(host) + ) ?? + hostOptions.find((host) => host.id === LOCAL_EXECUTION_HOST_ID && canSelectAddRepoHost(host)) ?? + hostOptions.find((host) => canSelectAddRepoHost(host)) ?? + hostOptions[0] + const selectedHostId = selectedHost?.id ?? LOCAL_EXECUTION_HOST_ID + const selectedParsedHost = parseExecutionHostId(selectedHostId) + const selectedSshTargetId = + selectedParsedHost?.kind === 'ssh' ? selectedParsedHost.targetId : null + + useEffect(() => { + if (isOpen && !previousOpenRef.current) { + const focusedHostId = getSettingsFocusedExecutionHostId(settings) + const nextHostId = hostOptions.some( + (host) => host.id === focusedHostId && canSelectAddRepoHost(host) + ) + ? focusedHostId + : LOCAL_EXECUTION_HOST_ID + setSelectedAddProjectHostId(nextHostId) + } + if (!isOpen) { + setHostSelectorOpen(false) + } + previousOpenRef.current = isOpen + }, [hostOptions, isOpen, settings]) + + const handleSelectAddProjectHost = useCallback( + async (hostId: ExecutionHostId): Promise<void> => { + const host = hostOptions.find((candidate) => candidate.id === hostId) + if (!host || !canSelectAddRepoHost(host)) { + return + } + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + const switched = await switchRuntimeEnvironment(parsed.environmentId) + if (!switched) { + return + } + } else if (settings?.activeRuntimeEnvironmentId?.trim()) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + return + } + } + setSelectedAddProjectHostId(hostId) + setStep('add') + }, + [hostOptions, settings?.activeRuntimeEnvironmentId, setStep, switchRuntimeEnvironment] + ) + + return { + hostOptions, + selectedHostId, + selectedParsedHost, + selectedSshTargetId, + hostSelectorOpen, + setHostSelectorOpen, + handleSelectAddProjectHost + } +} diff --git a/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts b/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts new file mode 100644 index 00000000000..1f451b0a115 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react' +import { track } from '@/lib/telemetry' +import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry' +import type { NestedRepoScanResult } from '../../../../shared/types' + +export function useAddRepoRemoteNestedScan({ + setActiveNestedScanId, + showNestedRepoReview +}: { + setActiveNestedScanId: (scanId: string | null) => void + showNestedRepoReview: (options: { + scan: NestedRepoScanResult + selectedPath: string + connectionId: string + attemptId: string + runtimeKind: 'ssh' + inProgress: boolean + scanId: string | null + }) => void +}) { + const showRemoteNestedRepoReview = useCallback( + ( + scan: NestedRepoScanResult, + selectedPath: string, + connectionId: string, + attemptId: string, + inProgress: boolean, + scanId: string | null + ) => { + setActiveNestedScanId(inProgress ? scanId : null) + showNestedRepoReview({ + scan, + selectedPath, + connectionId, + attemptId, + runtimeKind: 'ssh', + inProgress, + scanId + }) + }, + [setActiveNestedScanId, showNestedRepoReview] + ) + + const trackRemoteNestedScanResult = useCallback( + (scan: NestedRepoScanResult | null, attemptId: string) => { + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'ssh', + scan + }) + ) + }, + [] + ) + + return { showRemoteNestedRepoReview, trackRemoteNestedScanResult } +} diff --git a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts new file mode 100644 index 00000000000..a657bcce397 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts @@ -0,0 +1,55 @@ +import { useCallback, useRef } from 'react' +import { useAppStore } from '@/store' +import { track } from '@/lib/telemetry' +import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' +import { + buildAddRepoExistingWorkspacesTelemetry, + shouldTrackAddRepoExistingWorkspacesDetected +} from './add-repo-existing-workspaces-telemetry' +import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout' + +type CompleteGitRepoAddOptions = { + closeModal: () => void + setHideDefaultBranchWorkspace: (hide: boolean) => void +} + +export function useCompleteGitRepoAdd({ + closeModal, + setHideDefaultBranchWorkspace +}: CompleteGitRepoAddOptions): ( + repoId: string, + source: AddRepoExistingWorkspaceSource +) => Promise<void> { + const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set()) + + return useCallback( + async (repoId: string, source: AddRepoExistingWorkspaceSource): Promise<void> => { + const worktrees = useAppStore.getState().worktreesByRepo[repoId] ?? [] + const sortedWorktrees = [...worktrees].sort((a, b) => { + if (a.lastActivityAt !== b.lastActivityAt) { + return b.lastActivityAt - a.lastActivityAt + } + return a.displayName.localeCompare(b.displayName) + }) + const existingWorkspaceTelemetry = buildAddRepoExistingWorkspacesTelemetry( + source, + sortedWorktrees + ) + if ( + existingWorkspaceTelemetry && + shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) && + !detectedTelemetryTrackedRef.current.has(repoId) + ) { + detectedTelemetryTrackedRef.current.add(repoId) + track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry) + } + await finishProjectAddWithDefaultCheckout({ + repoId, + source, + closeModal, + setHideDefaultBranchWorkspace + }) + }, + [closeModal, setHideDefaultBranchWorkspace] + ) +} diff --git a/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts new file mode 100644 index 00000000000..039289c73bb --- /dev/null +++ b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { + buildSidebarHostOptions, + buildSidebarHostScopeOptions, + type SidebarHostOption, + type SidebarHostScopeOption +} from './sidebar-host-options' + +/** Shared host-scope derivation for the sidebar scope strip and the workspace + * options menu so both surfaces consume the same live runtime status without + * duplicating store wiring. */ +export function useSidebarHostScopeOptions(): { + hostOptions: SidebarHostOption[] + hostScopeOptions: SidebarHostScopeOption[] +} { + const repos = useAppStore((s) => s.repos) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const hostScopeOptions = useMemo(() => buildSidebarHostScopeOptions(hostOptions), [hostOptions]) + + return { hostOptions, hostScopeOptions } +} diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index b1402be5ae2..5ce06c51ad2 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import type { Repo, Worktree } from '../../../../shared/types' import { computeVisibleWorktreeIds } from './visible-worktrees' +import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' type UseVisibleWorkspaceKanbanWorktreeIdsParams = { allWorktrees: readonly Worktree[] @@ -15,6 +16,9 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const settings = useAppStore((s) => s.settings) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const tabsByWorktree = useAppStore((s) => (!showSleepingWorkspaces ? s.tabsByWorktree : null)) const ptyIdsByTabId = useAppStore((s) => (!showSleepingWorkspaces ? s.ptyIdsByTabId : null)) @@ -35,6 +39,9 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ browserTabsByWorktree, hideDefaultBranchWorkspace, repoMap, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(settings), // Why: the board has no nested lineage presentation. Ancestor injection // would make filtered-out parents appear as ordinary cards. worktreeLineageById: {} @@ -45,6 +52,9 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ browserTabsByWorktree, filterRepoIds, hideDefaultBranchWorkspace, + workspaceHostScope, + visibleWorkspaceHostIds, + settings, ptyIdsByTabId, repoMap, showSleepingWorkspaces, diff --git a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts new file mode 100644 index 00000000000..92c18aca836 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { useMountedRef } from '@/hooks/useMountedRef' +import { parseExplicitGitHubIssueUrl } from './worktree-meta-updates' + +/** Resolves the "open linked issue" affordance for the worktree meta dialog: + * explicit URLs open directly, numbers resolve via the issue cache or an + * owner-routed fetch. */ +export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: string }): { + canOpenIssue: boolean + openingIssue: boolean + handleOpenIssue: () => Promise<void> + resetOpeningIssue: () => void +} { + const { worktreeId, issueInput } = args + const fetchIssue = useAppStore((s) => s.fetchIssue) + const [openingIssue, setOpeningIssue] = useState(false) + const mountedRef = useMountedRef() + + const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput]) + const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput]) + const issueInputLooksLikeUrl = useMemo( + () => /^https?:\/\//i.test(issueInput.trim()), + [issueInput] + ) + const issueRepo = useAppStore((s) => { + const worktree = Object.values(s.worktreesByRepo) + .flat() + .find((item) => item.id === worktreeId) + if (!worktree) { + return undefined + } + return s.repos.find((repo) => repo.id === worktree.repoId) + }) + const cachedIssueUrl = useAppStore((s) => { + if (!issueRepo || issueNumber === null) { + return null + } + return ( + s.issueCache[ + getIssueCacheKey( + issueRepo.path, + issueRepo.id, + issueNumber, + s.settings, + issueRepo.connectionId, + issueRepo.executionHostId + ) + ]?.data?.url ?? null + ) + }) + const canOpenIssue = issueInputLooksLikeUrl + ? Boolean(issueUrlFromInput) + : Boolean(cachedIssueUrl || (issueRepo && issueNumber)) + + const handleOpenIssue = useCallback(async () => { + if (openingIssue) { + return + } + + if (issueUrlFromInput) { + void window.api.shell.openUrl(issueUrlFromInput) + return + } + + if (issueInputLooksLikeUrl) { + return + } + + if (cachedIssueUrl) { + void window.api.shell.openUrl(cachedIssueUrl) + return + } + + if (!issueRepo || issueNumber === null) { + return + } + + setOpeningIssue(true) + try { + const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id }) + if (issue?.url) { + void window.api.shell.openUrl(issue.url) + } + } finally { + if (mountedRef.current) { + setOpeningIssue(false) + } + } + }, [ + cachedIssueUrl, + fetchIssue, + issueInputLooksLikeUrl, + issueNumber, + issueRepo, + issueUrlFromInput, + mountedRef, + openingIssue + ]) + + const resetOpeningIssue = useCallback(() => setOpeningIssue(false), []) + + return { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } +} diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts new file mode 100644 index 00000000000..3d02f5fe1a9 --- /dev/null +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import type { Repo } from '../../../../shared/types' + +const mocks = vi.hoisted(() => ({ + stateValues: [] as unknown[], + stateSetters: [] as ReturnType<typeof vi.fn>[], + stateIndex: 0, + refValues: [] as unknown[], + refIndex: 0, + storeState: { + settings: { activeRuntimeEnvironmentId: null as string | null }, + repos: [] as Repo[], + projects: [], + projectHostSetups: [] + }, + cloneRemote: vi.fn(), + cloneLocal: vi.fn(), + pickDirectory: vi.fn(), + onCloneProgress: vi.fn(() => vi.fn()), + callRuntimeRpc: vi.fn(), + fetchWorktrees: vi.fn(), + onGitRepoReady: vi.fn() +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal<typeof ReactModule>() + return { + ...actual, + useCallback: <T extends (...args: never[]) => unknown>(fn: T) => fn, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useRef: <T>(value: T) => { + const index = mocks.refIndex++ + return { + current: index in mocks.refValues ? (mocks.refValues[index] as T) : value + } + }, + useState: <T>(initial: T | (() => T)) => { + const index = mocks.stateIndex++ + const value = + index in mocks.stateValues + ? mocks.stateValues[index] + : typeof initial === 'function' + ? (initial as () => T)() + : initial + const setter = vi.fn() + mocks.stateSetters[index] = setter + return [value as T, setter] + } + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), + { + getState: () => mocks.storeState, + setState: (next: Partial<typeof mocks.storeState>) => { + Object.assign(mocks.storeState, next) + } + } + ) + return { useAppStore } +}) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: mocks.callRuntimeRpc +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn() + } +})) + +function makeRepo(overrides: Partial<Repo> = {}): Repo { + return { + id: 'repo-cloned', + path: '/srv/orca', + displayName: 'orca', + badgeColor: '#999999', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +describe('useAddRepoCloneFlow', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.stateIndex = 0 + mocks.stateSetters = [] + mocks.refIndex = 0 + mocks.refValues = [] + mocks.stateValues = ['https://github.com/stablyai/orca.git', '/srv', false, null, null] + mocks.storeState.repos = [] + mocks.storeState.projects = [] + mocks.storeState.projectHostSetups = [] + vi.stubGlobal('window', { + api: { + repos: { + cloneRemote: mocks.cloneRemote, + clone: mocks.cloneLocal, + pickDirectory: mocks.pickDirectory, + onCloneProgress: mocks.onCloneProgress + } + } + }) + }) + + it('clones through the selected SSH target', async () => { + const repo = makeRepo({ connectionId: 'ssh-1' }) + mocks.cloneRemote.mockResolvedValue(repo) + mocks.callRuntimeRpc.mockReset() + mocks.fetchWorktrees.mockResolvedValue(true) + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: null, + sshTargetId: 'ssh-1', + workspaceDir: '/local/workspace', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + await result.handleClone() + + expect(mocks.cloneRemote).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + expect(mocks.cloneLocal).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.storeState.projects).toEqual( + expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) + ) + expect(mocks.storeState.projectHostSetups).toEqual( + expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) + ) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'clone_url') + }) + + it('does not prefill SSH clone destinations from the local workspace directory', async () => { + mocks.stateValues = ['https://github.com/stablyai/orca.git', '', false, null, null] + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: null, + sshTargetId: 'ssh-1', + workspaceDir: '/private/tmp/orca-setup-e2e.hOWO1f', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + + expect(result.cloneDestination).toBe('') + expect(mocks.stateSetters[1]).not.toHaveBeenCalledWith('/private/tmp/orca-setup-e2e.hOWO1f') + }) + + 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: ${cloneError}`) + ) + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: null, + sshTargetId: 'ssh-1', + workspaceDir: '/local/workspace', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + await result.handleClone() + + expect(mocks.stateSetters[3]).toHaveBeenCalledWith(cloneError) + }) + + it('clones through the selected runtime environment', async () => { + const repo = makeRepo({ id: 'runtime-repo', executionHostId: 'runtime:env-1' }) + mocks.callRuntimeRpc.mockResolvedValue({ repo }) + mocks.fetchWorktrees.mockResolvedValue(true) + const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow') + + const result = useAddRepoCloneFlow({ + step: 'clone', + activeRuntimeEnvironmentId: 'env-1', + sshTargetId: null, + workspaceDir: '/local/workspace', + fetchWorktrees: mocks.fetchWorktrees, + onGitRepoReady: mocks.onGitRepoReady + }) + await result.handleClone() + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'repo.clone', + { + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }, + { timeoutMs: 10 * 60_000 } + ) + expect(mocks.cloneLocal).not.toHaveBeenCalled() + expect(mocks.cloneRemote).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'clone_url') + }) +}) diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index 44ff4b94b55..cd376a014fa 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -7,16 +7,20 @@ import type { Repo } from '../../../../shared/types' import { getCloneDestinationAutoFill } from './clone-defaults' import type { AddRepoDialogStep } from './add-repo-dialog-types' import { translate } from '@/i18n/i18n' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' export function useAddRepoCloneFlow({ step, activeRuntimeEnvironmentId, + sshTargetId, workspaceDir, fetchWorktrees, onGitRepoReady }: { step: AddRepoDialogStep activeRuntimeEnvironmentId: string | null | undefined + sshTargetId?: string | null workspaceDir: string | null | undefined fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise<unknown> onGitRepoReady: (repoId: string, source: AddRepoExistingWorkspaceSource) => Promise<void> @@ -40,6 +44,9 @@ export function useAddRepoCloneFlow({ const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>( null ) + const hostToken = `${activeRuntimeEnvironmentId?.trim() ?? ''}:${sshTargetId?.trim() ?? ''}` + const hostTokenRef = useRef(hostToken) + hostTokenRef.current = hostToken // Why: monotonic ID so stale clone callbacks can detect they were superseded. const cloneGenRef = useRef(0) // Why: track whether we've already auto-filled for this entry into the clone step, @@ -57,6 +64,7 @@ export function useAddRepoCloneFlow({ step, cloneDestination, activeRuntimeEnvironmentId, + sshTargetId, workspaceDir, cloneStepAutoFilled: cloneStepAutoFilledRef.current }) @@ -79,13 +87,13 @@ export function useAddRepoCloneFlow({ }, []) const handlePickDestination = useCallback(async (): Promise<void> => { - if (activeRuntimeEnvironmentId?.trim()) { + 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 @@ -96,21 +104,32 @@ export function useAddRepoCloneFlow({ setCloneDestination(dir) setCloneError(null) } - }, [activeRuntimeEnvironmentId]) + }, [activeRuntimeEnvironmentId, sshTargetId]) const handleClone = useCallback(async (): Promise<void> => { const trimmedUrl = cloneUrl.trim() if (!trimmedUrl || !cloneDestination.trim()) { return } + const requestHostToken = hostTokenRef.current const gen = ++cloneGenRef.current setIsCloning(true) setCloneError(null) setCloneProgress(null) try { - const target = getActiveRuntimeTarget(useAppStore.getState().settings) - const repo = - target.kind === 'environment' + const target = activeRuntimeEnvironmentId?.trim() + ? { kind: 'environment' as const, environmentId: activeRuntimeEnvironmentId.trim() } + : getActiveRuntimeTarget({ + ...useAppStore.getState().settings, + activeRuntimeEnvironmentId: null + }) + const repo = sshTargetId?.trim() + ? await window.api.repos.cloneRemote({ + connectionId: sshTargetId.trim(), + url: trimmedUrl, + destination: cloneDestination.trim() + }) + : target.kind === 'environment' ? ( await callRuntimeRpc<{ repo: Repo }>( target, @@ -126,42 +145,40 @@ export function useAddRepoCloneFlow({ url: trimmedUrl, destination: cloneDestination.trim() })) as Repo) - if (gen !== cloneGenRef.current) { + if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) { return } toast.success( 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 }) - if (gen !== cloneGenRef.current) { + if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) { return } await onGitRepoReady(repo.id, 'clone_url') } catch (err) { - if (gen !== cloneGenRef.current) { + if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) { return } - const message = err instanceof Error ? err.message : String(err) + const message = extractIpcErrorMessage(err, String(err)) setCloneError(message) } finally { - if (gen === cloneGenRef.current) { + if (gen === cloneGenRef.current && requestHostToken === hostTokenRef.current) { setIsCloning(false) } } - }, [cloneUrl, cloneDestination, fetchWorktrees, onGitRepoReady]) + }, [ + activeRuntimeEnvironmentId, + cloneUrl, + cloneDestination, + fetchWorktrees, + onGitRepoReady, + sshTargetId + ]) return { cloneUrl, 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 78a26a3848c..116d97ffad7 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -9,9 +9,13 @@ const mocks = vi.hoisted(() => ({ storeState: { settings: { activeRuntimeEnvironmentId: null as string | null }, repos: [] as Repo[], + projects: [], + projectHostSetups: [], worktreesByRepo: {} as Record<string, unknown[]> }, createRepo: vi.fn(), + createRemoteRepo: vi.fn(), + callRuntimeRpc: vi.fn(), fetchWorktrees: vi.fn(), onGitRepoReady: vi.fn(), activateAndRevealWorktree: vi.fn(), @@ -72,6 +76,11 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: mocks.callRuntimeRpc +})) + function makeRepo(overrides: Partial<Repo> = {}): Repo { return { id: 'repo-created', @@ -89,14 +98,19 @@ 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: { repos: { create: mocks.createRepo, + createRemote: mocks.createRemoteRepo, pickDirectory: vi.fn() } } @@ -120,6 +134,12 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { requireAuthoritative: true }) + expect(mocks.storeState.projects).toEqual( + expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) + ) + expect(mocks.storeState.projectHostSetups).toEqual( + expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) + ) expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id) }) @@ -135,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() @@ -163,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] } @@ -181,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, { @@ -191,4 +212,58 @@ describe('useCreateRepo default-checkout handoff', () => { expect(closeModal).toHaveBeenCalled() expect(mocks.onGitRepoReady).not.toHaveBeenCalled() }) + + it('creates projects through the SSH host when an SSH target is selected', async () => { + const repo = makeRepo({ connectionId: 'ssh-1', path: '/srv/created' }) + mocks.createRemoteRepo.mockResolvedValue({ repo }) + mocks.fetchWorktrees.mockResolvedValue(true) + const { useCreateRepo } = await import('./useCreateRepo') + + const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { + sshTargetId: 'ssh-1' + }) + await result.handleCreate() + + expect(mocks.createRemoteRepo).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + parentPath: '/projects', + name: 'created', + kind: 'git' + }) + expect(mocks.createRepo).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id) + }) + + it('creates projects through the selected runtime environment', async () => { + const repo = makeRepo({ executionHostId: 'runtime:env-1', path: '/srv/created' }) + mocks.callRuntimeRpc.mockResolvedValue({ repo }) + mocks.fetchWorktrees.mockResolvedValue(true) + const { useCreateRepo } = await import('./useCreateRepo') + + const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, { + hostId: 'runtime:env-1', + runtimeEnvironmentId: 'env-1' + }) + await result.handleCreate() + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'repo.create', + { + parentPath: '/projects', + name: 'created', + kind: 'git' + }, + { timeoutMs: 60_000 } + ) + expect(mocks.createRepo).not.toHaveBeenCalled() + expect(mocks.createRemoteRepo).not.toHaveBeenCalled() + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true + }) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.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/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index baec0eff6d3..c0a2bb9d924 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -7,6 +7,7 @@ import { sidebarHasActiveFilters } from './visible-worktrees' import type { Repo, TerminalTab, Worktree, WorktreeLineage } from '../../../../shared/types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { return { @@ -81,6 +82,8 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions browserTabsByWorktree: {}, hideDefaultBranchWorkspace: false, repoMap, + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, worktreeLineageById: {}, ...overrides } @@ -206,6 +209,122 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([folder.id]) }) + it('filters worktrees to a selected SSH host scope', () => { + const local = makeWorktree('local', 'repo1') + const remote = makeWorktree('remote', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'win vm' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [remote] }, + [local.id, remote.id], + visibleOptions({ + repoMap: scopedRepoMap, + workspaceHostScope: 'ssh:win%20vm' + }) + ) + + expect(result).toEqual([remote.id]) + }) + + it('filters non-SSH worktrees to the focused runtime host compatibility scope', () => { + const runtime = makeWorktree('runtime', 'repo1') + const ssh = makeWorktree('ssh', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'ssh-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [runtime], repo2: [ssh] }, + [runtime.id, ssh.id], + visibleOptions({ + repoMap: scopedRepoMap, + defaultHostId: 'runtime:env-1', + workspaceHostScope: 'runtime:env-1' + }) + ) + + expect(result).toEqual([runtime.id]) + }) + + it('filters explicit runtime-owned repos independently of the focused default host', () => { + const local = makeWorktree('local', 'repo1') + const runtime = makeWorktree('runtime', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo1', { + ...makeRepo('repo1', 'Repo 1', '#000'), + executionHostId: 'local' + }) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + executionHostId: 'runtime:env-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [runtime] }, + [local.id, runtime.id], + visibleOptions({ + repoMap: scopedRepoMap, + defaultHostId: 'runtime:env-1', + workspaceHostScope: 'local' + }) + ) + + expect(result).toEqual([local.id]) + }) + + it('keeps every host visible when workspace host scope is all', () => { + const local = makeWorktree('local', 'repo1') + const remote = makeWorktree('remote', 'repo2') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'ssh-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [remote] }, + [local.id, remote.id], + visibleOptions({ + repoMap: scopedRepoMap, + workspaceHostScope: 'all' + }) + ) + + expect(result).toEqual([local.id, remote.id]) + }) + + it('filters worktrees to a selected set of visible hosts', () => { + const local = makeWorktree('local', 'repo1') + const ssh = makeWorktree('ssh', 'repo2') + const runtime = makeWorktree('runtime', 'repo3') + const scopedRepoMap = new Map(repoMap) + scopedRepoMap.set('repo2', { + ...makeRepo('repo2', 'Repo 2', '#111'), + connectionId: 'ssh-1' + }) + scopedRepoMap.set('repo3', { + ...makeRepo('repo3', 'Repo 3', '#222'), + executionHostId: 'runtime:env-1' + }) + + const result = computeVisibleWorktreeIds( + { repo1: [local], repo2: [ssh], repo3: [runtime] }, + [local.id, ssh.id, runtime.id], + visibleOptions({ + repoMap: scopedRepoMap, + visibleWorkspaceHostIds: ['local', 'ssh:ssh-1'] + }) + ) + + expect(result).toEqual([local.id, ssh.id]) + }) + it('hides branch-backed mains across every repo in a multi-repo workspace', () => { const main1 = makeWorktree('main1', 'repo1') main1.isMainWorktree = true @@ -405,6 +524,10 @@ describe('sidebarHasActiveFilters', () => { it('returns true when only filterRepoIds is non-empty', () => { expect(sidebarHasActiveFilters(filterState({ filterRepoIds: ['repo1'] }))).toBe(true) }) + + it('returns true when only host visibility is narrowed', () => { + expect(sidebarHasActiveFilters(filterState({ visibleWorkspaceHostIds: ['local'] }))).toBe(true) + }) }) describe('computeClearFilterActions', () => { @@ -412,7 +535,8 @@ describe('computeClearFilterActions', () => { expect(computeClearFilterActions(filterState())).toEqual({ resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, - resetHideDefaultBranchWorkspace: false + resetHideDefaultBranchWorkspace: false, + resetVisibleWorkspaceHostIds: false }) }) @@ -423,7 +547,8 @@ describe('computeClearFilterActions', () => { expect(computeClearFilterActions(filterState({ hideDefaultBranchWorkspace: true }))).toEqual({ resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, - resetHideDefaultBranchWorkspace: true + resetHideDefaultBranchWorkspace: true, + resetVisibleWorkspaceHostIds: false }) }) @@ -446,13 +571,15 @@ describe('computeClearFilterActions', () => { filterState({ showSleepingWorkspaces: false, filterRepoIds: ['repo1', 'repo2'], - hideDefaultBranchWorkspace: true + hideDefaultBranchWorkspace: true, + visibleWorkspaceHostIds: ['local'] }) ) ).toEqual({ resetShowSleepingWorkspaces: true, resetFilterRepoIds: true, - resetHideDefaultBranchWorkspace: true + resetHideDefaultBranchWorkspace: true, + resetVisibleWorkspaceHostIds: true }) }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index a4ce1d05549..41cbe9753b5 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -4,6 +4,13 @@ import { isInactiveWorkspace } from '@/lib/worktree-activity-state' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + type ExecutionHostId, + type ExecutionHostScope +} from '../../../../shared/execution-host' /** * Whether a worktree represents the repo's default-branch row that the @@ -23,6 +30,7 @@ export type SidebarFilterState = { showSleepingWorkspaces: boolean filterRepoIds: readonly string[] hideDefaultBranchWorkspace: boolean + visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null } /** @@ -38,7 +46,8 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { return ( state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES || state.filterRepoIds.length > 0 || - state.hideDefaultBranchWorkspace + state.hideDefaultBranchWorkspace || + state.visibleWorkspaceHostIds != null ) } @@ -48,6 +57,7 @@ export type ClearFilterActions = { resetShowSleepingWorkspaces: boolean resetFilterRepoIds: boolean resetHideDefaultBranchWorkspace: boolean + resetVisibleWorkspaceHostIds: boolean } /** @@ -64,7 +74,8 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte return { resetShowSleepingWorkspaces: state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES, resetFilterRepoIds: state.filterRepoIds.length > 0, - resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace + resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null } } @@ -93,6 +104,9 @@ export function computeVisibleWorktreeIds( // forgetting to pass it. hideDefaultBranchWorkspace: boolean repoMap: Map<string, Repo> + workspaceHostScope: ExecutionHostScope + visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + defaultHostId: ExecutionHostId worktreeLineageById: Record<string, WorktreeLineage> } ): string[] { @@ -109,6 +123,24 @@ export function computeVisibleWorktreeIds( all = all.filter((w) => !isDefaultBranchWorkspace(w)) } + const visibleHostIds = + opts.visibleWorkspaceHostIds ?? + (opts.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [opts.workspaceHostScope]) + if (visibleHostIds) { + const visibleHostIdSet = new Set(visibleHostIds) + all = all.filter((w) => { + const repo = opts.repoMap.get(w.repoId) + if (!repo) { + return false + } + const hostId = + repo.connectionId || repo.executionHostId + ? getRepoExecutionHostId(repo) + : opts.defaultHostId + return visibleHostIdSet.has(hostId) + }) + } + // Filter by repo if (opts.filterRepoIds.length > 0) { const selectedRepoIds = new Set(opts.filterRepoIds) @@ -258,6 +290,9 @@ export function getVisibleWorktreeIds(): string[] { browserTabsByWorktree: state.browserTabsByWorktree, hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, repoMap, + workspaceHostScope: state.workspaceHostScope, + visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(state.settings), worktreeLineageById: state.worktreeLineageById }) } diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 75c1091b85c..2d312983d3b 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -6,6 +6,7 @@ export type WorktreeDragUnitGroup = WorktreeDragGroup & { } type WorktreeDragUnitRow = + | { type: 'host-header' } | { type: 'header'; key: string } | { type: 'item'; worktree: { id: string }; depth: number } | { type: 'imported-worktrees-card' } @@ -29,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 6726a397a2d..afc712abeac 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, @@ -51,6 +53,59 @@ const worktree: Worktree = { const repoMap = new Map([[repo.id, repo]]) +const remoteRepo: Repo = { + id: 'repo-remote', + path: '/home/alice/orca', + displayName: 'orca', + badgeColor: '#111111', + addedAt: 1, + connectionId: 'gpu-vm' +} + +const remoteWorktree: Worktree = { + ...worktree, + id: 'wt-remote', + repoId: remoteRepo.id, + path: '/home/alice/orca-feature', + displayName: 'remote feature' +} + +const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#737373', + sourceRepoIds: [repo.id, remoteRepo.id], + createdAt: 1, + updatedAt: 1 +} + +const projectHostSetups: ProjectHostSetup[] = [ + { + id: repo.id, + projectId: project.id, + hostId: 'local', + repoId: repo.id, + path: repo.path, + displayName: repo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }, + { + id: remoteRepo.id, + projectId: project.id, + hostId: 'ssh:gpu-vm', + repoId: remoteRepo.id, + path: remoteRepo.path, + displayName: remoteRepo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } +] + function makeDetectedWorktree(overrides: Partial<DetectedWorktree> = {}): DetectedWorktree { return { ...worktree, @@ -254,6 +309,177 @@ describe('buildRows with pinned worktrees', () => { expect(rows[0]).toMatchObject({ type: 'header', label: 'c15t' }) }) + it('groups multiple host setups for the same project under one project header', () => { + const rows = buildRows( + 'repo', + [worktree, remoteWorktree], + new Map([ + [repo.id, repo], + [remoteRepo.id, remoteRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [remoteWorktree.id, remoteWorktree] + ]), + false, + undefined, + [], + new Set(), + new Map(), + [], + { projects: [project], projectHostSetups } + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, + { type: 'item', worktree: { id: worktree.id }, hostContextLabel: 'Local Mac' }, + { type: 'item', worktree: { id: remoteWorktree.id }, hostContextLabel: 'gpu-vm' } + ]) + }) + + it('uses saved host labels for mixed-host sidebar card badges', () => { + const runtimeRepo: Repo = { + ...remoteRepo, + id: 'repo-runtime', + path: '/Users/alice/runtime-orca', + connectionId: null, + executionHostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3' + } + const runtimeWorktree: Worktree = { + ...remoteWorktree, + id: 'wt-runtime', + repoId: runtimeRepo.id + } + const runtimeSetup: ProjectHostSetup = { + ...projectHostSetups[1]!, + id: runtimeRepo.id, + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + repoId: runtimeRepo.id, + path: runtimeRepo.path + } + const rows = buildRows( + 'repo', + [worktree, runtimeWorktree], + new Map([ + [repo.id, repo], + [runtimeRepo.id, runtimeRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [runtimeWorktree.id, runtimeWorktree] + ]), + false, + undefined, + [], + new Set(), + new Map(), + [], + { projects: [project], projectHostSetups: [projectHostSetups[0]!, runtimeSetup] }, + [], + new Map([ + ['local', 'Local Mac'], + ['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box'] + ]) + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, + { type: 'item', worktree: { id: worktree.id }, hostContextLabel: 'Local Mac' }, + { type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev box' } + ]) + }) + + it('omits host context labels when a project group only has one host', () => { + const secondLocalWorktree: Worktree = { + ...worktree, + id: 'wt-local-2', + displayName: 'local-only' + } + const rows = buildRows( + 'repo', + [worktree, secondLocalWorktree], + new Map([[repo.id, repo]]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [secondLocalWorktree.id, secondLocalWorktree] + ]), + false, + undefined, + [], + new Set(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id] }], + projectHostSetups: [projectHostSetups[0]] + } + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, + { type: 'item', worktree: { id: worktree.id } }, + { type: 'item', worktree: { id: secondLocalWorktree.id } } + ]) + for (const row of rows) { + if (row.type === 'item') { + expect(row.hostContextLabel).toBeUndefined() + } + } + }) + + it('keeps same-named repos separate without project setup identity', () => { + const rows = buildRows( + 'repo', + [worktree, remoteWorktree], + new Map([ + [repo.id, { ...repo, displayName: 'orca' }], + [remoteRepo.id, { ...remoteRepo, displayName: 'orca' }] + ]), + null, + new Set() + ) + + expect(rows.filter((row) => row.type === 'header')).toMatchObject([ + { key: 'repo:repo-1' }, + { key: 'repo:repo-remote' } + ]) + }) + + it('returns project group keys for worktree reveal when project setup identity exists', () => { + expect( + getGroupKeyForWorktree( + 'repo', + remoteWorktree, + new Map([[remoteRepo.id, remoteRepo]]), + null, + undefined, + undefined, + { + projects: [project], + projectHostSetups + } + ) + ).toBe('project:github:stablyai/orca') + }) + it('emits an imported worktrees card at the top of repo-group rows', () => { const hidden = [ makeDetectedWorktree({ id: 'hidden-1', displayName: 'payments-refactor' }), @@ -1185,77 +1411,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', @@ -1432,6 +1587,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) @@ -1508,6 +1664,7 @@ describe('project groups', () => { new Set(), new Map(), [], + undefined, [folderWorkspace] ) @@ -1576,6 +1733,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 43a7e5397f4..97e073fd43e 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 } @@ -57,6 +60,7 @@ export type WorktreeRow = { lineageChildCount: number lineageGroupKey?: string lineageCollapsed?: boolean + hostContextLabel?: string } export type ImportedWorktreesCardCandidate = { @@ -113,7 +117,63 @@ function buildPendingCreationRow( } } -type OrderedGroupEntry = [string, { label: string; items: Worktree[]; repo?: Repo }] +type OrderedGroupEntry = [string, WorktreeGroupEntry] + +export type ProjectGroupingModel = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} + +type WorktreeGroupEntry = { + label: string + items: Worktree[] + repo?: Repo + repoIds: Set<string> +} + +type ProjectGroupingIndex = { + projectById: Map<string, Project> + setupByRepoId: Map<string, ProjectHostSetup> +} + +function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { + const projects = model?.projects ?? [] + const projectHostSetups = model?.projectHostSetups ?? [] + if (projects.length === 0 || projectHostSetups.length === 0) { + return null + } + return { + projectById: new Map(projects.map((project) => [project.id, project])), + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) + } +} + +function getProjectGroupingForRepo( + repoId: string, + repoMap: Map<string, Repo>, + projectIndex: ProjectGroupingIndex | null +): { key: string; label: string; repo?: Repo; projectId?: string } { + const repo = repoMap.get(repoId) + const setup = projectIndex?.setupByRepoId.get(repoId) + const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined + if (!setup || !project) { + return { + key: `repo:${repoId}`, + label: repo?.displayName ?? 'Unknown', + repo + } + } + return { + key: `project:${project.id}`, + label: project.displayName, + repo, + projectId: project.id + } +} + +function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { + group.repoIds.add(repoId) +} export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' @@ -226,7 +286,14 @@ export function getPRGroupKey( const branch = branchName(worktree.branch) const repoScopedCacheKey = repo && branch - ? getGitHubPRCacheKey(repo.path, repo.id, branch, settings, repo.connectionId) + ? getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId + ) : '' const canUseLegacyPRCache = repo !== undefined && !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId @@ -328,7 +395,8 @@ function buildWorktreeRow( lineageTrail: boolean[], isLastLineageChild: boolean, lineageChildCount: number, - lineageCollapsed: boolean + lineageCollapsed: boolean, + hostContextLabel?: string ): WorktreeRow { return { type: 'item', @@ -339,6 +407,7 @@ function buildWorktreeRow( lineageTrail, isLastLineageChild, lineageChildCount, + ...(hostContextLabel ? { hostContextLabel } : {}), ...(lineageChildCount > 0 ? { lineageGroupKey: getLineageGroupKey(worktree.id) } : {}), ...(lineageChildCount > 0 ? { lineageCollapsed } : {}) } @@ -354,12 +423,25 @@ function appendWorktreeRows( nestLineage: boolean collapsedGroups: Set<string> groupDepth: number + hostContextLabelByRepoId?: ReadonlyMap<string, string> } ): void { - const { nestLineage, collapsedGroups, groupDepth } = options + const { nestLineage, collapsedGroups, groupDepth, hostContextLabelByRepoId } = options if (!nestLineage) { for (const worktree of worktrees) { - result.push(buildWorktreeRow(worktree, repoMap, 0, groupDepth, [], false, 0, false)) + result.push( + buildWorktreeRow( + worktree, + repoMap, + 0, + groupDepth, + [], + false, + 0, + false, + hostContextLabelByRepoId?.get(worktree.repoId) + ) + ) } return } @@ -401,7 +483,8 @@ function appendWorktreeRows( lineageTrail, isLastChild, children.length, - lineageCollapsed + lineageCollapsed, + hostContextLabelByRepoId?.get(worktree.repoId) ) ) if (lineageCollapsed) { @@ -432,6 +515,43 @@ function appendWorktreeRows( } } +function getRepoHostLabel( + repoId: string, + repoMap: Map<string, Repo>, + projectIndex: ProjectGroupingIndex | null, + hostLabelById: ReadonlyMap<string, string> | undefined +): string | null { + const setup = projectIndex?.setupByRepoId.get(repoId) + if (setup) { + return hostLabelById?.get(setup.hostId) ?? getExecutionHostLabel(setup.hostId) + } + const repo = repoMap.get(repoId) + if (!repo) { + return null + } + const hostId = getRepoExecutionHostId(repo) + return hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId) +} + +function getMixedHostContextLabels( + group: WorktreeGroupEntry, + repoMap: Map<string, Repo>, + projectIndex: ProjectGroupingIndex | null, + hostLabelById: ReadonlyMap<string, string> | undefined +): Map<string, string> | undefined { + const labelsByRepoId = new Map<string, string>() + const uniqueLabels = new Set<string>() + for (const repoId of group.repoIds) { + const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById) + if (!label) { + continue + } + labelsByRepoId.set(repoId, label) + uniqueLabels.add(label) + } + return uniqueLabels.size > 1 ? labelsByRepoId : undefined +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -561,9 +681,12 @@ export function buildRows( placeholderRepoIds: ReadonlySet<string> = new Set(), importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(), pendingCreations: readonly PendingCreationRef[] = [], - folderWorkspaces: readonly FolderWorkspace[] = [] + projectGrouping?: ProjectGroupingModel, + folderWorkspaces: readonly FolderWorkspace[] = [], + hostLabelById?: ReadonlyMap<string, string> ): Row[] { const result: Row[] = [] + const projectIndex = buildProjectGroupingIndex(projectGrouping) const pendingByRepo = new Map<string, PendingCreationRef[]>() for (const creation of pendingCreations) { @@ -618,15 +741,16 @@ export function buildRows( return result } - const grouped = new Map<string, { label: string; items: Worktree[]; repo?: Repo }>() + const grouped = new Map<string, WorktreeGroupEntry>() for (const w of unpinned) { let key: string let label: string let repo: Repo | undefined if (groupBy === 'repo') { - repo = repoMap.get(w.repoId) - key = `repo:${w.repoId}` - label = repo?.displayName ?? 'Unknown' + const grouping = getProjectGroupingForRepo(w.repoId, repoMap, projectIndex) + key = grouping.key + label = grouping.label + repo = grouping.repo } else if (groupBy === 'workspace-status') { const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses) key = getWorkspaceStatusGroupKey(workspaceStatus) @@ -638,45 +762,65 @@ export function buildRows( label = PR_GROUP_META[prGroup].label } if (!grouped.has(key)) { - grouped.set(key, { label, items: [], repo }) + grouped.set(key, { label, items: [], repo, repoIds: new Set() }) } - grouped.get(key)!.items.push(w) + const group = grouped.get(key)! + group.items.push(w) + addRepoIdToGroup(group, w.repoId) } if (groupBy === 'repo') { for (const repoId of placeholderRepoIds) { - const repo = repoMap.get(repoId) - if (!repo) { + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + if (!grouping.repo) { continue } - const key = `repo:${repoId}` + const key = grouping.key if (!grouped.has(key)) { // Why: repos can arrive before worktree scans, but stale IDs passed by // older snapshots must not render an "Unknown" project header. - grouped.set(key, { label: repo.displayName, items: [], repo }) + grouped.set(key, { + label: grouping.label, + items: [], + repo: grouping.repo, + repoIds: new Set([repoId]) + }) + } else { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } if (groupBy === 'repo') { for (const [repoId, candidate] of importedWorktreesByRepo) { - const key = `repo:${repoId}` + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + const key = grouping.key if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { grouped.set(key, { - label: candidate.repo.displayName, + label: grouping.label, items: [], - repo: candidate.repo + repo: grouping.repo ?? candidate.repo, + repoIds: new Set([repoId]) }) + } else if (grouped.has(key)) { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } if (groupBy === 'repo') { for (const repoId of pendingByRepo.keys()) { - const key = `repo:${repoId}` + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + const key = grouping.key if (!grouped.has(key)) { // Why: creating the first worktree in a repo leaves it with no group yet; // ensure one so the in-progress row nests under its repo instead of being // dropped. - const repo = repoMap.get(repoId) - grouped.set(key, { label: repo?.displayName ?? 'Unknown', items: [], repo }) + grouped.set(key, { + label: grouping.label, + items: [], + repo: grouping.repo, + repoIds: new Set([repoId]) + }) + } else { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } @@ -764,23 +908,39 @@ export function buildRows( result.push(header) if (!isCollapsed) { if (groupBy === 'repo') { - const repoId = repo?.id ?? key.slice('repo:'.length) - const candidate = importedWorktreesByRepo.get(repoId) - if (candidate) { - result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) + const repoIds = + group.repoIds.size > 0 + ? [...group.repoIds] + : repo + ? [repo.id] + : key.startsWith('repo:') + ? [key.slice('repo:'.length)] + : [] + for (const repoId of repoIds) { + const candidate = importedWorktreesByRepo.get(repoId) + if (candidate) { + result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) + } } // Why: surface in-progress creates at the top of their own repo so the // new workspace appears where it will land, not flashed to the very top // of the sidebar. - for (const creation of pendingByRepo.get(repoId) ?? []) { - result.push(buildPendingCreationRow(creation, repoMap)) + for (const repoId of repoIds) { + for (const creation of pendingByRepo.get(repoId) ?? []) { + result.push(buildPendingCreationRow(creation, repoMap)) + } } } const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items + const hostContextLabelByRepoId = + groupBy === 'repo' + ? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById) + : undefined appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, { nestLineage, collapsedGroups, - groupDepth: projectGroupDepth + groupDepth: projectGroupDepth, + hostContextLabelByRepoId }) } } @@ -808,27 +968,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 }) } @@ -925,7 +1078,8 @@ export function getGroupKeyForWorktree( repoMap: Map<string, Repo>, prCache: Record<string, unknown> | null, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), - settings?: AppState['settings'] + settings?: AppState['settings'], + projectGrouping?: ProjectGroupingModel ): string | null { if (groupBy === 'none') { return ALL_GROUP_KEY @@ -934,7 +1088,11 @@ export function getGroupKeyForWorktree( return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses)) } if (groupBy === 'repo') { - return `repo:${worktree.repoId}` + return getProjectGroupingForRepo( + worktree.repoId, + repoMap, + buildProjectGroupingIndex(projectGrouping) + ).key } return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}` } @@ -946,7 +1104,8 @@ export function getGroupKeysForWorktree( prCache: Record<string, unknown> | null, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), settings?: AppState['settings'], - projectGroups: readonly ProjectGroup[] = [] + projectGroups: readonly ProjectGroup[] = [], + projectGrouping?: ProjectGroupingModel ): string[] { const groupKey = getGroupKeyForWorktree( groupBy, @@ -954,7 +1113,8 @@ export function getGroupKeysForWorktree( repoMap, prCache, workspaceStatuses, - settings + settings, + projectGrouping ) if (!groupKey) { return [] diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts new file mode 100644 index 00000000000..e0449b6eeca --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import type { VirtualItem } from '@tanstack/react-virtual' +import { + HOST_STICKY_PINNED_HEIGHT, + extractWorktreeVirtualRowIndexes, + getActiveStickyIndexesForScroll, + getStickyHeaderIndexes, + type RenderRow +} from './worktree-list-virtual-rows' + +function hostRow(hostId: string): RenderRow { + return { + type: 'host-header', + key: `host:${hostId}`, + hostId: hostId as never, + kind: 'ssh', + label: hostId, + detail: 'SSH', + health: 'available', + collapsed: false, + count: 1 + } +} + +function groupRow(key: string): RenderRow { + return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' } +} + +function itemStub(id: string): RenderRow { + return { type: 'item', key: id } as unknown as RenderRow +} + +function virtualItem(index: number, start: number): VirtualItem { + return { index, start } as VirtualItem +} + +// rows: [host-a, group-a1, item, item, host-b, group-b1, item] +const rows: RenderRow[] = [ + hostRow('a'), + groupRow('a1'), + itemStub('wt-1'), + itemStub('wt-2'), + hostRow('b'), + groupRow('b1'), + itemStub('wt-3') +] +const stickyHeaderIndexes = getStickyHeaderIndexes(rows) +// Geometry: each row 100px tall for easy math. +const virtualItems = rows.map((_, index) => virtualItem(index, index * 100)) + +describe('getActiveStickyIndexesForScroll', () => { + it('pins the host and its inner group while scrolled inside a section', () => { + expect( + getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 2, + scrollOffset: 250, + stickyHeaderIndexes, + virtualItems + }) + ).toEqual({ hostIndex: 0, groupIndex: 1 }) + }) + + it('hands the host tier off when the next host card reaches the top', () => { + expect( + getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 4, + scrollOffset: 400, + stickyHeaderIndexes, + virtualItems + }) + ).toMatchObject({ hostIndex: 4 }) + }) + + it('never pins the previous host group beneath the next host card', () => { + const result = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 4, + scrollOffset: 400, + stickyHeaderIndexes, + virtualItems + }) + // group-a1 (index 1) must not survive into host b's tenure; group-b1 only + // pins once it reaches the slot beneath the pinned host card. + expect(result.groupIndex === 1).toBe(false) + }) + + it('offsets the group handoff by the pinned host height', () => { + // group-b1 starts at 500; with host pinned it should activate once + // scrollOffset + HOST_STICKY_PINNED_HEIGHT reaches 500. + const before = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 5, + scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT - 1, + stickyHeaderIndexes, + virtualItems + }) + const after = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 5, + scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT, + stickyHeaderIndexes, + virtualItems + }) + expect(before.groupIndex).not.toBe(5) + expect(after).toEqual({ hostIndex: 4, groupIndex: 5 }) + }) + + it('degrades to single-tier rules when no host sections exist', () => { + const flatRows: RenderRow[] = [ + groupRow('g1'), + itemStub('wt-1'), + groupRow('g2'), + itemStub('wt-2') + ] + const flatSticky = getStickyHeaderIndexes(flatRows) + const flatItems = flatRows.map((_, index) => virtualItem(index, index * 100)) + expect( + getActiveStickyIndexesForScroll({ + rows: flatRows, + rangeStartIndex: 1, + scrollOffset: 150, + stickyHeaderIndexes: flatSticky, + virtualItems: flatItems + }) + ).toEqual({ hostIndex: null, groupIndex: 0 }) + }) +}) + +describe('extractWorktreeVirtualRowIndexes', () => { + it('keeps the pinned host mounted even when scrolled out of range', () => { + const indexes = extractWorktreeVirtualRowIndexes({ + range: { + startIndex: 3, + endIndex: 3, + overscan: 0, + count: rows.length, + getItemIndex: (i: number) => i + } as never, + stickyHeaderIndexes, + rows + }) + expect(indexes).toContain(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 04c011409fd..e98b9c1ee09 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -1,16 +1,19 @@ import { defaultRangeExtractor } from '@tanstack/react-virtual' import type { Range, VirtualItem } from '@tanstack/react-virtual' -import type { Row } from './worktree-list-groups' +import type { HostSectionRow } from './host-section-rows' import { PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 +export const HOST_HEADER_ROW_HEIGHT = 32 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4 const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 const PENDING_CREATION_ROW_HEIGHT = 56 const FOLDER_WORKSPACE_ROW_HEIGHT = 64 -type WorktreeItemRow = Extract<Row, { type: 'item' }> -export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } +type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }> +export type RenderRow = + | HostSectionRow + | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } export function shouldUseHeaderTopSpacing(args: { rows: readonly RenderRow[] @@ -30,6 +33,18 @@ export function estimateRenderRowSize( _activeStickyHeaderIndex: number | null ): number { const row = rows[index] + if (row?.type === 'host-header') { + return ( + HOST_HEADER_ROW_HEIGHT + + (shouldUseHeaderTopSpacing({ + rows, + index, + firstHeaderIndex + }) + ? SECONDARY_GROUP_HEADER_TOP_MARGIN + : 0) + ) + } if (row?.type === 'header') { return ( GROUP_HEADER_ROW_HEIGHT + @@ -66,13 +81,97 @@ export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { rows.forEach((row, index) => { // Why: project groups are the top-level repo sidebar context; nested repo // headers should not replace their containing group as the pinned header. - if (row.type === 'header' && (row.projectGroupDepth ?? 0) === 0) { + if ( + row.type === 'host-header' || + (row.type === 'header' && (row.projectGroupDepth ?? 0) === 0) + ) { indexes.push(index) } }) return indexes } +// Why: the pinned host card is h-8 (32px) inside a pt-1 (4px) wrapper; the +// group tier pins one pixel up to sit flush beneath it. Keep in sync with +// HostSectionHeader's layout. +export const HOST_STICKY_PINNED_HEIGHT = 36 + +export type ActiveStickyIndexes = { + /** Pinned host card (tier 1), or null outside host sections. */ + hostIndex: number | null + /** Pinned group header (tier 2), offset below the host when one is pinned. */ + groupIndex: number | null +} + +function getHostStickyIndexes(rows: readonly RenderRow[], sticky: readonly number[]): number[] { + return sticky.filter((index) => rows[index]?.type === 'host-header') +} + +/** Two-tier sticky resolution: the host card is the outer hierarchy level so + * it stays pinned for the whole section while group headers hand off beneath + * it. Without host sections this degrades to the original single-tier rules. */ +export function getActiveStickyIndexesForScroll(args: { + rows: readonly RenderRow[] + rangeStartIndex: number + scrollOffset: number + stickyHeaderIndexes: readonly number[] + virtualItems: readonly VirtualItem[] +}): ActiveStickyIndexes { + const hostIndexes = getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) + + const resolveWithHandoff = ( + candidates: readonly number[], + pinnedOffset: number, + fallbackToCandidate: boolean + ): number | null => { + const candidateIndex = getActiveStickyHeaderIndex(candidates, args.rangeStartIndex) + if (candidateIndex === null) { + return null + } + const candidate = args.virtualItems.find((item) => item.index === candidateIndex) + if (!candidate) { + return candidateIndex + } + // Why: hand off the moment the incoming header reaches its pinned slot + // (top of the viewport, or the bottom edge of the pinned host card). + if (args.scrollOffset + pinnedOffset >= candidate.start) { + return candidateIndex + } + const previous = getPreviousStickyHeaderIndex(candidates, candidateIndex) + if (previous !== null) { + return previous + } + // Why: a host section's first group is still in flow below the pinned + // host card until it reaches the slot — pinning it early would double + // it up. The host tier keeps the legacy fallback. + return fallbackToCandidate ? candidateIndex : null + } + + const hostIndex = resolveWithHandoff(hostIndexes, 0, true) + + const hostPosition = hostIndex === null ? -1 : hostIndexes.indexOf(hostIndex) + const nextHostIndex = + hostPosition >= 0 ? (hostIndexes[hostPosition + 1] ?? Number.POSITIVE_INFINITY) : null + const groupIndexes = args.stickyHeaderIndexes.filter((index) => { + if (args.rows[index]?.type !== 'header') { + return false + } + // Why: a group from the previous host must never pin beneath the next + // host's card — only groups inside the pinned host's section qualify. + if (hostIndex !== null) { + return index > hostIndex && index < (nextHostIndex ?? Number.POSITIVE_INFINITY) + } + return true + }) + const groupIndex = resolveWithHandoff( + groupIndexes, + hostIndex !== null ? HOST_STICKY_PINNED_HEIGHT : 0, + hostIndex === null + ) + + return { hostIndex, groupIndex } +} + export function getActiveStickyHeaderIndex( stickyHeaderIndexes: readonly number[], rangeStartIndex: number @@ -100,6 +199,7 @@ export function getPreviousStickyHeaderIndex( export function extractWorktreeVirtualRowIndexes(args: { range: Range stickyHeaderIndexes: readonly number[] + rows?: readonly RenderRow[] }): number[] { const activeStickyHeaderIndex = getActiveStickyHeaderIndex( args.stickyHeaderIndexes, @@ -113,10 +213,15 @@ export function extractWorktreeVirtualRowIndexes(args: { args.stickyHeaderIndexes, activeStickyHeaderIndex ) + // Why: the pinned host card (tier 1) can be far above the visible range + // while group headers hand off beneath it — keep it mounted regardless. + const hostIndexes = args.rows ? getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) : [] + const activeHostIndex = getActiveStickyHeaderIndex(hostIndexes, args.range.startIndex) return Array.from( new Set([ activeStickyHeaderIndex, ...(previousStickyHeaderIndex === null ? [] : [previousStickyHeaderIndex]), + ...(activeHostIndex === null ? [] : [activeHostIndex]), ...defaultRangeExtractor(args.range) ]) ).sort((a, b) => a - b) diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts new file mode 100644 index 00000000000..7f6f5d70935 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts @@ -0,0 +1,51 @@ +import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import type { WorktreeMeta } from '../../../../shared/types' + +export type WorktreeMetaSavedPayload = { + worktreeId: string + updates: Partial<WorktreeMeta> +} + +export function parseExplicitGitHubIssueUrl(input: string): string | null { + const trimmed = input.trim() + const link = parseGitHubIssueOrPRLink(trimmed) + if (!link || link.type !== 'issue') { + return null + } + + return trimmed +} + +/** Pure save-payload builder for the worktree meta dialog: empty inputs clear + * the link (null), unparseable inputs leave it untouched (omitted). */ +export function buildWorktreeMetaUpdates(args: { + displayNameInput: string + currentDisplayName: string + issueInput: string + prInput: string + commentInput: string +}): Partial<WorktreeMeta> { + const trimmedIssue = args.issueInput.trim() + const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue) + const finalLinkedIssue = + trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined + const trimmedPR = args.prInput.trim() + const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR) + const finalLinkedPR = + trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined + + const trimmedDisplayName = args.displayNameInput.trim() + const updates: Partial<WorktreeMeta> = { + comment: args.commentInput.trim(), + ...(trimmedDisplayName !== args.currentDisplayName && { + displayName: trimmedDisplayName || undefined + }) + } + if (finalLinkedIssue !== undefined) { + updates.linkedIssue = finalLinkedIssue + } + if (finalLinkedPR !== undefined) { + updates.linkedPR = finalLinkedPR + } + return updates +} diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx index 6d3ea2e6e68..0626137c010 100644 --- a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx @@ -6,7 +6,7 @@ import { useAppStore } from '@/store' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { scanWorkspacePortsForTarget, - workspacePortRuntimeTargetKey + workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' import { getExternalWorkspacePorts, getWorkspacePortGroups } from '@/lib/workspace-port-groups' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -25,11 +25,12 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const [open, setOpen] = useState(false) const [externalOpen, setExternalOpen] = useState(false) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) - const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` + const scanKey = workspacePortScanKeyForTarget(runtimeTarget) const workspaceGroups = useMemo(() => getWorkspacePortGroups(scan), [scan]) const externalPorts = useMemo(() => getExternalWorkspacePorts(scan), [scan]) @@ -46,6 +47,7 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React // popover should still collapse that stale window without flashing icons. void scanWorkspacePortsForTarget(runtimeTarget) .then((result) => { + setWorkspacePortScanForKey(scanKey, result) setWorkspacePortScan({ key: scanKey, result }) }) .catch((error) => { @@ -61,7 +63,13 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React }) }) }, - [recordFeatureInteraction, runtimeTarget, scanKey, setWorkspacePortScan] + [ + recordFeatureInteraction, + runtimeTarget, + scanKey, + setWorkspacePortScan, + setWorkspacePortScanForKey + ] ) return ( diff --git a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx new file mode 100644 index 00000000000..a62296f6999 --- /dev/null +++ b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx @@ -0,0 +1,117 @@ +import { useCallback, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useMountedRef } from '@/hooks/useMountedRef' + +export type RuntimeHostConnectionState = 'connected' | 'available' | 'checking' | 'disconnected' + +function runtimeStatusLabel(state: RuntimeHostConnectionState): string { + switch (state) { + case 'connected': + return translate('auto.components.status.bar.SshStatusSegment.runtime_online', 'Connected') + case 'available': + return translate('auto.components.status.bar.SshStatusSegment.runtime_available', 'Available') + case 'checking': + return translate('auto.components.status.bar.SshStatusSegment.runtime_checking', 'Checking') + case 'disconnected': + return translate( + 'auto.components.status.bar.SshStatusSegment.runtime_unavailable', + 'Disconnected' + ) + } +} + +function runtimeDotColor(state: RuntimeHostConnectionState): string { + switch (state) { + case 'connected': + return 'bg-emerald-500' + case 'checking': + return 'bg-yellow-500' + case 'available': + case 'disconnected': + return 'bg-muted-foreground/40' + } +} + +function runtimeStatusTone(state: RuntimeHostConnectionState): string { + if (state === 'checking') { + return 'text-yellow-500' + } + return 'text-muted-foreground' +} + +function runtimeActionLabel(state: RuntimeHostConnectionState): string | null { + switch (state) { + case 'connected': + return translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect') + case 'available': + case 'disconnected': + return translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect') + case 'checking': + return null + } +} + +export function RuntimeHostStatusRow({ + label, + state, + onConnect, + onDisconnect +}: { + label: string + state: RuntimeHostConnectionState + onConnect?: () => Promise<void> + onDisconnect?: () => Promise<void> +}): React.JSX.Element { + const [busy, setBusy] = useState(false) + const mountedRef = useMountedRef() + const actionLabel = runtimeActionLabel(state) + + const handleAction = useCallback(async () => { + const action = state === 'connected' ? onDisconnect : onConnect + if (!action) { + return + } + setBusy(true) + try { + await action() + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, onConnect, onDisconnect, state]) + + return ( + <div className="flex items-center gap-2.5 px-2 py-1.5"> + <span className={`size-1.5 shrink-0 rounded-full ${runtimeDotColor(state)}`} /> + <div className="min-w-0 flex-1"> + <div className="truncate text-[12px] font-medium">{label}</div> + <div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground"> + <span> + {translate( + 'auto.components.status.bar.SshStatusSegment.remote_server', + 'Remote Server' + )} + </span> + <span aria-hidden="true">·</span> + <span className={`inline-flex min-w-0 items-center gap-1 ${runtimeStatusTone(state)}`}> + {state === 'checking' ? <Loader2 className="size-2.5 shrink-0 animate-spin" /> : null} + <span className="truncate">{runtimeStatusLabel(state)}</span> + </span> + </div> + </div> + {busy ? ( + <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> + ) : actionLabel && (state === 'connected' ? onDisconnect : onConnect) ? ( + <button + type="button" + onClick={() => void handleAction()} + className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent/70 hover:text-foreground" + > + {actionLabel} + </button> + ) : null} + </div> + ) +} diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 9a959648c4d..529f2da5130 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useState } from 'react' -import { AlertTriangle, Cloud, Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' +import React, { useCallback, useMemo } from 'react' +import { AlertTriangle, Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' import { toast } from 'sonner' import { DropdownMenu, @@ -8,23 +8,22 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '../../store' -import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard' import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh' import { translate } from '@/i18n/i18n' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow' +import { SshTargetStatusRow } from './SshTargetStatusRow' function isConnecting(status: SshConnectionStatus): boolean { return ['connecting', 'deploying-relay', 'reconnecting'].includes(status) } -function isReconnectable(status: SshConnectionStatus): boolean { - return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) -} +type HostStatus = 'connected' | 'disconnected' | 'connecting' function overallStatus( - statuses: SshConnectionStatus[] + statuses: HostStatus[] ): 'connected' | 'partial' | 'disconnected' | 'connecting' { if (statuses.length === 0) { return 'disconnected' @@ -32,7 +31,7 @@ function overallStatus( if (statuses.every((s) => s === 'connected')) { return 'connected' } - if (statuses.some((s) => isConnecting(s))) { + if (statuses.some((s) => s === 'connecting')) { return 'connecting' } if (statuses.some((s) => s === 'connected')) { @@ -41,12 +40,15 @@ function overallStatus( return 'disconnected' } -function overallDotColor(status: 'connected' | 'partial' | 'disconnected' | 'connecting'): string { +function overallDotColor( + status: 'connected' | 'partial' | 'disconnected' | 'connecting', + connectedCount: number +): string { switch (status) { case 'connected': return 'bg-emerald-500' case 'partial': - return 'bg-yellow-500' + return connectedCount > 0 ? 'bg-emerald-500' : 'bg-muted-foreground/40' case 'connecting': return 'bg-yellow-500' case 'disconnected': @@ -54,149 +56,45 @@ function overallDotColor(status: 'connected' | 'partial' | 'disconnected' | 'con } } -function overallLabel(status: 'connected' | 'partial' | 'disconnected' | 'connecting'): string { - switch (status) { - case 'connected': - return 'Connected' - case 'partial': - return 'Partial' - case 'connecting': - return 'Connecting…' - case 'disconnected': - return 'Disconnected' - } +function connectedHostCountLabel(count: number): string { + return `${count} ${count === 1 ? 'host' : 'hosts'}` } -function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string { - switch (status?.phase) { - case 'pulling': - return 'Sync pulling' - case 'pushing': - return 'Sync pushing' - case 'synced': - return status.direction === 'pull' ? 'Sync pulled' : 'Sync uploaded' - case 'conflict': - return 'Sync conflict' - case 'error': - return 'Sync error' - case 'offline': - return 'Sync unavailable' - case 'idle': - case undefined: - return 'Sync idle' +function sshStatusForOverall(status: SshConnectionStatus): HostStatus { + if (status === 'connected') { + return 'connected' } + return isConnecting(status) ? 'connecting' : 'disconnected' } -function syncStatusTone(status: RemoteWorkspaceSyncStatus | undefined): string { - switch (status?.phase) { - case 'conflict': - case 'error': - return 'text-destructive' - case 'offline': - return 'text-muted-foreground' - case 'pulling': - case 'pushing': - return 'text-yellow-500' - case 'synced': - return 'text-emerald-500' - case 'idle': - case undefined: - return 'text-muted-foreground' - } -} - -function TargetRow({ - targetId, - label, - status, - syncStatus +function runtimeHostConnectionState({ + hasStatus, + online, + active }: { - targetId: string - label: string - status: SshConnectionStatus - syncStatus: RemoteWorkspaceSyncStatus | undefined -}): React.JSX.Element { - const [busy, setBusy] = useState(false) - const mountedRef = useMountedRef() - const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + hasStatus: boolean + online: boolean + active: boolean +}): RuntimeHostConnectionState { + if (!hasStatus) { + return 'checking' + } + if (!online) { + return 'disconnected' + } + return active ? 'connected' : 'available' +} - const handleConnect = useCallback(async () => { - setBusy(true) - try { - await window.api.ssh.connect({ targetId }) - recordFeatureInteraction('ssh') - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed') - ) - } finally { - if (mountedRef.current) { - setBusy(false) - } - } - }, [mountedRef, recordFeatureInteraction, targetId]) - - const handleDisconnect = useCallback(async () => { - setBusy(true) - try { - await window.api.ssh.disconnect({ targetId }) - recordFeatureInteraction('ssh') - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.status.bar.SshStatusSegment.bf07aee59e', 'Disconnect failed') - ) - } finally { - if (mountedRef.current) { - setBusy(false) - } - } - }, [mountedRef, recordFeatureInteraction, targetId]) - - return ( - <div className="flex items-center gap-2.5 px-2 py-1.5"> - <span className={`size-1.5 shrink-0 rounded-full ${statusColor(status)}`} /> - <div className="min-w-0 flex-1"> - <div className="truncate text-[12px] font-medium">{label}</div> - <div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground"> - <span>{STATUS_LABELS[status]}</span> - <span aria-hidden="true">·</span> - <span className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`}> - {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? ( - <Loader2 className="size-2.5 shrink-0 animate-spin" /> - ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? ( - <AlertTriangle className="size-2.5 shrink-0" /> - ) : ( - <Cloud className="size-2.5 shrink-0" /> - )} - <span className="truncate">{syncStatusLabel(syncStatus)}</span> - </span> - </div> - </div> - {busy ? ( - <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> - ) : isReconnectable(status) ? ( - <button - type="button" - onClick={() => void handleConnect()} - className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent/70" - > - {translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect')} - </button> - ) : status === 'connected' ? ( - <button - type="button" - onClick={() => void handleDisconnect()} - className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent/70 hover:text-foreground" - > - {translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect')} - </button> - ) : null} - </div> - ) +function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus { + switch (state) { + case 'connected': + return 'connected' + case 'checking': + return 'connecting' + case 'available': + case 'disconnected': + return 'disconnected' + } } export function SshStatusSegment({ @@ -208,6 +106,11 @@ export function SshStatusSegment({ }): React.JSX.Element | null { const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) + const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) const remoteWorkspaceSyncStatusByTargetId = useAppStore( (s) => s.remoteWorkspaceSyncStatusByTargetId ) @@ -215,6 +118,7 @@ export function SshStatusSegment({ const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) const targets = Array.from(sshTargetLabels.entries()).map(([id, label]) => { const state = sshConnectionStates.get(id) return { @@ -224,13 +128,70 @@ export function SshStatusSegment({ syncStatus: remoteWorkspaceSyncStatusByTargetId[id] } }) + const runtimeHosts = runtimeEnvironments.map((environment) => { + const statusEntry = runtimeStatusByEnvironmentId.get(environment.id) + const override = hostLabelOverrides.get(toRuntimeExecutionHostId(environment.id)) + return { + id: environment.id, + label: override || environment.name || environment.id, + hasStatus: Boolean(statusEntry), + online: Boolean(statusEntry?.status), + active: settings?.activeRuntimeEnvironmentId === environment.id + } + }) + const runtimeHostRows = runtimeHosts.map((host) => ({ + ...host, + state: runtimeHostConnectionState(host) + })) + const connectedRuntimeHosts = runtimeHostRows.filter((host) => host.state === 'connected') + const inactiveRuntimeHosts = runtimeHostRows.filter((host) => host.state !== 'connected') + const connectedTargets = targets.filter((target) => target.status === 'connected') + const disconnectedTargets = targets.filter((target) => target.status !== 'connected') + const connectRuntimeHost = useCallback( + async (environmentId: string): Promise<void> => { + const switched = await switchRuntimeEnvironment(environmentId) + if (switched) { + recordFeatureInteraction('ssh') + } + }, + [recordFeatureInteraction, switchRuntimeEnvironment] + ) + const disconnectRuntimeHost = useCallback( + async (environmentId: string, isActive: boolean): Promise<void> => { + try { + if (isActive) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + return + } + } + await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) + setRuntimeEnvironmentStatus(environmentId, { status: null, checkedAt: Date.now() }) + recordFeatureInteraction('ssh') + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.status.bar.SshStatusSegment.runtime_disconnect_failed', + 'Disconnect failed' + ) + ) + } + }, + [recordFeatureInteraction, setRuntimeEnvironmentStatus, switchRuntimeEnvironment] + ) - if (targets.length === 0) { + if (targets.length === 0 && runtimeHosts.length === 0) { return null } - const statuses = targets.map((t) => t.status) + const statuses = [ + ...targets.map((t) => sshStatusForOverall(t.status)), + ...runtimeHostRows.map((host) => runtimeStatusForOverall(host.state)) + ] const overall = overallStatus(statuses) + const connectedHostCount = statuses.filter((status) => status === 'connected').length const anyConnecting = overall === 'connecting' const syncProblem = targets.find( (t) => t.syncStatus?.phase === 'conflict' || t.syncStatus?.phase === 'error' @@ -240,7 +201,6 @@ export function SshStatusSegment({ ? 'Workspace conflict' : 'Workspace sync error' : null - return ( <DropdownMenu onOpenChange={(open) => { @@ -255,14 +215,14 @@ export function SshStatusSegment({ className="inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70" aria-label={translate( 'auto.components.status.bar.SshStatusSegment.fdc57e9970', - 'SSH connection status' + 'Remote host connection status' )} > {iconOnly ? ( <span className="inline-flex items-center gap-1"> <span className={`inline-block size-2 rounded-full ${ - syncProblem ? 'bg-destructive' : overallDotColor(overall) + syncProblem ? 'bg-destructive' : overallDotColor(overall, connectedHostCount) }`} /> {syncProblem ? ( @@ -288,15 +248,15 @@ export function SshStatusSegment({ )} {!compact && ( <span className="text-[11px]"> - {translate('auto.components.status.bar.SshStatusSegment.d09ec41831', 'SSH')}{' '} <span className={syncProblem ? 'text-destructive' : 'text-muted-foreground'}> - {syncProblemLabel ?? overallLabel(overall)} + {syncProblemLabel ?? + (anyConnecting ? 'Connecting…' : connectedHostCountLabel(connectedHostCount))} </span> </span> )} <span className={`inline-block size-1.5 rounded-full ${ - syncProblem ? 'bg-destructive' : overallDotColor(overall) + syncProblem ? 'bg-destructive' : overallDotColor(overall, connectedHostCount) }`} /> </span> @@ -310,10 +270,37 @@ export function SshStatusSegment({ className="w-[min(20rem,calc(100vw-1rem))]" > <div className="px-2 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> - {translate('auto.components.status.bar.SshStatusSegment.6e8a9a4242', 'SSH Connections')} + {translate('auto.components.status.bar.SshStatusSegment.6e8a9a4242', 'Remote Hosts')} </div> - {targets.map((t) => ( - <TargetRow + {connectedRuntimeHosts.map((host) => ( + <RuntimeHostStatusRow + key={host.id} + label={host.label} + state={host.state} + onConnect={() => connectRuntimeHost(host.id)} + onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} + /> + ))} + {connectedTargets.map((t) => ( + <SshTargetStatusRow + key={t.id} + targetId={t.id} + label={t.label} + status={t.status} + syncStatus={t.syncStatus} + /> + ))} + {inactiveRuntimeHosts.map((host) => ( + <RuntimeHostStatusRow + key={host.id} + label={host.label} + state={host.state} + onConnect={() => connectRuntimeHost(host.id)} + onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} + /> + ))} + {disconnectedTargets.map((t) => ( + <SshTargetStatusRow key={t.id} targetId={t.id} label={t.label} @@ -325,11 +312,14 @@ export function SshStatusSegment({ <DropdownMenuItem onSelect={() => { recordFeatureInteraction('ssh') - openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + openSettingsTarget({ pane: 'servers', repoId: null }) setActiveView('settings') }} > - {translate('auto.components.status.bar.SshStatusSegment.3ad70e0365', 'Manage SSH…')} + {translate( + 'auto.components.status.bar.SshStatusSegment.3ad70e0365', + 'Manage Remote Hosts…' + )} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx new file mode 100644 index 00000000000..a3f050bf0e6 --- /dev/null +++ b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx @@ -0,0 +1,152 @@ +import { useCallback, useState } from 'react' +import { AlertTriangle, Cloud, Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '../../store' +import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh' + +function isReconnectable(status: SshConnectionStatus): boolean { + return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) +} + +function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string | null { + switch (status?.phase) { + case 'pulling': + case 'pushing': + return 'Workspace syncing' + case 'conflict': + return 'Workspace sync conflict' + case 'error': + return 'Workspace sync error' + case 'offline': + return 'Workspace sync unavailable' + case 'synced': + case 'idle': + case undefined: + return null + } +} + +function syncStatusTone(status: RemoteWorkspaceSyncStatus | undefined): string { + switch (status?.phase) { + case 'conflict': + case 'error': + return 'text-destructive' + case 'offline': + return 'text-muted-foreground' + case 'pulling': + case 'pushing': + return 'text-yellow-500' + case 'synced': + return 'text-emerald-500' + case 'idle': + case undefined: + return 'text-muted-foreground' + } +} + +export function SshTargetStatusRow({ + targetId, + label, + status, + syncStatus +}: { + targetId: string + label: string + status: SshConnectionStatus + syncStatus: RemoteWorkspaceSyncStatus | undefined +}): React.JSX.Element { + const [busy, setBusy] = useState(false) + const mountedRef = useMountedRef() + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const visibleSyncStatusLabel = syncStatusLabel(syncStatus) + + const handleConnect = useCallback(async () => { + setBusy(true) + try { + await window.api.ssh.connect({ targetId }) + recordFeatureInteraction('ssh') + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed') + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, recordFeatureInteraction, targetId]) + + const handleDisconnect = useCallback(async () => { + setBusy(true) + try { + await window.api.ssh.disconnect({ targetId }) + recordFeatureInteraction('ssh') + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.status.bar.SshStatusSegment.bf07aee59e', 'Disconnect failed') + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [mountedRef, recordFeatureInteraction, targetId]) + + return ( + <div className="flex items-center gap-2.5 px-2 py-1.5"> + <span className={`size-1.5 shrink-0 rounded-full ${statusColor(status)}`} /> + <div className="min-w-0 flex-1"> + <div className="truncate text-[12px] font-medium">{label}</div> + <div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground"> + <span>SSH Host</span> + <span aria-hidden="true">·</span> + <span>{STATUS_LABELS[status]}</span> + {visibleSyncStatusLabel ? ( + <> + <span aria-hidden="true">·</span> + <span + className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`} + > + {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? ( + <Loader2 className="size-2.5 shrink-0 animate-spin" /> + ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? ( + <AlertTriangle className="size-2.5 shrink-0" /> + ) : ( + <Cloud className="size-2.5 shrink-0" /> + )} + <span className="truncate">{visibleSyncStatusLabel}</span> + </span> + </> + ) : null} + </div> + </div> + {busy ? ( + <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" /> + ) : isReconnectable(status) ? ( + <button + type="button" + onClick={() => void handleConnect()} + className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent/70" + > + {translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect')} + </button> + ) : status === 'connected' ? ( + <button + type="button" + onClick={() => void handleDisconnect()} + className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent/70 hover:text-foreground" + > + {translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect')} + </button> + ) : null} + </div> + ) +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 3929d01e000..d64fea08500 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1905,7 +1905,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele }} > <Server className="size-3.5" /> - {translate('auto.components.status.bar.StatusBar.24ac89df1a', 'SSH Status')} + {translate('auto.components.status.bar.StatusBar.24ac89df1a', 'Remote Hosts')} </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem checked={statusBarItems.includes('resource-usage')} diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index dbd505533e4..7e759dbaac7 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -29,7 +29,7 @@ import type { AgentStatusEntry, MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' -import type { GitStatusResult, TerminalTab, Worktree } from '../../../../shared/types' +import type { GitStatusResult, Repo, TerminalTab, Worktree } from '../../../../shared/types' import type { WorkspaceSpaceItem, WorkspaceSpaceWorktree @@ -38,8 +38,9 @@ import { cn } from '@/lib/utils' import { toast } from 'sonner' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '../../store' -import { getWorktreeMapFromState } from '../../store/selectors' +import { getRepoMapFromState, getWorktreeMapFromState } from '../../store/selectors' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review' +import { issueCacheKey as getIssueCacheKey } from '../../store/slices/github' import { refreshGitStatusForWorktree } from '../right-sidebar/git-status-refresh' import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow' import { branchDisplayName } from '../sidebar/WorktreeCardHelpers' @@ -120,6 +121,7 @@ type WorkspaceDecisionDetails = { } type WorkspaceDecisionInputs = { + repoMap: Map<string, Repo> worktreeMap: Map<string, Worktree> tabsByWorktree: Record<string, TerminalTab[]> ptyIdsByTabId: Record<string, string[]> @@ -211,7 +213,20 @@ function getWorkspaceDecisionDetails( ? `PR #${linkedPR}` : null const linkedIssue = workspaceRecord?.linkedIssue ?? null - const issue = linkedIssue ? inputs.issueCache[`${worktree.repoId}::${linkedIssue}`]?.data : null + const repo = inputs.repoMap.get(worktree.repoId) + const issue = + linkedIssue && repo + ? inputs.issueCache[ + getIssueCacheKey( + repo.path, + repo.id, + linkedIssue, + inputs.settings, + repo.connectionId, + repo.executionHostId + ) + ]?.data + : null const issueLabel = linkedIssue ? issue ? `#${issue.number} ${issue.state}: ${issue.title}` @@ -1205,6 +1220,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees) const removeWorktree = useAppStore((state) => state.removeWorktree) const deleteStateByWorktreeId = useAppStore((state) => state.deleteStateByWorktreeId) + const repoMap = useAppStore((state) => getRepoMapFromState(state)) const worktreeMap = useAppStore((state) => getWorktreeMapFromState(state)) const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId) @@ -1260,6 +1276,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { details.set( worktree.worktreeId, getWorkspaceDecisionDetails(worktree, { + repoMap, worktreeMap, tabsByWorktree, ptyIdsByTabId, @@ -1294,6 +1311,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { linearIssueCache, openFiles, ptyIdsByTabId, + repoMap, remoteStatusesByWorktree, retainedAgentsByPaneKey, migrationUnsupportedByPtyId, @@ -1999,6 +2017,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { decisionDetails={ decisionDetailsByWorktreeId.get(worktree.worktreeId) ?? getWorkspaceDecisionDetails(worktree, { + repoMap, worktreeMap, tabsByWorktree, ptyIdsByTabId, diff --git a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx index ccb52012710..e949b9f306d 100644 --- a/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx +++ b/src/renderer/src/components/status-bar/ports-status-popover-rows.tsx @@ -15,6 +15,7 @@ import { import type { WorkspacePortGroup } from '@/lib/workspace-port-groups' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { WorkspacePort } from '../../../../shared/workspace-ports' import { translate } from '@/i18n/i18n' @@ -72,12 +73,22 @@ export function PortRow({ external?: boolean }): React.JSX.Element { const settings = useAppStore((s) => s.settings) + const runtimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree( + s, + port.kind === 'workspace' ? port.owner.worktreeId : activeWorktreeId + ) + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const runtimeTarget = useMemo( + () => getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }), + [runtimeEnvironmentId, settings] + ) const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings) const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId) @@ -161,6 +172,8 @@ export function PortRow({ const refreshResult = await refreshWorkspacePortScanAfterStop({ runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, setWorkspacePortScanRefreshing }) if (!refreshResult.ok) { @@ -182,6 +195,7 @@ export function PortRow({ recordFeatureInteraction, runtimeTarget, setWorkspacePortScan, + setWorkspacePortScanForKey, setWorkspacePortScanRefreshing ] ) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index be3d5529f11..655bf95f62d 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -38,6 +38,7 @@ import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, @@ -233,8 +234,10 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) + // Why: probe Windows shell capabilities on the host that owns this worktree, so + // the offered shells match the host that actually runs the terminal. const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId?.trim() || null + (s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null ) const worktreeHasRemoteConnection = useAppStore((s) => { const worktree = Object.values(s.worktreesByRepo ?? {}) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index fc353ae6359..db56eab1db2 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -194,4 +194,31 @@ describe('openTabEntryWithOperations', () => { }) expect(operations.createBrowserTab).not.toHaveBeenCalled() }) + + it('falls back to a local browser tab when paired runtime browser creation fails', async () => { + const operations = makeOperations({ + createWebRuntimeSessionBrowserTab: vi.fn().mockResolvedValue(false), + isWebRuntimeSessionActive: vi.fn().mockReturnValue(true) + }) + + await openTabEntryWithOperations({ + ...baseArgs, + query: 'https://example.com', + activeRuntimeEnvironmentId: 'runtime-1', + operations + }) + + expect(operations.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'runtime-1', + url: 'https://example.com/', + targetGroupId: 'group-1' + }) + expect(operations.createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true, + browserRuntimeEnvironmentId: null, + targetGroupId: 'group-1', + title: 'https://example.com/' + }) + }) }) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts index 7e84b5fc5de..c9b9c515fa7 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts @@ -14,6 +14,7 @@ import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import type { BrowserTab as BrowserTabState } from '../../../../shared/types' import type { RuntimeFileListState } from '../quick-open-file-list' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { classifyTabEntryQuery, type TabEntryActionClassification @@ -41,6 +42,7 @@ export type TabEntryOperations = { url: string, options?: { activate?: boolean + browserRuntimeEnvironmentId?: string | null targetGroupId?: string title?: string } @@ -146,18 +148,26 @@ export async function openTabEntryWithOperations({ } if (classification.kind === 'explicit-url' || classification.kind === 'host-url') { - if ( - operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId) && - !(await operations.createWebRuntimeSessionBrowserTab({ + const runtimeSessionActive = operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId) + if (runtimeSessionActive) { + const created = await operations.createWebRuntimeSessionBrowserTab({ worktreeId, environmentId: activeRuntimeEnvironmentId, url: classification.url, targetGroupId: groupId - })) - ) { - throw new Error('Failed to create browser tab.') - } - if (!operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { + }) + if (created) { + return + } + // Why: headless remote runtimes cannot host browser panes yet; a URL open + // should still give the user a usable client-local browser tab. + operations.createBrowserTab(worktreeId, classification.url, { + activate: true, + browserRuntimeEnvironmentId: null, + targetGroupId: groupId, + title: classification.url + }) + } else { operations.createBrowserTab(worktreeId, classification.url, { activate: true, targetGroupId: groupId, @@ -210,7 +220,9 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> { throw new Error('No active worktree.') } const runtimeContext: RuntimeFileOperationArgs = { - settings: state.settings, + settings: { + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) + }, worktreeId: args.worktreeId, worktreePath: worktree.path, connectionId: getConnectionId(args.worktreeId) ?? undefined @@ -222,7 +234,7 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> { groupId: args.groupId, worktreePath: worktree.path, runtimeContext, - activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId?.trim() ?? null, + activeRuntimeEnvironmentId: runtimeContext.settings?.activeRuntimeEnvironmentId?.trim() ?? null, classification: args.classification, operations: { createBrowserTab: state.createBrowserTab, diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 99cc14cd095..86deb40852d 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -29,6 +29,7 @@ import { type HoveredTabInsertion } from './tab-insertion' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' export type { HoveredTabInsertion } @@ -71,7 +72,7 @@ function mirrorWebRuntimeTabMove( worktreeId: string } ): void { - const environmentId = useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ?? null + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) if (!isWebRuntimeSessionActive(environmentId)) { return } diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 6e7828e416f..735cde1bec7 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -27,6 +27,7 @@ import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action' import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { ensureSimulatorTab, getSimulatorTabForWorktree } from '@/lib/ensure-simulator-tab' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' export function recordTerminalTabGroupSplit(createdTerminal: TerminalTab | null | undefined): void { if (!createdTerminal) { @@ -237,9 +238,10 @@ export function useTabGroupWorkspaceModel({ if (item.isPinned) { return } - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if (item.contentType === 'terminal') { closeTerminalTab(item.entityId) if (!opts?.skipEmptyCheck) { @@ -290,9 +292,10 @@ export function useTabGroupWorkspaceModel({ if (!item || item.isPinned) { continue } - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if ( (item.contentType === 'terminal' || item.contentType === 'browser') && isWebRuntimeSessionActive(runtimeEnvironmentId) @@ -332,9 +335,10 @@ export function useTabGroupWorkspaceModel({ } focusGroup(worktreeId, groupId) activateTab(item.id) - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId, @@ -402,9 +406,10 @@ export function useTabGroupWorkspaceModel({ } focusGroup(worktreeId, groupId) activateTab(item.id) - const runtimeEnvironmentId = useAppStore - .getState() - .settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { void activateWebRuntimeSessionTab({ worktreeId, @@ -604,6 +609,7 @@ export function useTabGroupWorkspaceModel({ if ( await createWebRuntimeSessionBrowserTab({ worktreeId, + environmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId), url: source.url, profileId: source.sessionProfileId, targetGroupId: groupId @@ -633,6 +639,7 @@ export function useTabGroupWorkspaceModel({ if ( await createWebRuntimeSessionTerminal({ worktreeId, + environmentId: getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId), targetGroupId: groupId, command: shellOverride, activate: true diff --git a/src/renderer/src/components/task-drawer-source-boundary.test.ts b/src/renderer/src/components/task-drawer-source-boundary.test.ts new file mode 100644 index 00000000000..0ebea790c34 --- /dev/null +++ b/src/renderer/src/components/task-drawer-source-boundary.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const COMPONENT_ROOT = __dirname + +function componentSource(relativePath: string): string { + return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') +} + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('task drawer source boundaries', () => { + it('threads GitHub task source context through detail mutations', () => { + const source = componentSource('GitHubItemDialog.tsx') + const issueUpdate = sourceBetween( + source, + 'async function runIssueUpdate', + 'async function runWorkItemBodyUpdate' + ) + const commentUpdate = sourceBetween( + source, + 'function addIssueCommentForRepo', + 'function addPRReviewCommentForRepo' + ) + const editSection = sourceBetween( + source, + 'function GHEditSection', + 'function GHCommentComposer' + ) + + expect(issueUpdate).toContain('sourceContext: args.sourceContext') + expect(commentUpdate).toContain('sourceContext: args.sourceContext') + expect(editSection).toContain('sourceContext,') + expect(editSection).toContain( + 'patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext })' + ) + expect(editSection).toContain( + 'patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext })' + ) + }) + + it('threads GitLab task source context through the shared drawer selector', () => { + const source = componentSource('GitLabItemDialog.tsx') + const selector = sourceBetween( + source, + 'const repoSelector = useMemo', + 'const updateCommentDraft' + ) + + expect(selector).toContain('...(repoId ? { repoId } : {})') + expect(selector).toContain('...(sourceContext ? { sourceContext } : {})') + expect(selector).toContain('}, [repoId, repoPath, sourceContext])') + expect(source).toContain('workItemDetails({ ...repoSelector') + expect(source).toContain('updateMR({ ...repoSelector') + expect(source).toContain('addMRComment({ ...repoSelector') + expect(source).toContain('addIssueComment({ ...repoSelector') + }) + + it('uses Linear task source context for drawer reads, mutations, and optimistic patches', () => { + const source = componentSource('LinearItemDrawer.tsx') + const editSection = sourceBetween( + source, + 'export function LinearIssueEditSection', + 'export function LinearIssueCommentFooter' + ) + const drawer = sourceBetween(source, 'export default function LinearItemDrawer', 'return (') + + expect(editSection).toContain('const providerSettings = sourceContext ?? settings') + expect(editSection).toContain('linearUpdateIssue(providerSettings') + expect(editSection).toContain( + 'patchLinearIssue(issue.id, { state: stateValue }, { sourceContext })' + ) + expect(editSection).toContain( + 'patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext })' + ) + expect(drawer).toContain('const providerSettings = sourceContext ?? settings') + expect(drawer).toContain('linearGetIssue(providerSettings') + expect(drawer).toContain('linearIssueComments(providerSettings') + }) + + it('uses Jira task source context for drawer reads, mutations, and optimistic patches', () => { + const source = componentSource('JiraIssueWorkspace.tsx') + const drawer = sourceBetween(source, 'export default function JiraIssueWorkspace', 'return (') + + expect(drawer).toContain('const providerSettings = sourceContext ?? settings') + expect(drawer).toContain('jiraIssueComments(providerSettings') + expect(drawer).toContain('jiraGetIssue(providerSettings') + expect(drawer).toContain('jiraListTransitions(providerSettings') + expect(drawer).toContain('jiraUpdateIssue(providerSettings') + expect(drawer).toContain('jiraAddIssueComment(') + expect(drawer).toContain('patchJiraIssue(displayed.key, optimistic, { sourceContext })') + expect(drawer).toContain('patchJiraIssue(previous.key, previous, { sourceContext })') + }) +}) diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index c58a863a6f6..4e476aa9def 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -66,12 +66,35 @@ describe('task page cache selectors', () => { { repoId: 'repo-1', repoPath: '/repo/one', + sourceKey: 'repo-1::local', sources: null, error: null } ]) }) + it('scopes repo source rows by source cache scope for retry ownership', () => { + const localRepo = { + id: 'repo-1', + path: '/same/path', + sourceCacheScope: 'source:local:github:stablyai/orca' + } + const sshRepo = { + id: 'repo-1', + path: '/same/path', + sourceCacheScope: 'source:ssh:devbox:github:stablyai/orca' + } + + expect(buildTaskPageRepoSourceState([localRepo, sshRepo], [])).toMatchObject([ + { + sourceKey: 'repo-1::source:local:github:stablyai/orca' + }, + { + sourceKey: 'repo-1::source:ssh:devbox:github:stablyai/orca' + } + ]) + }) + it('selects work-item cache entries by repo id, not legacy path keys', () => { const repo = { id: 'repo-1', path: '/same/path' } const repoEntry = entry<GitHubWorkItem[]>([workItem('issue-1', 'repo-1')]) @@ -84,6 +107,18 @@ describe('task page cache selectors', () => { expect(selectTaskPageWorkItemsCacheEntries(cache, [repo], 20, '')).toEqual([repoEntry]) }) + it('selects host-scoped work-item cache entries for remote repos', () => { + const repo = { id: 'repo-1', path: '/same/path', executionHostId: 'runtime:env-1' } + const remoteEntry = entry<GitHubWorkItem[]>([workItem('issue-remote', 'repo-1')]) + const localEntry = entry<GitHubWorkItem[]>([workItem('issue-local', 'repo-1')]) + const cache = { + [workItemsCacheKey(repo.id, 20, '')]: localEntry, + [workItemsCacheKey(repo.id, 20, '', repo.executionHostId)]: remoteEntry + } + + expect(selectTaskPageWorkItemsCacheEntries(cache, [repo], 20, '')).toEqual([remoteEntry]) + }) + it('returns null while the GitHub dialog is closed so cache writes do not re-render it', () => { const item = workItem('issue-1', 'repo-1') const cache = { diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index a02601784ed..a61710abc65 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -11,6 +11,8 @@ import type { GitHubWorkItem, LinearCollectionResult, LinearIssue } from '../../ export type TaskPageRepoCacheInput = { id: string path: string + executionHostId?: string | null + sourceCacheScope?: string | null } export type TaskPageDialogWorkItemKey = { @@ -21,6 +23,7 @@ export type TaskPageDialogWorkItemKey = { export type TaskPageRepoSourceState = { repoId: string repoPath: string + sourceKey: string sources: WorkItemsCacheSources | null error: WorkItemsCacheError | null } @@ -51,7 +54,12 @@ export function selectTaskPageWorkItemsCacheEntries( limit: number, query: string ): (CacheEntry<GitHubWorkItem[]> | undefined)[] { - return repos.map((repo) => workItemsCache[workItemsCacheKey(repo.id, limit, query)]) + return repos.map( + (repo) => + workItemsCache[ + workItemsCacheKey(repo.id, limit, query, repo.sourceCacheScope ?? repo.executionHostId) + ] + ) } export function buildTaskPageRepoSourceState( @@ -63,6 +71,7 @@ export function buildTaskPageRepoSourceState( return { repoId: repo.id, repoPath: repo.path, + sourceKey: `${repo.id}::${repo.sourceCacheScope ?? repo.executionHostId ?? 'local'}`, sources: entry?.sources ?? null, error: entry?.error ?? null } 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..80d57bcfae3 --- /dev/null +++ b/src/renderer/src/components/task-page-default-repo-selection.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/types' +import { + getDefaultTaskRepoSelection, + getTaskProjectPickerGroups, + getTaskProjectPickerRepos, + normalizeTaskRepoSelection +} 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']) + }) + + it('uses GitHub repo icon metadata to identify legacy duplicate projects', () => { + const selection = getDefaultTaskRepoSelection([ + repo({ + id: 'local-claude-swap', + displayName: 'claude-swap', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/claude-swap' + } + }), + repo({ + id: 'ssh-claude-swap', + displayName: 'claude-swap', + connectionId: 'builder', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'StablyAI/claude-swap' + } + }) + ]) + + expect([...selection]).toEqual(['local-claude-swap']) + }) +}) + +describe('getTaskProjectPickerRepos', () => { + it('shows one picker row per logical GitHub project', () => { + const pickerRepos = getTaskProjectPickerRepos([ + 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(pickerRepos.map((candidate) => candidate.id)).toEqual(['local-orca', 'other']) + }) + + it('uses an explicitly selected remote source as the visible project row', () => { + const pickerRepos = getTaskProjectPickerRepos( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['ssh-orca']) + ) + + expect(pickerRepos.map((candidate) => candidate.id)).toEqual(['ssh-orca']) + }) + + it('collapses legacy local and SSH rows that share a GitHub repo icon identity', () => { + const pickerRepos = getTaskProjectPickerRepos([ + repo({ + id: 'local-claude-swap', + displayName: 'claude-swap', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/claude-swap' + } + }), + repo({ + id: 'ssh-claude-swap', + displayName: 'claude-swap', + connectionId: 'builder', + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'StablyAI/claude-swap' + } + }) + ]) + + expect(pickerRepos.map((candidate) => candidate.id)).toEqual(['local-claude-swap']) + }) +}) + +describe('getTaskProjectPickerGroups', () => { + it('keeps all host sources under one logical project row', () => { + const groups = getTaskProjectPickerGroups([ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'docs', + upstream: { owner: 'stablyai', repo: 'docs' } + }) + ]) + + expect(groups).toHaveLength(2) + expect(groups[0]).toMatchObject({ + projectKey: 'github:stablyai/orca', + repo: { id: 'local-orca' } + }) + expect(groups[0]?.sources.map((source) => source.id)).toEqual(['local-orca', 'ssh-orca']) + expect(groups[1]).toMatchObject({ + projectKey: 'github:stablyai/docs', + repo: { id: 'docs' } + }) + }) + + it('uses the explicitly selected source as the project representative', () => { + const groups = getTaskProjectPickerGroups( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['ssh-orca']) + ) + + expect(groups[0]?.repo.id).toBe('ssh-orca') + expect(groups[0]?.sources.map((source) => source.id)).toEqual(['local-orca', 'ssh-orca']) + }) +}) + +describe('normalizeTaskRepoSelection', () => { + it('collapses duplicate selected sources for the same logical project', () => { + const selection = normalizeTaskRepoSelection( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['local-orca', 'ssh-orca']) + ) + + expect([...selection]).toEqual(['local-orca']) + }) + + it('preserves a single explicit remote source selection', () => { + const selection = normalizeTaskRepoSelection( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ], + new Set(['ssh-orca']) + ) + + expect([...selection]).toEqual(['ssh-orca']) + }) + + it('normalizes raw all-host selection to one source per logical project', () => { + const selection = normalizeTaskRepoSelection( + [ + repo({ + id: 'local-orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'ssh-orca', + connectionId: 'builder', + upstream: { owner: 'stablyai', repo: 'orca' } + }), + repo({ + id: 'docs', + upstream: { owner: 'stablyai', repo: 'docs' } + }) + ], + new Set(['local-orca', 'ssh-orca', 'docs']) + ) + + expect([...selection].sort()).toEqual(['docs', 'local-orca']) + }) +}) 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..1c10ae637b7 --- /dev/null +++ b/src/renderer/src/components/task-page-default-repo-selection.ts @@ -0,0 +1,101 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { getProjectIdentityKey } from '../../../shared/project-host-setup-projection' +import type { Repo } from '../../../shared/types' + +export type TaskProjectPickerGroup = { + projectKey: string + repo: Repo + sources: Repo[] +} + +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)) +} + +export function getTaskProjectPickerRepos( + repos: readonly Repo[], + preferredSelection: ReadonlySet<string> = new Set() +): Repo[] { + return getTaskProjectPickerGroups(repos, preferredSelection).map((group) => group.repo) +} + +export function getTaskProjectPickerGroups( + repos: readonly Repo[], + preferredSelection: ReadonlySet<string> = new Set() +): TaskProjectPickerGroup[] { + const groupsByProject = new Map<string, TaskProjectPickerGroup>() + for (const repo of repos) { + const projectKey = getTaskRepoProjectKey(repo) + const current = groupsByProject.get(projectKey) + if (!current) { + groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) + continue + } + current.sources.push(repo) + if (compareTaskProjectPickerCandidate(repo, current.repo, preferredSelection) < 0) { + current.repo = repo + } + } + return [...groupsByProject.values()].map((group) => ({ + ...group, + sources: [...group.sources].sort(compareDefaultTaskRepoCandidate) + })) +} + +export function normalizeTaskRepoSelection( + repos: readonly Repo[], + selection: ReadonlySet<string> +): Set<string> { + const selectedByProject = new Map<string, Repo>() + const selectedIds = new Set(selection) + for (const repo of repos) { + if (!selectedIds.has(repo.id)) { + continue + } + const projectKey = getTaskRepoProjectKey(repo) + const current = selectedByProject.get(projectKey) + if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { + selectedByProject.set(projectKey, repo) + } + } + if (selectedByProject.size === 0) { + return getDefaultTaskRepoSelection(repos) + } + return new Set([...selectedByProject.values()].map((repo) => repo.id)) +} + +export function getTaskRepoProjectKey(repo: Repo): string { + return getProjectIdentityKey(repo) +} + +function compareTaskProjectPickerCandidate( + a: Repo, + b: Repo, + preferredSelection: ReadonlySet<string> +): number { + const aPreferred = preferredSelection.has(a.id) + const bPreferred = preferredSelection.has(b.id) + if (aPreferred !== bPreferred) { + return aPreferred ? -1 : 1 + } + return compareDefaultTaskRepoCandidate(a, b) +} + +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-page-jira-cache-selectors.test.ts b/src/renderer/src/components/task-page-jira-cache-selectors.test.ts new file mode 100644 index 00000000000..f5f60d3cbe2 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-cache-selectors.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' +import type { JiraIssue } from '../../../shared/types' +import { findTaskPageJiraIssue } from './task-page-jira-cache-selectors' + +function jiraSourceContext(environmentId: string): TaskSourceContext { + return { + kind: 'task-source', + provider: 'jira', + projectId: 'logical-project', + hostId: `runtime:${environmentId}`, + providerIdentity: { + provider: 'jira', + siteId: 'site-1' + } + } +} + +function jiraIssue(key: string, title: string, siteId = 'site-1'): JiraIssue { + return { + id: `${siteId}:${key}`, + key, + title, + url: `https://example.atlassian.net/browse/${key}`, + siteId, + siteName: 'Example Jira', + project: { id: '10000', key: 'ALP', name: 'Alpha', siteId }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z' + } +} + +describe('findTaskPageJiraIssue', () => { + it('keeps same-key Jira issues separated by source context', () => { + const localSource = jiraSourceContext('local-runtime') + const remoteSource = jiraSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + + const found = findTaskPageJiraIssue( + { + [`${localScope}::site-1::ALP-1`]: { + data: jiraIssue('ALP-1', 'Local issue'), + fetchedAt: Date.now() + } + }, + { + [`${remoteScope}::site-1::list::assigned::30`]: { + data: [jiraIssue('ALP-1', 'Remote issue')], + fetchedAt: Date.now() + } + }, + 'ALP-1', + { + sourceContext: remoteSource, + siteId: 'site-1' + } + ) + + expect(found?.title).toBe('Remote issue') + }) + + it('filters same-key Jira issues by site id', () => { + const source = jiraSourceContext('remote-runtime') + const scope = getTaskSourceCacheScope(source) + + const found = findTaskPageJiraIssue( + {}, + { + [`${scope}::site-1::list::assigned::30`]: { + data: [jiraIssue('ALP-1', 'Site one issue', 'site-1')], + fetchedAt: Date.now() + }, + [`${scope}::site-2::list::assigned::30`]: { + data: [jiraIssue('ALP-1', 'Site two issue', 'site-2')], + fetchedAt: Date.now() + } + }, + 'ALP-1', + { + sourceContext: source, + siteId: 'site-2' + } + ) + + expect(found?.title).toBe('Site two issue') + }) +}) diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page-jira-cache-selectors.ts index 36fb5125901..5426c404dbf 100644 --- a/src/renderer/src/components/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page-jira-cache-selectors.ts @@ -1,26 +1,51 @@ import type { CacheEntry } from '@/store/slices/github' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' import type { JiraIssue } from '../../../shared/types' type JiraIssueCache = Record<string, CacheEntry<JiraIssue>> type JiraSearchCache = Record<string, CacheEntry<JiraIssue[]>> +export type TaskPageJiraIssueLookupOptions = { + sourceContext?: TaskSourceContext | null + siteId?: string | null +} + export function findTaskPageJiraIssue( jiraIssueCache: JiraIssueCache, jiraSearchCache: JiraSearchCache, - jiraIssueKey: string | null + jiraIssueKey: string | null, + options: TaskPageJiraIssueLookupOptions = {} ): JiraIssue | null { if (!jiraIssueKey) { return null } + const sourceScope = + options.sourceContext?.provider === 'jira' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const matchesLookup = (cacheKey: string, issue: JiraIssue | null | undefined): boolean => { + if (!issue || issue.key !== jiraIssueKey) { + return false + } + if (options.siteId && issue.siteId !== options.siteId) { + return false + } + // Why: Jira issue keys are only unique within a site/source, so drawer lookup + // must not borrow a same-key issue cached for another host/account. + return sourceScope === null || cacheKey.startsWith(`${sourceScope}::`) + } - for (const entry of Object.values(jiraIssueCache)) { - if (entry?.data?.key === jiraIssueKey) { + for (const [cacheKey, entry] of Object.entries(jiraIssueCache)) { + if (matchesLookup(cacheKey, entry?.data)) { return entry.data } } - for (const entry of Object.values(jiraSearchCache)) { - const found = entry?.data?.find((issue) => issue.key === jiraIssueKey) + for (const [cacheKey, entry] of Object.entries(jiraSearchCache)) { + const found = entry?.data?.find((issue) => matchesLookup(cacheKey, issue)) if (found) { return found } diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts new file mode 100644 index 00000000000..8207399c98b --- /dev/null +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const TASK_PAGE_SOURCE = readFileSync(join(__dirname, 'TaskPage.tsx'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('TaskPage source switching host boundary', () => { + it('switches task source without mutating the focused run host', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + '{visibleSourceOptions.map((source) => {', + "{taskSource === 'linear' && linearConnected ?" + ) + + expect(section).toContain('openTaskPage(') + expect(section).toContain('taskSource: source.id') + expect(section).toContain('defaultTaskSource: source.id') + expect(section).not.toContain('activeRuntimeEnvironmentId') + expect(section).not.toContain('projectHostSetupId') + expect(section).not.toContain('workspaceRunContext') + }) + + it('treats missing remote task-source capability as source unavailable', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + 'function getTaskSourceHostAvailabilityForHost', + 'function getTaskPageRepoCacheInput' + ) + + expect(section).toContain('TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY') + expect(section).toContain("reason: 'checking-task-source-capability'") + expect(section).toContain("reason: 'missing-task-source-capability'") + }) + + it('checks runtime-owned provider auth on the owning runtime', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + 'const runtimeTaskSourceHostIds = useMemo(() => {', + 'const getTaskPickerRepoHostLabel = useCallback(' + ) + + expect(section).toContain('TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY') + expect(section).toContain("'preflight.check'") + expect(section).toContain("{ kind: 'environment', environmentId: parsed.environmentId }") + expect(TASK_PAGE_SOURCE).toContain('runtimePreflightStatusByHostId') + }) + + it('preserves exact GitLab project identity when opening or starting from an item', () => { + const sourceContextBuilder = sourceBetween( + TASK_PAGE_SOURCE, + 'function getTaskPageRepoSourceContext', + 'function getTaskSourceHostAvailabilityForHost' + ) + expect(sourceContextBuilder).toContain('gitlabProjectRef?: GitLabProjectRef | null') + expect(sourceContextBuilder).toContain('buildGitLabProviderIdentity(gitlabProjectRef)') + + const openGitLabDetail = sourceBetween( + TASK_PAGE_SOURCE, + 'const openGitLabDetailPage = useCallback(', + 'const patchTaskPageWorkItemRows = useCallback(' + ) + expect(openGitLabDetail).toContain('item.projectRef') + + const startGitLabWorkspace = sourceBetween( + TASK_PAGE_SOURCE, + 'const openComposerForGitLabItem = useCallback(', + 'const handleUseGitLabItem = useCallback(' + ) + expect(startGitLabWorkspace).toContain('item.projectRef') + }) +}) diff --git a/src/renderer/src/components/task-project-source-combobox-model.ts b/src/renderer/src/components/task-project-source-combobox-model.ts new file mode 100644 index 00000000000..248adfcfa11 --- /dev/null +++ b/src/renderer/src/components/task-project-source-combobox-model.ts @@ -0,0 +1,41 @@ +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/types' +import type { TaskProjectPickerGroup } from './task-page-default-repo-selection' + +export function selectedTaskProjectGroups( + groups: readonly TaskProjectPickerGroup[], + selected: ReadonlySet<string> +): TaskProjectPickerGroup[] { + return groups.filter((group) => group.sources.some((source) => selected.has(source.id))) +} + +export function isTaskProjectGroupSelected( + group: TaskProjectPickerGroup, + selected: ReadonlySet<string> +): boolean { + return group.sources.some((source) => selected.has(source.id)) +} + +export function getSelectedTaskProjectSource( + group: TaskProjectPickerGroup, + selected: ReadonlySet<string> +): Repo { + return group.sources.find((source) => selected.has(source.id)) ?? group.repo +} + +export function hasMultipleTaskProjectHosts(groups: readonly TaskProjectPickerGroup[]): boolean { + const hostIds = new Set<string>() + for (const group of groups) { + for (const source of group.sources) { + hostIds.add(getRepoExecutionHostId(source)) + if (hostIds.size > 1) { + return true + } + } + } + return false +} + +export function hasMultipleTaskProjectHostsInGroup(group: TaskProjectPickerGroup): boolean { + return hasMultipleTaskProjectHosts([group]) +} diff --git a/src/renderer/src/components/task-project-source-combobox.tsx b/src/renderer/src/components/task-project-source-combobox.tsx new file mode 100644 index 00000000000..a2c9d6fa9dc --- /dev/null +++ b/src/renderer/src/components/task-project-source-combobox.tsx @@ -0,0 +1,406 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronRight, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandInput, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { searchRepos } from '@/lib/repo-search' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../shared/types' +import type { TaskProjectPickerGroup } from './task-page-default-repo-selection' +import { + getSelectedTaskProjectSource, + hasMultipleTaskProjectHosts, + hasMultipleTaskProjectHostsInGroup, + isTaskProjectGroupSelected, + selectedTaskProjectGroups +} from './task-project-source-combobox-model' + +type TaskProjectSourceStatus = { + label: string + title?: string + disabled?: boolean +} + +type TaskProjectSourceComboboxProps = { + groups: TaskProjectPickerGroup[] + selected: ReadonlySet<string> + onChange: (next: ReadonlySet<string>) => void + onSelectAll: () => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined + getRepoSourceStatus?: (repo: Repo) => TaskProjectSourceStatus | null | undefined + triggerClassName?: string +} + +function renderTriggerLabel( + groups: readonly TaskProjectPickerGroup[], + selected: ReadonlySet<string> +): React.JSX.Element { + if (groups.length === 0) { + return ( + <span className="text-muted-foreground"> + {translate('auto.components.task.project.source.combobox.noProjects', 'No projects')} + </span> + ) + } + const selectedProjectGroups = selectedTaskProjectGroups(groups, selected) + if (selectedProjectGroups.length === groups.length) { + return ( + <span className="inline-flex min-w-0 items-center gap-1.5"> + {translate('auto.components.task.project.source.combobox.allProjects', 'All projects')} + </span> + ) + } + const [first, second, ...rest] = selectedProjectGroups + return ( + <span className="inline-flex min-w-0 items-center gap-1.5 truncate"> + {first ? ( + <RepoBadgeLabel + name={first.repo.displayName} + color={first.repo.badgeColor} + badgeClassName="size-1.5" + /> + ) : null} + {second ? <span className="text-muted-foreground">, {second.repo.displayName}</span> : null} + {rest.length > 0 ? <span className="text-muted-foreground">+{rest.length}</span> : null} + </span> + ) +} + +function getProjectDetail( + group: TaskProjectPickerGroup, + selected: ReadonlySet<string>, + showHostLabels: boolean, + getRepoHostLabel?: (repo: Repo) => string | null | undefined +): string { + const selectedSource = getSelectedTaskProjectSource(group, selected) + const hostLabel = showHostLabels ? getRepoHostLabel?.(selectedSource)?.trim() : '' + if (hasMultipleTaskProjectHostsInGroup(group)) { + const hostCount = translate( + 'auto.components.task.project.source.combobox.hostCount', + '{{value0}} hosts', + { + value0: String(group.sources.length) + } + ) + return hostLabel ? `${hostLabel} · ${hostCount}` : hostCount + } + return hostLabel ? `${hostLabel} · ${selectedSource.path}` : selectedSource.path +} + +function getSourceDetail(repo: Repo, status?: TaskProjectSourceStatus | null): string { + return status?.label ? `${repo.path} · ${status.label}` : repo.path +} + +export default function TaskProjectSourceCombobox({ + groups, + selected, + onChange, + onSelectAll, + getRepoHostLabel, + getRepoSourceStatus, + triggerClassName +}: TaskProjectSourceComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(false) + const [sourceMenuProjectKey, setSourceMenuProjectKey] = useState<string | null>(null) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + const sourceMenuCloseTimerRef = useRef<number | null>(null) + const sourceMenuHoverRef = useRef<{ + projectKey: string | null + row: boolean + content: boolean + }>({ projectKey: null, row: false, content: false }) + + const filteredGroups = useMemo(() => { + const trimmed = query.trim() + if (!trimmed) { + return groups + } + return groups.filter((group) => searchRepos(group.sources, trimmed).length > 0) + }, [groups, query]) + const showHostLabels = useMemo(() => hasMultipleTaskProjectHosts(groups), [groups]) + const allSelected = + groups.length > 0 && selectedTaskProjectGroups(groups, selected).length === groups.length + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setQuery('') + setSourceMenuProjectKey(null) + sourceMenuHoverRef.current = { projectKey: null, row: false, content: false } + } + }, []) + + const clearSourceMenuCloseTimer = useCallback(() => { + if (sourceMenuCloseTimerRef.current !== null) { + window.clearTimeout(sourceMenuCloseTimerRef.current) + sourceMenuCloseTimerRef.current = null + } + }, []) + + const setSourceMenuHover = useCallback( + (projectKey: string, region: 'row' | 'content', hovered: boolean) => { + clearSourceMenuCloseTimer() + if (sourceMenuHoverRef.current.projectKey !== projectKey) { + sourceMenuHoverRef.current = { projectKey, row: false, content: false } + } + sourceMenuHoverRef.current[region] = hovered + if (hovered) { + setSourceMenuProjectKey(projectKey) + return + } + sourceMenuCloseTimerRef.current = window.setTimeout(() => { + const hover = sourceMenuHoverRef.current + if (hover.projectKey === projectKey && !hover.row && !hover.content) { + setSourceMenuProjectKey((current) => (current === projectKey ? null : current)) + sourceMenuHoverRef.current = { projectKey: null, row: false, content: false } + } + sourceMenuCloseTimerRef.current = null + }, 100) + }, + [clearSourceMenuCloseTimer] + ) + + useEffect(() => clearSourceMenuCloseTimer, [clearSourceMenuCloseTimer]) + + const toggleProject = useCallback( + (group: TaskProjectPickerGroup) => { + const next = new Set(selected) + const selectedSource = group.sources.find((source) => next.has(source.id)) + if (selectedSource) { + if (selectedTaskProjectGroups(groups, selected).length <= 1) { + return + } + for (const source of group.sources) { + next.delete(source.id) + } + } else { + next.add(group.repo.id) + } + onChange(next) + }, + [groups, onChange, selected] + ) + + const selectProjectSource = useCallback( + (group: TaskProjectPickerGroup, source: Repo) => { + const status = getRepoSourceStatus?.(source) + if (status?.disabled) { + return + } + const next = new Set(selected) + for (const candidate of group.sources) { + next.delete(candidate.id) + } + next.add(source.id) + onChange(next) + setSourceMenuProjectKey(null) + sourceMenuHoverRef.current = { projectKey: null, row: false, content: false } + }, + [getRepoSourceStatus, onChange, selected] + ) + + const handleSelectAll = useCallback(() => { + if (allSelected) { + const first = groups[0] + if (!first) { + return + } + onChange(new Set([first.repo.id])) + return + } + onSelectAll() + }, [allSelected, groups, onChange, onSelectAll]) + + return ( + <Popover open={open} onOpenChange={handleOpenChange}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + role="combobox" + aria-expanded={open} + className={cn('h-8 w-full justify-between px-3 text-xs font-normal', triggerClassName)} + > + {renderTriggerLabel(groups, selected)} + <ChevronsUpDown className="size-3.5 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-[min(360px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0" + > + <Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}> + <CommandInput + autoFocus + placeholder={translate( + 'auto.components.task.project.source.combobox.searchProjects', + 'Search projects...' + )} + value={query} + onValueChange={setQuery} + className="text-xs" + /> + <div className="border-b border-border"> + <button + type="button" + onClick={handleSelectAll} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => setCommandValue('')} + className={cn( + 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground', + allSelected && 'opacity-80' + )} + > + <Check + className={cn( + 'size-3 text-muted-foreground', + allSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <span> + {translate( + 'auto.components.task.project.source.combobox.allProjects', + 'All projects' + )} + </span> + </button> + </div> + <CommandList> + {filteredGroups.length === 0 ? ( + <div className="px-3 py-6 text-center text-xs text-muted-foreground"> + {translate( + 'auto.components.task.project.source.combobox.noMatches', + 'No projects match your search.' + )} + </div> + ) : null} + {filteredGroups.map((group) => { + const selectedProject = isTaskProjectGroupSelected(group, selected) + const selectedSource = getSelectedTaskProjectSource(group, selected) + const detail = getProjectDetail(group, selected, showHostLabels, getRepoHostLabel) + const hasSourceMenu = hasMultipleTaskProjectHostsInGroup(group) + return ( + <div + key={group.projectKey} + onMouseEnter={() => { + setCommandValue(group.repo.id) + if (hasSourceMenu) { + setSourceMenuHover(group.projectKey, 'row', true) + } + }} + onMouseLeave={() => { + if (hasSourceMenu) { + setSourceMenuHover(group.projectKey, 'row', false) + } + }} + className={cn( + 'group/source-row flex items-stretch transition-colors hover:bg-accent hover:text-accent-foreground', + commandValue === group.repo.id && 'bg-accent text-accent-foreground' + )} + > + <button + type="button" + onClick={() => toggleProject(group)} + onMouseDown={(event) => event.preventDefault()} + className="flex min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left text-xs" + > + <Check + className={cn( + 'size-3 text-muted-foreground', + selectedProject ? 'opacity-70' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <span className="inline-flex items-center gap-1.5 text-xs"> + <RepoBadgeLabel + name={group.repo.displayName} + color={group.repo.badgeColor} + className="max-w-full" + /> + </span> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{detail}</p> + </div> + </button> + {hasSourceMenu ? ( + <Popover + open={sourceMenuProjectKey === group.projectKey} + onOpenChange={(nextOpen) => + setSourceMenuProjectKey(nextOpen ? group.projectKey : null) + } + > + <PopoverTrigger asChild> + <button + type="button" + title={translate( + 'auto.components.task.project.source.combobox.chooseSource', + 'Choose task source' + )} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => event.preventDefault()} + className="flex w-8 shrink-0 items-center justify-center text-muted-foreground" + > + <ChevronRight className="size-3.5" /> + </button> + </PopoverTrigger> + <PopoverContent + side="right" + align="start" + sideOffset={6} + className="w-[min(280px,calc(100vw-1rem))] p-1" + onMouseEnter={() => setSourceMenuHover(group.projectKey, 'content', true)} + onMouseLeave={() => setSourceMenuHover(group.projectKey, 'content', false)} + > + <div className="py-1"> + {group.sources.map((source) => { + const status = getRepoSourceStatus?.(source) + const sourceSelected = source.id === selectedSource.id + const sourceDetail = getSourceDetail(source, status) + return ( + <button + key={source.id} + type="button" + disabled={status?.disabled} + title={status?.title} + onMouseDown={(event) => event.preventDefault()} + onClick={() => selectProjectSource(group, source)} + className={cn( + 'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground', + status?.disabled && 'cursor-not-allowed opacity-50' + )} + > + <Check + className={cn( + 'size-3 text-muted-foreground', + sourceSelected ? 'opacity-70' : 'opacity-0' + )} + /> + <div className="min-w-0 flex-1"> + <div className="truncate text-xs"> + {getRepoHostLabel?.(source) ?? source.displayName} + </div> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground"> + {sourceDetail} + </p> + </div> + </button> + ) + })} + </div> + </PopoverContent> + </Popover> + ) : null} + </div> + ) + })} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} 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..e403ab07aa1 --- /dev/null +++ b/src/renderer/src/components/task-source-context-summary.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from 'vitest' +import { + getTaskSourceAvailabilityNotice, + 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 repo-backed provider account labels when accounts can differ by host', () => { + const summary = getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 2, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'local', + projectHostSetupId: 'setup-local', + repoId: 'repo-local', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: 'personal-gh' + }, + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder', + repoId: 'repo-builder', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + accountLabel: 'work-gh' + } + ] + }) + + expect(summary.label).toBe('GitHub · Local Mac, builder · personal-gh, work-gh') + expect(summary.title).toBe( + 'GitHub · Host: Local Mac, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects' + ) + }) + + 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 remote-server task-source capability checks', () => { + 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', reason: 'checking-task-source-capability' } + ] + }) + + expect(summary.label).toBe('GitHub · old-server · checking server capabilities · stablyai/orca') + expect(summary.title).toBe( + 'GitHub · Host: old-server · Availability: old-server checking server capabilities · Source: stablyai/orca' + ) + }) + + it('uses saved remote server labels in repo-backed source summaries and notices', () => { + const hostLabelById = new Map([['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box']]) + + expect( + getTaskSourceContextSummary({ + provider: 'github', + providerLabel: 'GitHub', + selectedRepoCount: 1, + hostLabelById, + repoContexts: [ + { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + repoId: 'repo-runtime', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } + ], + hostAvailability: [ + { + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + health: 'blocked' + } + ] + }) + ).toEqual({ + label: 'GitHub · dev box · server update needed · stablyai/orca', + title: + 'GitHub · Host: dev box · Availability: dev box server update needed · Source: stablyai/orca' + }) + + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + sourceCount: 1, + hostLabelById, + hostAvailability: [ + { + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + reason: 'missing-task-source-capability' + } + ] + })?.label + ).toBe('GitHub source unavailable: dev box server update needed for task sources') + }) + + it('shows remote-server task-source capability version skew', () => { + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + sourceCount: 1, + hostAvailability: [ + { hostId: 'runtime:old-server', reason: 'missing-task-source-capability' } + ] + }) + ).toEqual({ + label: 'GitHub source unavailable: old-server server update needed for task sources', + title: + 'Reconnect or update old-server server update needed for task sources to load this source.', + blocking: true + }) + }) + + 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' + ) + }) + + it('builds a visible unavailable-source notice from host availability', () => { + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + hostAvailability: [{ hostId: 'ssh:devbox', status: 'auth-failed' }] + }) + ).toEqual({ + label: 'GitHub source unavailable: devbox auth needed', + title: 'Reconnect or update devbox auth needed to load this source.', + blocking: true + }) + + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitLab', + sourceCount: 3, + hostAvailability: [ + { hostId: 'ssh:devbox', status: 'disconnected' }, + { hostId: 'runtime:old-server', health: 'blocked' } + ] + })?.label + ).toBe('Some GitLab source hosts unavailable: 2 source hosts') + }) + + it('shows provider-specific source availability reasons', () => { + expect( + 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', reason: 'missing-provider-auth' }] + }) + ).toEqual({ + label: 'GitHub · devbox · provider auth needed · stablyai/orca', + title: + 'GitHub · Host: devbox · Availability: devbox provider auth needed · Source: stablyai/orca' + }) + + expect( + getTaskSourceAvailabilityNotice({ + providerLabel: 'GitHub', + sourceCount: 3, + hostAvailability: [ + { hostId: 'ssh:devbox', reason: 'unavailable-source-tool' }, + { hostId: 'runtime:linux', reason: 'unsupported-provider' } + ] + }) + ).toEqual({ + label: 'Some GitHub source hosts unavailable: 2 source hosts', + title: + 'Reconnect or update devbox source tool unavailable, linux provider unsupported on this host to load this source.', + blocking: false + }) + }) +}) 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..a277298aecd --- /dev/null +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -0,0 +1,301 @@ +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 TaskSourceAvailabilityNotice = { + label: string + title: string + blocking: boolean +} + +export type TaskSourceHostAvailability = { + hostId: ExecutionHostScope + status?: SshConnectionStatus + health?: ExecutionHostHealth + reason?: + | 'checking-task-source-capability' + | 'missing-task-source-capability' + | 'missing-provider-auth' + | 'unavailable-source-tool' + | 'unsupported-provider' +} + +type HostLabelLookup = ReadonlyMap<string, string> | undefined + +function getHostLabel(hostId: ExecutionHostScope, hostLabelById: HostLabelLookup): string { + return hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId) +} + +export function getTaskSourceContextSummary(args: { + provider: TaskProvider + providerLabel: string + repoContexts?: readonly TaskSourceContext[] + hostAvailability?: readonly TaskSourceHostAvailability[] + hostLabelById?: HostLabelLookup + 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, + hostLabelById: args.hostLabelById, + hostAvailability: args.hostAvailability + }) + case 'jira': + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: args.jiraSiteName, + accountHostId: args.accountHostId, + hostLabelById: args.hostLabelById, + hostAvailability: args.hostAvailability + }) + } +} + +export function getTaskSourceAvailabilityNotice(args: { + providerLabel: string + hostAvailability?: readonly TaskSourceHostAvailability[] + hostLabelById?: HostLabelLookup + sourceCount?: number +}): TaskSourceAvailabilityNotice | null { + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? [], args.hostLabelById) + if (unavailableHosts.length === 0) { + return null + } + const sourceCount = Math.max(args.sourceCount ?? unavailableHosts.length, unavailableHosts.length) + const blocking = unavailableHosts.length >= sourceCount + const hostStatusLabels = unavailableHosts.map((host) => `${host.hostLabel} ${host.statusLabel}`) + const target = + unavailableHosts.length === 1 ? hostStatusLabels[0] : `${unavailableHosts.length} source hosts` + return { + label: blocking + ? `${args.providerLabel} source unavailable: ${target}` + : `Some ${args.providerLabel} source hosts unavailable: ${target}`, + title: `Reconnect or update ${formatLongList(hostStatusLabels)} to load this source.`, + blocking + } +} + +function getRepoBackedTaskSourceSummary(args: { + providerLabel: string + repoContexts?: readonly TaskSourceContext[] + hostAvailability?: readonly TaskSourceHostAvailability[] + hostLabelById?: HostLabelLookup + selectedRepoCount?: number +}): TaskSourceContextSummary { + const contexts = args.repoContexts ?? [] + const hostLabels = uniqueLabels( + contexts.map((context) => getHostLabel(context.hostId, args.hostLabelById)) + ) + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? [], args.hostLabelById) + const availabilityLabel = getAvailabilityLabel(unavailableHosts) + const identityLabels = uniqueLabels( + contexts.map((context) => getProviderIdentityLabel(context.providerIdentity)) + ) + const accountLabels = uniqueLabels(contexts.map((context) => context.accountLabel)) + const repoCount = args.selectedRepoCount ?? contexts.length + const hostLabel = hostLabels.length === 0 ? 'No host' : formatShortList(hostLabels) + const accountLabel = accountLabels.length > 0 ? `Account: ${formatLongList(accountLabels)}` : null + const targetLabel = + accountLabels.length > 1 + ? formatShortList(accountLabels) + : 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, + accountLabel, + 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 + hostLabelById?: HostLabelLookup + hostAvailability?: readonly TaskSourceHostAvailability[] + } +): TaskSourceContextSummary { + const target = args.accountLabel?.trim() || 'Current account' + const hostLabel = getHostLabel(args.accountHostId ?? 'local', args.hostLabelById) + const unavailableHosts = getUnavailableHosts(args.hostAvailability ?? [], args.hostLabelById) + 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[], + hostLabelById?: HostLabelLookup +): { + 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 = getHostLabel(availability.hostId, hostLabelById) + 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 { + switch (availability.reason) { + case 'checking-task-source-capability': + return 'checking server capabilities' + case 'missing-task-source-capability': + return 'server update needed for task sources' + case 'missing-provider-auth': + return 'provider auth needed' + case 'unavailable-source-tool': + return 'source tool unavailable' + case 'unsupported-provider': + return 'provider unsupported on this host' + } + 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/task-source-provider-availability.test.ts b/src/renderer/src/components/task-source-provider-availability.test.ts new file mode 100644 index 00000000000..8fd08bbf8c2 --- /dev/null +++ b/src/renderer/src/components/task-source-provider-availability.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { PreflightStatus } from '../../../preload/api-types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getRepoBackedProviderAvailability } from './task-source-provider-availability' + +const readyPreflight: PreflightStatus = { + git: { installed: true }, + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } +} + +function source(hostId: TaskSourceContext['hostId']): TaskSourceContext { + return { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId, + repoId: `repo-${hostId}` + } +} + +describe('task source provider availability', () => { + it('marks desktop-owned GitHub sources unavailable when gh auth is missing', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('local'), source('ssh:builder')], + preflightReady: true, + preflightStatus: { + ...readyPreflight, + gh: { installed: true, authenticated: false } + } + }) + ).toEqual([ + { hostId: 'local', reason: 'missing-provider-auth' }, + { hostId: 'ssh:builder', reason: 'missing-provider-auth' } + ]) + }) + + it('marks desktop-owned GitLab sources unavailable when glab is missing', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'gitlab', + contexts: [source('local')], + preflightReady: true, + preflightStatus: { + ...readyPreflight, + glab: { installed: false, authenticated: false } + } + }) + ).toEqual([{ hostId: 'local', reason: 'unavailable-source-tool' }]) + }) + + it('marks GitLab unsupported when a host preflight payload predates GitLab support', () => { + const { glab: _glab, ...preGitLabPreflight } = readyPreflight + + expect( + getRepoBackedProviderAvailability({ + provider: 'gitlab', + contexts: [source('local')], + preflightReady: true, + preflightStatus: preGitLabPreflight + }) + ).toEqual([{ hostId: 'local', reason: 'unsupported-provider' }]) + }) + + it('does not apply desktop preflight to runtime-owned sources', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: { + ...readyPreflight, + gh: { installed: false, authenticated: false } + } + }) + ).toEqual([]) + }) + + it('marks runtime-owned GitHub sources unavailable from their own preflight', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: readyPreflight, + runtimePreflightStatusByHostId: new Map([ + [ + 'runtime:server', + { + checked: true, + status: { + ...readyPreflight, + gh: { installed: true, authenticated: false } + } + } + ] + ]) + }) + ).toEqual([{ hostId: 'runtime:server', reason: 'missing-provider-auth' }]) + }) + + it('waits for runtime preflight before reporting runtime provider availability', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: readyPreflight, + runtimePreflightStatusByHostId: new Map([ + [ + 'runtime:server', + { + checked: false, + status: null + } + ] + ]) + }) + ).toEqual([]) + }) + + it('marks runtime-owned GitLab sources unsupported when runtime preflight lacks GitLab', () => { + const { glab: _glab, ...preGitLabPreflight } = readyPreflight + + expect( + getRepoBackedProviderAvailability({ + provider: 'gitlab', + contexts: [source('runtime:server')], + preflightReady: true, + preflightStatus: readyPreflight, + runtimePreflightStatusByHostId: new Map([ + [ + 'runtime:server', + { + checked: true, + status: preGitLabPreflight + } + ] + ]) + }) + ).toEqual([{ hostId: 'runtime:server', reason: 'unsupported-provider' }]) + }) + + it('waits for preflight before reporting provider availability', () => { + expect( + getRepoBackedProviderAvailability({ + provider: 'github', + contexts: [source('local')], + preflightReady: false, + preflightStatus: { + ...readyPreflight, + gh: { installed: false, authenticated: false } + } + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/task-source-provider-availability.ts b/src/renderer/src/components/task-source-provider-availability.ts new file mode 100644 index 00000000000..e01e0fef057 --- /dev/null +++ b/src/renderer/src/components/task-source-provider-availability.ts @@ -0,0 +1,77 @@ +import { parseExecutionHostId } from '../../../shared/execution-host' +import type { TaskProvider } from '../../../shared/types' +import type { PreflightStatus } from '../../../preload/api-types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import type { TaskSourceHostAvailability } from './task-source-context-summary' + +type ProviderToolStatus = { + installed: boolean + authenticated: boolean +} + +type ProviderAvailabilityStatus = ProviderToolStatus | 'unsupported' + +export type RuntimeProviderPreflightStatus = { + checked: boolean + status: PreflightStatus | null +} + +function isDesktopOwnedHost(hostId: TaskSourceContext['hostId']): boolean { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind !== 'runtime' +} + +function getRepoBackedProviderToolStatus( + provider: Extract<TaskProvider, 'github' | 'gitlab'>, + preflightStatus: PreflightStatus | null +): ProviderAvailabilityStatus | null { + if (!preflightStatus) { + return null + } + if (provider === 'github') { + return preflightStatus.gh + } + // Why: older remote servers can predate GitLab preflight entirely. That is a + // host capability gap, not a user-fixable missing `glab` install. + return Object.hasOwn(preflightStatus, 'glab') + ? (preflightStatus.glab ?? { installed: false, authenticated: false }) + : 'unsupported' +} + +function getProviderReason( + status: ProviderAvailabilityStatus +): TaskSourceHostAvailability['reason'] | null { + if (status === 'unsupported') { + return 'unsupported-provider' + } + if (!status.installed) { + return 'unavailable-source-tool' + } + if (!status.authenticated) { + return 'missing-provider-auth' + } + return null +} + +export function getRepoBackedProviderAvailability(args: { + provider: Extract<TaskProvider, 'github' | 'gitlab'> + contexts: readonly TaskSourceContext[] + preflightStatus: PreflightStatus | null + preflightReady: boolean + runtimePreflightStatusByHostId?: ReadonlyMap< + TaskSourceContext['hostId'], + RuntimeProviderPreflightStatus + > +}): TaskSourceHostAvailability[] { + return args.contexts.flatMap((context) => { + const hostPreflight = isDesktopOwnedHost(context.hostId) + ? { checked: args.preflightReady, status: args.preflightStatus } + : args.runtimePreflightStatusByHostId?.get(context.hostId) + if (!hostPreflight?.checked) { + return [] + } + const status = getRepoBackedProviderToolStatus(args.provider, hostPreflight.status) + const reason = status ? getProviderReason(status) : null + return reason ? [{ hostId: context.hostId, reason }] : [] + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index bdcd2ce2c8d..e845dabb8c7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -51,7 +51,12 @@ type StoreState = { workspaceStatus?: string }[] > - repos: { id: string; connectionId?: string | null; displayName?: string }[] + repos: { + id: string + connectionId?: string | null + displayName?: string + executionHostId?: string | null + }[] sshConnectionStates: Map<string, { status: string }> cacheTimerByKey: Record<string, number | null> settings: { @@ -4735,6 +4740,81 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') }) + it('spawns fresh PTYs through the worktree owner runtime when focus differs', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport('remote:owner-runtime@@terminal-1') + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + repos: [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'runtime:owner-runtime' + } + ], + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'focused-runtime' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith( + 'owner-runtime', + expect.any(Object) + ) + expect(transport.connect).toHaveBeenCalled() + }) + + it('spawns fresh PTYs locally for explicitly local worktrees while a runtime is focused', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport('pty-local-1') + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + repos: [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'local' + } + ], + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'focused-runtime' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createIpcPtyTransport).toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalled() + }) + it('attaches restored remote PTYs for later split panes instead of spawning host tabs', async () => { const { connectPanePty } = await import('./pty-connection') const existingTransport = createMockTransport('remote:env-1@@terminal-1') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 3a0429622f2..3d1331ef5b1 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 { @@ -1460,8 +1461,8 @@ export function connectPanePty( (restoredPtyIdForTransport ? getRemoteRuntimePtyEnvironmentId(restoredPtyIdForTransport) : null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null) - const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || null - const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? activeRuntimeEnvironmentId + const runtimeEnvironmentId = + remoteRuntimeOwnerForTransport ?? getRuntimeEnvironmentIdForWorktree(state, deps.worktreeId) const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 @@ -1798,40 +1799,35 @@ export function connectPanePty( } 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 sleepingRecord = state.sleepingAgentSessionsByPaneKey[cacheKey] + const useLiveEntry = entry && entry.state !== 'done' + const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent + if (!agent || !isResumableTuiAgent(agent)) { return false } - const providerSession = normalizeAgentProviderSession(rawProviderSession) + const providerSession = normalizeAgentProviderSession( + useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession + ) if (!providerSession) { return false } const startupPlan = buildAgentResumeStartupPlan({ - agent: agentType, + agent, providerSession, cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agentType, state.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agentType, state.settings?.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, state.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 + if (!useLiveEntry && sleepingRecord) { + state.clearSleepingAgentSession(cacheKey) + } return true } const schedulePendingStartupCommandDelivery = (): void => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index 2a4a29743c5..561fa2fa503 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -7,8 +7,15 @@ const mocks = vi.hoisted(() => ({ importExternalPathsToRuntime: vi.fn(), storeState: { settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, + repos: [ + { + id: 'repo1', + connectionId: null as string | null, + executionHostId: 'runtime:env-1' as string | null + } + ], worktreesByRepo: { - repo1: [{ id: 'wt-1', path: '/remote/repo' }] + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }] } } })) @@ -38,8 +45,9 @@ describe('handleTerminalFileDrop', () => { beforeEach(() => { vi.clearAllMocks() mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + mocks.storeState.repos = [{ id: 'repo1', connectionId: null, executionHostId: 'runtime:env-1' }] mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', path: '/remote/repo' }] + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }] } }) @@ -88,7 +96,7 @@ describe('handleTerminalFileDrop', () => { it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => { mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', path: '//server/share/repo' }] + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '//server/share/repo' }] } mocks.importExternalPathsToRuntime.mockResolvedValue({ results: [ @@ -128,6 +136,74 @@ describe('handleTerminalFileDrop', () => { ) expect(sendInput).toHaveBeenCalledWith('\\\\server\\share\\repo\\.orca\\drops\\logo.png ') }) + + it('uploads to the worktree owner runtime instead of the focused runtime', async () => { + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + mocks.storeState.repos = [ + { id: 'repo1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + mocks.importExternalPathsToRuntime.mockResolvedValue({ + results: [ + { + sourcePath: '/Users/me/spec.pdf', + status: 'imported', + destPath: '/remote/repo/.orca/drops/spec.pdf', + kind: 'file', + renamed: false + } + ] + }) + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( + { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/spec.pdf'], + '/remote/repo/.orca/drops' + ) + expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ') + }) + + it('keeps explicit local worktree drops local while a runtime is focused', async () => { + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + mocks.storeState.repos = [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] + const sendInput = vi.fn() + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + cwd: undefined, + data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' } + }) + + expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() + expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(focus).toHaveBeenCalled() + }) }) describe('resolveTerminalDropTargetShell', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts index 966d4e0e9c1..66315f4849e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { extractIpcErrorMessage } from '@/lib/ipc-error' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' import { isWindowsUserAgent, shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' @@ -65,8 +66,9 @@ export async function handleTerminalFileDrop(args: Args): Promise<void> { if (!transport) { return } - const settings = useAppStore.getState().settings - const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() + const state = useAppStore.getState() + const settings = state.settings + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) const worktreePath = resolveWorktreePath(worktreeId, cwd) if (!worktreePath) { toast.error( @@ -78,7 +80,7 @@ export async function handleTerminalFileDrop(args: Args): Promise<void> { return } - if (activeRuntimeEnvironmentId) { + if (runtimeEnvironmentId) { const targetShell = getTerminalTargetShellForWorktreePath(worktreePath) const destinationDir = joinRuntimeDropDir(worktreePath) const pending = toast.loading( @@ -91,7 +93,9 @@ export async function handleTerminalFileDrop(args: Args): Promise<void> { try { const { results } = await importExternalPathsToRuntime( { - settings, + // Why: drops into existing worktrees must follow the worktree owner, + // not the currently focused host in the sidebar. + settings: { ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }, worktreeId, worktreePath }, diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 6ae54286006..4740bd7f1b6 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -90,12 +90,36 @@ describe('createNewTerminalTab', () => { expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', + environmentId: 'web-runtime', command: 'pwsh', activate: true }) expect(createTab).not.toHaveBeenCalled() expect(setActiveTabType).not.toHaveBeenCalled() }) + + it('delegates terminal creation to the explicit owner runtime when another runtime is focused', () => { + const createTab = vi.fn(() => ({ id: 'tab-1' })) + const setActiveTabType = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', executionHostId: 'runtime:owner-runtime', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }, + createTab, + setActiveTabType + }) + + createNewTerminalTab('wt-1', 'pwsh') + + expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'owner-runtime', + command: 'pwsh', + activate: true + }) + expect(createTab).not.toHaveBeenCalled() + }) }) describe('closeTerminalTab', () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index fc2f780c54d..0956b20b691 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -10,6 +10,7 @@ import { isWebTerminalSurfaceTabId } from '@/runtime/web-runtime-session' import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'conflict-review']) @@ -100,13 +101,14 @@ export function createNewTerminalTab( return } const state = useAppStore.getState() - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: paired web clients receive host-owned terminal tabs through // session.tabs. Creating a local tab first races the host snapshot and can // leave stale remote handles in the web store. void createWebRuntimeSessionTerminal({ worktreeId: activeWorktreeId, + environmentId: runtimeEnvironmentId, command: shellOverride, activate: true }) @@ -146,7 +148,7 @@ export function closeTerminalTab(tabId: string): void { return } - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, owningWorktreeId) if (runtimeEnvironmentId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { const hostBackedTabId = resolveHostSessionTabIdForWebSessionTab(state, { @@ -212,7 +214,7 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | const state = useAppStore.getState() const currentTabs = state.tabsByWorktree[activeWorktreeId] ?? [] state.setActiveTab(tabId) - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) for (const tab of currentTabs) { if (tab.id !== tabId) { @@ -242,7 +244,7 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string const state = useAppStore.getState() const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? [] const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) const terminalIds = currentTerminalTabs.map((t) => t.id) const terminalIdSet = new Set(terminalIds) @@ -290,7 +292,7 @@ export function activateTerminalTab(tabId: string): void { Object.entries(s.tabsByWorktree).find(([, worktreeTabs]) => worktreeTabs.some((tab) => tab.id === tabId) )?.[0] ?? null - const runtimeEnvironmentId = s.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(s, owningWorktreeId) if (owningWorktreeId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: activation needs to update the host's active tab as well as the // local optimistic state, otherwise the next host snapshot snaps back. diff --git a/src/renderer/src/components/ui/repo-multi-combobox.test.ts b/src/renderer/src/components/ui/repo-multi-combobox.test.ts new file mode 100644 index 00000000000..a0a4a747e31 --- /dev/null +++ b/src/renderer/src/components/ui/repo-multi-combobox.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { getRepoMultiComboboxDetail } from './repo-multi-combobox' + +function repo(overrides: Partial<Repo> = {}): Repo { + return { + id: 'repo-1', + path: '/Users/jinwoo/orca', + displayName: 'orca', + badgeColor: '#999999', + addedAt: 1, + ...overrides + } +} + +describe('getRepoMultiComboboxDetail', () => { + it('shows host context before the path when available', () => { + expect(getRepoMultiComboboxDetail(repo(), 'Local Mac')).toBe('Local Mac · /Users/jinwoo/orca') + expect(getRepoMultiComboboxDetail(repo({ path: '/home/orca/orca' }), 'openclaw 2')).toBe( + 'openclaw 2 · /home/orca/orca' + ) + }) + + it('keeps the existing path-only detail when no host label is provided', () => { + expect(getRepoMultiComboboxDetail(repo(), null)).toBe('/Users/jinwoo/orca') + expect(getRepoMultiComboboxDetail(repo(), ' ')).toBe('/Users/jinwoo/orca') + }) +}) diff --git a/src/renderer/src/components/ui/repo-multi-combobox.tsx b/src/renderer/src/components/ui/repo-multi-combobox.tsx index 0b89a5bda10..6334f74179e 100644 --- a/src/renderer/src/components/ui/repo-multi-combobox.tsx +++ b/src/renderer/src/components/ui/repo-multi-combobox.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown, Server } from 'lucide-react' +import { Check, ChevronsUpDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { Command, @@ -28,6 +28,7 @@ type RepoMultiComboboxProps = { * signal, so the caller can persist `null` (sticky-all) rather than a * frozen snapshot that would exclude repos added later. */ onSelectAll: () => void + getRepoHostLabel?: (repo: Repo) => string | null | undefined triggerClassName?: string } @@ -63,11 +64,17 @@ function renderTriggerLabel(repos: Repo[], selected: ReadonlySet<string>): React ) } +export function getRepoMultiComboboxDetail(repo: Repo, hostLabel?: string | null): string { + const trimmedHostLabel = hostLabel?.trim() + return trimmedHostLabel ? `${trimmedHostLabel} · ${repo.path}` : repo.path +} + export default function RepoMultiCombobox({ repos, selected, onChange, onSelectAll, + getRepoHostLabel, triggerClassName }: RepoMultiComboboxProps): React.JSX.Element { const [open, setOpen] = useState(false) @@ -188,6 +195,7 @@ export default function RepoMultiCombobox({ {filteredRepos.map((repo) => { const isSelected = selected.has(repo.id) const isLastSelected = isSelected && selected.size <= 1 + const detail = getRepoMultiComboboxDetail(repo, getRepoHostLabel?.(repo)) return ( <CommandItem key={repo.id} @@ -209,14 +217,8 @@ export default function RepoMultiCombobox({ color={repo.badgeColor} className="max-w-full" /> - {repo.connectionId && ( - <span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground"> - <Server className="size-2.5" /> - {translate('auto.components.ui.repo.multi.combobox.286ce70256', 'SSH')} - </span> - )} </span> - <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{repo.path}</p> + <p className="mt-0.5 truncate text-[10px] text-muted-foreground">{detail}</p> </div> </CommandItem> ) diff --git a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts new file mode 100644 index 00000000000..ac60d86d2eb --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(__dirname, 'WorktreeJumpPalette.tsx'), 'utf8') + +function sourceBetween(startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('WorktreeJumpPalette source-context boundaries', () => { + it('resolves typed GitHub issue/PR entries through the lookup repo source host', () => { + expect(source).toContain('buildTaskSourceContextFromRepo') + + const githubLinkSection = sourceBetween( + 'void lookupGitHubWorkItemByOwnerRepoForSource({', + '// Case 2: user typed a raw issue number.' + ) + expect(githubLinkSection).toContain('sourceContext') + + const rawNumberSection = sourceBetween( + 'void lookupGitHubWorkItemForSource({', + '.then((item) => {' + ) + expect(rawNumberSection).toContain('sourceContext') + }) +}) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index cbc17bbf783..2fc2eb3e744 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -11,6 +11,7 @@ import type { AutomationDispatchResult, AutomationPrecheckResult } from '../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' import { didAutomationPrecheckPass, formatAutomationPrecheckFailure @@ -57,7 +58,8 @@ export function useAutomationDispatchEvents(): void { activeTabId: state.activeTabId, activeTabType: state.activeTabType } - const repo = state.repos.find((entry) => entry.id === automation.projectId) + const runRepoId = getAutomationRunRepoId(automation) + const repo = state.repos.find((entry) => entry.id === runRepoId) const automationWorktree = automation.workspaceId ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId) : null @@ -117,6 +119,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 +177,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-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts new file mode 100644 index 00000000000..75ce1b86d14 --- /dev/null +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveInitialWorkspaceRunSeed } from './useComposerState' + +const HOOK_SOURCE = readFileSync(join(__dirname, 'useComposerState.ts'), 'utf8') + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('useComposerState host-context boundaries', () => { + it('resolves GitHub PR bases against the selected run repo, not the source item repo', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleSmartGitHubItemSelect', + 'const handleSmartGitLabItemSelect' + ) + + expect(section).toContain('const runRepo = selectedRepo ??') + expect(section).toContain('repoId: runRepo.id') + expect(section).toContain('repo: runRepo.id') + expect(section).not.toContain('repoId: repoForItem.id') + expect(section).not.toContain('repo: repoForItem.id') + }) + + it('resolves GitLab MR bases against the selected run repo, not the source item repo', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleSmartGitLabItemSelect', + 'const handleSmartBranchSelect' + ) + + expect(section).toContain('const runRepo = selectedRepo ??') + expect(section).toContain('repoId: runRepo.id') + expect(section).not.toContain('repoId: repoForItem.id') + }) + + it('seeds initial workspace run target from the task source context', () => { + expect( + resolveInitialWorkspaceRunSeed({ + initialTaskSourceContext: { + projectId: 'logical-project', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder' + } + }) + ).toEqual({ + projectId: 'logical-project', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder' + }) + + expect( + resolveInitialWorkspaceRunSeed({ + draftProjectId: 'draft-project', + draftHostId: 'local', + draftProjectHostSetupId: 'setup-local', + initialTaskSourceContext: { + projectId: 'logical-project', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder' + } + }) + ).toEqual({ + projectId: 'draft-project', + hostId: 'local', + projectHostSetupId: 'setup-local' + }) + + const section = sourceBetween(HOOK_SOURCE, 'const initialRunSeed', 'const [internalRepoId') + + expect(section).toContain('resolveInitialWorkspaceRunSeed') + expect(section).toContain('initialTaskSourceContext') + expect(section).toContain('projectId: initialRunSeed.projectId') + expect(section).toContain('hostId: initialRunSeed.hostId') + expect(section).toContain('projectHostSetupId: initialRunSeed.projectHostSetupId') + }) + + it('resolves typed GitHub issue/PR input through the selected repo source context', () => { + expect(HOOK_SOURCE).toContain('const selectedRepoGitHubSourceContext = useMemo') + + const directLookup = sourceBetween( + HOOK_SOURCE, + 'void window.api.gh', + 'const applyLinkedWorkItem = useCallback' + ) + expect(directLookup).toContain('sourceContext: selectedRepoGitHubSourceContext') + + const submitLookup = sourceBetween( + HOOK_SOURCE, + 'const resolvePendingSmartGitHubSubmit', + 'const resolution = getSmartGitHubSubmitResolution(item)' + ) + expect(submitLookup).toContain('sourceContext: selectedRepoGitHubSourceContext') + }) +}) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 5a364f0cd20..dd53da51fa5 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -25,6 +25,11 @@ import { 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, @@ -78,14 +83,34 @@ import { getSmartGitHubSubmitResolution, type SmartGitHubSubmitResolution } from '@/lib/smart-github-submit' +import { + lookupGitHubWorkItemByOwnerRepoForSource, + lookupGitHubWorkItemForSource +} from '@/lib/github-work-item-source-lookup' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' import { canUseRepoBackedComposerSources, getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate' -import { getComposerEligibleRepos, resolveComposerRepoId } from '@/lib/new-workspace-composer-repo' +import { getComposerEligibleRepos } from '@/lib/new-workspace-composer-repo' +import { + resolveWorkspaceCreationRepoId, + resolveWorkspaceCreationTarget +} from '@/lib/project-host-workspace-target' +import { + buildProjectHostSetupOptions, + type ProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import { + buildNewWorkspaceProjectOptions, + type NewWorkspaceProjectOption +} from '@/lib/new-workspace-project-options' +import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { getForkPushWarning } from './fork-push-warning' @@ -116,6 +141,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 @@ -148,8 +174,14 @@ export type UseComposerStateOptions = { export type ComposerCardProps = { eligibleRepos: ReturnType<typeof useAppStore.getState>['repos'] repoId: string + projectOptions: NewWorkspaceProjectOption[] + selectedProjectId: string | null selectedRepoIsGit: boolean onRepoChange: (value: string) => void + onProjectChange: (value: string) => void + projectHostSetupOptions: ProjectHostSetupOption[] + selectedProjectHostSetupId: string | null + onProjectHostSetupChange: (setupId: string) => void name: string onNameValueChange: (value: string) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void @@ -254,6 +286,34 @@ export type UseComposerStateResult = { createDisabled: boolean } +export type InitialWorkspaceRunSeedInput = { + draftProjectId?: string | null + draftHostId?: string | null + draftProjectHostSetupId?: string | null + initialTaskSourceContext?: Pick< + TaskSourceContext, + 'projectId' | 'hostId' | 'projectHostSetupId' + > | null +} + +export function resolveInitialWorkspaceRunSeed({ + draftProjectId, + draftHostId, + draftProjectHostSetupId, + initialTaskSourceContext +}: InitialWorkspaceRunSeedInput): { + projectId: string | null + hostId: ExecutionHostId | null + projectHostSetupId: string | null +} { + return { + projectId: draftProjectId ?? initialTaskSourceContext?.projectId ?? null, + hostId: normalizeExecutionHostId(draftHostId ?? initialTaskSourceContext?.hostId), + projectHostSetupId: + draftProjectHostSetupId ?? initialTaskSourceContext?.projectHostSetupId ?? null + } +} + // Why: both the full-page TaskPage composer and the Cmd+J modal can be // mounted simultaneously. Without instance scoping, a single native file // drop fires every subscriber and duplicates attachments/prompt edits across @@ -270,6 +330,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS initialName = '', initialPrompt = '', initialLinkedWorkItem = null, + initialTaskSourceContext = null, initialWorkspaceStatus, initialBaseBranch, persistDraft, @@ -315,6 +376,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } = actions const repos = useAppStore((s) => s.repos) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) const activeRepoId = useAppStore((s) => s.activeRepoId) const settings = useAppStore((s) => s.settings) const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft) @@ -322,9 +385,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const eligibleRepos = useMemo(() => getComposerEligibleRepos(repos), [repos]) const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null + const draftProjectId = persistDraft ? (newWorkspaceDraft?.projectId ?? null) : null + const draftHostId = persistDraft ? (newWorkspaceDraft?.hostId ?? null) : null + const draftProjectHostSetupId = persistDraft + ? (newWorkspaceDraft?.projectHostSetupId ?? null) + : null + // Why: Tasks can start work from Linear/Jira source contexts that are not + // repo-backed. Seed the run target from the logical project/source host so + // the modal does not silently fall back to the ambient active repo. + const initialRunSeed = resolveInitialWorkspaceRunSeed({ + draftProjectId, + draftHostId, + draftProjectHostSetupId, + initialTaskSourceContext + }) const resolvedInitialWorkspaceStatus = useMemo( () => initialWorkspaceStatus && isWorkspaceStatusId(initialWorkspaceStatus, workspaceStatuses) @@ -333,17 +414,90 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [initialWorkspaceStatus, workspaceStatuses] ) - const resolvedInitialRepoId = resolveComposerRepoId({ + const resolvedInitialRepoId = resolveWorkspaceCreationRepoId({ eligibleRepos, + projects, + projectHostSetups, draftRepoId, initialRepoId, - activeRepoId + activeRepoId, + projectId: initialRunSeed.projectId, + hostId: initialRunSeed.hostId, + projectHostSetupId: initialRunSeed.projectHostSetupId, + focusedHostScope: workspaceHostScope }) const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId) const [projectError, setProjectError] = useState<string | null>(null) const repoId = repoIdOverride ?? internalRepoId + const selectedWorkspaceTarget = useMemo( + () => + resolveWorkspaceCreationTarget({ + eligibleRepos, + projects, + projectHostSetups, + draftRepoId: repoId, + focusedHostScope: workspaceHostScope + }), + [eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope] + ) const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) + const selectedProjectId = + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null + const selectedProjectHostSetupId = + selectedWorkspaceTarget.status === 'ready' + ? selectedWorkspaceTarget.target.projectHostSetupId + : null + const hostOptions = useMemo( + () => + buildExecutionHostRegistry({ + repos, + settings, + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(settings) + }), + [ + repos, + settings, + sshConnectionStates, + sshTargetLabels, + runtimeEnvironments, + runtimeStatusByEnvironmentId + ] + ) + const projectHostSetupOptions = useMemo( + () => + buildProjectHostSetupOptions({ + projectId: selectedProjectId, + projectHostSetups, + eligibleRepos, + hosts: hostOptions + }), + [eligibleRepos, hostOptions, projectHostSetups, selectedProjectId] + ) + const projectOptions = useMemo( + () => + buildNewWorkspaceProjectOptions({ + projects, + projectHostSetups, + eligibleRepos + }), + [eligibleRepos, projectHostSetups, projects] + ) + const selectedRepoSettings = useMemo(() => { + if (!settings) { + return settings + } + // Why: composer probes and attachment uploads inspect the selected repo, + // even though workspace creation defaults still follow host scope. + return getSettingsForRepoRuntimeOwner( + { repos: selectedRepo ? [selectedRepo] : [], settings }, + selectedRepo?.id ?? null + ) + }, [selectedRepo, settings]) const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const selectedRepoSshState = selectedRepoConnectionId @@ -382,6 +536,77 @@ 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?.linkedWorkItem?.url, + newWorkspaceDraft?.taskSourceContext, + persistDraft, + projects, + selectedRepo, + selectedWorkspaceTarget + ]) + const selectedRepoGitHubSourceContext = useMemo(() => { + if (!selectedRepo || !selectedRepoIsGit) { + return null + } + if (taskSourceContext?.provider === 'github') { + return taskSourceContext + } + if (selectedWorkspaceTarget.status === 'ready') { + const selectedProject = projects.find( + (project) => project.id === selectedWorkspaceTarget.target.projectId + ) + return buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedWorkspaceTarget.target.projectId, + repo: selectedRepo, + projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId, + providerIdentity: + selectedProject?.providerIdentity?.provider === 'github' + ? selectedProject.providerIdentity + : null + }) + } + return buildTaskSourceContextFromRepo({ + provider: 'github', + projectId: selectedRepo.id, + repo: selectedRepo + }) + }, [projects, selectedRepo, selectedRepoIsGit, selectedWorkspaceTarget, taskSourceContext]) const [linkedIssue, setLinkedIssue] = useState<string>(() => { if (persistDraft && newWorkspaceDraft?.linkedIssue) { return newWorkspaceDraft.linkedIssue @@ -559,8 +784,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const selectedRepoPath = selectedRepo?.path const selectedRepoPathRef = useRef<string | undefined>(selectedRepoPath) selectedRepoPathRef.current = selectedRepoPath - const settingsRef = useRef(settings) - settingsRef.current = settings + const selectedRepoSettingsRef = useRef(selectedRepoSettings) + selectedRepoSettingsRef.current = selectedRepoSettings const cancelPromptCaretFrame = useCallback((): void => { if (promptCaretFrameRef.current === null) { @@ -586,12 +811,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS promise: Promise<HookCheckResult> } | null>(null) const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise<HookCheckResult> => { - const key = `${settingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}` + const key = `${selectedRepoSettingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}` const existing = hookCheckRef.current if (existing?.key === key) { return existing.promise } - const promise = checkRuntimeHooks(settingsRef.current, targetRepoId) + const promise = checkRuntimeHooks(selectedRepoSettingsRef.current, targetRepoId) hookCheckRef.current = { key, promise } return promise }, []) @@ -612,12 +837,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return } let cancelled = false - void ( - window.api.gh.repoSlug({ repoPath: selectedRepoPath, repoId }) as Promise<{ - owner: string - repo: string - } | null> - ) + const target = getActiveRuntimeTarget(selectedRepoSettings) + const slugRequest = + target.kind === 'environment' + ? callRuntimeRpc<{ owner: string; repo: string } | null>( + target, + 'github.repoSlug', + { repo: repoId }, + { timeoutMs: 30_000 } + ) + : (window.api.gh.repoSlug({ repoPath: selectedRepoPath, repoId }) as Promise<{ + owner: string + repo: string + } | null>) + void slugRequest .then((result) => { if (cancelled) { return @@ -632,7 +865,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [repoId, selectedRepo, selectedRepoIsGit, selectedRepoPath]) + }, [repoId, selectedRepo, selectedRepoIsGit, selectedRepoPath, selectedRepoSettings]) const sparsePresetsForRepo = sparsePresetsByRepo[repoId] const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS const normalizedSparseDirectories = useMemo( @@ -806,11 +1039,22 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } setNewWorkspaceDraft({ repoId: repoId || null, + projectId: + selectedWorkspaceTarget.status === 'ready' + ? selectedWorkspaceTarget.target.projectId + : null, + hostId: + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null, + projectHostSetupId: + selectedWorkspaceTarget.status === 'ready' + ? selectedWorkspaceTarget.target.projectHostSetupId + : null, name, prompt: agentPrompt, note, attachments: attachmentPaths, linkedWorkItem, + taskSourceContext, agent: tuiAgent, linkedIssue, linkedPR, @@ -831,7 +1075,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note, name, repoId, + selectedWorkspaceTarget, setNewWorkspaceDraft, + taskSourceContext, tuiAgent ]) @@ -932,7 +1178,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } } - void readRuntimeIssueCommand(settings, repoId) + void readRuntimeIssueCommand(selectedRepoSettings, repoId) .then((result) => { if (!cancelled) { setIssueCommandTemplate(result.effectiveContent ?? '') @@ -955,7 +1201,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS loadHookCheckForRepo, repoId, selectedRepoIsGit, - settings + selectedRepoSettings ]) const onConnectSelectedRepo = useCallback(async (): Promise<void> => { @@ -1121,12 +1367,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // resolving direct lookups against the selected repo instead of requiring a // text match in the recent-items list. const lookupRepoId = selectedRepo.id - void window.api.gh - .workItem({ - repoPath: selectedRepo.path, - repoId: selectedRepo.id, - number: normalizedLinkQuery.directNumber - }) + void lookupGitHubWorkItemForSource({ + repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: selectedRepoGitHubSourceContext, + number: normalizedLinkQuery.directNumber + }) .then((item) => { if (!cancelled) { setLinkDirectItem( @@ -1148,7 +1394,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - }, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, selectedRepoIsGit]) + }, [ + linkPopoverOpen, + normalizedLinkQuery.directNumber, + selectedRepo, + selectedRepoGitHubSourceContext, + selectedRepoIsGit + ]) const applyLinkedWorkItem = useCallback( (item: GitHubWorkItem, options: { preserveBranchNameOverride?: boolean } = {}): void => { @@ -1199,10 +1451,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const item = await lookupSmartGitHubSubmitItem({ repoPath: selectedRepo.path, repoId: selectedRepo.id, + sourceContext: selectedRepoGitHubSourceContext, intent, - workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>, - workItemByOwnerRepo: (args) => - window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null> + workItem: lookupGitHubWorkItemForSource, + workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }) if (!item) { throw new Error('Could not resolve the GitHub item before creating the workspace.') @@ -1225,7 +1477,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS branchAutoNameRef.current = '' setStartFromResetHint(null) return resolution - }, [linkedWorkItem, name, selectedRepo, selectedRepoIsGit]) + }, [linkedWorkItem, name, selectedRepo, selectedRepoGitHubSourceContext, selectedRepoIsGit]) // Why: parallel of applyLinkedWorkItem for GitLab. Touches the GitLab // state slots only — the GitHub linkedIssue/linkedPR remain unchanged @@ -1400,7 +1652,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const uploadComposerPaths = useCallback( async ( sourcePaths: string[], - targetSettings = settings, + targetSettings = selectedRepoSettings, targetConnectionId = connectionId, targetRepoPath = selectedRepoPath ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { @@ -1411,7 +1663,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS toast.error( translate( 'auto.hooks.useComposerState.3db83fc58a', - 'No remote project path is available for attachments.' + 'No project path is available on this host for attachments.' ) ) return { filePaths: [], folderPaths: [] } @@ -1452,7 +1704,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } return { filePaths, folderPaths } }, - [connectionId, selectedRepoPath, settings] + [connectionId, selectedRepoPath, selectedRepoSettings] ) const handleAddAttachment = useCallback(async (): Promise<void> => { @@ -1530,7 +1782,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS void (async () => { const uploaded = await uploadComposerPathsRef.current( data.paths, - settingsRef.current, + selectedRepoSettingsRef.current, connectionIdRef.current, selectedRepoPathRef.current ) @@ -1552,7 +1804,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) @@ -1562,26 +1814,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('') @@ -1591,21 +1847,60 @@ 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] ) - + const handleProjectHostSetupChange = useCallback( + (setupId: string): void => { + const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId) + if (!option || option.kind !== 'ready') { + return + } + // 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] + ) + const handleProjectChange = useCallback( + (projectId: string): void => { + const preferredHostId = + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null + const nextRepoId = resolveWorkspaceCreationRepoId({ + eligibleRepos, + projects, + projectHostSetups, + projectId, + hostId: preferredHostId, + focusedHostScope: workspaceHostScope + }) + if (!nextRepoId) { + return + } + handleRepoChange(nextRepoId) + }, + [ + eligibleRepos, + handleRepoChange, + projectHostSetups, + projects, + selectedWorkspaceTarget, + workspaceHostScope + ] + ) const showProjectRequiredError = useCallback((): void => { setProjectError('Choose or add a project before creating a workspace.') requestAnimationFrame(() => { document .querySelector<HTMLElement>( - '[data-contextual-tour-target="workspace-creation-project"] [data-repo-combobox-root="true"][role="combobox"]' + '[data-contextual-tour-target="workspace-creation-project"] [data-project-combobox-root="true"][role="combobox"]' ) ?.focus() }) @@ -1694,18 +1989,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBranchNameOverride(undefined) setForkPushWarning(null) branchAutoNameRef.current = '' - const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo + // Why: provider items can come from a different source host than the + // selected run host. Resolve git refs against the run repo; keep item + // metadata/source context separate for provider identity. + const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId) applyLinkedWorkItem(item) - if (item.type !== 'pr' || !repoForItem) { + if (item.type !== 'pr' || !runRepo) { setPushTarget(undefined) return } setPushTarget(undefined) - const target = getActiveRuntimeTarget(settings) + const itemRepoSettings = getSettingsForRepoRuntimeOwner( + { repos: [runRepo], settings }, + runRepo.id + ) + const target = getActiveRuntimeTarget(itemRepoSettings) const resolvePrBase = target.kind === 'local' ? window.api.worktrees.resolvePrBase({ - repoId: repoForItem.id, + repoId: runRepo.id, prNumber: item.number, ...(item.branchName ? { headRefName: item.branchName } : {}), ...(item.isCrossRepository !== undefined @@ -1716,7 +2018,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS target, 'worktree.resolvePrBase', { - repo: repoForItem.id, + repo: runRepo.id, prNumber: item.number, ...(item.branchName ? { headRefName: item.branchName } : {}), ...(item.isCrossRepository !== undefined @@ -1768,13 +2070,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBranchNameOverride(undefined) setForkPushWarning(null) branchAutoNameRef.current = '' - const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo - if (item.type !== 'mr' || !repoForItem) { + // Why: MR metadata can be sourced from one host/account while the + // workspace is created on another host for the same logical project. + const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId) + if (item.type !== 'mr' || !runRepo) { return } void window.api.worktrees .resolveMrBase({ - repoId: repoForItem.id, + repoId: runRepo.id, mrIid: item.number, ...(item.branchName ? { sourceBranch: item.branchName } : {}), ...(item.isCrossRepository !== undefined @@ -2311,6 +2615,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS workspaceName, preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits }) + const submitBaseBranch = + selectedRepoIsGit && !baseBranch + ? ((await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null)) + ?.defaultBaseRef ?? undefined) + : baseBranch const createDisplayName = smartGitHubResolution?.displayName ?? (nameIsAutoManaged ? submitTitleName?.displayName : undefined) @@ -2389,9 +2698,22 @@ 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 && baseBranch ? { baseBranch } : {}), + ...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}), setupDecision: effectiveSetupDecision, ...(selectedRepoIsGit && sparseEnabled ? { @@ -2469,7 +2791,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS resolvedInitialWorkspaceStatus, selectedRepo, selectedRepoIsGit, + selectedRepoSettings, selectedRepoRequiresConnection, + selectedWorkspaceTarget, showProjectRequiredError, settings?.agentCmdOverrides, settings?.agentDefaultArgs, @@ -2481,6 +2805,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS sparseError, effectivePresetId, telemetrySource, + taskSourceContext, checkedHooksRepoId, commitHookCheckIfCurrent, loadHookCheckForRepo, @@ -2507,8 +2832,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const cardProps: ComposerCardProps = { eligibleRepos, repoId, + projectOptions, + selectedProjectId, selectedRepoIsGit, onRepoChange: handleRepoChange, + onProjectChange: handleProjectChange, + projectHostSetupOptions, + selectedProjectHostSetupId, + onProjectHostSetupChange: handleProjectHostSetupChange, name, onNameValueChange: handleNameValueChange, onSmartGitHubItemSelect: handleSmartGitHubItemSelect, diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index 68cf34b8be8..7d0679fbacf 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -17,13 +17,15 @@ vi.mock('@/components/editor/editor-autosave', () => ({ describe('getEditorExternalWatchTargets', () => { const makeRepo = ( id: string, - connectionId: string | null = null + connectionId: string | null = null, + executionHostId?: EditorExternalWatchTargetState['repos'][number]['executionHostId'] ): EditorExternalWatchTargetState['repos'][number] => ({ id, path: `/${id}`, kind: 'git', - connectionId + connectionId, + executionHostId }) as EditorExternalWatchTargetState['repos'][number] const makeWorktree = ( diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 2606209a3be..10fa8423135 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -21,6 +21,7 @@ import type { FsChangedPayload } from '../../../shared/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { OpenFile } from '@/store/slices/editor' import { readRuntimeFileContent, subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' // Why: atomic-write patterns (Claude Code's Edit tool, editors like vim, // VSCode) land as a short burst of `update` events — or `delete + create` on @@ -158,7 +159,9 @@ export function getEditorExternalWatchTargets( owners = new Set() targetOwnersByWorktreeId.set(state.activeWorktreeId, owners) } - owners.add(runtimeEnvironmentId ?? null) + // Why: the Explorer is mounted for the selected worktree. Its watcher must + // follow that worktree's host owner, not the host currently focused in the UI. + owners.add(getRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)) } const nextTargets: WatchedTarget[] = [] 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/useGlobalFileDrop.test.ts b/src/renderer/src/hooks/useGlobalFileDrop.test.ts index 1439e401c69..fb4dac202b8 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.test.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { shouldUploadRemoteEditorFileDrop } from './useGlobalFileDrop' +import { + getEditorFileDropOperationContext, + getEditorFileDropSettingsForWorktree, + shouldUploadRemoteEditorFileDrop +} from './useGlobalFileDrop' describe('shouldUploadRemoteEditorFileDrop', () => { it('does not upload editor drops for local workspaces', () => { @@ -17,4 +21,70 @@ describe('shouldUploadRemoteEditorFileDrop', () => { true ) }) + + it('uses the worktree owner runtime instead of the focused runtime', () => { + expect( + getEditorFileDropSettingsForWorktree( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: 'owner-runtime' }) + }) + + it('keeps explicit local worktree editor drops local while a runtime is focused', () => { + expect( + getEditorFileDropSettingsForWorktree( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: null }) + }) + + it('builds file operation context from the worktree owner instead of global focus', () => { + expect( + getEditorFileDropOperationContext( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1', + '/repos/repo-1', + undefined + ) + ).toEqual({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repos/repo-1', + connectionId: undefined + }) + }) + + it('preserves SSH ownership in editor drop operation context', () => { + expect( + getEditorFileDropOperationContext( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }, + 'wt-1', + '/home/orca/repo-1', + 'ssh-1' + ) + ).toEqual({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/home/orca/repo-1', + connectionId: 'ssh-1' + }) + }) }) diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index 0d3039fbd44..c319e4dc301 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -5,6 +5,7 @@ import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-lin import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { joinPath } from '@/lib/path' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { importExternalPathsToRuntime, isRemoteRuntimeFileOperation, @@ -13,6 +14,20 @@ import { } from '@/runtime/runtime-file-client' import type { GlobalSettings } from '../../../shared/types' import { translate } from '@/i18n/i18n' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner' + +export function getEditorFileDropSettingsForWorktree( + store: WorktreeRuntimeOwnerState, + worktreeId: string +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) + // Why: OS drops target the selected worktree. Use that worktree's host owner + // so a focused runtime cannot hijack local/SSH editor drops. + return { + ...store.settings, + activeRuntimeEnvironmentId: runtimeEnvironmentId + } +} export function shouldUploadRemoteEditorFileDrop( settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, @@ -21,6 +36,20 @@ export function shouldUploadRemoteEditorFileDrop( return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) } +export function getEditorFileDropOperationContext( + store: WorktreeRuntimeOwnerState, + worktreeId: string, + worktreePath: string | null | undefined, + connectionId: string | undefined +): RuntimeFileOperationArgs { + return { + settings: getEditorFileDropSettingsForWorktree(store, worktreeId), + worktreeId, + worktreePath, + connectionId + } +} + export function useGlobalFileDrop(): void { useEffect(() => { return window.api.ui.onFileDrop((data) => { @@ -37,8 +66,14 @@ export function useGlobalFileDrop(): void { const activeWorktree = store.getKnownWorktreeById(activeWorktreeId) const worktreePath = activeWorktree?.path const connectionId = getConnectionId(activeWorktreeId) ?? undefined - const dropSettings = store.settings - const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId?.trim() || undefined + const fileContext = getEditorFileDropOperationContext( + store, + activeWorktreeId, + worktreePath, + connectionId + ) + const dropSettings = fileContext.settings + const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId ?? null if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) { if (!worktreePath) { toast.error( @@ -55,12 +90,7 @@ export function useGlobalFileDrop(): void { // SSH editors must upload into the server worktree before opening. const destinationDir = joinPath(worktreePath, '.orca/drops') const { results } = await importExternalPathsToRuntime( - { - settings: dropSettings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, + fileContext, data.paths, destinationDir, { ensureDestinationDir: true } @@ -77,11 +107,11 @@ export function useGlobalFileDrop(): void { filePath: result.destPath, relativePath: maybeRelative ?? result.destPath, worktreeId: activeWorktreeId, - runtimeEnvironmentId, + runtimeEnvironmentId: runtimeEnvironmentId ?? undefined, language: detectLanguage(result.destPath), mode: 'edit' }, - { suppressActiveRuntimeFallback: runtimeEnvironmentId === undefined } + { suppressActiveRuntimeFallback: runtimeEnvironmentId === null } ) } if (results.some((result) => result.status !== 'imported')) { @@ -110,12 +140,6 @@ export function useGlobalFileDrop(): void { for (const filePath of data.paths) { void (async () => { try { - const fileContext: RuntimeFileOperationArgs = { - settings: store.settings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - } const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath) // Why: remote paths don't need local auth — the relay/runtime is the security boundary. if (!connectionId && !isRemoteRuntimePath) { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 249513de517..5861b8ba65b 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1534,6 +1534,9 @@ describe('useIpcEvents updater integration', () => { expect(createFloatingWorkspaceTerminalTab).not.toHaveBeenCalled() expect(createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ worktreeId: 'wt-1', + // Why: multi-host scopes the new terminal to the worktree's own runtime + // env (null here -> falls back to the active env inside the helper). + environmentId: null, activate: true }) expect(createTab).toHaveBeenCalledWith('wt-1') diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e7ca0ded98e..de7ac997edc 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -109,6 +109,7 @@ import { import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { titleHasAgentName } from '../../../shared/agent-detection' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { translate } from '@/i18n/i18n' function getShortcutPlatform(): NodeJS.Platform { @@ -684,6 +685,10 @@ function getActiveRuntimeEnvironmentId(): string | null { return useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null } +function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { + return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) +} + export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] @@ -1662,9 +1667,6 @@ export function useIpcEvents(): void { unsubs.push( window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { - if (isRuntimeEnvironmentActive()) { - return - } const store = useAppStore.getState() const sourcePage = Object.values(store.browserPagesByWorkspace) .flat() @@ -1672,6 +1674,9 @@ export function useIpcEvents(): void { if (!sourcePage) { return } + if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { + return + } // Why: the guest process can request "open this link in Orca", but it // does not own Orca's worktree/tab model. Resolve the source page's // worktree and create a new outer browser tab so the link opens as a @@ -1691,8 +1696,8 @@ export function useIpcEvents(): void { } const worktreeId = store.activeWorktreeId if (worktreeId) { - if (isRuntimeEnvironmentActive()) { - const environmentId = getActiveRuntimeEnvironmentId() + const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) + if (environmentId) { if (!isWebRuntimeSessionActive(environmentId)) { store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', { title: translate('auto.hooks.useIpcEvents.f6300deb8b', 'New Browser Tab'), @@ -1706,6 +1711,7 @@ export function useIpcEvents(): void { // the next host snapshot remains authoritative. await createWebRuntimeSessionBrowserTab({ worktreeId, + environmentId, url: store.browserDefaultUrl ?? 'about:blank' }) })() @@ -2034,6 +2040,7 @@ export function useIpcEvents(): void { if ( await createWebRuntimeSessionTerminal({ worktreeId, + environmentId: getWorktreeRuntimeEnvironmentId(worktreeId), activate: true }) ) { @@ -2083,8 +2090,8 @@ export function useIpcEvents(): void { ) { return } - if (isRuntimeEnvironmentActive() && store.activeWorktreeId) { - const environmentId = getActiveRuntimeEnvironmentId() + const environmentId = getWorktreeRuntimeEnvironmentId(store.activeWorktreeId) + if (environmentId && store.activeWorktreeId) { if (!isWebRuntimeSessionActive(environmentId)) { store.closeBrowserTab(store.activeBrowserTabId) return @@ -2092,7 +2099,8 @@ export function useIpcEvents(): void { void (async () => { await closeWebRuntimeSessionTab({ worktreeId: store.activeWorktreeId!, - tabId: store.activeBrowserTabId! + tabId: store.activeBrowserTabId!, + environmentId }) })() return diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index a61b0e71da2..ea1e306ca13 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -6,15 +6,16 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { linearTeamLabels, linearTeamMembers, - linearTeamStates + linearTeamStates, + type RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import type { GitHubAssignableUser, - GlobalSettings, LinearWorkflowState, LinearLabel, LinearMember } from '../../../shared/types' +import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' import { clearMetadataRequestStore, createMetadataRequestStore, @@ -30,6 +31,7 @@ type MetadataState<T> = { type GitHubMetadataOptions = { runtimeEnvironmentId?: string | null + activeRuntimeEnvironmentId?: string | null } // ─── GitHub ──────────────────────────────────────────────── @@ -53,7 +55,8 @@ export function useRepoLabels( if (!repoPath && !repoId) { return } - const runtimeEnvironmentId = options?.runtimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null const repoSelector = repoId ?? repoPath ?? '' // Why: SSH/runtime metadata must not reuse host-path cache entries; the same // repo id may resolve through a different credential/runtime boundary. @@ -106,7 +109,7 @@ export function useRepoLabels( error: err instanceof Error ? err.message : 'Failed to load labels' })) }) - }, [repoPath, repoId, options?.runtimeEnvironmentId]) + }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) return state } @@ -127,7 +130,8 @@ export function useRepoAssignees( if (!repoPath && !repoId) { return } - const runtimeEnvironmentId = options?.runtimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null const repoSelector = repoId ?? repoPath ?? '' // Why: SSH/runtime metadata must not reuse host-path cache entries; the same // repo id may resolve through a different credential/runtime boundary. @@ -180,7 +184,7 @@ export function useRepoAssignees( error: err instanceof Error ? err.message : 'Failed to load assignees' })) }) - }, [repoPath, repoId, options?.runtimeEnvironmentId]) + }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) return state } @@ -193,10 +197,12 @@ const linearMemberStore = createMetadataRequestStore<LinearMember[]>() function linearMetadataCacheKey( teamId: string, - settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, + settings: RuntimeLinearSettings, workspaceId?: string | null ): string { - const target = getActiveRuntimeTarget(settings) + const runtimeSettings = + settings && 'kind' in settings ? getTaskSourceRuntimeSettings(settings) : settings + const target = getActiveRuntimeTarget(runtimeSettings) const workspaceKey = workspaceId ?? 'selected' return target.kind === 'environment' ? `runtime:${target.environmentId}:${workspaceKey}:${teamId}` @@ -216,7 +222,7 @@ export function clearGitHubMetadataCache(): void { export function useTeamStates( teamId: string | null, - settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + settings?: RuntimeLinearSettings, workspaceId?: string | null ): MetadataState<LinearWorkflowState[]> { const [state, setState] = useState<MetadataState<LinearWorkflowState[]>>({ @@ -278,7 +284,7 @@ export function useTeamStates( export function useTeamLabels( teamId: string | null, - settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + settings?: RuntimeLinearSettings, workspaceId?: string | null ): MetadataState<LinearLabel[]> { const [state, setState] = useState<MetadataState<LinearLabel[]>>({ @@ -338,7 +344,7 @@ export function useTeamLabels( export function useTeamMembers( teamId: string | null, - settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + settings?: RuntimeLinearSettings, workspaceId?: string | null ): MetadataState<LinearMember[]> { const [state, setState] = useState<MetadataState<LinearMember[]>>({ diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 33830ba845e..4459e9ed99d 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -389,7 +389,7 @@ export function buildSettingsNavigationMetadata({ ), description: isWebClient ? 'Connect this browser to a saved Orca server.' - : 'Switch between local desktop mode and paired remote Orca runtimes.', + : 'Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.', icon: Server, searchEntries: [runtimeEnvironmentsSearchEntry], group: 'remote', @@ -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.' + 'Use existing machines over SSH for files, terminals, Git, and workspaces.' ), icon: Cable, searchEntries: getSshPaneSearchEntries(), @@ -417,7 +417,7 @@ export function buildSettingsNavigationMetadata({ ), icon: Smartphone, searchEntries: getMobileSettingsPaneSearchEntries(), - group: 'remote' + group: 'mobile' } ] : []), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f7d9af0546b..a82f28fa748 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace.", "opencodeGoToggleDescription": "Show OpenCode Go token and cost usage for the active workspace.", "kimiToggleDescription": "Show Kimi subscription usage for the active workspace.", - "sshToggleDescription": "Show the active SSH connection. Only visible once an SSH target is configured.", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners." } @@ -218,7 +218,7 @@ }, "repos": { "b7e14472ae": "Failed to add folder", - "e649269645": "Use a server path to add projects from a remote runtime.", + "e649269645": "Use Add Project to enter a path on the selected host.", "c6e022ddfc": "Failed to add project", "90d129b48b": "Folder added", "8bb3ad7935": "Project added", @@ -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": { @@ -505,7 +475,7 @@ "7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.", "b2ead86962": "Failed to resolve PR base.", "a9ff236145": "Some attachments could not be uploaded.", - "3db83fc58a": "No remote project path is available for attachments.", + "3db83fc58a": "No project path is available on this host for attachments.", "ba6cb77082": "Failed to connect to project." }, "useGlobalFileDrop": { @@ -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": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "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", @@ -1635,7 +1609,8 @@ "worktreesHeader": "Worktrees", "recentWorktreesHeader": "Recent Worktrees", "settingsBadge": "Settings", - "actionBadge": "Action" + "actionBadge": "Action", + "paletteHostBadge": "Host: {{value0}}" }, "github": { "pr": { @@ -1817,11 +1792,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", @@ -1895,47 +1866,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": { @@ -2211,8 +2141,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", @@ -2448,9 +2377,7 @@ "77ac113df0": "Start {{value0}}: {{value1}}", "7b1c9d6ae1": "Run", "c781f992e4": "destructive", - "be8f0ff166": "Delete", - "f3a8c2d1e7": "Search quick commands...", - "b4e7f9a2c1": "No commands match" + "be8f0ff166": "Delete" }, "shell": { "icons": { @@ -2589,10 +2516,10 @@ "e9a5d3c2b1f0": "Kill {{value0}}?" }, "SshStatusSegment": { - "3ad70e0365": "Manage SSH…", - "6e8a9a4242": "SSH Connections", - "d09ec41831": "SSH", - "fdc57e9970": "SSH connection status", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "Disconnect", "63f36455cc": "Connect", "bf07aee59e": "Disconnect failed", @@ -2602,12 +2529,16 @@ "fd9a3c600e": "error", "fbb3f9f05e": "conflict", "95e4ff5b4b": "pushing", - "63a2b965f6": "pulling" + "63a2b965f6": "pulling", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "Ports", "d1e1a7a6bf": "Resource Manager", - "24ac89df1a": "SSH Status", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi Usage", "8c86cd77b0": "OpenCode Go Usage", "c1df0d67ec": "Gemini Usage", @@ -2840,11 +2771,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", @@ -2893,11 +2820,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", @@ -2936,11 +2859,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", @@ -3059,22 +2978,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": { @@ -3159,7 +3062,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", @@ -3172,22 +3075,21 @@ "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", + "local": "Local", + "runtime": "Server", + "ssh": "SSH" }, "AddRepoNestedImportStep": { "496f68cf8c": "Scanning repositories. Click to stop.", @@ -3195,6 +3097,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", @@ -3203,10 +3106,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}}.", @@ -3215,35 +3119,36 @@ "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", - "35831a7312": "Adding..." + "36d427bb66": "Add project on SSH host", + "35831a7312": "Adding...", + "lockedDescription": "Enter the path to a Git repository on {{value0}}." }, "AddRepoServerStartStep": { "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." }, @@ -3260,7 +3165,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", @@ -3268,10 +3173,12 @@ "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..." + "69f5b5380d": "Cloning...", + "cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.", + "cloneParentFolder": "Parent folder" }, "AutoRenameFailedDialog": { "aed1623b1e": "Close", @@ -3286,7 +3193,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." @@ -3334,7 +3241,7 @@ "e52454b7f6": "Open as Folder", "05b33a17a9": "Cancel", "8fba4b8cbb": "This folder isn't a Git repository. You'll have the editor, terminal, and search, but Git-based features won't be available.", - "c49fb13492": "Failed to add remote folder" + "c49fb13492": "Failed to add folder on this host" }, "OrcaYamlTrustDialog": { "f3e2b868fb": "Run hooks", @@ -3380,7 +3287,7 @@ "9e060f5815": "Select folder", "f8b1deb1a4": "Cancel", "51001182e3": "Empty directory", - "971d85cc84": "Opens as a remote project · {{value0}}", + "971d85cc84": "Opens as a project on this host · {{value0}}", "00c4235c10": "No matches for '{{value0}}'" }, "RemoveFolderDialog": { @@ -3523,7 +3430,21 @@ "ed1611b65b": "Hide sleeping", "82594419ba": "Filters" }, + "sidebarHostOptions": { + "3e102f111c": "All hosts", + "visibleHostsCount": "{{value0}} hosts" + }, + "SidebarHostScopeStrip": { + "scopedTo": "{{value0}} visible", + "backToAll": "All hosts" + }, "SidebarWorkspaceOptionsMenu": { + "hosts": "Hosts", + "allHostsDetail": "Show every host", + "configuredSshHost": "Configured SSH", + "projectSshHost": "Project SSH", + "activeRuntimeHost": "Active server", + "projectRuntimeHost": "Project server", "95c9754653": "Agent activity layout", "3d4b9c4997": "Hover", "ba87080fb7": "Show properties", @@ -3531,6 +3452,7 @@ "09faabd875": "Project order", "7bada3b1ab": "Sort by", "dc0bb670bc": "Group by", + "631b97eea9": "Host scope", "9919ae1082": "Workspace options", "bc96dbd041": "Workspace options ({{value0}})", "af9249c505": "Most recent workspace activity", @@ -3564,11 +3486,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…", @@ -3627,7 +3545,7 @@ "01f45d3d8a": "sidebar", "93aebe4529": "Folder", "0d224eff10": "Primary worktree", - "ca74db7550": "Remote project via SSH", + "ca74db7550": "Project on SSH host", "021538e1d1": "SSH disconnected" }, "WorktreeCardAgents": { @@ -3698,6 +3616,9 @@ "250de158fd": "Remove Workspace" }, "WorktreeList": { + "7a8b9c0d1e": "Update required", + "hostAuthNeeded": "Authentication needed", + "hostDisconnected": "Disconnected", "d880ea0744": "Create a group and move this project into it.", "bc1460beb3": "Update the group name shown in the sidebar.", "13757c053c": "New Project Group", @@ -3790,11 +3711,12 @@ "5f9ffac036": "Clone a remote Git repository", "7edb8ebe24": "Clone from URL", "a6c20dca96": "Open a project from an SSH target", - "3d162cc76f": "Remote project", + "sshCreateUnavailable": "Not available for SSH hosts yet", + "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" } } } @@ -3845,7 +3767,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" @@ -3862,10 +3784,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", @@ -3875,7 +3797,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." }, @@ -3940,6 +3862,39 @@ "index": { "b826a98b6f": "busy" }, + "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…" + }, "LinearAgentSkillSetupPrompt": { "missingCliAndSkill": "Orca CLI and Linear agent skill are missing.", "modalTitle": "Enable Linear ticket access", @@ -3982,15 +3937,10 @@ "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": { @@ -4222,19 +4172,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", @@ -4252,7 +4190,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...", @@ -4309,6 +4247,10 @@ "612f7f6861": "Failed to create profile.", "8f22b7580d": "Profile \"{{value0}}\" created.", "8481ee0331": "New Browser Profile", + "c0f85056d9": "Browser profiles on this Orca server.", + "86b7c83fee": "This computer", + "6480776a03": "Browser profiles for the selected host.", + "5e19a692f7": "Host", "6f2584b39e": "Add Profile", "e4aaf8051b": "toolbar menu.", "cd47bc9622": "Select a default profile for new browser tabs. Import cookies and switch profiles per-tab via the", @@ -4391,8 +4333,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", @@ -5081,8 +5021,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.", @@ -5293,7 +5232,38 @@ "0909e5d650": "Remove Project", "ee5a290616": "Opened as folder. Git features are unavailable for this workspace.", "323debba71": "Type:", - "499a437335": "Identity" + "availableHosts": "Available Hosts", + "availableHostsDescription": "Hosts where this project is set up.", + "availableHostsHelp": "Project paths and worktree settings are host-specific; creating a workspace can target any ready setup.", + "viewingHost": "Viewing host", + "currentSetup": "Current", + "hostSetupStateReady": "Ready", + "hostSetupStateNotSetUp": "Not set up", + "hostSetupStateSettingUp": "Setting up", + "hostSetupStateError": "Error", + "hostSetupStateUnsupported": "Unsupported", + "setupPathPending": "Path pending", + "openSetup": "Open", + "removeSetup": "Remove", + "hostSetupBlockedVersion": "Orca server version is incompatible", + "hostSetupMissingCapability": "Update Orca on this host to set up projects", + "setupProjectOnHost": "Set up on another host", + "setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.", + "setupExistingFolder": "Import existing folder", + "setupExistingFolderHelp": "Make this project available on another host by linking a checkout that already exists there.", + "setupExistingFolderPathPlaceholder": "/path/to/project/on/host", + "cloneUrlPlaceholder": "Repository URL", + "cloneDestinationPlaceholder": "/destination/on/host", + "setupKindGit": "Git repo", + "setupKindFolder": "Folder", + "settingUpHost": "Importing...", + "setupHost": "Import", + "cloningHost": "Cloning...", + "cloneHost": "Clone", + "creatingPendingSetup": "Creating...", + "createPendingSetup": "Track setup", + "499a437335": "Identity", + "hostSetupCheckingCapability": "Checking host capabilities" }, "RepositorySourceControlAiActionRows": { "548a6e1281": "Command template", @@ -5357,8 +5327,15 @@ "bb90dd6487": "Remove Server", "d2e00809e4": "Switch", "05e0fc3ebf": "Switch to", - "b2290ed203": "Orca will close remote terminals and browser tabs from the current server before loading projects from the next server.", + "b2290ed203": "Orca will focus this host and load its projects. Existing terminals and browser tabs on other hosts stay alive.", "d570c35a99": "Switch Server", + "f3a3d6d834": "{{value0}} capabilities", + "0ef838094a": "Protocol {{value0}}", + "9a91c4a0eb": "Compatible", + "86ed75bec8": "Update server", + "62ac182a27": "Update client", + "c8791efc45": "Status unavailable", + "5120beaac6": "Checking…", "84b9b2be05": "Create a revocable access grant so a browser or another Orca client can connect.", "6e1280ca55": "Share this Orca server", "9a3758d983": "No saved servers.", @@ -5385,8 +5362,8 @@ "e6410d72c3": "Failed to load runtime environments.", "6ef71985da": "No endpoint", "ed3e3f069d": "This removes the saved server from Orca. It does not change the active server.", - "b2fda48c39": "Removing the active server disconnects this browser and closes remote terminals and browser tabs for that server.", - "9f7665a01b": "Removing the active server first switches Orca back to Local desktop and closes remote terminals and browser tabs for that server.", + "b2fda48c39": "Removing the active server disconnects this browser from that host. Existing host sessions are left alone.", + "9f7665a01b": "Removing the active server first switches Orca back to Local desktop. Existing host sessions are left alone.", "3595fd1948": "New Link", "54dee18f5c": "Hide Form", "8cf8790697": "Saved servers route this browser through a paired Orca runtime.", @@ -5437,9 +5414,9 @@ "65660d4548": "macOS Permissions", "c6c01ac209": "Control terminals and agents from your phone.", "c40dadaac8": "Mobile", - "c2ee313198": "Remote SSH hosts for files, terminals, and git.", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSH Hosts", - "b5ee17826b": "Switch between local desktop mode and paired remote Orca runtimes.", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "Connect this browser to a saved Orca server.", "bd0181eeca": "Remote Orca Servers", "8acf3f22e0": "Orca stats plus Claude, Codex, and OpenCode usage analytics.", @@ -5489,14 +5466,14 @@ "43b68e10f0": "You have unsaved Git AI Author changes. Leaving will discard them.", "17bdee4ff1": "Discard unsaved Git AI Author changes?", "084d8fac5b": "Privacy & Security", - "23931df7e8": "Remote Access", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "Interface", "e1578cd4bc": "Workflows", "9abb9be3bc": "Set Up", "23c6874fdf": "AI Capabilities", "2309068a6f": "destructive", - "65358016ea": "Discard", - "thisDevice": "This device" + "65358016ea": "Discard" }, "SettingsFormControls": { "42a4d15a30": "No matching fonts.", @@ -6247,8 +6224,8 @@ "f4997e0f8a": "connection", "a278406ed5": "remote", "6ecad74eb3": "ssh", - "f17d66d0d2": "Show the active SSH connection status in the status bar.", - "57fb424c56": "SSH Status", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "moonshot", "de586def95": "subscription", "00a028f25f": "usage", @@ -6277,10 +6254,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." } } }, @@ -6305,7 +6278,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" } } } @@ -7124,6 +7097,12 @@ "a47f51127e": "source control", "6cc5c65e64": "Project-specific git generation overrides.", "eec3995dc6": "Git AI Author", + "availableHosts": "Available Hosts", + "availableHostsDescription": "Hosts where this project is set up.", + "host": "host", + "ssh": "ssh", + "remote": "remote", + "vm": "vm", "cc876ca5f2": "repository", "6469de5368": "project", "3067595d82": "delete", @@ -7535,6 +7514,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.", @@ -7598,16 +7584,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" } } } @@ -7653,19 +7630,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": { @@ -7713,9 +7677,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", @@ -8078,8 +8040,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.", @@ -8159,7 +8120,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", @@ -8207,7 +8168,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", @@ -8579,10 +8540,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.", @@ -8666,9 +8624,9 @@ "RepoStep": { "e8fdb36338": "Scanning repositories. Click to stop.", "b7c4da0504": "SSH? Set hosts up in Settings", - "c33b190ca3": "Server paths only", + "c33b190ca3": "Host paths only", "7b679207e4": "Workspace", - "24c7c8696c": "Clone into server path", + "24c7c8696c": "Clone into host path", "7932e95f68": "Clone", "955134915e": "git@github.com:org/repo.git", "288d8444b7": "Paste an HTTPS or SSH URL.", @@ -8679,14 +8637,14 @@ "e8214aa632": "Open as Folder", "3863747c56": "Add Git Project", "2ebbc26343": "/home/user/project", - "466108ab89": "Enter a path that exists on the runtime server.", - "8cab104e3c": "Open a server project", + "466108ab89": "Enter a path that exists on the selected host.", + "8cab104e3c": "Open a project on this host", "2d20200346": "Import repositories", "27ca610db1": "Back", "cecd6593fa": "Scanned folder:", "c7af322fc3": "Stop scanning", "c3d9d44ca2": "Stop scan", - "cf23006ba7": "Runtime server", + "cf23006ba7": "Selected host", "7ec3f48820": "/home/user", "2e6438dd34": "{{value0}}Found {{value1}} {{value2}} in this folder." }, @@ -8724,7 +8682,7 @@ } }, "AgentFeatureSetupStep": { - "97dcdc010f": "Install CLI & Skills" + "97dcdc010f": "Enable capabilities" } }, "new": { @@ -8760,6 +8718,10 @@ "3e8bb1176a": "Connect Linear in Settings to search issues.", "69ce292138": "linear", "9c004911c3": "gitlab" + }, + "ProjectHostSetupCombobox": { + "empty": "No hosts are ready for this project.", + "placeholder": "Choose host" } } }, @@ -8997,9 +8959,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}}", @@ -9105,11 +9065,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" @@ -9182,9 +9138,7 @@ "6e3f5223c5": "Explorer", "ab2901bce6": "Checks", "d7f80060ca": "Source Control", - "8e715588e4": "Search", - "a6c8b9e32f": "Checks passed", - "f4d5e1a7b2": "3 checks" + "8e715588e4": "Search" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Create PR", @@ -10608,44 +10562,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/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a68d254f968..70061fcf189 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "Muestra el token de Gemini y el uso de costos para el espacio de trabajo activo.", "opencodeGoToggleDescription": "Muestra el token de OpenCode Go y el uso de costos para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", - "sshToggleDescription": "Muestra la conexión SSH activa. Solo es visible una vez que se configura un destino SSH.", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haga clic en él para ver la CPU, la memoria, las sesiones, los controles del demonio y los análisis del disco del espacio de trabajo.", "portsToggleDescription": "Mostrar puertos del espacio de trabajo en vivo. Haga clic en él para puertos con ámbito de espacio de trabajo y oyentes externos." } @@ -540,7 +540,7 @@ "d91ae31fbd": "Permisos de macOS", "95a1886d94": "Controla terminales y agents desde tu teléfono.", "1cd25673df": "Móvil", - "31e57d1c70": "Hosts SSH remotos para archivos, terminales y git.", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "Anfitriones SSH", "40d80bad8a": "Beta", "de0c2907a1": "Servidores remotos de Orca", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "¿Matar a {{value0}}?" }, "SshStatusSegment": { - "3ad70e0365": "Administrar SSH…", - "6e8a9a4242": "Conexiones SSH", - "d09ec41831": "SSH", - "fdc57e9970": "Estado de la conexión SSH", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "Desconectar", "63f36455cc": "Conectar", "bf07aee59e": "Falló la desconexión", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "error", "fbb3f9f05e": "conflicto", "95e4ff5b4b": "emprendedor", - "63a2b965f6": "tracción" + "63a2b965f6": "tracción", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "Puertos", "d1e1a7a6bf": "Administrador de recursos", - "24ac89df1a": "Estado SSH", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Uso de Kimi", "8c86cd77b0": "Uso de OpenCode Go", "c1df0d67ec": "Uso de Gemini", @@ -5400,9 +5404,9 @@ "65660d4548": "Permisos de macOS", "c6c01ac209": "Controla terminales y agents desde tu teléfono.", "c40dadaac8": "Móvil", - "c2ee313198": "Hosts SSH remotos para archivos, terminales y git.", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "Anfitriones SSH", - "b5ee17826b": "Cambie entre el modo de escritorio local y los tiempos de ejecución remotos de Orca emparejados.", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "Conecte este navegador a un servidor Orca guardado.", "bd0181eeca": "Servidores remotos de Orca", "8acf3f22e0": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", @@ -5452,7 +5456,8 @@ "43b68e10f0": "No has guardado los cambios de autor de Git AI. Irse los descartará.", "17bdee4ff1": "¿Descartar los cambios de autor de Git AI no guardados?", "084d8fac5b": "Privacidad y seguridad", - "23931df7e8": "Acceso remoto", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "Interfaz", "e1578cd4bc": "Flujos de trabajo", "9abb9be3bc": "Configuración", @@ -6210,8 +6215,8 @@ "f4997e0f8a": "conexión", "a278406ed5": "remoto", "6ecad74eb3": "ssh", - "f17d66d0d2": "Muestra el estado de la conexión SSH activa en la barra de estado.", - "57fb424c56": "Estado SSH", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "disparo a la luna", "de586def95": "suscripción", "00a028f25f": "uso", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 86f92eae24d..0d57c8941c8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "Kimi サブスクリプション", - "sshToggleDescription": "アクティブな SSH 接続を表示します。 SSH ターゲットが設定された場合にのみ表示されます。", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" } @@ -540,7 +540,7 @@ "d91ae31fbd": "macOS のアクセス許可", "95a1886d94": "スマートフォンから terminals と agents を操作", "1cd25673df": "モバイル", - "31e57d1c70": "ファイル、terminals、git 用のリモート SSH ホスト。", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "SSHホスト", "40d80bad8a": "ベータ", "de0c2907a1": "リモート Orca サーバー", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "{{value0}} を終了しますか?" }, "SshStatusSegment": { - "3ad70e0365": "SSHを管理…", - "6e8a9a4242": "SSH接続", - "d09ec41831": "SSH", - "fdc57e9970": "SSH接続状態", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "切断", "63f36455cc": "接続", "bf07aee59e": "切断に失敗しました", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "エラー", "fbb3f9f05e": "競合", "95e4ff5b4b": "押す", - "63a2b965f6": "引っ張る" + "63a2b965f6": "引っ張る", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "ポート", "d1e1a7a6bf": "リソースマネージャー", - "24ac89df1a": "SSHステータス", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go の使用法", "c1df0d67ec": "Gemini 使用量", @@ -5422,9 +5426,9 @@ "65660d4548": "macOS のアクセス許可", "c6c01ac209": "スマートフォンから terminals と agents を操作", "c40dadaac8": "モバイル", - "c2ee313198": "ファイル、terminals、git 用のリモート SSH ホスト。", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSHホスト", - "b5ee17826b": "ローカル デスクトップ モードとペアリングされたリモート Orca ランタイムを切り替えます。", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "このブラウザを保存された Orca サーバーに接続します。", "bd0181eeca": "リモート Orca サーバー", "8acf3f22e0": "Orca の統計と Claude、Codex、OpenCode の使用状況分析。", @@ -5474,7 +5478,8 @@ "43b68e10f0": "Git AI Author の変更が保存されていません。離れるとそれらは破棄されます。", "17bdee4ff1": "保存されていない Git AI Author の変更を破棄しますか?", "084d8fac5b": "プライバシーとセキュリティ", - "23931df7e8": "リモートアクセス", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "インターフェース", "e1578cd4bc": "ワークフロー", "9abb9be3bc": "セットアップ", @@ -6232,8 +6237,8 @@ "f4997e0f8a": "接続", "a278406ed5": "リモート", "6ecad74eb3": "ssh", - "f17d66d0d2": "アクティブな SSH 接続ステータスをステータス バーに表示します。", - "57fb424c56": "SSHステータス", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "ムーンショット", "de586def95": "サブスクリプション", "00a028f25f": "使用法", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7d93e37aac6..aa975a24897 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "활성 워크스페이스에 대한 Gemini 토큰 및 비용 사용량을 표시합니다.", "opencodeGoToggleDescription": "활성 워크스페이스에 대한 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "kimiToggleDescription": "활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.", - "sshToggleDescription": "활성 SSH 연결을 표시합니다. SSH 대상이 구성된 후에만 표시됩니다.", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. CPU, 메모리, 세션, 데몬 제어 및 워크스페이스 디스크 검색을 위해 클릭하세요.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요." } @@ -540,7 +540,7 @@ "d91ae31fbd": "macOS 권한", "95a1886d94": "휴대폰에서 terminals과 agents를 제어하세요.", "1cd25673df": "모바일", - "31e57d1c70": "파일, terminals, Git을 위한 원격 SSH 호스트입니다.", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "SSH 호스트", "40d80bad8a": "베타", "de0c2907a1": "원격 Orca 서버", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "{{value0}}을(를) 종료할까요?" }, "SshStatusSegment": { - "3ad70e0365": "SSH 관리…", - "6e8a9a4242": "SSH 연결", - "d09ec41831": "SSH", - "fdc57e9970": "SSH 연결 상태", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "연결 해제", "63f36455cc": "연결", "bf07aee59e": "연결 해제 실패", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "오류", "fbb3f9f05e": "충돌", "95e4ff5b4b": "푸시 중", - "63a2b965f6": "가져오는 중" + "63a2b965f6": "가져오는 중", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "포트", "d1e1a7a6bf": "리소스 관리자", - "24ac89df1a": "SSH 상태", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi 사용량", "8c86cd77b0": "OpenCode Go 사용량", "c1df0d67ec": "Gemini 사용량", @@ -5385,9 +5389,9 @@ "65660d4548": "macOS 권한", "c6c01ac209": "휴대폰에서 terminals과 agents를 제어하세요.", "c40dadaac8": "모바일", - "c2ee313198": "파일, terminals, Git을 위한 원격 SSH 호스트입니다.", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSH 호스트", - "b5ee17826b": "로컬 데스크톱 모드와 페어링된 원격 Orca 런타임 간에 전환합니다.", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "이 브라우저를 저장된 Orca 서버에 연결하세요.", "bd0181eeca": "원격 Orca 서버", "8acf3f22e0": "Orca 통계와 Claude, Codex 및 OpenCode 사용 분석.", @@ -5437,7 +5441,8 @@ "43b68e10f0": "저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.", "17bdee4ff1": "저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?", "084d8fac5b": "개인 정보 보호 및 보안", - "23931df7e8": "원격 액세스", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "인터페이스", "e1578cd4bc": "워크플로", "9abb9be3bc": "설정 시작", @@ -6195,8 +6200,8 @@ "f4997e0f8a": "연결", "a278406ed5": "원격", "6ecad74eb3": "SSH", - "f17d66d0d2": "상태 표시줄에 활성 SSH 연결 상태를 표시합니다.", - "57fb424c56": "SSH 상태", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "문샷", "de586def95": "신청", "00a028f25f": "용법", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 44e389dcc12..e53778f8188 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -25,7 +25,7 @@ "geminiToggleDescription": "显示Gemini Token 和成本使用情况。", "opencodeGoToggleDescription": "显示OpenCode Go Token 和成本使用情况。", "kimiToggleDescription": "Kimi 订阅", - "sshToggleDescription": "显示SSH 连接。仅在配置 SSH 目标后才可见。", + "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" } @@ -540,7 +540,7 @@ "d91ae31fbd": "macOS 权限", "95a1886d94": "通过手机控制 terminal 和 Agent。", "1cd25673df": "手机端", - "31e57d1c70": "文件、terminals 和 git 的远程 SSH 主机。", + "31e57d1c70": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "94a5afe910": "SSH 主机", "40d80bad8a": "测试版", "de0c2907a1": "远程 Orca 服务器", @@ -2589,10 +2589,10 @@ "e9a5d3c2b1f0": "终止 {{value0}}?" }, "SshStatusSegment": { - "3ad70e0365": "管理 SSH...", - "6e8a9a4242": "SSH 连接", - "d09ec41831": "SSH", - "fdc57e9970": "SSH 连接状态", + "3ad70e0365": "Manage Remote Hosts…", + "6e8a9a4242": "Remote Hosts", + "d09ec41831": "Remote Hosts", + "fdc57e9970": "Remote host connection status", "59b553e2aa": "断开连接", "63f36455cc": "连接", "bf07aee59e": "断开连接失败", @@ -2602,12 +2602,16 @@ "fd9a3c600e": "错误", "fbb3f9f05e": "冲突", "95e4ff5b4b": "推动", - "63a2b965f6": "拉" + "63a2b965f6": "拉", + "remote_server": "Remote Server", + "runtime_checking": "Checking", + "runtime_online": "Connected", + "runtime_unavailable": "Disconnected" }, "StatusBar": { "9659e38343": "端口", "d1e1a7a6bf": "资源管理器", - "24ac89df1a": "SSH 状态", + "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go 使用量", "c1df0d67ec": "Gemini 使用情况", @@ -5385,9 +5389,9 @@ "65660d4548": "macOS 权限", "c6c01ac209": "通过手机控制 terminal 和 Agent。", "c40dadaac8": "手机端", - "c2ee313198": "文件、terminals 和 git 的远程 SSH 主机。", + "c2ee313198": "Use existing machines over SSH for files, terminals, Git, and workspaces.", "9b02492d1f": "SSH 主机", - "b5ee17826b": "在本地桌面模式和配对的远程 Orca 运行时之间切换。", + "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "将此浏览器连接到已保存的 Orca 服务器。", "bd0181eeca": "远程 Orca 服务器", "8acf3f22e0": "Orca 统计数据以及 Claude、Codex 和 OpenCode 使用情况分析。", @@ -5437,7 +5441,8 @@ "43b68e10f0": "您有未保存的 Git AI Author 更改。离开将丢弃它们。", "17bdee4ff1": "放弃未保存的 Git AI Author 更改?", "084d8fac5b": "隐私与安全", - "23931df7e8": "远程访问", + "23931df7e8": "Remote Hosts", + "mobile_group": "Mobile", "8bd117d669": "界面", "e1578cd4bc": "工作流程", "9abb9be3bc": "初始设置", @@ -6195,8 +6200,8 @@ "f4997e0f8a": "联系", "a278406ed5": "偏僻的", "6ecad74eb3": "SSH", - "f17d66d0d2": "在状态栏中显示活动的 SSH 连接状态。", - "57fb424c56": "SSH 状态", + "f17d66d0d2": "Show remote host connection status in the status bar.", + "57fb424c56": "Remote Hosts", "35565867cb": "登月计划", "de586def95": "订阅", "00a028f25f": "用法", diff --git a/src/renderer/src/i18n/no-top-level-translate.test.ts b/src/renderer/src/i18n/no-top-level-translate.test.ts index 65d6a702db0..bbbc0827ab8 100644 --- a/src/renderer/src/i18n/no-top-level-translate.test.ts +++ b/src/renderer/src/i18n/no-top-level-translate.test.ts @@ -78,5 +78,5 @@ describe('i18n import-time safety', () => { } expect(violations).toEqual([]) - }) + }, 15_000) }) diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index dd30bc579ce..3307812055e 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -1,6 +1,7 @@ import type { RuntimeTerminalSend, RuntimeTerminalWait } from '../../../shared/runtime-types' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { findActiveRuntimeTerminal, getActiveTerminalNoteTarget } from './active-agent-note-target' export { @@ -47,7 +48,11 @@ export async function sendNotesToActiveAgentSession({ return { status: 'no-active-terminal' } } - const runtimeTarget = getActiveRuntimeTarget(state.settings) + // Route by the worktree's owner host so the agent terminal is found and driven + // on the host that actually runs it, not on the focused runtime. + const runtimeTarget = getActiveRuntimeTarget( + getSettingsForWorktreeRuntimeOwner(state, worktreeId) + ) const terminal = await findActiveRuntimeTerminal( runtimeTarget, worktreeId, diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index 77a5e63dffd..f1b36ae3078 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -7,6 +7,10 @@ import { import type { AppState } from '@/store/types' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + getSettingsForWorktreeRuntimeOwner, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../shared/types' @@ -47,7 +51,7 @@ export type ActiveTerminalNoteTargetState = { runtimePaneTitlesByTabId?: Record<string, Record<number, string> | undefined> agentStatusByPaneKey?: Record<string, AgentStatusEntry | undefined> settings: Parameters<typeof getActiveRuntimeTarget>[0] -} +} & Pick<WorktreeRuntimeOwnerState, 'repos' | 'worktreesByRepo'> type ActiveAgentRuntimeProbeDescriptor = { key: string @@ -159,7 +163,11 @@ export function getActiveAgentRuntimeProbeDescriptor( if (!activePtyId) { return null } - const runtimeTarget = getActiveRuntimeTarget(state.settings) + // Route by the worktree's owner host so the probe targets the host that runs + // this worktree's agent terminal, not the focused runtime. + const runtimeTarget = getActiveRuntimeTarget( + getSettingsForWorktreeRuntimeOwner(state, worktreeId) + ) const runtimeKey = runtimeTarget.kind === 'environment' ? `env:${runtimeTarget.environmentId}` : 'local' return { diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 5b950aae0c5..c9c5f56b98d 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -1,12 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { pasteDraftWhenAgentReady, sendBracketedPasteToRunningAgent } from './agent-paste-draft' +import { + getSettingsForAgentTabRuntimeOwner, + pasteDraftWhenAgentReady, + sendBracketedPasteToRunningAgent +} from './agent-paste-draft' const testState = vi.hoisted(() => ({ appState: { settings: {}, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, runtimePaneTitlesByTabId: {}, - tabsByWorktree: {} + tabsByWorktree: {} as Record<string, { id: string; title?: string }[]>, + repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[], + worktreesByRepo: {} as Record<string, { id: string; repoId: string }[]> }, ptyObserver: null as ((data: string) => void) | null, unsubscribe: vi.fn(), @@ -53,6 +59,8 @@ describe('pasteDraftWhenAgentReady', () => { testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] } testState.appState.runtimePaneTitlesByTabId = {} testState.appState.tabsByWorktree = {} + testState.appState.repos = [] + testState.appState.worktreesByRepo = {} testState.ptyObserver = null testState.unsubscribe.mockReset() testState.subscribeToPtyData.mockReset() @@ -261,6 +269,70 @@ describe('pasteDraftWhenAgentReady', () => { ) }) + it('routes tab-owned paste writes through the worktree runtime owner', async () => { + testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + testState.appState.repos = [ + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex' + }) + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + + await expect(promise).resolves.toBe(true) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'owner-runtime' }, + 'pty-1', + PASTED_ISSUE_URL + ) + }) + + it('routes legacy remote PTY readiness subscription through the tab owner', async () => { + testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + testState.appState.ptyIdsByTabId = { 'tab-1': ['remote:terminal-handle'] } + testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + testState.appState.repos = [ + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + testState.isRemoteRuntimePtyId.mockReturnValue(true) + testState.subscribeToRuntimeTerminalData.mockImplementation( + async ( + _settings: unknown, + _ptyId: string, + _clientId: string, + observer: (data: string) => void + ) => { + testState.ptyObserver = observer + return testState.unsubscribe + } + ) + + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex' + }) + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + + await expect(promise).resolves.toBe(true) + expect(testState.subscribeToRuntimeTerminalData).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'owner-runtime' }, + 'remote:terminal-handle', + 'desktop:paste-ready:remote:terminal-handle', + expect.any(Function) + ) + }) + it('submits to an already running agent without waiting for readiness signals', async () => { const promise = sendBracketedPasteToRunningAgent({ ptyId: 'pty-1', @@ -286,6 +358,33 @@ describe('pasteDraftWhenAgentReady', () => { }) }) +describe('getSettingsForAgentTabRuntimeOwner', () => { + beforeEach(() => { + testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + testState.appState.tabsByWorktree = {} + testState.appState.repos = [] + testState.appState.worktreesByRepo = {} + }) + + it('falls back to focused settings when the tab is not mapped to a worktree', () => { + expect(getSettingsForAgentTabRuntimeOwner('missing-tab')).toEqual({ + activeRuntimeEnvironmentId: 'focused-runtime' + }) + }) + + it('uses the tab worktree owner when mapped', () => { + testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + testState.appState.repos = [ + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + + expect(getSettingsForAgentTabRuntimeOwner('tab-1')).toEqual({ + activeRuntimeEnvironmentId: 'owner-runtime' + }) + }) +}) + async function flushMicrotasks(): Promise<void> { await Promise.resolve() await Promise.resolve() diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 6515144a38a..15419c2b18b 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -8,6 +8,8 @@ import { } from '@/runtime/runtime-terminal-inspection' import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' import { waitForAgentReady } from './agent-ready-wait' +import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' +import type { GlobalSettings } from '../../../shared/types' // Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Pi / // OpenCode / Gemini / cursor-agent / copilot) treat the inserted text as a @@ -44,6 +46,20 @@ const BRACKETED_PASTE_QUIET_MS = 1500 // stuck launch doesn't pin a Promise forever. const READINESS_TIMEOUT_MS = 8000 +export function getSettingsForAgentTabRuntimeOwner( + tabId: string +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined { + const store = useAppStore.getState() + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree ?? {})) { + if (tabs?.some((tab) => tab.id === tabId)) { + // Why: legacy remote PTY ids may not embed their runtime owner. The tab's + // worktree still identifies which host should receive readiness/send RPCs. + return getSettingsForWorktreeRuntimeOwner(store, worktreeId) + } + } + return store.settings +} + /** * Wait until the agent on `tabId` has rendered its input-accepting TUI, * then bracketed-paste `content` into its input buffer. By default the @@ -90,7 +106,8 @@ export async function pasteDraftWhenAgentReady(args: { return false } - const ready = await waitForInputBoxReady(ptyId, budget, readySignal) + const settings = getSettingsForAgentTabRuntimeOwner(tabId) + const ready = await waitForInputBoxReady(ptyId, budget, readySignal, settings) if (!ready) { // Why: fast-starting TUIs can emit the paste-ready escape sequence before // this sidecar subscription attaches. If process/title inspection says the @@ -106,6 +123,7 @@ export async function pasteDraftWhenAgentReady(args: { } return await sendBracketedPasteToAgent({ + settings, ptyId, content, submit: submit === true @@ -122,7 +140,12 @@ export async function submitPromptToAgentTab(args: { if (!ptyId) { return false } - return await sendBracketedPasteToAgent({ ptyId, content, submit: true }) + return await sendBracketedPasteToAgent({ + settings: getSettingsForAgentTabRuntimeOwner(tabId), + ptyId, + content, + submit: true + }) } export async function sendBracketedPasteToRunningAgent(args: { @@ -133,12 +156,12 @@ export async function sendBracketedPasteToRunningAgent(args: { } async function sendBracketedPasteToAgent(args: { + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null ptyId: string content: string submit: boolean }): Promise<boolean> { - const { ptyId, content, submit } = args - const settings = useAppStore.getState().settings + const { settings = useAppStore.getState().settings, ptyId, content, submit } = args const pastePayload = `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}` try { const pasted = await sendRuntimePtyInputVerified(settings, ptyId, pastePayload) @@ -176,7 +199,8 @@ async function sendBracketedPasteToAgent(args: { function waitForInputBoxReady( ptyId: string, timeoutMs: number, - readySignal: DraftPasteReadySignal + readySignal: DraftPasteReadySignal, + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined ): Promise<boolean> { return new Promise<boolean>((resolve) => { let settled = false @@ -257,7 +281,7 @@ function waitForInputBoxReady( if (isRemoteRuntimePtyId(ptyId)) { void subscribeToRuntimeTerminalData( - useAppStore.getState().settings, + settings, ptyId, `desktop:paste-ready:${ptyId}`, observeData diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 8f584416e37..b484519feb6 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -3,9 +3,16 @@ import type { AppState } from '@/store/types' import type { PersistedTrustedOrcaHooks } from '../../../shared/types' import { __resetTrustPromptChainForTests, ensureHooksConfirmed } from './ensure-hooks-confirmed' import { hashOrcaHookScript } from './orca-hook-trust' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const hooksCheckMock = vi.fn() const readIssueCommandMock = vi.fn() +const runtimeEnvironmentCallMock = vi.fn() +const runtimeEnvironmentTransportCallMock = vi.fn() function installHooksApiMock(): void { vi.stubGlobal('window', { @@ -13,6 +20,9 @@ function installHooksApiMock(): void { hooks: { check: hooksCheckMock, readIssueCommand: readIssueCommandMock + }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCallMock } } }) @@ -45,6 +55,16 @@ describe('ensureHooksConfirmed', () => { beforeEach(() => { hooksCheckMock.mockReset() readIssueCommandMock.mockReset() + runtimeEnvironmentCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => { + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCallMock(args) + ) + } + ) + clearRuntimeCompatibilityCacheForTests() installHooksApiMock() __resetTrustPromptChainForTests() }) @@ -147,6 +167,65 @@ describe('ensureHooksConfirmed', () => { expect(pending).toHaveLength(0) }) + it('checks SSH repo hooks through local IPC even when a runtime is focused', async () => { + const { state, pending } = createTestState({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + repos: [ + { + id: 'repo-1', + displayName: 'Repo One', + connectionId: 'ssh-1' + } + ] + } as unknown as Partial<AppState>) + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: {} }, + mayNeedUpdate: false + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive') + + expect(decision).toBe('run') + expect(hooksCheckMock).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(pending).toHaveLength(0) + }) + + it('checks runtime-owned repo hooks through the repo owner runtime', async () => { + const { state, pending } = createTestState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { + id: 'repo-1', + displayName: 'Repo One', + executionHostId: 'runtime:owner-env' + } + ] + } as unknown as Partial<AppState>) + runtimeEnvironmentCallMock.mockResolvedValue({ + id: 'rpc-hooks', + ok: true, + result: { + hasHooks: true, + hooks: { scripts: {} }, + mayNeedUpdate: false + }, + _meta: { runtimeId: 'runtime-owner' } + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive') + + expect(decision).toBe('run') + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ + selector: 'owner-env', + method: 'repo.hooksCheck', + params: { repo: 'repo-1' }, + timeoutMs: 15_000 + }) + expect(hooksCheckMock).not.toHaveBeenCalled() + expect(pending).toHaveLength(0) + }) + it('does not prompt for orca.yaml when the repo uses local commands only', async () => { const { state, pending } = createTestState({ repos: [ diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 1c9e0d4b64e..4b37be5f9c5 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -3,6 +3,7 @@ import type { OrcaHooks } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust' import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' +import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner' export type HookScriptKind = OrcaHookScriptKind @@ -33,6 +34,15 @@ function getSetupTrustContent(yamlHooks: OrcaHooks | null): string { return [yamlHooks?.scripts?.setup?.trim(), ...defaultTabCommands].filter(Boolean).join('\n\n') } +function settingsForHookRepoOwner(state: AppState, repoId: string): AppState['settings'] { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForRepo(state, repoId) + // Why: hook inspection must follow the repo owner. SSH/local repos execute + // through desktop IPC, while runtime repos may differ from the focused host. + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +} + export async function ensureHooksConfirmed( state: AppState, repoId: string, @@ -47,7 +57,10 @@ export async function ensureHooksConfirmed( try { if (scriptKind === 'issueCommand') { // Local overrides are user-owned; only shared orca.yaml commands need repo trust. - const result = await readRuntimeIssueCommand(state.settings, repoId) + const result = await readRuntimeIssueCommand( + settingsForHookRepoOwner(state, repoId), + repoId + ) if (result.source === 'local') { return 'run' } @@ -70,7 +83,7 @@ export async function ensureHooksConfirmed( if (sourcePolicy === 'local-only') { return 'run' } - const result = await checkRuntimeHooks(state.settings, repoId) + const result = await checkRuntimeHooks(settingsForHookRepoOwner(state, repoId), repoId) if (result.status === 'error') { return 'skip' } diff --git a/src/renderer/src/lib/github-work-item-source-lookup.ts b/src/renderer/src/lib/github-work-item-source-lookup.ts new file mode 100644 index 00000000000..cfba24b672c --- /dev/null +++ b/src/renderer/src/lib/github-work-item-source-lookup.ts @@ -0,0 +1,76 @@ +import type { GitHubWorkItem } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' + +type GitHubWorkItemLookupArgs = { + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' +} + +type GitHubWorkItemByOwnerRepoLookupArgs = GitHubWorkItemLookupArgs & { + owner: string + repo: string + type: 'issue' | 'pr' +} + +function runtimeRepoId(args: Pick<GitHubWorkItemLookupArgs, 'repoId' | 'sourceContext'>): string { + return args.sourceContext?.repoId ?? args.repoId +} + +export async function lookupGitHubWorkItemForSource( + args: GitHubWorkItemLookupArgs +): Promise<GitHubWorkItem | null> { + const target = getActiveRuntimeTarget(getTaskSourceRuntimeSettings(args.sourceContext)) + const item = + target.kind === 'environment' + ? await callRuntimeRpc<Omit<GitHubWorkItem, 'repoId'> | null>( + target, + 'github.workItem', + { + repo: runtimeRepoId(args), + number: args.number, + type: args.type + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.workItem({ + repoPath: args.repoPath, + repoId: args.repoId, + number: args.number, + type: args.type + }) + return item ? ({ ...item, repoId: args.repoId } as GitHubWorkItem) : null +} + +export async function lookupGitHubWorkItemByOwnerRepoForSource( + args: GitHubWorkItemByOwnerRepoLookupArgs +): Promise<GitHubWorkItem | null> { + const target = getActiveRuntimeTarget(getTaskSourceRuntimeSettings(args.sourceContext)) + const item = + target.kind === 'environment' + ? await callRuntimeRpc<Omit<GitHubWorkItem, 'repoId'> | null>( + target, + 'github.workItemByOwnerRepo', + { + repo: runtimeRepoId(args), + owner: args.owner, + ownerRepo: args.repo, + number: args.number, + type: args.type + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.workItemByOwnerRepo({ + repoPath: args.repoPath, + repoId: args.repoId, + owner: args.owner, + repo: args.repo, + number: args.number, + type: args.type + }) + return item ? ({ ...item, repoId: args.repoId } as GitHubWorkItem) : null +} diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index eafd5d3289b..1e798fc6f58 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -18,6 +18,7 @@ import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { @@ -153,7 +154,11 @@ export async function launchAgentBackgroundSession( window.api.pty.write(ptyId, submittedCommand) }, 50) } - const runtimeTarget = getActiveRuntimeTarget(store.settings) + // Route by the worktree's owner host: the agent terminal must spawn on the host + // that owns this worktree, not on the focused runtime. + const runtimeTarget = getActiveRuntimeTarget( + getSettingsForWorktreeRuntimeOwner(store, worktreeId) + ) let ptyId: string try { if (runtimeTarget.kind === 'environment') { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index fa94d3fb872..5687b23a004 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -10,6 +10,7 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive, @@ -229,7 +230,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI return null } - const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId) && pasteDraftAfterLaunch === null) { // Why: paired web tabs are host-owned and return tabId: null on success. // Local-only agent tabs cannot be closed because close routes through diff --git a/src/renderer/src/lib/launch-work-item-direct-preflight.ts b/src/renderer/src/lib/launch-work-item-direct-preflight.ts index c31bb23aed8..e103e83103a 100644 --- a/src/renderer/src/lib/launch-work-item-direct-preflight.ts +++ b/src/renderer/src/lib/launch-work-item-direct-preflight.ts @@ -1,18 +1,22 @@ -import { useAppStore, type AppState } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSetupConfig } from '@/lib/new-workspace' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import type { GitHubPrStartPoint, + GlobalSettings, OrcaHooks, RepoHookSettings, SetupDecision } from '../../../shared/types' +// Why: preflight routes by the repo's owner host, which `getSettingsForRepoRuntimeOwner` +// hands back as a narrow runtime-scope pick rather than the full GlobalSettings. +type PreflightSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined + export async function resolveDirectPrStartPoint( repoId: string, prNumber: number, - settings: AppState['settings'] + settings: PreflightSettings ): Promise<GitHubPrStartPoint> { const target = getActiveRuntimeTarget(settings) const result = @@ -32,11 +36,14 @@ export async function resolveDirectPrStartPoint( export async function resolveDirectSetupDecision( repoId: string, - repo: { hookSettings?: RepoHookSettings } + repo: { hookSettings?: RepoHookSettings }, + settings: PreflightSettings ): Promise<{ kind: 'decided'; decision: SetupDecision } | { kind: 'needs-modal' }> { let yamlHooks: OrcaHooks | null = null try { - const result = await checkRuntimeHooks(useAppStore.getState().settings, repoId) + // Why: route the hooks probe by the repo's owner host (passed in) so preflight + // and the subsequent owner-routed createWorktree hit the same host. + const result = await checkRuntimeHooks(settings, repoId) yamlHooks = (result.hooks as OrcaHooks | null) ?? null } catch { yamlHooks = null diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index a3a5b113769..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, @@ -81,6 +86,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } const settings = store.settings + // Why: preflight (PR base + hooks probe) must run on the repo's owner host so it + // matches the owner-routed createWorktree below, not the focused runtime. + const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, repoId) const promptDelivery = args.promptDelivery ?? 'draft' const repoConnectionId = repo.connectionId?.trim() || null const preflightLaunchPlatform = @@ -107,7 +115,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom ? store.ensureRemoteDetectedAgents(repoConnectionId) : store.ensureDetectedAgents() - const setupResolution = await resolveDirectSetupDecision(repoId, repo) + const setupResolution = await resolveDirectSetupDecision(repoId, repo, repoOwnerSettings) if (setupResolution.kind === 'needs-modal') { openModalFallback() return false @@ -139,7 +147,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom try { // Why: direct "Use PR" launches bypass the Start-from picker, so they // must still resolve the PR head before `git worktree add`. - const result = await resolveDirectPrStartPoint(repoId, item.number, settings) + const result = await resolveDirectPrStartPoint(repoId, item.number, repoOwnerSettings) resolvedBaseBranch = result.baseBranch resolvedPushTarget = result.pushTarget resolvedBranchNameOverride = result.branchNameOverride diff --git a/src/renderer/src/lib/new-workspace-composer-repo.test.ts b/src/renderer/src/lib/new-workspace-composer-repo.test.ts index 48a49a0bd01..3f1fea13ea1 100644 --- a/src/renderer/src/lib/new-workspace-composer-repo.test.ts +++ b/src/renderer/src/lib/new-workspace-composer-repo.test.ts @@ -54,4 +54,46 @@ describe('new-workspace-composer-repo', () => { getComposerEligibleRepos([makeRepo('missing-path', { path: '' }), makeRepo('repo')]) ).toEqual([expect.objectContaining({ id: 'repo' })]) }) + + it('defaults to a repo on the focused host when no explicit repo is chosen', () => { + const eligibleRepos = [ + makeRepo('local-repo'), + makeRepo('ssh-repo', { connectionId: 'win-vm' }), + makeRepo('runtime-repo', { executionHostId: 'runtime:env-1' }) + ] + + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'ssh:win-vm' })).toBe( + 'ssh-repo' + ) + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'runtime:env-1' })).toBe( + 'runtime-repo' + ) + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'local' })).toBe('local-repo') + }) + + it('lets explicit draft/initial/active choices win over the focused host', () => { + const eligibleRepos = [makeRepo('local-repo'), makeRepo('ssh-repo', { connectionId: 'win-vm' })] + + expect( + resolveComposerRepoId({ + eligibleRepos, + activeRepoId: 'local-repo', + focusedHostScope: 'ssh:win-vm' + }) + ).toBe('local-repo') + }) + + it('ignores host scope "all" and falls back to the first eligible repo', () => { + const eligibleRepos = [makeRepo('local-repo'), makeRepo('ssh-repo', { connectionId: 'win-vm' })] + + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'all' })).toBe('local-repo') + }) + + it('falls back to the first eligible repo when the focused host has no repos', () => { + const eligibleRepos = [makeRepo('local-repo')] + + expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'ssh:gone' })).toBe( + 'local-repo' + ) + }) }) diff --git a/src/renderer/src/lib/new-workspace-composer-repo.ts b/src/renderer/src/lib/new-workspace-composer-repo.ts index 23869a646f5..1c4dd896e8b 100644 --- a/src/renderer/src/lib/new-workspace-composer-repo.ts +++ b/src/renderer/src/lib/new-workspace-composer-repo.ts @@ -1,3 +1,8 @@ +import { + ALL_EXECUTION_HOSTS_SCOPE, + getRepoExecutionHostId, + type ExecutionHostScope +} from '../../../shared/execution-host' import { isGitRepoKind } from '../../../shared/repo-kind' import type { Repo } from '../../../shared/types' @@ -9,17 +14,28 @@ export function resolveComposerRepoId({ eligibleRepos, draftRepoId, initialRepoId, - activeRepoId + activeRepoId, + focusedHostScope }: { eligibleRepos: readonly Repo[] draftRepoId?: string | null initialRepoId?: string | null activeRepoId?: string | null + focusedHostScope?: ExecutionHostScope | null }): string { + // Why: explicit choices (draft/initial/active) win, but the generic fallback + // must honor the focused host scope so "new workspace defaults to the + // focused host" holds for Landing/Cmd+J entry points (multi-host plan). + const focusedHostRepo = + focusedHostScope && focusedHostScope !== ALL_EXECUTION_HOSTS_SCOPE + ? eligibleRepos.find((repo) => getRepoExecutionHostId(repo) === focusedHostScope) + : undefined + const resolvedRepo = (draftRepoId && eligibleRepos.find((repo) => repo.id === draftRepoId)) || (initialRepoId && eligibleRepos.find((repo) => repo.id === initialRepoId)) || (activeRepoId && eligibleRepos.find((repo) => repo.id === activeRepoId)) || + focusedHostRepo || eligibleRepos[0] return resolvedRepo?.id ?? '' @@ -30,6 +46,7 @@ export function resolveComposerGitRepoId(args: { draftRepoId?: string | null initialRepoId?: string | null activeRepoId?: string | null + focusedHostScope?: ExecutionHostScope | null }): string | null { const repoId = resolveComposerRepoId(args) const repo = repoId ? args.eligibleRepos.find((entry) => entry.id === repoId) : null diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts new file mode 100644 index 00000000000..fc2990b0879 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { buildNewWorkspaceProjectOptions } from './new-workspace-project-options' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' + +function repo(id: string, overrides: Partial<Repo> = {}): Repo { + return { + id, + path: `/tmp/${id}`, + displayName: id, + badgeColor: '#111111', + addedAt: 1, + upstream: { owner: 'stablyai', repo: 'orca' }, + ...overrides + } +} + +function project(overrides: Partial<Project> = {}): Project { + return { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + sourceRepoIds: ['local-repo', 'ssh-repo'], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function setup(overrides: Partial<ProjectHostSetup>): ProjectHostSetup { + return { + id: overrides.id ?? 'local-setup', + projectId: overrides.projectId ?? 'github:stablyai/orca', + hostId: overrides.hostId ?? 'local', + repoId: overrides.repoId ?? 'local-repo', + path: overrides.path ?? '/tmp/orca', + displayName: overrides.displayName ?? 'orca', + setupState: overrides.setupState ?? 'ready', + setupMethod: overrides.setupMethod ?? 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('buildNewWorkspaceProjectOptions', () => { + it('deduplicates a logical project across local and SSH setups', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project()], + projectHostSetups: [ + setup({ id: 'local-setup', hostId: 'local', repoId: 'local-repo' }), + setup({ id: 'ssh-setup', hostId: 'ssh:builder', repoId: 'ssh-repo' }) + ], + eligibleRepos: [repo('local-repo'), repo('ssh-repo', { connectionId: 'ssh:builder' })] + }) + + expect(options).toEqual([ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + detail: 'stablyai/orca' + } + ]) + }) + + it('excludes projects that do not have a ready eligible setup', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project(), project({ id: 'repo:other', displayName: 'other' })], + projectHostSetups: [ + setup({ id: 'local-setup', repoId: 'local-repo' }), + setup({ + id: 'other-setup', + projectId: 'repo:other', + repoId: 'other-repo', + setupState: 'not-set-up' + }) + ], + eligibleRepos: [repo('local-repo'), repo('other-repo')] + }) + + expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) + }) +}) diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts new file mode 100644 index 00000000000..ebc3240d204 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -0,0 +1,85 @@ +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' + +export type NewWorkspaceProjectOption = { + id: string + displayName: string + badgeColor: string + detail: string +} + +type BuildNewWorkspaceProjectOptionsInput = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + eligibleRepos: readonly Repo[] +} + +function getProjectModel({ + projects, + projectHostSetups, + eligibleRepos +}: BuildNewWorkspaceProjectOptionsInput): { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} { + if (projects.length > 0 || projectHostSetups.length > 0) { + return { projects, projectHostSetups } + } + const projection = projectHostSetupProjectionFromRepos(eligibleRepos) + return { + projects: projection.projects, + projectHostSetups: projection.setups + } +} + +function getProjectDetail(project: Project, readySetupCount: number): string { + if (project.providerIdentity) { + return `${project.providerIdentity.owner}/${project.providerIdentity.repo}` + } + if (readySetupCount > 1) { + return `${readySetupCount} hosts configured` + } + return 'Project' +} + +export function buildNewWorkspaceProjectOptions( + input: BuildNewWorkspaceProjectOptionsInput +): NewWorkspaceProjectOption[] { + const { eligibleRepos } = input + const { projects, projectHostSetups } = getProjectModel(input) + const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const readySetupCountsByProjectId = new Map<string, number>() + + for (const setup of projectHostSetups) { + if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { + continue + } + readySetupCountsByProjectId.set( + setup.projectId, + (readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1 + ) + } + + return projects + .filter((project) => (readySetupCountsByProjectId.get(project.id) ?? 0) > 0) + .map((project) => ({ + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0) + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) +} + +export function searchNewWorkspaceProjectOptions( + options: readonly NewWorkspaceProjectOption[], + rawQuery: string +): NewWorkspaceProjectOption[] { + const query = rawQuery.trim().toLowerCase() + if (!query) { + return [...options] + } + return options.filter((option) => + [option.displayName, option.detail].some((value) => value.toLowerCase().includes(query)) + ) +} 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/project-host-clone-url.test.ts b/src/renderer/src/lib/project-host-clone-url.test.ts new file mode 100644 index 00000000000..26a0c9f1395 --- /dev/null +++ b/src/renderer/src/lib/project-host-clone-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { Project } from '../../../shared/types' +import { getProjectHostCloneUrl } from './project-host-clone-url' + +function createProject(overrides: Partial<Project> = {}): Project { + return { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectHostCloneUrl', () => { + it('builds a GitHub HTTPS clone URL from provider identity', () => { + expect( + getProjectHostCloneUrl( + createProject({ + providerIdentity: { + provider: 'github', + owner: ' stablyai ', + repo: ' orca ' + } + }) + ) + ).toBe('https://github.com/stablyai/orca.git') + }) + + it('returns null when provider identity is missing or incomplete', () => { + expect(getProjectHostCloneUrl(createProject())).toBeNull() + expect( + getProjectHostCloneUrl( + createProject({ + providerIdentity: { + provider: 'github', + owner: '', + repo: 'orca' + } + }) + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/project-host-clone-url.ts b/src/renderer/src/lib/project-host-clone-url.ts new file mode 100644 index 00000000000..ae1937ca1a3 --- /dev/null +++ b/src/renderer/src/lib/project-host-clone-url.ts @@ -0,0 +1,14 @@ +import type { Project } from '../../../shared/types' + +export function getProjectHostCloneUrl(project: Project | null | undefined): string | null { + const identity = project?.providerIdentity + if (!identity || identity.provider !== 'github') { + return null + } + const owner = identity.owner.trim() + const repo = identity.repo.trim() + if (!owner || !repo) { + return null + } + return `https://github.com/${owner}/${repo}.git` +} diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts new file mode 100644 index 00000000000..328aebb5fb5 --- /dev/null +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import type { ProjectHostSetup, Repo } from '../../../shared/types' +import { buildProjectHostSetupOptions } from './project-host-setup-options' + +const FULL_HOST_MODEL_RUNTIME_CAPABILITIES = [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +] + +function repo(id: string): Repo { + return { + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 1 + } +} + +function setup( + id: string, + projectId: string, + hostId: ExecutionHostId, + repoId: string, + overrides: Partial<ProjectHostSetup> = {} +): ProjectHostSetup { + return { + id, + projectId, + hostId, + repoId, + path: `/repos/${repoId}`, + displayName: repoId, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function host( + id: ExecutionHostId, + overrides: Partial<ExecutionHostRegistryEntry> = {} +): ExecutionHostRegistryEntry { + return { + id, + kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime', + label: id === 'local' ? 'Local Mac' : id.replace(/^ssh:|^runtime:/, ''), + detail: id === 'local' ? 'This computer' : 'Host', + health: id === 'local' ? 'local' : 'available', + ...overrides + } +} + +describe('buildProjectHostSetupOptions', () => { + it('returns ready setup choices for one project sorted with local first', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo'), repo('remote-repo')], + projectHostSetups: [ + setup('remote', 'project-1', 'ssh:builder', 'remote-repo'), + setup('local', 'project-1', 'local', 'local-repo') + ] + }) + + expect(options.map((option) => option.id)).toEqual(['local', 'remote']) + expect(options[0]).toMatchObject({ label: 'Local Mac', repoId: 'local-repo' }) + expect(options[1]).toMatchObject({ label: 'builder', repoId: 'remote-repo' }) + }) + + it('uses saved host labels for ready runtime setup choices', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('runtime-repo')], + hosts: [ + host('runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', { + label: 'dev box', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + setup( + 'runtime', + 'project-1', + 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', + 'runtime-repo' + ) + ] + }) + + expect(options).toEqual([ + expect.objectContaining({ + id: 'runtime', + kind: 'ready', + label: 'dev box', + repoId: 'runtime-repo' + }) + ]) + }) + + it('omits setups that are not ready or cannot create through an eligible repo', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('ready-repo')], + projectHostSetups: [ + setup('ready', 'project-1', 'local', 'ready-repo'), + setup('setting-up', 'project-1', 'ssh:builder', 'missing-repo', { + setupState: 'setting-up' + }), + setup('other-project', 'project-2', 'local', 'ready-repo') + ] + }) + + expect(options.map((option) => option.id)).toEqual(['ready']) + }) + + it('includes known hosts that still need project setup', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('ssh:builder', { label: 'Builder' })], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }), + expect.objectContaining({ + id: 'needs-setup:ssh:builder', + kind: 'needs-setup', + label: 'Builder', + detail: 'Project not set up on this host', + isAvailable: true + }) + ]) + }) + + it('shows pending setup status for known hosts with non-ready setup metadata', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + setup('local', 'project-1', 'local', 'local-repo'), + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }), + expect.objectContaining({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + label: 'GPU VM', + detail: 'Project setup is in progress', + isAvailable: true + }) + ]) + }) + + it('uses specific pending details for not-set-up, error, and unsupported setup metadata', () => { + const base = { + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + } + + expect( + buildProjectHostSetupOptions({ + ...base, + hosts: [ + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + ...base.projectHostSetups, + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + ] + }).at(-1) + ).toMatchObject({ detail: 'Project tracked on this host but not set up' }) + + expect( + buildProjectHostSetupOptions({ + ...base, + hosts: [ + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + ...base.projectHostSetups, + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'error', + setupMethod: 'provisioned' + }) + ] + }).at(-1) + ).toMatchObject({ detail: 'Project setup needs attention' }) + + expect( + buildProjectHostSetupOptions({ + ...base, + hosts: [ + host('runtime:gpu', { + label: 'GPU VM', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [ + ...base.projectHostSetups, + setup('gpu-pending', 'project-1', 'runtime:gpu', '', { + path: '', + setupState: 'unsupported', + setupMethod: 'provisioned' + }) + ] + }).at(-1) + ).toMatchObject({ detail: 'Project is unsupported on this host' }) + }) + + it('marks incompatible runtime hosts as visible but unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host('runtime:gpu', { + label: 'GPU VM', + health: 'blocked', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }), + expect.objectContaining({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + label: 'GPU VM', + detail: 'Orca server version is incompatible', + isAvailable: false + }) + ]) + }) + + it('marks runtime hosts without project setup capability as unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM', capabilities: [] })], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + detail: 'Update Orca on this host to set up projects', + isAvailable: false + }) + }) + + it('marks runtime hosts without workspace run-context capability as unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host('runtime:gpu', { + label: 'GPU VM', + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + }) + ], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + detail: 'Update Orca on this host to set up projects', + isAvailable: false + }) + }) + + it('marks runtime hosts with unknown capabilities as unavailable while checking', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM' })], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + detail: 'Checking host capabilities', + isAvailable: false + }) + }) +}) diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts new file mode 100644 index 00000000000..74654ecfb68 --- /dev/null +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -0,0 +1,218 @@ +import { + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../shared/execution-host' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import type { ProjectHostSetup, Repo } from '../../../shared/types' + +export type ProjectHostSetupOption = + | { + id: string + kind: 'ready' + projectId: string + hostId: ExecutionHostId + repoId: string + label: string + detail: string + path: string + } + | { + id: string + kind: 'needs-setup' + projectId: string + hostId: ExecutionHostId + label: string + detail: string + isAvailable: boolean + } + +export type ReadyProjectHostSetupOption = Extract<ProjectHostSetupOption, { kind: 'ready' }> + +export type NeedsSetupProjectHostOption = Extract<ProjectHostSetupOption, { kind: 'needs-setup' }> + +type BuildReadySetupOptionsInput = { + projectId: string + projectHostSetups: readonly ProjectHostSetup[] + eligibleRepos: readonly Repo[] + hosts: readonly ExecutionHostRegistryEntry[] +} + +type BuildNeedsSetupOptionsInput = { + projectId: string + hosts: readonly ExecutionHostRegistryEntry[] + readySetupByHost: ReadonlyMap<ExecutionHostId, ReadyProjectHostSetupOption> + pendingSetupByHost: ReadonlyMap<ExecutionHostId, ProjectHostSetup> +} + +type BuildProjectHostSetupOptionsInput = { + projectId: string | null + projectHostSetups: readonly ProjectHostSetup[] + eligibleRepos: readonly Repo[] + hosts?: readonly ExecutionHostRegistryEntry[] +} + +export function buildProjectHostSetupOptions({ + projectId, + projectHostSetups, + eligibleRepos, + hosts = [] +}: BuildProjectHostSetupOptionsInput): ProjectHostSetupOption[] { + if (!projectId) { + return [] + } + const readyOptions = buildReadySetupOptions({ + projectId, + projectHostSetups, + eligibleRepos, + hosts + }) + const readySetupByHost = new Map(readyOptions.map((option) => [option.hostId, option])) + const pendingSetupByHost = getPendingSetupByHost(projectId, projectHostSetups) + return [ + ...readyOptions, + ...buildNeedsSetupOptions({ + projectId, + hosts, + readySetupByHost, + pendingSetupByHost + }) + ].sort((a, b) => compareProjectHostSetupOptions(a, b)) +} + +function getPendingSetupByHost( + projectId: string, + projectHostSetups: readonly ProjectHostSetup[] +): Map<ExecutionHostId, ProjectHostSetup> { + const setups = new Map<ExecutionHostId, ProjectHostSetup>() + for (const setup of projectHostSetups) { + if (setup.projectId !== projectId || setup.setupState === 'ready') { + continue + } + if (!setups.has(setup.hostId)) { + setups.set(setup.hostId, setup) + } + } + return setups +} + +function buildReadySetupOptions({ + projectId, + projectHostSetups, + eligibleRepos, + hosts +}: BuildReadySetupOptionsInput): ReadyProjectHostSetupOption[] { + const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const hostLabelById = new Map(hosts.map((host) => [host.id, host.label])) + return projectHostSetups + .filter( + (setup) => + setup.projectId === projectId && + setup.setupState === 'ready' && + eligibleRepoIds.has(setup.repoId) + ) + .map((setup) => ({ + id: setup.id, + kind: 'ready' as const, + projectId: setup.projectId, + hostId: setup.hostId, + repoId: setup.repoId, + label: hostLabelById.get(setup.hostId) || getExecutionHostLabel(setup.hostId), + detail: setup.displayName, + path: setup.path + })) +} + +function buildNeedsSetupOptions({ + projectId, + hosts, + readySetupByHost, + pendingSetupByHost +}: BuildNeedsSetupOptionsInput): NeedsSetupProjectHostOption[] { + return hosts + .filter((host) => !readySetupByHost.has(host.id)) + .map((host) => { + const pendingSetup = pendingSetupByHost.get(host.id) + const availability = getHostSetupAvailability(host) + return { + id: `needs-setup:${host.id}`, + kind: 'needs-setup' as const, + projectId, + hostId: host.id, + label: host.label || getExecutionHostLabel(host.id), + detail: availability.isAvailable + ? pendingSetup + ? getPendingSetupDetail(pendingSetup) + : 'Project not set up on this host' + : availability.detail, + isAvailable: availability.isAvailable + } + }) +} + +function getHostSetupAvailability(host: ExecutionHostRegistryEntry): { + isAvailable: boolean + detail: string +} { + if (host.health === 'blocked') { + return { + isAvailable: false, + detail: 'Orca server version is incompatible' + } + } + if (host.kind === 'runtime') { + if (!host.capabilities) { + return { + isAvailable: false, + detail: 'Checking host capabilities' + } + } + if ( + !host.capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) || + !host.capabilities.includes(WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY) + ) { + return { + isAvailable: false, + detail: 'Update Orca on this host to set up projects' + } + } + } + return { + isAvailable: true, + detail: '' + } +} + +function getPendingSetupDetail(setup: ProjectHostSetup): string { + switch (setup.setupState) { + case 'not-set-up': + return 'Project tracked on this host but not set up' + case 'setting-up': + return 'Project setup is in progress' + case 'error': + return 'Project setup needs attention' + case 'unsupported': + return 'Project is unsupported on this host' + case 'ready': + return setup.path + } +} + +function compareProjectHostSetupOptions( + a: ProjectHostSetupOption, + b: ProjectHostSetupOption +): number { + if (a.hostId === LOCAL_EXECUTION_HOST_ID && b.hostId !== LOCAL_EXECUTION_HOST_ID) { + return -1 + } + if (b.hostId === LOCAL_EXECUTION_HOST_ID && a.hostId !== LOCAL_EXECUTION_HOST_ID) { + return 1 + } + const aDetail = a.kind === 'ready' ? a.path : a.detail + const bDetail = b.kind === 'ready' ? b.path : b.detail + return a.label.localeCompare(b.label) || aDetail.localeCompare(bDetail) +} diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts new file mode 100644 index 00000000000..a6fd417de25 --- /dev/null +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' +import { + resolveWorkspaceCreationRepoId, + resolveWorkspaceCreationTarget +} from './project-host-workspace-target' + +function makeRepo(id: string, overrides: Partial<Repo> = {}): Repo { + return { + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 1, + ...overrides + } +} + +function makeProject( + id: string, + sourceRepoIds: string[], + overrides: Partial<Project> = {} +): Project { + return { + id, + displayName: id, + badgeColor: '#000000', + sourceRepoIds, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSetup( + id: string, + projectId: string, + hostId: ExecutionHostId, + repoId: string, + overrides: Partial<ProjectHostSetup> = {} +): ProjectHostSetup { + return { + id, + projectId, + hostId, + repoId, + path: `/repos/${repoId}`, + displayName: repoId, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('project-host workspace target resolution', () => { + it('falls back to a local setup for a local-only repo', () => { + const repo = makeRepo('orca') + + const resolution = resolveWorkspaceCreationTarget({ eligibleRepos: [repo] }) + + expect(resolution).toMatchObject({ + status: 'ready', + target: { + projectId: 'repo:orca', + hostId: 'local', + projectHostSetupId: 'orca', + repoId: 'orca' + } + }) + }) + + it('chooses the focused host setup when one project exists on multiple hosts', () => { + const repos = [makeRepo('orca-local'), makeRepo('orca-ssh', { connectionId: 'openclaw-2' })] + const projects = [makeProject('github:stablyai/orca', ['orca-local', 'orca-ssh'])] + const projectHostSetups = [ + makeSetup('orca-local', 'github:stablyai/orca', 'local', 'orca-local'), + makeSetup('orca-ssh', 'github:stablyai/orca', 'ssh:openclaw-2', 'orca-ssh') + ] + + expect( + resolveWorkspaceCreationRepoId({ + eligibleRepos: repos, + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + focusedHostScope: 'ssh:openclaw-2' + }) + ).toBe('orca-ssh') + }) + + it('resolves an explicit project and host to the matching setup', () => { + const repos = [ + makeRepo('orca-local'), + makeRepo('orca-runtime', { executionHostId: 'runtime:gpu-1' }) + ] + const projects = [makeProject('github:stablyai/orca', ['orca-local', 'orca-runtime'])] + const projectHostSetups = [ + makeSetup('orca-local', 'github:stablyai/orca', 'local', 'orca-local'), + makeSetup('orca-runtime', 'github:stablyai/orca', 'runtime:gpu-1', 'orca-runtime') + ] + + const resolution = resolveWorkspaceCreationTarget({ + eligibleRepos: repos, + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu-1' + }) + + expect(resolution).toMatchObject({ + status: 'ready', + target: { + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu-1', + projectHostSetupId: 'orca-runtime', + repoId: 'orca-runtime' + } + }) + }) + + it('does not merge same-name repos without shared project identity', () => { + const repos = [ + makeRepo('personal-orca', { displayName: 'orca' }), + makeRepo('work-orca', { displayName: 'orca', connectionId: 'work-linux' }) + ] + + expect( + resolveWorkspaceCreationRepoId({ + eligibleRepos: repos, + projectId: 'repo:personal-orca', + focusedHostScope: 'ssh:work-linux' + }) + ).toBe('personal-orca') + }) + + it('reports unavailable when the project is not set up on the selected host', () => { + const repo = makeRepo('orca') + const projects = [makeProject('github:stablyai/orca', ['orca'])] + const projectHostSetups = [makeSetup('orca', 'github:stablyai/orca', 'local', 'orca')] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [repo], + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw-2' + }) + ).toEqual({ + status: 'unavailable', + reason: 'project-not-set-up-on-host' + }) + }) + + it('reports setup-not-ready when the selected host has pending setup metadata', () => { + const repo = makeRepo('orca') + const projects = [makeProject('github:stablyai/orca', ['orca'])] + const projectHostSetups = [ + makeSetup('orca', 'github:stablyai/orca', 'local', 'orca'), + makeSetup('gpu-pending', 'github:stablyai/orca', 'runtime:gpu', '', { + path: '', + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [repo], + projects, + projectHostSetups, + projectId: 'github:stablyai/orca', + hostId: 'runtime:gpu' + }) + ).toEqual({ + status: 'unavailable', + reason: 'setup-not-ready' + }) + }) + + it('reports unavailable when an explicit setup is not ready', () => { + const repo = makeRepo('orca') + const projects = [makeProject('github:stablyai/orca', ['orca'])] + const projectHostSetups = [ + makeSetup('orca', 'github:stablyai/orca', 'local', 'orca', { setupState: 'setting-up' }) + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [repo], + projects, + projectHostSetups, + projectHostSetupId: 'orca' + }) + ).toEqual({ + status: 'unavailable', + reason: 'setup-not-ready' + }) + }) +}) diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts new file mode 100644 index 00000000000..13e9271bac0 --- /dev/null +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -0,0 +1,208 @@ +import { + ALL_EXECUTION_HOSTS_SCOPE, + type ExecutionHostId, + type ExecutionHostScope +} from '../../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' +import { resolveComposerRepoId } from './new-workspace-composer-repo' + +export type WorkspaceCreationTarget = { + projectId: string + hostId: ExecutionHostId + projectHostSetupId: string + repoId: string + repo: Repo + setup: ProjectHostSetup +} + +export type WorkspaceCreationTargetResolution = + | { status: 'ready'; target: WorkspaceCreationTarget } + | { + status: 'unavailable' + reason: + | 'no-eligible-repo' + | 'project-not-found' + | 'project-not-set-up-on-host' + | 'project-has-no-ready-setup' + | 'setup-not-found' + | 'setup-not-ready' + } + +type ProjectHostWorkspaceTargetInput = { + eligibleRepos: readonly Repo[] + projects?: readonly Project[] + projectHostSetups?: readonly ProjectHostSetup[] + draftRepoId?: string | null + initialRepoId?: string | null + activeRepoId?: string | null + projectId?: string | null + hostId?: ExecutionHostId | null + projectHostSetupId?: string | null + focusedHostScope?: ExecutionHostScope | null +} + +type ProjectSetupModel = { + projects: readonly Project[] + setups: readonly ProjectHostSetup[] +} + +function getProjectSetupModel({ + eligibleRepos, + projects, + projectHostSetups +}: Pick< + ProjectHostWorkspaceTargetInput, + 'eligibleRepos' | 'projects' | 'projectHostSetups' +>): ProjectSetupModel | null { + if (projects?.length || projectHostSetups?.length) { + return { + projects: projects ?? [], + setups: projectHostSetups ?? [] + } + } + if (eligibleRepos.length === 0) { + return null + } + const projection = projectHostSetupProjectionFromRepos(eligibleRepos) + return { + projects: projection.projects, + setups: projection.setups + } +} + +function isReadySetup(setup: ProjectHostSetup): boolean { + return setup.setupState === 'ready' +} + +function createTarget( + setup: ProjectHostSetup, + repoById: ReadonlyMap<string, Repo> +): WorkspaceCreationTarget | null { + const repo = repoById.get(setup.repoId) + if (!repo) { + return null + } + return { + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + repo, + setup + } +} + +function findReadySetupTarget( + setups: readonly ProjectHostSetup[], + repoById: ReadonlyMap<string, Repo>, + predicate: (setup: ProjectHostSetup) => boolean +): WorkspaceCreationTarget | null { + for (const setup of setups) { + if (!isReadySetup(setup) || !predicate(setup)) { + continue + } + const target = createTarget(setup, repoById) + if (target) { + return target + } + } + return null +} + +export function resolveWorkspaceCreationTarget( + input: ProjectHostWorkspaceTargetInput +): WorkspaceCreationTargetResolution { + const { eligibleRepos, focusedHostScope, hostId, projectHostSetupId, projectId } = input + if (eligibleRepos.length === 0) { + return { status: 'unavailable', reason: 'no-eligible-repo' } + } + + const model = getProjectSetupModel(input) + const repoById = new Map(eligibleRepos.map((repo) => [repo.id, repo])) + const setups = model?.setups ?? [] + + if (projectHostSetupId) { + const setup = setups.find((entry) => entry.id === projectHostSetupId) + if (!setup) { + return { status: 'unavailable', reason: 'setup-not-found' } + } + if (!isReadySetup(setup)) { + return { status: 'unavailable', reason: 'setup-not-ready' } + } + const target = createTarget(setup, repoById) + if (target) { + return { status: 'ready', target } + } + return { status: 'unavailable', reason: 'setup-not-found' } + } + + if (projectId && !model?.projects.some((project) => project.id === projectId)) { + return { status: 'unavailable', reason: 'project-not-found' } + } + + if (projectId && hostId) { + const hostSetup = setups.find( + (setup) => setup.projectId === projectId && setup.hostId === hostId + ) + if (hostSetup && !isReadySetup(hostSetup)) { + return { status: 'unavailable', reason: 'setup-not-ready' } + } + const target = findReadySetupTarget( + setups, + repoById, + (setup) => setup.projectId === projectId && setup.hostId === hostId + ) + if (target) { + return { status: 'ready', target } + } + return { status: 'unavailable', reason: 'project-not-set-up-on-host' } + } + + if (projectId) { + const focusedHostId = + focusedHostScope && focusedHostScope !== ALL_EXECUTION_HOSTS_SCOPE ? focusedHostScope : null + const focusedTarget = focusedHostId + ? findReadySetupTarget( + setups, + repoById, + (setup) => setup.projectId === projectId && setup.hostId === focusedHostId + ) + : null + if (focusedTarget) { + return { status: 'ready', target: focusedTarget } + } + const target = findReadySetupTarget(setups, repoById, (setup) => setup.projectId === projectId) + if (target) { + return { status: 'ready', target } + } + return { status: 'unavailable', reason: 'project-has-no-ready-setup' } + } + + if (hostId) { + const target = findReadySetupTarget(setups, repoById, (setup) => setup.hostId === hostId) + if (target) { + return { status: 'ready', target } + } + } + + const repoId = resolveComposerRepoId(input) + const legacyRepo = repoId ? repoById.get(repoId) : null + if (!legacyRepo) { + return { status: 'unavailable', reason: 'no-eligible-repo' } + } + + const legacySetup = + setups.find((setup) => setup.repoId === legacyRepo.id && isReadySetup(setup)) ?? + projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] + const legacyTarget = legacySetup ? createTarget(legacySetup, repoById) : null + if (!legacyTarget) { + return { status: 'unavailable', reason: 'setup-not-found' } + } + return { status: 'ready', target: legacyTarget } +} + +export function resolveWorkspaceCreationRepoId(input: ProjectHostWorkspaceTargetInput): string { + const resolution = resolveWorkspaceCreationTarget(input) + return resolution.status === 'ready' ? resolution.target.repoId : '' +} diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts new file mode 100644 index 00000000000..c564ff74dfe --- /dev/null +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../../shared/types' +import { + getRepoOwnerRoutedSettings, + getRuntimeEnvironmentIdForRepo, + getSettingsForRepoRuntimeOwner +} from './repo-runtime-owner' + +describe('getRuntimeEnvironmentIdForRepo', () => { + it('uses an explicit runtime repo owner instead of the focused runtime', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('keeps explicit local repos local while a runtime is focused', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }, + 'repo-1' + ) + ).toBeNull() + }) + + it('falls back to the focused runtime for legacy repos without an owner', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: null }] + }, + 'repo-1' + ) + ).toBe('focused-runtime') + }) + + it('returns settings scoped to an explicit local repo owner', () => { + expect( + getSettingsForRepoRuntimeOwner( + { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }, + 'repo-1' + ) + ).toEqual({ activeRuntimeEnvironmentId: null }) + }) +}) + +describe('getRepoOwnerRoutedSettings', () => { + // Why: SourceControl builds its git/file mutation contexts from this value, + // so it must rebind activeRuntimeEnvironmentId to the repo OWNER even while a + // different host is focused — otherwise stage/commit/push hit the wrong host. + it('routes a git mutation context for a runtime-owned active repo to the owner, not the focused runtime', () => { + const settings = { + activeRuntimeEnvironmentId: 'focused-runtime', + sourceControlViewMode: 'list' + } as unknown as GlobalSettings + + const routed = getRepoOwnerRoutedSettings(settings, { + id: 'repo-1', + connectionId: null, + executionHostId: 'runtime:owner-runtime' + }) + + expect(routed?.activeRuntimeEnvironmentId).toBe('owner-runtime') + // Non-routing (display) fields must survive the rebind untouched. + expect((routed as { sourceControlViewMode?: string }).sourceControlViewMode).toBe('list') + }) + + it('falls back to the focused runtime for a legacy repo without an explicit owner', () => { + const settings = { activeRuntimeEnvironmentId: 'focused-runtime' } as unknown as GlobalSettings + const routed = getRepoOwnerRoutedSettings(settings, { + id: 'repo-1', + connectionId: null, + executionHostId: null + }) + expect(routed?.activeRuntimeEnvironmentId).toBe('focused-runtime') + }) + + it('passes null settings through unchanged', () => { + expect( + getRepoOwnerRoutedSettings(null, { + id: 'repo-1', + connectionId: null, + executionHostId: null + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts new file mode 100644 index 00000000000..81905e9d1b3 --- /dev/null +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -0,0 +1,50 @@ +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import type { GlobalSettings, Repo } from '../../../shared/types' + +export type RepoRuntimeOwnerState = { + repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null +} + +export function getRuntimeEnvironmentIdForRepo( + state: RepoRuntimeOwnerState, + repoId: string | null | undefined +): string | null { + if (!repoId) { + return null + } + const repo = state.repos?.find((entry) => entry.id === repoId) + const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) + if (repo && hasExplicitOwner) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null + } + return state.settings?.activeRuntimeEnvironmentId?.trim() || null +} + +export function getSettingsForRepoRuntimeOwner( + state: RepoRuntimeOwnerState, + repoId: string | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return { + ...state.settings, + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForRepo(state, repoId) + } +} + +// Why: git/file/terminal mutations must route by the OWNER host of the repo, +// not the currently focused runtime. This rebinds activeRuntimeEnvironmentId to +// the repo owner while preserving every other (display/AI) settings field. +export function getRepoOwnerRoutedSettings<T extends GlobalSettings | null>( + settings: T, + repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'> | null | undefined +): T { + if (!settings) { + return settings + } + const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForRepo( + { repos: repo ? [repo] : [], settings }, + repo?.id ?? null + ) + return { ...settings, activeRuntimeEnvironmentId } +} diff --git a/src/renderer/src/lib/repo-slug-cache.ts b/src/renderer/src/lib/repo-slug-cache.ts new file mode 100644 index 00000000000..193d871eae1 --- /dev/null +++ b/src/renderer/src/lib/repo-slug-cache.ts @@ -0,0 +1,51 @@ +// Why: the slug → Repo cache and its synchronous lookup live here (separate from +// repo-slug-index.ts) so store slices can import the sync lookup without pulling +// in repo-slug-index's `@/store` dependency, which would form an import cycle. +import type { GlobalSettings, Repo } from '../../../shared/types' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getSettingsForRepoRuntimeOwner } from './repo-runtime-owner' + +/** Lowercased `owner/repo` → Repo[]. */ +export type SlugIndex = Map<string, Repo[]> + +/** Module-scope cache keyed by runtime scope + repo.id. A Repo that has already + * failed resolution is recorded as `null` so it is not retried on re-mount. */ +export const slugByRepoId = new Map<string, string | null>() + +export function slugCacheKey( + repoId: string, + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + return `${target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'}:${repoId}` +} + +export function settingsForRepoOwner( + repo: Repo, + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return getSettingsForRepoRuntimeOwner({ repos: [repo], settings }, repo.id) +} + +/** Synchronous slug → Repo lookup against the already-resolved module cache. + * Used by store slices (which can't run the async hook-based index) to route + * project-row mutations to the matched repo's owner host; callers fall back to + * focused settings when nothing matches. */ +export function lookupReposBySlugFromCache( + repos: readonly Repo[], + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, + slug: string | null | undefined +): Repo[] { + const target = slug?.toLowerCase() + if (!target) { + return [] + } + const matched: Repo[] = [] + for (const repo of repos) { + const cacheKey = slugCacheKey(repo.id, settingsForRepoOwner(repo, settings)) + if (slugByRepoId.get(cacheKey)?.toLowerCase() === target) { + matched.push(repo) + } + } + return matched +} diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index dde60964f9e..8a4832b04fa 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -18,26 +18,9 @@ import { useAppStore } from '@/store' import type { Repo } from '../../../shared/types' import type { GlobalSettings } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { settingsForRepoOwner, slugByRepoId, slugCacheKey, type SlugIndex } from './repo-slug-cache' -/** Lowercased `owner/repo` → Repo[]. Case folded because GitHub treats slugs - * case-insensitively but displays the canonical casing; the lookup side - * uses the row's `content.repository` which may or may not match the - * canonical casing depending on when the project item was indexed. */ -type SlugIndex = Map<string, Repo[]> - -/** Module-scope cache keyed by runtime scope + repo.id. A Repo that has already failed - * resolution is not retried on re-mount; the value in the map is `null` - * to record the negative result so we don't keep poking `git remote` for - * repos that will never match. */ -const slugByRepoId = new Map<string, string | null>() - -function slugCacheKey( - repoId: string, - settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined -): string { - const target = getActiveRuntimeTarget(settings) - return `${target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'}:${repoId}` -} +export { lookupReposBySlugFromCache } from './repo-slug-cache' /** Drop a repo's cached slug result. Call when a repo is removed or its * remote URL is known to have changed (e.g. after `git remote set-url`), @@ -97,7 +80,7 @@ async function buildIndex( // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its // negative-cached null) lingers forever. - const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settings))) + const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settingsForRepoOwner(r, settings)))) for (const key of slugByRepoId.keys()) { if (!liveKeys.has(key)) { slugByRepoId.delete(key) @@ -105,7 +88,12 @@ async function buildIndex( } const next: SlugIndex = new Map() const results = await Promise.all( - repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r, settings) })) + repos.map(async (r) => ({ + repo: r, + // Why: the project slug index spans repos from multiple hosts; each + // repo's remote metadata must be read from its owner. + slug: await resolveRepoSlug(r, settingsForRepoOwner(r, settings)) + })) ) for (const { repo, slug } of results) { if (slug) { @@ -125,11 +113,7 @@ export type RepoSlugIndexState = { * deep trees can treat it as referentially equal inside a single render cycle. */ export function useRepoSlugIndex(): RepoSlugIndexState { const repos = useAppStore((s) => s.repos) - const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) - const runtimeSettings = useMemo( - () => (activeRuntimeEnvironmentId ? { activeRuntimeEnvironmentId } : null), - [activeRuntimeEnvironmentId] - ) + const settings = useAppStore((s) => s.settings) const [index, setIndex] = useState<SlugIndex>(() => new Map()) const [ready, setReady] = useState(false) // Why: track the current repos snapshot so the effect can ignore stale @@ -139,14 +123,14 @@ export function useRepoSlugIndex(): RepoSlugIndexState { useEffect(() => { const gen = ++generationRef.current setReady(false) - void buildIndex(repos, runtimeSettings).then((next) => { + void buildIndex(repos, settings).then((next) => { if (gen !== generationRef.current) { return } setIndex(next) setReady(true) }) - }, [repos, runtimeSettings]) + }, [repos, settings]) return useMemo( () => ({ diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts index 572053f1fde..dc683db8537 100644 --- a/src/renderer/src/lib/smart-github-submit.ts +++ b/src/renderer/src/lib/smart-github-submit.ts @@ -1,4 +1,6 @@ import type { GitHubWorkItem } from '../../../shared/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getTaskSourceCacheScope } from '../../../shared/task-source-context' import { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' import type { LinkedWorkItemSummary } from './new-workspace' import { parseGitHubIssueOrPRLink } from './github-links' @@ -27,15 +29,18 @@ export type SmartGitHubSubmitResolution = { export type SmartGitHubSubmitLookup = { repoId: string repoPath: string + sourceContext?: TaskSourceContext | null intent: SmartGitHubSubmitIntent workItem: (args: { repoPath: string repoId: string + sourceContext?: TaskSourceContext | null number: number }) => Promise<GitHubWorkItem | null> workItemByOwnerRepo: (args: { repoPath: string repoId: string + sourceContext?: TaskSourceContext | null owner: string repo: string number: number @@ -109,13 +114,16 @@ function parseGitHubIssueOrPRLinkFromText( function getSmartGitHubSubmitLookupCacheKey({ repoId, repoPath, + sourceContext, intent }: { repoId: string repoPath: string + sourceContext?: TaskSourceContext | null intent: SmartGitHubSubmitIntent }): string { - const repoScope = `${repoId}:${repoPath}` + const sourceScope = sourceContext ? getTaskSourceCacheScope(sourceContext) : 'default' + const repoScope = `${sourceScope}:${repoId}:${repoPath}` if (intent.kind === 'hash-number') { return `${repoScope}:hash:${intent.number}` } @@ -127,11 +135,12 @@ function getSmartGitHubSubmitLookupCacheKey({ export function lookupSmartGitHubSubmitItem({ repoId, repoPath, + sourceContext, intent, workItem, workItemByOwnerRepo }: SmartGitHubSubmitLookup): Promise<GitHubWorkItem | null> { - const key = getSmartGitHubSubmitLookupCacheKey({ repoId, repoPath, intent }) + const key = getSmartGitHubSubmitLookupCacheKey({ repoId, repoPath, sourceContext, intent }) const now = Date.now() pruneSmartGitHubSubmitLookupCache(now) const cached = smartGitHubSubmitLookupCache.get(key) @@ -144,6 +153,7 @@ export function lookupSmartGitHubSubmitItem({ ? workItemByOwnerRepo({ repoPath, repoId, + sourceContext, owner: intent.owner, repo: intent.repo, number: intent.number, @@ -152,6 +162,7 @@ export function lookupSmartGitHubSubmitItem({ : workItem({ repoPath, repoId, + sourceContext, number: intent.number }) const stampedPromise = promise.then((item) => (item ? { ...item, repoId } : null)) diff --git a/src/renderer/src/lib/tab-number-shortcuts.test.ts b/src/renderer/src/lib/tab-number-shortcuts.test.ts index ef22db29d6f..4840e6aafd0 100644 --- a/src/renderer/src/lib/tab-number-shortcuts.test.ts +++ b/src/renderer/src/lib/tab-number-shortcuts.test.ts @@ -32,7 +32,10 @@ function state(overrides: { | 'activeView' | 'activeWorktreeId' | 'groupsByWorktree' + | 'repos' + | 'settings' | 'unifiedTabsByWorktree' + | 'worktreesByRepo' > { const worktreeId = overrides.activeWorktreeId ?? 'wt-1' return { @@ -41,6 +44,13 @@ function state(overrides: { activeGroupIdByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.activeGroupId ?? 'group-a' }, groupsByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.groups ?? [] }, + repos: + worktreeId === null + ? [] + : ([{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] as never), + settings: { activeRuntimeEnvironmentId: null } as never, + worktreesByRepo: + worktreeId === null ? {} : { 'repo-1': [{ id: worktreeId, repoId: 'repo-1' }] as never }, unifiedTabsByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.tabs ?? [] } } } diff --git a/src/renderer/src/lib/tab-number-shortcuts.ts b/src/renderer/src/lib/tab-number-shortcuts.ts index 38afae6f657..27a65675ce8 100644 --- a/src/renderer/src/lib/tab-number-shortcuts.ts +++ b/src/renderer/src/lib/tab-number-shortcuts.ts @@ -1,6 +1,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { dedupeTabOrder } from '@/store/slices/tab-group-state' import type { Tab } from '../../../shared/types' import { @@ -14,7 +15,10 @@ type TabNumberShortcutState = Pick< | 'activeView' | 'activeWorktreeId' | 'groupsByWorktree' + | 'repos' + | 'settings' | 'unifiedTabsByWorktree' + | 'worktreesByRepo' > export function resolveTabNumberShortcutTarget( @@ -57,8 +61,8 @@ export function activateTabNumberShortcut(index: number): boolean { return false } - const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim() const worktreeId = target.worktreeId + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) store.focusGroup(worktreeId, target.groupId) store.activateTab(target.id) diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts index 844fd4fe3b7..8e40333e7b8 100644 --- a/src/renderer/src/lib/workspace-port-actions.ts +++ b/src/renderer/src/lib/workspace-port-actions.ts @@ -6,6 +6,7 @@ import { type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { WorkspacePort, WorkspacePortKillResult, @@ -28,6 +29,9 @@ type RemoteBrowserPageHandleSetter = ReturnType< typeof useAppStore.getState >['setRemoteBrowserPageHandle'] type WorkspacePortScanSetter = ReturnType<typeof useAppStore.getState>['setWorkspacePortScan'] +type WorkspacePortScanByKeySetter = ReturnType< + typeof useAppStore.getState +>['setWorkspacePortScanForKey'] type WorkspacePortScanRefreshingSetter = ReturnType< typeof useAppStore.getState >['setWorkspacePortScanRefreshing'] @@ -111,7 +115,7 @@ export async function refreshWorkspacePortScanState(args: { try { const scan = await scanWorkspacePortsForTarget(args.runtimeTarget) args.setWorkspacePortScan({ - key: `${workspacePortRuntimeTargetKey(args.runtimeTarget)}:all`, + key: workspacePortScanKeyForTarget(args.runtimeTarget), result: scan }) return scan @@ -123,8 +127,20 @@ export async function refreshWorkspacePortScanState(args: { export async function refreshWorkspacePortScanAfterStop(args: { runtimeTarget: RuntimeClientTarget setWorkspacePortScan: WorkspacePortScanSetter + setWorkspacePortScanForKey?: WorkspacePortScanByKeySetter setWorkspacePortScanRefreshing: WorkspacePortScanRefreshingSetter + getWorkspacePortScansByKey?: () => Record<string, WorkspacePortScanResult> }): Promise<{ ok: true } | { ok: false; reason: string }> { + const scanKey = workspacePortScanKeyForTarget(args.runtimeTarget) + const publishScan = (scan: WorkspacePortScanResult): void => { + args.setWorkspacePortScanForKey?.(scanKey, scan) + const currentScans = args.getWorkspacePortScansByKey?.() ?? {} + const merged = mergeWorkspacePortScans({ ...currentScans, [scanKey]: scan }) + args.setWorkspacePortScan({ + key: merged && Object.keys(currentScans).length > 0 ? 'all-hosts:all' : scanKey, + result: merged ?? scan + }) + } args.setWorkspacePortScanRefreshing(true) try { let firstScan: WorkspacePortScanResult @@ -134,10 +150,7 @@ export async function refreshWorkspacePortScanAfterStop(args: { const message = error instanceof Error ? error.message : String(error) return { ok: false, reason: message || 'Workspace port scan failed.' } } - args.setWorkspacePortScan({ - key: `${workspacePortRuntimeTargetKey(args.runtimeTarget)}:all`, - result: firstScan - }) + publishScan(firstScan) // Why: stopping sends SIGTERM, and the listener can remain visible for a // short window. A settled re-scan keeps worktree cards from showing a stale @@ -147,10 +160,7 @@ export async function refreshWorkspacePortScanAfterStop(args: { await delay(WORKSPACE_PORT_STOP_SETTLE_MS) try { const settledScan = await scanWorkspacePortsForTarget(args.runtimeTarget) - args.setWorkspacePortScan({ - key: `${workspacePortRuntimeTargetKey(args.runtimeTarget)}:all`, - result: settledScan - }) + publishScan(settledScan) } catch { // Intentionally ignored: first scan already updated the UI. } @@ -164,6 +174,56 @@ export function workspacePortRuntimeTargetKey(target: RuntimeClientTarget): stri return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } +export function runtimeTargetForExecutionHostId( + hostId: ExecutionHostId +): RuntimeClientTarget | null { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'local') { + return { kind: 'local' } + } + if (parsed?.kind === 'runtime') { + return { kind: 'environment', environmentId: parsed.environmentId } + } + return null +} + +export function workspacePortScanKeyForTarget(target: RuntimeClientTarget): string { + return `${workspacePortRuntimeTargetKey(target)}:all` +} + +export function mergeWorkspacePortScans( + scansByKey: Record<string, WorkspacePortScanResult> +): WorkspacePortScanResult | null { + const entries = Object.entries(scansByKey) + .filter(([, scan]) => scan) + .sort(([a], [b]) => a.localeCompare(b)) + if (entries.length === 0) { + return null + } + if (entries.length === 1) { + return entries[0][1] + } + const ports = entries.flatMap(([key, scan]) => + scan.ports.map((port) => ({ + ...port, + // Why: local and runtime scanners can both report simple ids like + // `tcp:3000`; aggregate All-hosts views need stable unique row keys. + id: `${key}:${port.id}` + })) + ) + const unavailable = entries + .map(([key, scan]) => (scan.unavailableReason ? `${key}: ${scan.unavailableReason}` : null)) + .filter((entry): entry is string => entry !== null) + return { + platform: 'unknown', + scannedAt: Math.max(...entries.map(([, scan]) => scan.scannedAt)), + ports, + ...(unavailable.length === entries.length && unavailable.length > 0 + ? { unavailableReason: unavailable.join('; ') } + : {}) + } +} + const inFlightWorkspacePortScans = new Map<string, Promise<WorkspacePortScanResult>>() function workspacePortScanRequestKey(target: RuntimeClientTarget, repoId?: string): string { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts new file mode 100644 index 00000000000..80d56c23636 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -0,0 +1,138 @@ +import type { + Repo, + Worktree, + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { + mergeWorkspaceSessionsFromHosts, + splitWorkspaceSessionByHost, + type HostSessionSlices, + type HostIdByWorktreeId +} from './workspace-session-host-split' + +export type HostPersistenceState = { + repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] + worktreesByRepo: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]> +} + +type SessionApi = { + get: (hostId?: ExecutionHostId) => Promise<WorkspaceSessionState> + patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise<void> + setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void +} + +/** Map a worktree to the host partition it persists under. + * + * Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay + * in the 'local' partition because the SSH flow already persists them there (in + * the unified blob) and separately mirrors them to each target's remote + * snapshot — partitioning them too would double-own that data. */ +export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId { + const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) + const repoIdByWorktreeId = new Map<string, string>() + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + repoIdByWorktreeId.set(worktree.id, worktree.repoId) + } + } + + return (worktreeId: string): ExecutionHostId => { + const repoId = repoIdByWorktreeId.get(worktreeId) ?? getRepoIdFromWorktreeId(worktreeId) + const repo = repoId ? repoById.get(repoId) : undefined + if (!repo) { + return LOCAL_EXECUTION_HOST_ID + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + return parsed?.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID + } +} + +function nonLocalEntries(slices: HostSessionSlices): [ExecutionHostId, WorkspaceSessionState][] { + return (Object.entries(slices) as [ExecutionHostId, WorkspaceSessionState][]).filter( + ([hostId, slice]) => hostId !== LOCAL_EXECUTION_HOST_ID && slice !== undefined + ) +} + +/** Patch path of the debounced session writer: split the partial patch by owner + * host and patch each partition. Returns the promise for the local write so + * App.tsx can keep chaining the SSH remote-workspace upload off it. */ +export function patchWorkspaceSessionByHost( + api: SessionApi, + patch: WorkspaceSessionPatch, + state: HostPersistenceState +): Promise<void> { + const slices = splitWorkspaceSessionByHost( + patch as WorkspaceSessionState, + buildHostIdByWorktreeId(state) + ) + const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch + const localWrite = api.patch(local) + for (const [hostId, slice] of nonLocalEntries(slices)) { + // Why: a failed runtime-partition write must not reject the local chain. + void api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => { + console.warn(`[session] host partition patch failed for ${hostId}:`, err) + }) + } + return localWrite +} + +/** Synchronous full-session split for the beforeunload / quit paths. */ +export function persistWorkspaceSessionByHostSync( + api: SessionApi, + payload: WorkspaceSessionState, + state: HostPersistenceState +): void { + const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + api.setSync(slices[LOCAL_EXECUTION_HOST_ID] ?? payload) + for (const [hostId, slice] of nonLocalEntries(slices)) { + api.setSync(slice, hostId) + } +} + +/** Collect the distinct runtime hosts owning any persisted repo. */ +export function listKnownRuntimeHostIds( + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] +): ExecutionHostId[] { + const hostIds = new Set<ExecutionHostId>() + for (const repo of repos) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + hostIds.add(parsed.id) + } + } + return [...hostIds] +} + +/** Boot-time hydration: fetch the local partition plus one partition per known + * runtime host (repos are already loaded before session hydration in App.tsx) + * and merge them into the unified session the hydrators expect. + * + * Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with + * the rest. Corrupt partitions never reach here; persistence zod-validates + * each one and falls back to defaults on the main side. */ +export async function fetchWorkspaceSessionFromHosts( + api: Pick<SessionApi, 'get'>, + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] +): Promise<WorkspaceSessionState> { + const slices: HostSessionSlices = { + [LOCAL_EXECUTION_HOST_ID]: await api.get() + } + await Promise.all( + listKnownRuntimeHostIds(repos).map(async (hostId) => { + try { + slices[hostId] = await api.get(hostId) + } catch (err) { + console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) + } + }) + ) + return mergeWorkspaceSessionsFromHosts(slices) +} diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts new file mode 100644 index 00000000000..a2cff0a2eab --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect } from 'vitest' +import { + splitWorkspaceSessionByHost, + mergeWorkspaceSessionsFromHosts, + type HostIdByWorktreeId +} from './workspace-session-host-split' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import type { + BrowserPage, + Tab, + TerminalLayoutSnapshot, + TerminalTab, + WorkspaceSessionState +} from '../../../shared/types' + +const RUNTIME_A: ExecutionHostId = 'runtime:env-a' +const RUNTIME_B: ExecutionHostId = 'runtime:env-b' + +function makeTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeUnifiedTab(id: string, worktreeId: string): Tab { + return { + id, + entityId: id, + groupId: `group-${worktreeId}`, + worktreeId, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(): TerminalLayoutSnapshot { + return { root: { type: 'leaf', leafId: 'leaf-1' }, activeLeafId: 'leaf-1', expandedLeafId: null } +} + +function makeBrowserPage(id: string, workspaceId: string, worktreeId: string): BrowserPage { + return { + id, + workspaceId, + worktreeId, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } +} + +/** worktree id convention in these tests: `<host>-wt-...`, except local ones. */ +function ownerByPrefix(): HostIdByWorktreeId { + return (worktreeId: string) => { + if (worktreeId.startsWith('a-')) { + return RUNTIME_A + } + if (worktreeId.startsWith('b-')) { + return RUNTIME_B + } + return LOCAL_EXECUTION_HOST_ID + } +} + +describe('splitWorkspaceSessionByHost', () => { + it('keeps global fields only on the local slice', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-1', + activeWorktreeId: 'local-wt', + activeTabId: 'tab-1', + browserUrlHistory: [ + { url: 'u', normalizedUrl: 'u', title: 't', lastVisitedAt: 1, visitCount: 1 } + ], + activeConnectionIdsAtShutdown: ['ssh-target'] + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeRepoId).toBe('repo-1') + expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeConnectionIdsAtShutdown).toEqual(['ssh-target']) + // No runtime slice is created when nothing is worktree-owned by it. + expect(slices[RUNTIME_A]).toBeUndefined() + }) + + it('routes worktree-keyed maps to their owner host', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + 'local-wt': [makeTab('t-local', 'local-wt')], + 'a-wt': [makeTab('t-a', 'a-wt')], + 'b-wt': [makeTab('t-b', 'b-wt')] + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(Object.keys(slices[LOCAL_EXECUTION_HOST_ID]?.tabsByWorktree ?? {})).toEqual(['local-wt']) + expect(Object.keys(slices[RUNTIME_A]?.tabsByWorktree ?? {})).toEqual(['a-wt']) + expect(Object.keys(slices[RUNTIME_B]?.tabsByWorktree ?? {})).toEqual(['b-wt']) + }) + + it('routes tab-keyed maps via the owning tab worktree (legacy + unified)', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] }, + unifiedTabs: { 'b-wt': [makeUnifiedTab('t-b', 'b-wt')] }, + terminalLayoutsByTabId: { 't-a': makeLayout(), 't-b': makeLayout() }, + remoteSessionIdsByTabId: { 't-a': 'sess-a', 't-b': 'sess-b' } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.terminalLayoutsByTabId).toHaveProperty('t-a') + expect(slices[RUNTIME_B]?.terminalLayoutsByTabId).toHaveProperty('t-b') + expect(slices[RUNTIME_A]?.remoteSessionIdsByTabId).toEqual({ 't-a': 'sess-a' }) + expect(slices[RUNTIME_B]?.remoteSessionIdsByTabId).toEqual({ 't-b': 'sess-b' }) + }) + + it('keeps orphan tab layouts (unknown worktree) in the local slice', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { orphan: makeLayout() } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toHaveProperty('orphan') + expect(slices[RUNTIME_A]).toBeUndefined() + }) + + it('routes browser pages via their record worktreeId', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + browserPagesByWorkspace: { + 'ws-a': [makeBrowserPage('p-a', 'ws-a', 'a-wt')], + 'ws-local': [makeBrowserPage('p-local', 'ws-local', 'local-wt')] + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.browserPagesByWorkspace).toHaveProperty('ws-a') + expect(slices[LOCAL_EXECUTION_HOST_ID]?.browserPagesByWorkspace).toHaveProperty('ws-local') + }) + + it('routes markdown frontmatter visibility via the open file worktree', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + 'a-wt': [ + { + filePath: '/a/file.md', + relativePath: 'file.md', + worktreeId: 'a-wt', + language: 'markdown' + } + ] + }, + markdownFrontmatterVisible: { '/a/file.md': true, '/unknown.md': true } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.markdownFrontmatterVisible).toEqual({ '/a/file.md': true }) + // Unknown file id has no owner → stays local. + expect(slices[LOCAL_EXECUTION_HOST_ID]?.markdownFrontmatterVisible).toEqual({ + '/unknown.md': true + }) + }) + + it('partitions activeWorktreeIdsOnShutdown by owner', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeIdsOnShutdown: ['a-wt', 'b-wt', 'local-wt'] + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.activeWorktreeIdsOnShutdown).toEqual(['a-wt']) + expect(slices[RUNTIME_B]?.activeWorktreeIdsOnShutdown).toEqual(['b-wt']) + expect(slices[LOCAL_EXECUTION_HOST_ID]?.activeWorktreeIdsOnShutdown).toEqual(['local-wt']) + }) + + it('routes sleeping agent records via their worktreeId', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'pane-a': { + paneKey: 'pane-a', + worktreeId: 'a-wt', + agent: 'claude', + providerSession: { key: 'session_id', id: 'x' }, + prompt: 'p', + state: 'done', + capturedAt: 1, + updatedAt: 2 + } + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.sleepingAgentSessionsByPaneKey).toHaveProperty('pane-a') + }) +}) + +describe('mergeWorkspaceSessionsFromHosts', () => { + it('takes global fields from the local slice', () => { + const merged = mergeWorkspaceSessionsFromHosts({ + [LOCAL_EXECUTION_HOST_ID]: { + ...getDefaultWorkspaceSession(), + activeRepoId: 'local-repo', + activeWorktreeId: 'local-wt' + }, + [RUNTIME_A]: { + ...getDefaultWorkspaceSession(), + // A non-local slice's global fields must lose to local. + activeRepoId: 'runtime-repo', + tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] } + } + }) + + expect(merged.activeRepoId).toBe('local-repo') + expect(merged.tabsByWorktree).toHaveProperty('a-wt') + }) + + it('falls back to a non-local slice for globals when local is absent', () => { + const merged = mergeWorkspaceSessionsFromHosts({ + [RUNTIME_A]: { + ...getDefaultWorkspaceSession(), + activeRepoId: 'runtime-repo' + } + }) + expect(merged.activeRepoId).toBe('runtime-repo') + }) + + it('tolerates missing and empty slices', () => { + const merged = mergeWorkspaceSessionsFromHosts({}) + expect(merged.tabsByWorktree).toBeUndefined() + expect(() => mergeWorkspaceSessionsFromHosts({ [RUNTIME_A]: undefined })).not.toThrow() + }) +}) + +describe('split → merge round trip', () => { + function roundTrip(state: WorkspaceSessionState): WorkspaceSessionState { + return mergeWorkspaceSessionsFromHosts(splitWorkspaceSessionByHost(state, ownerByPrefix())) + } + + it('preserves a representative multi-host state', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-1', + activeWorktreeId: 'a-wt', + activeTabId: 't-a', + tabsByWorktree: { + 'local-wt': [makeTab('t-local', 'local-wt')], + 'a-wt': [makeTab('t-a', 'a-wt')], + 'b-wt': [makeTab('t-b', 'b-wt')] + }, + unifiedTabs: { 'b-wt': [makeUnifiedTab('t-b', 'b-wt')] }, + terminalLayoutsByTabId: { 't-local': makeLayout(), 't-a': makeLayout(), 't-b': makeLayout() }, + remoteSessionIdsByTabId: { 't-a': 'sess-a' }, + activeTabIdByWorktree: { 'local-wt': 't-local', 'a-wt': 't-a' }, + activeWorktreeIdsOnShutdown: ['a-wt', 'b-wt'], + lastVisitedAtByWorktreeId: { 'a-wt': 10, 'local-wt': 5 }, + defaultTerminalTabsAppliedByWorktreeId: { 'a-wt': true }, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + browserUrlHistory: [ + { url: 'u', normalizedUrl: 'u', title: 't', lastVisitedAt: 1, visitCount: 1 } + ] + } + + expect(roundTrip(state)).toEqual(state) + }) + + it('preserves the default (empty) session', () => { + const state = getDefaultWorkspaceSession() + expect(roundTrip(state)).toEqual(state) + }) + + it('keeps orphan-owned entries in local and preserves them', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { orphan: makeLayout() }, + remoteSessionIdsByTabId: { orphan: 'sess' } + } + const result = roundTrip(state) + expect(result.terminalLayoutsByTabId).toEqual(state.terminalLayoutsByTabId) + expect(result.remoteSessionIdsByTabId).toEqual(state.remoteSessionIdsByTabId) + }) + + it('handles a host with only runtime-owned worktrees (empty local maps)', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'a-wt': [makeTab('t-a', 'a-wt')] }, + terminalLayoutsByTabId: { 't-a': makeLayout() } + } + expect(roundTrip(state)).toEqual(state) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts new file mode 100644 index 00000000000..2be516f48cc --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -0,0 +1,379 @@ +import type { WorkspaceSessionState } from '../../../shared/types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' + +/** + * Split / merge the unified WorkspaceSessionState across per-host partitions. + * + * Persistence stores one session slice per execution host (see + * src/main/persistence.ts host-keyed getWorkspaceSession/setWorkspaceSession). + * The renderer holds a single unified session, so before writing it must + * partition each worktree-scoped slice to its owning host, and on hydration it + * must merge the per-host slices back into one. + * + * Field classification lives in FIELD_OWNERSHIP below and is checked for + * exhaustiveness at compile time, mirroring SESSION_RELEVANT_FIELDS in + * workspace-session.ts. The remote-workspace SSH projection + * (src/shared/remote-workspace-session-projection.ts) enumerates the same + * worktree/tab-scoped fields by worktree-path; the two surfaces are kept + * deliberately aligned — when a new worktree-scoped field is added there it + * must be classified here too. + */ + +export type HostSessionSlices = Partial<Record<ExecutionHostId, WorkspaceSessionState>> + +export type HostIdByWorktreeId = (worktreeId: string) => ExecutionHostId + +/** How a WorkspaceSessionState field is partitioned across hosts. + * - global: client-wide; always stays in the 'local' slice. + * - worktreeKeyed: Record keyed by worktree id; each entry goes to its owner. + * - worktreeArray: array of worktree ids; each id goes to its owner. + * - tabKeyed: Record keyed by tab id; follows the owning tab's worktree. + * - browserWorkspaceKeyed: Record keyed by browser-workspace id; follows the + * page record's own worktreeId. + * - fileKeyed: Record keyed by editor file id; follows the open file's worktree. + * - sleepingAgentKeyed: Record keyed by pane key; follows the record's worktreeId. */ +type FieldOwnership = + | 'global' + | 'worktreeKeyed' + | 'worktreeArray' + | 'tabKeyed' + | 'browserWorkspaceKeyed' + | 'fileKeyed' + | 'sleepingAgentKeyed' + +const FIELD_OWNERSHIP = { + activeRepoId: 'global', + activeWorktreeId: 'global', + activeTabId: 'global', + browserUrlHistory: 'global', + // Why: SSH-connection ids, not worktrees. SSH stays in the local blob today + // (the runtime split intentionally leaves SSH ownership unchanged), so this + // reconnect list rides along in 'local'. + activeConnectionIdsAtShutdown: 'global', + tabsByWorktree: 'worktreeKeyed', + openFilesByWorktree: 'worktreeKeyed', + activeFileIdByWorktree: 'worktreeKeyed', + activeBrowserTabIdByWorktree: 'worktreeKeyed', + activeTabTypeByWorktree: 'worktreeKeyed', + activeTabIdByWorktree: 'worktreeKeyed', + browserTabsByWorktree: 'worktreeKeyed', + unifiedTabs: 'worktreeKeyed', + tabGroups: 'worktreeKeyed', + tabGroupLayouts: 'worktreeKeyed', + activeGroupIdByWorktree: 'worktreeKeyed', + lastVisitedAtByWorktreeId: 'worktreeKeyed', + defaultTerminalTabsAppliedByWorktreeId: 'worktreeKeyed', + activeWorkspaceKey: 'global', + activeWorktreeIdsOnShutdown: 'worktreeArray', + terminalLayoutsByTabId: 'tabKeyed', + remoteSessionIdsByTabId: 'tabKeyed', + browserPagesByWorkspace: 'browserWorkspaceKeyed', + markdownFrontmatterVisible: 'fileKeyed', + sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed' +} as const satisfies Record<keyof WorkspaceSessionState, FieldOwnership> + +// Why: a new WorkspaceSessionState field must be classified above or the split +// would silently drop it from every non-local host. This fails compilation +// until the table is updated, mirroring the _exhaustive guard in +// workspace-session.ts. +type _MissingOwnership = Exclude<keyof WorkspaceSessionState, keyof typeof FIELD_OWNERSHIP> +const _exhaustive: [_MissingOwnership] extends [never] ? true : never = true +void _exhaustive + +const GLOBAL_FIELDS = (Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]).filter( + (field) => FIELD_OWNERSHIP[field] === 'global' +) + +type AnyRecord = Record<string, unknown> + +function isPlainRecord(value: unknown): value is AnyRecord { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +/** Build tabId → worktreeId from both the legacy and unified tab models so + * tab-keyed maps (terminal layouts, remote session ids) follow their tab. */ +function buildWorktreeIdByTabId(state: WorkspaceSessionState): Map<string, string> { + const byTab = new Map<string, string>() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { + for (const tab of tabs) { + byTab.set(tab.id, worktreeId) + } + } + // Why: unified tabs carry their own worktreeId; index it too so layouts for a + // tab that exists only in the unified model still resolve to an owner. + for (const tabs of Object.values(state.unifiedTabs ?? {})) { + for (const tab of tabs) { + if (!byTab.has(tab.id)) { + byTab.set(tab.id, tab.worktreeId) + } + } + } + return byTab +} + +/** Build editor-file id → worktreeId so markdownFrontmatterVisible (keyed by + * file id) follows the file's worktree. */ +function buildWorktreeIdByFileId(state: WorkspaceSessionState): Map<string, string> { + const byFile = new Map<string, string>() + for (const files of Object.values(state.openFilesByWorktree ?? {})) { + for (const file of files) { + // PersistedOpenFile.filePath is the editor tab/file id used elsewhere. + byFile.set(file.filePath, file.worktreeId) + } + } + return byFile +} + +type SplitContext = { + hostIdByWorktreeId: HostIdByWorktreeId + worktreeIdByTabId: Map<string, string> + worktreeIdByFileId: Map<string, string> +} + +function ensureSlice( + slices: HostSessionSlices, + hostId: ExecutionHostId, + template: WorkspaceSessionState +): WorkspaceSessionState { + let slice = slices[hostId] + if (!slice) { + // Why: clone the global fields onto every slice so a partition read in + // isolation still carries the active pointers; merge later prefers 'local'. + slice = { ...template } + slices[hostId] = slice + } + return slice +} + +function hostForWorktree( + ctx: SplitContext, + worktreeId: string | undefined +): ExecutionHostId | null { + if (!worktreeId) { + return null + } + return ctx.hostIdByWorktreeId(worktreeId) +} + +function assignWorktreeKeyed( + slices: HostSessionSlices, + template: WorkspaceSessionState, + field: keyof WorkspaceSessionState, + value: unknown, + ctx: SplitContext +): void { + if (!isPlainRecord(value)) { + return + } + for (const [worktreeId, entry] of Object.entries(value)) { + const host = ctx.hostIdByWorktreeId(worktreeId) + const slice = ensureSlice(slices, host, template) as AnyRecord + const target = (slice[field] ??= {}) as AnyRecord + target[worktreeId] = entry + } +} + +function assignKeyedByResolvedWorktree( + slices: HostSessionSlices, + template: WorkspaceSessionState, + field: keyof WorkspaceSessionState, + value: unknown, + resolveWorktreeId: (key: string, entry: unknown) => string | undefined, + ctx: SplitContext +): void { + if (!isPlainRecord(value)) { + return + } + for (const [key, entry] of Object.entries(value)) { + const worktreeId = resolveWorktreeId(key, entry) + const host = hostForWorktree(ctx, worktreeId) ?? LOCAL_EXECUTION_HOST_ID + const slice = ensureSlice(slices, host, template) as AnyRecord + const target = (slice[field] ??= {}) as AnyRecord + target[key] = entry + } +} + +/** Partition a unified session into per-host slices keyed by ExecutionHostId. + * Global fields are copied to the 'local' slice; worktree-scoped data is routed + * to its owner host. Entries whose owning worktree is unknown (orphan tabs, + * files, pages) stay in 'local' so they are never silently dropped. */ +export function splitWorkspaceSessionByHost( + state: WorkspaceSessionState, + hostIdByWorktreeId: HostIdByWorktreeId +): HostSessionSlices { + // Template carries only the global fields; per-field assigners add the rest. + // Why: copy only own-keys so a partial patch (where most globals are absent) + // does not inject `undefined` values that would clobber persisted state when + // the slice is applied as a patch. Intentional `undefined` keys are preserved. + const template = {} as WorkspaceSessionState + for (const field of GLOBAL_FIELDS) { + if (Object.hasOwn(state, field)) { + ;(template as AnyRecord)[field] = state[field] + } + } + + const slices: HostSessionSlices = {} + // Why: 'local' must always exist — it owns the global fields and is the + // hydration anchor even when every worktree belongs to a runtime host. + ensureSlice(slices, LOCAL_EXECUTION_HOST_ID, template) + + const ctx: SplitContext = { + hostIdByWorktreeId, + worktreeIdByTabId: buildWorktreeIdByTabId(state), + worktreeIdByFileId: buildWorktreeIdByFileId(state) + } + + const localSlice = slices[LOCAL_EXECUTION_HOST_ID] as AnyRecord + + for (const field of Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]) { + const ownership = FIELD_OWNERSHIP[field] + const value = state[field] + if (value === undefined) { + continue + } + // Why: a present-but-empty container ({} / []) must survive the round trip. + // Seed it on 'local' so merge reproduces the field instead of dropping it. + if (ownership !== 'global') { + localSlice[field] ??= Array.isArray(value) ? [] : {} + } + switch (ownership) { + case 'global': + // Already on the template / local slice. + break + case 'worktreeKeyed': + assignWorktreeKeyed(slices, template, field, value, ctx) + break + case 'worktreeArray': { + if (!Array.isArray(value)) { + break + } + for (const worktreeId of value as string[]) { + const host = ctx.hostIdByWorktreeId(worktreeId) + const slice = ensureSlice(slices, host, template) as AnyRecord + const target = (slice[field] ??= []) as string[] + target.push(worktreeId) + } + break + } + case 'tabKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (tabId) => ctx.worktreeIdByTabId.get(tabId), + ctx + ) + break + case 'fileKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (fileId) => ctx.worktreeIdByFileId.get(fileId), + ctx + ) + break + case 'browserWorkspaceKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (_workspaceId, pages) => { + const first = Array.isArray(pages) + ? (pages[0] as { worktreeId?: string } | undefined) + : undefined + return first?.worktreeId + }, + ctx + ) + break + case 'sleepingAgentKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (_paneKey, record) => + isPlainRecord(record) && typeof record.worktreeId === 'string' + ? record.worktreeId + : undefined, + ctx + ) + break + } + } + + return slices +} + +function mergeRecordField( + out: AnyRecord, + field: keyof WorkspaceSessionState, + slice: WorkspaceSessionState +): void { + const value = slice[field] + if (!isPlainRecord(value)) { + return + } + const target = (out[field] ??= {}) as AnyRecord + Object.assign(target, value) +} + +function mergeArrayField( + out: AnyRecord, + field: keyof WorkspaceSessionState, + slice: WorkspaceSessionState +): void { + const value = slice[field] + if (!Array.isArray(value)) { + return + } + const target = (out[field] ??= []) as unknown[] + target.push(...value) +} + +/** Inverse of split: combine per-host slices into one unified session. Global + * fields are taken from the 'local' slice (it owns them); worktree/tab-scoped + * maps are unioned across all hosts. Tolerates missing or partial slices. */ +export function mergeWorkspaceSessionsFromHosts(slices: HostSessionSlices): WorkspaceSessionState { + const out = {} as WorkspaceSessionState + const local = slices[LOCAL_EXECUTION_HOST_ID] + + // Global fields: 'local' wins. Fall back to any slice that has them so a + // standalone non-local slice still yields sane active pointers. + for (const field of GLOBAL_FIELDS) { + const fromLocal = local?.[field] + if (fromLocal !== undefined) { + ;(out as AnyRecord)[field] = fromLocal + continue + } + for (const slice of Object.values(slices)) { + if (slice && slice[field] !== undefined) { + ;(out as AnyRecord)[field] = slice[field] + break + } + } + } + + for (const slice of Object.values(slices)) { + if (!slice) { + continue + } + for (const field of Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]) { + const ownership = FIELD_OWNERSHIP[field] + if (ownership === 'global') { + continue + } + if (ownership === 'worktreeArray') { + mergeArrayField(out as AnyRecord, field, slice) + } else { + mergeRecordField(out as AnyRecord, field, slice) + } + } + } + + return out +} diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index ab671c9c346..72a77c8b123 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -415,6 +415,76 @@ describe('activateAndRevealWorktree created agent reopen', () => { }) }) + it('activates the explicit owner runtime when another runtime is focused', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi.fn().mockResolvedValue({ + ok: true, + result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:owner-runtime' + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + activeRepoId: 'repo-1', + activeView: 'terminal', + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + settings: { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'focused-runtime', + setupScriptLaunchMode: 'new-tab' + } as unknown as ReturnType<typeof useAppStore.getState>['settings'], + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + }) + + const result = activateAndRevealWorktree(worktree.id) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(result).toEqual({ primaryTabId: null }) + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-runtime', + method: 'worktree.activate' + }) + ) + expect(callRuntimeEnvironment).not.toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'focused-runtime', + method: 'worktree.activate' + }) + ) + }) + it('does not echo host-originated runtime activation events back to the host', async () => { const worktree = makeWorktree() const callRuntimeEnvironment = vi.fn().mockResolvedValue({ @@ -634,4 +704,74 @@ describe('activateAndRevealWorktree created agent reopen', () => { }) ) }) + + it('respawns wake terminals on the explicit owner runtime when focus changed', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi.fn().mockResolvedValue({ + ok: true, + result: { tabId: 'host-tab-1', terminal: 'term_host' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment, + subscribe: vi.fn() + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:owner-runtime' + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + tabsByWorktree: { + [worktree.id]: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: worktree.id, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'tab-1': [] }, + settings: { + ...getDefaultSettings('/workspace/.orca-workspaces'), + activeRuntimeEnvironmentId: 'focused-runtime' + }, + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 1, + activeRenderableTabId: 'tab-1' + })) + }) + + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-runtime', + method: 'session.tabs.createTerminal' + }) + ) + expect(callRuntimeEnvironment).not.toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'focused-runtime', + method: 'session.tabs.createTerminal' + }) + ) + }) }) diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 04bec5792aa..7321641de5c 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -176,6 +176,27 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.setActiveTab).not.toHaveBeenCalled() }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + const store = createMockStore({ + settings: { activeRuntimeEnvironmentId: 'web-runtime-1' }, + repos: [{ id: 'repo-1', executionHostId: 'local', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }) + + const result = ensureWorktreeHasInitialTerminal(store, 'wt-1') + + expect(result).toBe('tab-1') + expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + pendingActivationSpawn: true + }) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') + }) + it('does not create or queue anything when the worktree already has renderable content', () => { const store = createMockStore({ reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index b4cf018069c..b4cee8005a8 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, @@ -68,7 +72,7 @@ export type IssueCommandLaunch = | WorktreeSetupLaunch | { command: string; env?: Record<string, string> } -type WorktreeActivationStore = { +type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & { tabsByWorktree: Record<string, { id: string }[]> defaultTerminalTabsAppliedByWorktreeId: Record<string, true> createTab: ( @@ -264,14 +268,16 @@ export function activateAndRevealWorktree( // 3. Core activation: sets activeWorktreeId, restores per-worktree state, // clears unread, bumps dead PTY generations, triggers GitHub refresh state.setActiveWorktree(worktreeId) - if ( - opts?.notifyHostRuntime !== false && - isWebRuntimeSessionActive(useAppStore.getState().settings?.activeRuntimeEnvironmentId) - ) { + const postActivationState = useAppStore.getState() + const ownerRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(postActivationState, wt.id) + if (opts?.notifyHostRuntime !== false && isWebRuntimeSessionActive(ownerRuntimeEnvironmentId)) { // Why: paired web clients own only local selection state. The desktop host // must also activate the worktree so hidden renderer-owned terminal panes // mount and publish session surfaces back to the web client. - void activateWebRuntimeSessionWorktree({ worktreeId }) + void activateWebRuntimeSessionWorktree({ + worktreeId, + environmentId: ownerRuntimeEnvironmentId + }) } // Why: record focus recency for Cmd+J's empty-query ordering BEFORE any @@ -330,7 +336,11 @@ export function activateAndRevealWorktree( export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void { const state = useAppStore.getState() - const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const worktree = state.getKnownWorktreeById(worktreeId) + if (!worktree) { + return + } + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktree.id) if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) { return } @@ -394,9 +404,11 @@ export function ensureWorktreeHasInitialTerminal( } // Why: remote web clients mirror the runtime server's session tabs. A local // activation fallback can spawn a second host terminal before the mirror lands. - if ( - isWebRuntimeSessionActive(useAppStore.getState().settings?.activeRuntimeEnvironmentId ?? null) - ) { + const ownerState = + store.settings !== undefined || store.repos !== undefined || store.worktreesByRepo !== undefined + ? store + : useAppStore.getState() + if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { return null } @@ -570,8 +582,14 @@ setWorktreeNavViewActivator((entry) => { taskPageData: { ...state.taskPageData, openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, - openLinearIssue: undefined + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined } })) return @@ -584,8 +602,55 @@ setWorktreeNavViewActivator((entry) => { taskSource: 'github', preselectedRepoId: entry.workItem.repoId, openGitHubWorkItem: entry.workItem, + openGitHubSourceContext: entry.sourceContext, openGitHubInitialTab: entry.initialTab, - openLinearIssue: undefined + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined + } + })) + return + } + if (entry.source === 'gitlab') { + useAppStore.setState((state) => ({ + activeView: 'tasks', + githubTaskDrawerWorkItem: null, + taskPageData: { + ...state.taskPageData, + taskSource: 'gitlab', + preselectedRepoId: entry.workItem.repoId, + openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, + openGitHubInitialTab: undefined, + openGitLabWorkItem: entry.workItem, + openGitLabSourceContext: entry.sourceContext, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: undefined, + openJiraSourceContext: undefined + } + })) + return + } + if (entry.source === 'jira') { + useAppStore.setState((state) => ({ + activeView: 'tasks', + githubTaskDrawerWorkItem: null, + taskPageData: { + ...state.taskPageData, + taskSource: 'jira', + openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, + openGitHubInitialTab: undefined, + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: undefined, + openLinearSourceContext: undefined, + openJiraIssue: entry.issue, + openJiraSourceContext: entry.sourceContext } })) return @@ -597,8 +662,14 @@ setWorktreeNavViewActivator((entry) => { ...state.taskPageData, taskSource: 'linear', openGitHubWorkItem: undefined, + openGitHubSourceContext: undefined, openGitHubInitialTab: undefined, - openLinearIssue: entry.issue + openGitLabWorkItem: undefined, + openGitLabSourceContext: undefined, + openLinearIssue: entry.issue, + openLinearSourceContext: entry.sourceContext, + openJiraIssue: undefined, + openJiraSourceContext: undefined } })) }) diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts index 876f3850344..3ccbc59ff41 100644 --- a/src/renderer/src/lib/worktree-palette-search.ts +++ b/src/renderer/src/lib/worktree-palette-search.ts @@ -1,4 +1,5 @@ import { branchName } from '@/lib/git-utils' +import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' import type { Repo, Worktree } from '../../../shared/types' export type MatchRange = { start: number; end: number } @@ -301,7 +302,16 @@ export function searchWorktrees( continue } - const issueKey = repo ? `${repo.path}::${worktree.linkedIssue}` : '' + const issueKey = repo + ? getIssueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + undefined, + repo.connectionId, + repo.executionHostId + ) + : '' const issue = issueKey && issueCache ? issueCache[issueKey]?.data : undefined if (!issue?.title) { continue diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts new file mode 100644 index 00000000000..6c10f729577 --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + getSettingsForWorktreeRuntimeOwner, + type WorktreeRuntimeOwnerState +} from './worktree-runtime-owner' + +const state: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:owner-env' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }], + 'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }] + } +} + +describe('getSettingsForWorktreeRuntimeOwner', () => { + it('routes to the runtime owner of the worktree', () => { + expect(getSettingsForWorktreeRuntimeOwner(state, 'runtime-repo::wt-b')).toEqual({ + activeRuntimeEnvironmentId: 'owner-env' + }) + }) + + it('keeps explicit-local worktrees local even while a runtime is focused', () => { + expect(getSettingsForWorktreeRuntimeOwner(state, 'local-repo::wt-a')).toEqual({ + activeRuntimeEnvironmentId: null + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts new file mode 100644 index 00000000000..e004bca2cfa --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -0,0 +1,69 @@ +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { GlobalSettings, Repo, Worktree } from '../../../shared/types' +import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' + +export type WorktreeRuntimeOwnerState = { + repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null + worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]> +} + +function findWorktreeRepoId( + worktreesByRepo: WorktreeRuntimeOwnerState['worktreesByRepo'], + worktreeId: string +): string | null { + for (const worktrees of Object.values(worktreesByRepo ?? {})) { + const match = worktrees.find((worktree) => worktree.id === worktreeId) + if (match) { + return match.repoId + } + } + return null +} + +export function getRuntimeEnvironmentIdForWorktree( + state: WorktreeRuntimeOwnerState, + worktreeId: string | null | undefined +): string | null { + if (!worktreeId) { + return null + } + const repoId = + findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos?.find((entry) => entry.id === repoId) + const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) + if (repo && hasExplicitOwner) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null + } + return state.settings?.activeRuntimeEnvironmentId?.trim() || null +} + +export function getExecutionHostIdForWorktree( + state: WorktreeRuntimeOwnerState, + worktreeId: string | null | undefined +): ExecutionHostId { + if (!worktreeId) { + return 'local' + } + const repoId = + findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos?.find((entry) => entry.id === repoId) + const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) + if (repo && hasExplicitOwner) { + return getRepoExecutionHostId(repo) + } + const environmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? `runtime:${encodeURIComponent(environmentId)}` : 'local' +} + +export function getSettingsForWorktreeRuntimeOwner( + state: WorktreeRuntimeOwnerState, + worktreeId: string | null | undefined +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> { + return { + ...state.settings, + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) + } +} diff --git a/src/renderer/src/lib/worktree-sort-order-host-split.test.ts b/src/renderer/src/lib/worktree-sort-order-host-split.test.ts new file mode 100644 index 00000000000..5bc20ca4f0c --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-host-split.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { splitWorktreeSortOrderByHost } from './worktree-sort-order-host-split' + +const state: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }], + 'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }] + } +} + +describe('splitWorktreeSortOrderByHost', () => { + it('groups worktree ids by owner host, preserving relative order', () => { + const groups = splitWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + expect(groups).toEqual([ + { hostId: 'runtime:env-1', orderedIds: ['runtime-repo::wt-b'] }, + { hostId: 'local', orderedIds: ['local-repo::wt-a'] } + ]) + }) + + it('routes legacy worktrees without an explicit owner to the focused host', () => { + const groups = splitWorktreeSortOrderByHost( + { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [{ id: 'legacy', connectionId: null, executionHostId: null }], + worktreesByRepo: { legacy: [{ id: 'legacy::wt', repoId: 'legacy' }] } + }, + ['legacy::wt'] + ) + expect(groups).toEqual([{ hostId: 'runtime:focused-env', orderedIds: ['legacy::wt'] }]) + }) +}) diff --git a/src/renderer/src/lib/worktree-sort-order-host-split.ts b/src/renderer/src/lib/worktree-sort-order-host-split.ts new file mode 100644 index 00000000000..008f7486274 --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-host-split.ts @@ -0,0 +1,36 @@ +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from './worktree-runtime-owner' + +export type WorktreeSortOrderHostGroup = { + hostId: string + orderedIds: string[] +} + +/** Split a worktree sort order into per-owner-host groups (preserving relative + * order within each host). + * + * Why: persisted `sortOrder` lives in each host's `worktreeMeta` and is enriched + * onto worktrees from their owner host. Stamping the full cross-host id list on + * only the focused host loses other hosts' ordering and pollutes the focused + * host with foreign ids, so persist each host's ids on that host. + */ +export function splitWorktreeSortOrderByHost( + state: WorktreeRuntimeOwnerState, + orderedIds: readonly string[] +): WorktreeSortOrderHostGroup[] { + const groups = new Map<string, string[]>() + for (const id of orderedIds) { + const environmentId = getRuntimeEnvironmentIdForWorktree(state, id) + const hostId = environmentId ? toRuntimeExecutionHostId(environmentId) : LOCAL_EXECUTION_HOST_ID + const existing = groups.get(hostId) + if (existing) { + existing.push(id) + } else { + groups.set(hostId, [id]) + } + } + return [...groups.entries()].map(([hostId, ids]) => ({ hostId, orderedIds: ids })) +} diff --git a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts index eb1bff0c680..ccb20944e58 100644 --- a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts +++ b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts @@ -2,6 +2,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' @@ -23,7 +24,8 @@ export function createCompatibleRuntimeStatusResponse( liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [...RUNTIME_CAPABILITIES] }, _meta: { runtimeId } } 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/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 5e6869328e1..a7f9c225b89 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { callRuntimeRpc, + assertRuntimeEnvironmentCapability, clearRuntimeCompatibilityCacheForTests, getActiveRuntimeTarget, RuntimeRpcCallError, @@ -135,6 +136,52 @@ describe('runtime RPC client routing', () => { ]) }) + it('checks advertised runtime capabilities after protocol compatibility', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['project-host-setup.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).resolves.toBeUndefined() + }) + + it('rejects missing advertised runtime capabilities with the caller message', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).rejects.toThrow('Project setup is unavailable.') + }) + it('marks remote UI-owned runtime calls so feature interaction tracking can ignore them', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 79a15f8ac0d..74183cce258 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../shared/types' import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' @@ -141,6 +142,35 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void { rememberRuntimeEnvironmentCompatibility(trimmed, Promise.resolve()) } +export async function getRuntimeEnvironmentStatus( + environmentId: string, + timeoutMs?: number +): Promise<RuntimeStatus> { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'status.get', + timeoutMs + }) + const status = unwrapRuntimeRpcResult<RuntimeStatus>( + response as RuntimeRpcResponse<RuntimeStatus> + ) + assertRuntimeStatusCompatible(status) + markRuntimeEnvironmentCompatible(environmentId) + return status +} + +export async function assertRuntimeEnvironmentCapability( + environmentId: string, + capability: RuntimeCapability, + message: string, + timeoutMs?: number +): Promise<void> { + const status = await getRuntimeEnvironmentStatus(environmentId, timeoutMs) + if (!status.capabilities?.includes(capability)) { + throw new Error(message) + } +} + export function clearRuntimeCompatibilityCacheForTests(): void { clearRuntimeCompatibilityCache() } diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 445af07419b..7401ec55c7f 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -150,7 +150,8 @@ describe('createWebRuntimeSessionBrowserTab', () => { ) expect(mocks.createBrowserTab).toHaveBeenCalledWith(WORKTREE_ID, 'https://example.com/', { title: 'https://example.com/', - focusAddressBar: true + focusAddressBar: true, + browserRuntimeEnvironmentId: ENVIRONMENT_ID }) expect(mocks.setRemoteBrowserPageHandle).toHaveBeenCalledWith('local-page-1', { environmentId: ENVIRONMENT_ID, diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 3d8ce8cb01b..9dab68eea3a 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -182,6 +182,7 @@ function stageWebRuntimeBrowserTab(args: { const browserTab = useAppStore.getState().createBrowserTab(args.worktreeId, url, { title: url === 'about:blank' ? 'New Browser Tab' : url, focusAddressBar: true, + browserRuntimeEnvironmentId: args.environmentId, targetGroupId: args.targetGroupId }) const pageId = browserTab.activePageId ?? browserTab.pageIds?.[0] ?? null diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 73b9c5ec28e..c3714eebb09 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -824,6 +824,7 @@ function buildMirroredBrowserTabs( canGoForward: tab.canGoForward, loadError: null, createdAt, + browserRuntimeEnvironmentId: environmentId, viewportPresetId: existing?.page.viewportPresetId ?? null } const workspace: BrowserWorkspace = { @@ -1320,6 +1321,7 @@ function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean { a.loadError?.description === b.loadError?.description && a.loadError?.validatedUrl === b.loadError?.validatedUrl && a.createdAt === b.createdAt && + a.browserRuntimeEnvironmentId === b.browserRuntimeEnvironmentId && a.viewportPresetId === b.viewportPresetId ) } 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/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index 8879b6fe1ab..231c31e5be6 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest' -import type { Worktree } from '../../../shared/types' +import type { Repo, Worktree } from '../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import type { AppState } from './types' import { getAllWorktreesFromState, + getProjectHostSetupProjectionFromState, getWorktreeMapFromState, resetFloatingVisibleTabCountSelectorCacheForTest, selectFloatingVisibleTabCount @@ -31,6 +33,15 @@ function makeWorktree(args: { id: string; repoId: string; displayName: string }) } } +function makeRepo(args: Pick<Repo, 'id' | 'path' | 'displayName'> & Partial<Repo>): Repo { + return { + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...args + } +} + describe('store selectors', () => { beforeEach(() => { resetFloatingVisibleTabCountSelectorCacheForTest() @@ -156,4 +167,147 @@ describe('store selectors', () => { expect(selectFloatingVisibleTabCount({ ...state })).toBe(3) expect(openFileScans).toBe(1) }) + + it('caches the project host setup projection by repo slice identity', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca' + }) + ] + const state = { repos } + + const projection = getProjectHostSetupProjectionFromState(state) + + expect(projection.projects).toHaveLength(1) + expect(projection.setups[0]).toMatchObject({ + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local' + }) + expect(getProjectHostSetupProjectionFromState({ repos })).toBe(projection) + expect(getProjectHostSetupProjectionFromState({ repos: [...repos] })).not.toBe(projection) + }) + + it('prefers hydrated project host setup state when present', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca' + }) + ] + const projects = [ + { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ] + const projectHostSetups = [ + { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local' as const, + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready' as const, + setupMethod: 'legacy-repo' as const, + createdAt: 1, + updatedAt: 1 + } + ] + + expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toEqual({ + projects, + setups: projectHostSetups + }) + }) + + it('falls back to repo compatibility projection when hydrated setup state is empty', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + ] + + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects: [], + projectHostSetups: [] + }) + + expect(projection.projects).toEqual([ + expect.objectContaining({ + id: 'github:stablyai/orca', + sourceRepoIds: ['repo-1'] + }) + ]) + expect(projection.setups).toEqual([ + expect.objectContaining({ + id: 'repo-1', + projectId: 'github:stablyai/orca', + repoId: 'repo-1', + hostId: 'local', + path: '/Users/alice/orca' + }) + ]) + }) + + it('merges missing repo compatibility rows with independent hydrated setups', () => { + const repos = [ + makeRepo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca' + }) + ] + const projects = [ + { + id: 'cloud-project', + displayName: 'Cloud Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + } + ] + const projectHostSetups = [ + { + id: 'cloud-project::gpu-vm', + projectId: 'cloud-project', + hostId: toRuntimeExecutionHostId('gpu-vm'), + repoId: '', + path: '/srv/cloud-project', + displayName: 'GPU VM', + setupState: 'ready' as const, + setupMethod: 'provisioned' as const, + createdAt: 1, + updatedAt: 1 + } + ] + + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects, + projectHostSetups + }) + + expect(projection.projects.map((project) => project.id)).toEqual([ + 'repo:repo-1', + 'cloud-project' + ]) + expect(projection.setups.map((setup) => setup.id)).toEqual(['repo-1', 'cloud-project::gpu-vm']) + expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toBe( + projection + ) + }) }) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 45e7e3bcd49..53a03d2e3d9 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -1,8 +1,12 @@ import { useAppStore } from './index' import { useShallow } from 'zustand/react/shallow' -import type { Repo, Worktree, TerminalTab } from '../../../shared/types' +import type { Project, ProjectHostSetup, Repo, Worktree, TerminalTab } from '../../../shared/types' import type { AppState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from '../../../shared/project-host-setup-projection' const EMPTY_WORKTREES: Worktree[] = [] const EMPTY_TABS: TerminalTab[] = [] @@ -31,6 +35,15 @@ type FloatingVisibleTabCountCache = { const worktreeSnapshotCache = new WeakMap<AppState['worktreesByRepo'], WorktreeSnapshot>() const hasAnyWorktreesCache = new WeakMap<AppState['worktreesByRepo'], boolean>() const repoMapCache = new WeakMap<AppState['repos'], Map<string, Repo>>() +const projectHostSetupProjectionCache = new WeakMap<AppState['repos'], ProjectHostSetupProjection>() +const providedProjectHostSetupProjectionCache = new WeakMap< + Project[], + WeakMap<ProjectHostSetup[], ProjectHostSetupProjection> +>() +const mergedProjectHostSetupProjectionCache = new WeakMap< + AppState['repos'], + WeakMap<Project[], WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>> +>() let floatingVisibleTabCountCache: FloatingVisibleTabCountCache | null = null function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): WorktreeSnapshot { @@ -94,6 +107,85 @@ function getCachedRepoMap(repos: AppState['repos']): Map<string, Repo> { return repoMap } +function getCachedProjectHostSetupProjection(repos: AppState['repos']): ProjectHostSetupProjection { + const cachedProjection = projectHostSetupProjectionCache.get(repos) + if (cachedProjection) { + return cachedProjection + } + + const projection = projectHostSetupProjectionFromRepos(repos) + projectHostSetupProjectionCache.set(repos, projection) + return projection +} + +function getCachedProvidedProjectHostSetupProjection( + projects: Project[], + setups: ProjectHostSetup[] +): ProjectHostSetupProjection { + const cachedBySetups = providedProjectHostSetupProjectionCache.get(projects) + const cachedProjection = cachedBySetups?.get(setups) + if (cachedProjection) { + return cachedProjection + } + + const projection = { projects, setups } + const nextCachedBySetups = + cachedBySetups ?? new WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>() + nextCachedBySetups.set(setups, projection) + if (!cachedBySetups) { + providedProjectHostSetupProjectionCache.set(projects, nextCachedBySetups) + } + return projection +} + +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 mergeProjectHostSetupProjection( + repos: AppState['repos'], + projects: Project[], + setups: ProjectHostSetup[] +): ProjectHostSetupProjection { + const cachedByProjects = mergedProjectHostSetupProjectionCache.get(repos) + const cachedBySetups = cachedByProjects?.get(projects) + const cachedProjection = cachedBySetups?.get(setups) + if (cachedProjection) { + return cachedProjection + } + const derived = getCachedProjectHostSetupProjection(repos) + // Why: older runtimes/profiles may hydrate empty or partial project/setup arrays + // beside legacy repos. Keep repo-backed compatibility rows visible in that case. + const projection = { + projects: mergeById(derived.projects, projects), + setups: mergeById(derived.setups, setups) + } + const nextCachedByProjects = + cachedByProjects ?? + new WeakMap<Project[], WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>>() + const nextCachedBySetups = + cachedBySetups ?? new WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>() + nextCachedBySetups.set(setups, projection) + if (!cachedBySetups) { + nextCachedByProjects.set(projects, nextCachedBySetups) + } + if (!cachedByProjects) { + mergedProjectHostSetupProjectionCache.set(repos, nextCachedByProjects) + } + return projection +} + export function selectFloatingVisibleTabCount(state: FloatingVisibleTabCountState): number { const terminalTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TABS const browserTabs = @@ -169,6 +261,36 @@ export function getRepoMapFromState(state: Pick<AppState, 'repos'>): Map<string, return getCachedRepoMap(state.repos) } +export function getProjectHostSetupProjectionFromState( + state: Pick<AppState, 'repos'> & Partial<Pick<AppState, 'projects' | 'projectHostSetups'>> +): ProjectHostSetupProjection { + if (state.projects && state.projectHostSetups) { + const repoIds = new Set(state.repos.map((repo) => repo.id)) + const coveredRepoIds = new Set<string>() + for (const setup of state.projectHostSetups) { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + if (repoIds.has(repoId)) { + coveredRepoIds.add(repoId) + } + if (repoIds.has(setup.id)) { + coveredRepoIds.add(setup.id) + } + } + if (state.repos.length > 0 && coveredRepoIds.size < repoIds.size) { + return mergeProjectHostSetupProjection( + state.repos, + state.projects as Project[], + state.projectHostSetups as ProjectHostSetup[] + ) + } + return getCachedProvidedProjectHostSetupProjection( + state.projects as Project[], + state.projectHostSetups as ProjectHostSetup[] + ) + } + return getCachedProjectHostSetupProjection(state.repos) +} + // ─── Repos ────────────────────────────────────────────────────────── export const useRepos = () => useAppStore((s) => s.repos) export const useActiveRepoId = () => useAppStore((s) => s.activeRepoId) @@ -177,6 +299,8 @@ export const useActiveRepo = () => export const useRepoMap = () => useAppStore((s) => getCachedRepoMap(s.repos)) export const useRepoById = (repoId: string | null) => useAppStore((s) => (repoId ? (getCachedRepoMap(s.repos).get(repoId) ?? null) : null)) +export const useProjectHostSetupProjection = () => + useAppStore((s) => getProjectHostSetupProjectionFromState(s)) // ─── Worktrees ────────────────────────────────────────────────────── export const useActiveWorktreeId = () => useAppStore((s) => s.activeWorktreeId) diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index d07fa741299..314bdae32f6 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -11,9 +11,14 @@ import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/brow import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock +})) + const mockApi = { browser: { sessionListProfiles: vi.fn().mockResolvedValue([]), @@ -189,6 +194,27 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull() }) + it('uses local browser profile defaults for client-local fallback pages', () => { + const store = createTestStore() + store.setState({ + settings: settingsWithRuntime('env-1'), + defaultBrowserSessionProfileIdByHostId: { + local: 'local-profile', + 'runtime:env-1': 'runtime-profile' + } + }) + + const localFallback = store.getState().createBrowserTab('wt-1', 'about:blank', { + browserRuntimeEnvironmentId: null + }) + const remoteTab = store.getState().createBrowserTab('wt-1', 'about:blank', { + browserRuntimeEnvironmentId: 'env-1' + }) + + expect(localFallback.sessionProfileId).toBe('local-profile') + expect(remoteTab.sessionProfileId).toBe('runtime-profile') + }) + it('preserves browser map references when a page-state update is unchanged', () => { const store = createTestStore() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { @@ -441,6 +467,8 @@ describe('createBrowserSlice runtime guard', () => { clearRuntimeCompatibilityCacheForTests() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() + createWebRuntimeSessionBrowserTabMock.mockReset() + createWebRuntimeSessionBrowserTabMock.mockResolvedValue(true) runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -488,6 +516,193 @@ describe('createBrowserSlice runtime guard', () => { source: null } ]) + expect(store.getState().browserSessionProfilesByHostId['runtime:env-1']).toEqual([ + { + id: 'default', + scope: 'default', + partition: 'persist:orca-default', + label: 'Default', + source: null + } + ]) + }) + + it('keeps browser profile lists separate per host', async () => { + const store = createTestStore() + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-remote', + ok: true, + result: { + profiles: [ + { + id: 'remote-default', + scope: 'default', + partition: 'persist:orca-remote', + label: 'Remote Default', + source: null + } + ] + }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ settings: settingsWithRuntime('env-1') }) + + await store.getState().fetchBrowserSessionProfiles() + + mockApi.browser.sessionListProfiles.mockResolvedValueOnce([ + { + id: 'local-default', + scope: 'default', + partition: 'persist:orca-local', + label: 'Local Default', + source: null + } + ]) + store.setState({ settings: { activeRuntimeEnvironmentId: null } as AppState['settings'] }) + + await store.getState().fetchBrowserSessionProfiles() + + expect(store.getState().browserSessionProfilesByHostId['runtime:env-1']?.[0]?.id).toBe( + 'remote-default' + ) + expect(store.getState().browserSessionProfilesByHostId.local?.[0]?.id).toBe('local-default') + expect(store.getState().browserSessionProfiles[0]?.id).toBe('local-default') + }) + + it('uses the target worktree host default profile when creating a browser tab', () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 1, + connectionId: null, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-remote', + repoId: 'repo-1', + path: '/repo/wt', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'Workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } + ] + }, + defaultBrowserSessionProfileId: 'local-default', + defaultBrowserSessionProfileIdByHostId: { + local: 'local-default', + 'runtime:env-1': 'remote-default' + } + }) + + const tab = store.getState().createBrowserTab('wt-remote', 'https://example.com') + + expect(tab.sessionProfileId).toBe('remote-default') + }) + + it('creates new browser tabs through the owning runtime for desktop remote worktrees', async () => { + const store = createTestStore() + store.setState({ + activeWorktreeId: 'wt-remote', + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + browserDefaultUrl: 'about:blank', + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 1, + connectionId: null, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-remote', + repoId: 'repo-1', + path: '/repo/wt', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'Workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } + ] + } + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-remote', + environmentId: 'env-1', + url: 'about:blank', + targetGroupId: 'group-1' + }) + expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() + expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') + }) + + it('creates a local fallback tab when runtime browser creation fails', async () => { + const store = createTestStore() + createWebRuntimeSessionBrowserTabMock.mockResolvedValueOnce(false) + store.setState({ + activeWorktreeId: 'wt-remote', + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-remote', + environmentId: 'env-1', + url: 'about:blank', + targetGroupId: 'group-1' + }) + expect(store.getState().createUnifiedTab).toHaveBeenCalledWith( + 'wt-remote', + 'browser', + expect.objectContaining({ targetGroupId: 'group-1' }) + ) + const [tab] = store.getState().browserTabsByWorktree['wt-remote'] ?? [] + expect(tab).toBeDefined() + expect(store.getState().browserPagesByWorkspace[tab!.id]?.[0]).toMatchObject({ + browserRuntimeEnvironmentId: null, + url: 'about:blank', + title: 'New Tab' + }) + expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') }) it('does not import local browser cookies while a runtime environment is active', async () => { diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index a9c4a07a7c3..89344d6b569 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -39,6 +39,16 @@ import type { } from '../../../../shared/runtime-types' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' +import { + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' type CreateBrowserTabOptions = { activate?: boolean @@ -54,11 +64,13 @@ type CreateBrowserTabOptions = { // (context menu, window.open, http link routing) leave this unset so focus // stays on the webview. When omitted, we fall back to the blank-URL check. focusAddressBar?: boolean + browserRuntimeEnvironmentId?: string | null } type CreateBrowserPageOptions = { activate?: boolean title?: string + browserRuntimeEnvironmentId?: string | null } type BrowserTabPageState = { @@ -158,6 +170,7 @@ export type BrowserSlice = { hydrateBrowserSession: (session: WorkspaceSessionState) => void switchBrowserTabProfile: (workspaceId: string, profileId: string | null) => void browserSessionProfiles: BrowserSessionProfile[] + browserSessionProfilesByHostId: Partial<Record<ExecutionHostId, BrowserSessionProfile[]>> browserSessionImportState: { profileId: string status: 'idle' | 'importing' | 'success' | 'error' @@ -190,6 +203,7 @@ export type BrowserSlice = { addBrowserHistoryEntry: (url: string, title: string) => void clearBrowserHistory: () => void defaultBrowserSessionProfileId: string | null + defaultBrowserSessionProfileIdByHostId: Partial<Record<ExecutionHostId, string | null>> setDefaultBrowserSessionProfileId: (profileId: string | null) => void } @@ -226,6 +240,44 @@ function isRuntimeEnvironmentActive(state: AppState): boolean { return Boolean(state.settings?.activeRuntimeEnvironmentId?.trim()) } +function getBrowserSettingsHostId(state: Pick<AppState, 'settings'>): ExecutionHostId { + return getSettingsFocusedExecutionHostId(state.settings) +} + +function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId { + return getExecutionHostIdForWorktree(state, worktreeId) +} + +function getBrowserSessionProfileHostId( + state: AppState, + worktreeId: string, + browserRuntimeEnvironmentId: string | null | undefined +): ExecutionHostId { + if (browserRuntimeEnvironmentId === null) { + return LOCAL_EXECUTION_HOST_ID + } + if (browserRuntimeEnvironmentId !== undefined) { + const runtimeEnvironmentId = browserRuntimeEnvironmentId.trim() + return runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + } + return getBrowserWorktreeHostId(state, worktreeId) +} + +function profileListByHostUpdate( + state: Pick<AppState, 'browserSessionProfilesByHostId' | 'settings'>, + profiles: BrowserSessionProfile[] +): Partial<BrowserSlice> { + return { + browserSessionProfiles: profiles, + browserSessionProfilesByHostId: { + ...state.browserSessionProfilesByHostId, + [getBrowserSettingsHostId(state)]: profiles + } + } +} + function closeRemoteBrowserPageInOwningEnvironment( worktreeId: string, handle: RemoteBrowserPageHandle @@ -243,7 +295,8 @@ function buildBrowserPage( workspaceId: string, worktreeId: string, url: string, - title?: string + title?: string, + browserRuntimeEnvironmentId?: string | null ): BrowserPage { const normalizedUrl = normalizeUrl(url) return { @@ -260,7 +313,8 @@ function buildBrowserPage( canGoBack: false, canGoForward: false, loadError: null, - createdAt: Date.now() + createdAt: Date.now(), + ...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {}) } } @@ -413,24 +467,40 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = pendingAddressBarFocusByTabId: {}, pendingAddressBarFocusByPageId: {}, browserSessionProfiles: [], + browserSessionProfilesByHostId: {}, browserSessionImportState: null, browserUrlHistory: [], defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: {}, setDefaultBrowserSessionProfileId: (profileId) => { - set({ defaultBrowserSessionProfileId: profileId }) + set((s) => ({ + defaultBrowserSessionProfileId: profileId, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: profileId + } + })) }, createBrowserTab: (worktreeId, url, options) => { const workspaceId = createBrowserUuid() - const page = buildBrowserPage(workspaceId, worktreeId, url, options?.title) + const page = buildBrowserPage( + workspaceId, + worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId + ) // Why: when no explicit profile is passed, inherit the user's chosen default // profile. This lets users set a preferred profile in Settings that all new // browser tabs use automatically. const sessionProfileId = options?.sessionProfileId !== undefined ? options.sessionProfileId - : get().defaultBrowserSessionProfileId + : (get().defaultBrowserSessionProfileIdByHostId[ + getBrowserSessionProfileHostId(get(), worktreeId, options?.browserRuntimeEnvironmentId) + ] ?? get().defaultBrowserSessionProfileId) const browserTab = buildWorkspaceFromPage( workspaceId, worktreeId, @@ -531,17 +601,30 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = return } const defaultUrl = state.browserDefaultUrl ?? 'about:blank' - const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }) - .__ORCA_WEB_CLIENT__ - ? state.settings?.activeRuntimeEnvironmentId?.trim() - : null - if (pairedWebRuntimeEnvironmentId) { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (runtimeEnvironmentId) { const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') - await createWebRuntimeSessionBrowserTab({ - worktreeId, - environmentId: pairedWebRuntimeEnvironmentId, - url: defaultUrl, - targetGroupId: groupId + try { + const created = await createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId: runtimeEnvironmentId, + url: defaultUrl, + targetGroupId: groupId + }) + if (created) { + get().recordFeatureInteraction('browser-tab-created') + return + } + } catch { + // Fall through to the client-local fallback below. + } + // Why: headless remote runtimes cannot host browser panes yet. Keep the + // workspace remote-owned, but open this browser page on the desktop client. + get().createBrowserTab(worktreeId, defaultUrl, { + title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'), + focusAddressBar: true, + targetGroupId: groupId, + browserRuntimeEnvironmentId: null }) get().recordFeatureInteraction('browser-tab-created') return @@ -553,7 +636,6 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = }) get().recordFeatureInteraction('browser-tab-created') }, - closeBrowserTab: (tabId) => { let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => { @@ -755,13 +837,15 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = const restored = get().createBrowserTab(worktreeId, firstPage.url, { title: firstPage.title, activate: true, - sessionProfileId + sessionProfileId, + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId }) for (const p of restPages) { get().createBrowserPage(restored.id, p.url, { activate: false, - title: p.title + title: p.title, + browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId }) } @@ -829,7 +913,13 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = if (!workspace) { return null } - const page = buildBrowserPage(workspaceId, workspace.worktreeId, url, options?.title) + const page = buildBrowserPage( + workspaceId, + workspace.worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId + ) set((s) => { const pages = s.browserPagesByWorkspace[workspaceId] ?? [] @@ -1005,7 +1095,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = return get().createBrowserPage(workspaceId, pageToRestore.url, { title: pageToRestore.title, - activate: true + activate: true, + browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId }) }, @@ -1622,15 +1713,15 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = undefined, { timeoutMs: 15_000 } ) - set({ browserSessionProfiles: result.profiles }) + set((s) => profileListByHostUpdate(s, result.profiles)) } catch { - set({ browserSessionProfiles: [] }) + set((s) => profileListByHostUpdate(s, [])) } return } try { const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] - set({ browserSessionProfiles: profiles }) + set((s) => profileListByHostUpdate(s, profiles)) } catch { /* best-effort — stale profile list is preferable to a crash */ } @@ -1648,7 +1739,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = const profile = result.profile if (profile) { set((s) => ({ - browserSessionProfiles: [...s.browserSessionProfiles, profile] + ...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile]) })) } return profile @@ -1663,7 +1754,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = })) as BrowserSessionProfile | null if (profile) { set((s) => ({ - browserSessionProfiles: [...s.browserSessionProfiles, profile] + ...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile]) })) } return profile @@ -1683,9 +1774,18 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = ) if (result.deleted) { set((s) => ({ - browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId), + ...profileListByHostUpdate( + s, + s.browserSessionProfiles.filter((p) => p.id !== profileId) + ), ...(s.defaultBrowserSessionProfileId === profileId - ? { defaultBrowserSessionProfileId: null } + ? { + defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: null + } + } : {}) })) } @@ -1698,9 +1798,18 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = const ok = await window.api.browser.sessionDeleteProfile({ profileId }) if (ok) { set((s) => ({ - browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId), + ...profileListByHostUpdate( + s, + s.browserSessionProfiles.filter((p) => p.id !== profileId) + ), ...(s.defaultBrowserSessionProfileId === profileId - ? { defaultBrowserSessionProfileId: null } + ? { + defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: null + } + } : {}) })) } diff --git a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts index 4de07397ea0..733261f1daa 100644 --- a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts +++ b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts @@ -41,7 +41,7 @@ describe('Cmd+J lifted creation actions', () => { delete pairedWebFlag.__ORCA_WEB_CLIENT__ }) - it('does not fall back to a local browser tab when paired-web creation fails', async () => { + it('opens a local browser tab when paired-web browser creation fails', async () => { createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) const store = createTestStore() seedActiveWorkspace(store) @@ -54,7 +54,42 @@ describe('Cmd+J lifted creation actions', () => { url: 'about:blank', targetGroupId: 'group-1' }) - expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([]) + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) + }) + + it('creates browser tabs on the explicit owner runtime when another runtime is focused', async () => { + createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) + const store = createTestStore() + seedActiveWorkspace(store) + store.setState({ + repos: [{ ...TEST_REPO, executionHostId: 'runtime:owner-runtime' }], + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'] + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'owner-runtime', + url: 'about:blank', + targetGroupId: 'group-1' + }) + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) + }) + + it('creates a local browser tab for explicitly local workspaces while a runtime is focused', async () => { + createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false) + const store = createTestStore() + seedActiveWorkspace(store) + store.setState({ + repos: [{ ...TEST_REPO, executionHostId: 'local' }], + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'] + }) + + await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + + expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toHaveLength(1) }) it('does not fall back to a local terminal tab when paired-web creation fails', async () => { diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index d5672d751cb..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) })) @@ -350,6 +352,35 @@ describe('updateDiffComment', () => { }) }) + it('persists explicit local worktree comments locally while a runtime is focused', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: REPO, + path: '/path/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' + } + ] + }) + seed(store, [makeComment({ id: 'c1', body: 'old body' })]) + + const ok = await store.getState().updateDiffComment(WT, 'c1', 'local body') + + expect(ok).toBe(true) + expect(updateMeta).toHaveBeenCalledWith({ + worktreeId: WT, + updates: { + diffComments: [expect.objectContaining({ id: 'c1', body: 'local body' })] + } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('rejects an empty body without persisting', async () => { const store = createTestStore() seed(store, [ diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index adc695231f8..5e0f4b82cec 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -8,6 +8,7 @@ import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { createBrowserUuid } from '@/lib/browser-uuid' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' export type DiffCommentsSlice = { getDiffComments: (worktreeId: string | null | undefined) => DiffComment[] @@ -115,6 +116,13 @@ async function persist( ) } +function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +} + // Why: IPC writes from `persist` are not ordered with respect to each other. // If two mutations (e.g. rapid add then delete, or two adds) are in flight // concurrently, their `updateMeta` resolutions can arrive out of call order, @@ -141,7 +149,7 @@ function enqueuePersist(worktreeId: string, get: () => AppState): Promise<void> const repoList = get().worktreesByRepo[repoId] const target = repoList?.find((w) => w.id === worktreeId) const latest = (target?.diffComments ?? []).map(normalizeDiffComment) - await persist(get().settings, worktreeId, latest) + await persist(settingsForWorktreeOwner(get(), worktreeId), worktreeId, latest) } const next = prior.then(run, run) persistQueueByWorktree.set(worktreeId, next) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 11d38269717..f9774e42dfa 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -2098,6 +2098,28 @@ describe('createEditorSlice remote branch actions', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) + it('routes git operations through the explicit runtime owner instead of ambient focus', async () => { + const store = createEditorStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + await store.getState().pushBranch('wt-1', '/repo', false, undefined, undefined, { + runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + }) + + expect(gitPushMock).toHaveBeenCalledWith({ + worktreePath: '/repo', + publish: false, + connectionId: undefined, + pushTarget: undefined, + forceWithLease: undefined + }) + expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + pushTarget: undefined + }) + }) + it('runs rebase from base and refreshes upstream on success', async () => { const store = createEditorStore() const pushTarget = { remoteName: 'fork', branchName: 'feature' } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index ed5b75804b8..334818b20f6 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -16,6 +16,7 @@ import type { GitConflictOperation, GitConflictResolutionStatus, GitConflictStatusSource, + GlobalSettings, GitPushTarget, GitStatusEntry, GitStatusResult, @@ -240,6 +241,10 @@ type EditorOpenTargetOptions = { runtimeEnvironmentId?: string | null } +type GitRuntimeOperationOptions = { + runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null +} + export type PendingEditorReveal = { filePath: string fileId?: string @@ -512,7 +517,8 @@ export type EditorSlice = { worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> pushBranch: ( worktreeId: string, @@ -520,38 +526,43 @@ export type EditorSlice = { publish?: boolean, connectionId?: string, pushTarget?: GitPushTarget, - options?: { forceWithLease?: boolean } + options?: GitRuntimeOperationOptions & { forceWithLease?: boolean } ) => Promise<void> pullBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> fastForwardBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> syncBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> rebaseFromBase: ( worktreeId: string, worktreePath: string, baseRef: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> fetchBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise<void> gitBranchChangesByWorktree: Record<string, GitBranchChangeEntry[]> gitBranchCompareSummaryByWorktree: Record<string, GitBranchCompareSummary | null> @@ -3385,11 +3396,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s inFlightRemoteOpKind: next > 0 ? s.inFlightRemoteOpKind : null } }), - fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { try { + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings const status = await getRuntimeGitUpstreamStatus( { - settings: get().settings, + settings: runtimeSettings, worktreeId, worktreePath, connectionId @@ -3428,9 +3440,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s publish ? 'publish' : options.forceWithLease === true ? 'force_push' : 'push' ) let shouldRefreshAfterRejectedPush = false + const runtimeSettings = options.runtimeTargetSettings ?? get().settings try { await pushRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, { publish, pushTarget, forceWithLease: options.forceWithLease } ) } catch (error) { @@ -3446,26 +3459,33 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() if (shouldRefreshAfterRejectedPush) { - const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } // Why: the rejected push proved the publish branch moved. Fetch first // so legacy base-tracking worktrees can discover origin/<branch>, then // refresh ahead/behind so Pull/Sync become actionable immediately. void fetchRuntimeGit(context, pushTarget) .catch(() => undefined) - .then(() => get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget)) + .then(() => + get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) + ) } } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { get().beginRemoteOperation('pull') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await pullRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3474,17 +3494,20 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { get().beginRemoteOperation('fast_forward') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fastForwardRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3493,13 +3516,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the // post-op upstream refresh after the busy flag clears so the primary // button label rotates immediately when the IPC resolves. @@ -3510,8 +3535,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s // outer catch must then skip toasting to avoid a double-toast. let pushStageToastShown = false let pushed = false + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { - const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } await fetchRuntimeGit(context, pushTarget) const upstreamStatusBeforePull = await getRuntimeGitUpstreamStatus(context, pushTarget) if (shouldForcePushWithLeaseForUpstream(upstreamStatusBeforePull)) { @@ -3556,7 +3582,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) if (pushed) { const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { @@ -3564,11 +3592,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } } }, - rebaseFromBase: async (worktreeId, worktreePath, baseRef, connectionId, pushTarget) => { + rebaseFromBase: async (worktreeId, worktreePath, baseRef, connectionId, pushTarget, options) => { get().beginRemoteOperation('rebase') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await rebaseRuntimeGitFromBase( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, baseRef ) } catch (error) { @@ -3577,21 +3606,24 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the // upstream refresh after the busy flag clears. Fetch updates the // remote refs only, so the visible signal we want is the new // ahead/behind counts on the upstream-status payload. get().beginRemoteOperation('fetch') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fetchRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3600,7 +3632,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) }, gitBranchChangesByWorktree: {}, gitBranchCompareSummaryByWorktree: {}, diff --git a/src/renderer/src/store/slices/github-cache-key.ts b/src/renderer/src/store/slices/github-cache-key.ts index 4d74acbe3be..1bab2b1093e 100644 --- a/src/renderer/src/store/slices/github-cache-key.ts +++ b/src/renderer/src/store/slices/github-cache-key.ts @@ -1,21 +1,45 @@ -import type { AppState } from '../types' +import type { GlobalSettings } from '../../../../shared/types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' + +type RuntimeFocusSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined export function getGitHubRepoCacheKey( repoPath: string, repoId: string | undefined, suffix: string, - settings?: AppState['settings'], - connectionId?: string | null + settings?: RuntimeFocusSettings, + connectionId?: string | null, + executionHostId?: string | null ): string { - const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() const owner = repoId ?? repoPath + const scope = getGitHubCacheHostScope(settings, connectionId, executionHostId) // Why: runtime/SSH lookups can observe different remotes than the local repo - // path, so cache keys include the active remote execution boundary. + // path, so cache keys include the repo's owning execution boundary. + if (scope) { + return `${scope}::${owner}::${suffix}` + } + return `${owner}::${suffix}` +} + +function getGitHubCacheHostScope( + settings?: RuntimeFocusSettings, + connectionId?: string | null, + executionHostId?: string | null +): string | null { + const hostId = normalizeExecutionHostId(executionHostId) + if (hostId) { + return hostId === LOCAL_EXECUTION_HOST_ID ? null : hostId + } + const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() if (runtimeEnvironmentId) { - return `runtime:${runtimeEnvironmentId}::${owner}::${suffix}` + return `runtime:${encodeURIComponent(runtimeEnvironmentId)}` } const sshConnectionId = connectionId?.trim() - return sshConnectionId ? `ssh:${sshConnectionId}::${owner}::${suffix}` : `${owner}::${suffix}` + return sshConnectionId ? toSshExecutionHostId(sshConnectionId) : null } export function getLegacyGitHubRepoCacheKey( @@ -30,10 +54,11 @@ export function getGitHubPRCacheKey( repoPath: string, repoId: string | undefined, branch: string, - settings?: AppState['settings'], - connectionId?: string | null + settings?: RuntimeFocusSettings, + connectionId?: string | null, + executionHostId?: string | null ): string { - return getGitHubRepoCacheKey(repoPath, repoId, branch, settings, connectionId) + return getGitHubRepoCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId) } export function getLegacyGitHubPRCacheKey( diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index 3149f280d86..067d18d655b 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -43,14 +43,22 @@ export function syncPRChecksStatus( headSha?: string, prRepo?: GitHubOwnerRepo | null, settings?: AppState['settings'], - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): Partial<AppState> | null { const normalized = branch ? normalizeBranchName(branch) : '' if (!normalized) { return null } - const prCacheKey = getGitHubPRCacheKey(repoPath, repoId, normalized, settings, connectionId) + const prCacheKey = getGitHubPRCacheKey( + repoPath, + repoId, + normalized, + settings, + connectionId, + executionHostId + ) const prEntry = state.prCache[prCacheKey] if (!prEntry?.data) { return null diff --git a/src/renderer/src/store/slices/github-project-row-owner.test.ts b/src/renderer/src/store/slices/github-project-row-owner.test.ts new file mode 100644 index 00000000000..dccaec073e3 --- /dev/null +++ b/src/renderer/src/store/slices/github-project-row-owner.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { settingsForProjectRowOwner } from './github-project-row-owner' +import { lookupReposBySlugFromCache } from '@/lib/repo-slug-cache' + +vi.mock('@/lib/repo-slug-cache', () => ({ + lookupReposBySlugFromCache: vi.fn() +})) + +const mockedLookup = vi.mocked(lookupReposBySlugFromCache) + +function repo(id: string, executionHostId: string | null): Repo { + return { id, executionHostId, connectionId: null } as unknown as Repo +} + +describe('settingsForProjectRowOwner', () => { + beforeEach(() => { + mockedLookup.mockReset() + }) + + it('routes to the matched repo owner host when the slug matches', () => { + mockedLookup.mockReturnValue([repo('repo-1', 'runtime:owner-env')]) + const state = { + repos: [repo('repo-1', 'runtime:owner-env')], + settings: { activeRuntimeEnvironmentId: 'focused-env' } + } + expect(settingsForProjectRowOwner(state, 'acme', 'widgets')).toEqual({ + activeRuntimeEnvironmentId: 'owner-env' + }) + }) + + it('falls back to focused settings when no repo matches the slug', () => { + mockedLookup.mockReturnValue([]) + const state = { + repos: [repo('repo-1', 'runtime:owner-env')], + settings: { activeRuntimeEnvironmentId: 'focused-env' } + } + expect(settingsForProjectRowOwner(state, 'acme', 'widgets')).toEqual({ + activeRuntimeEnvironmentId: 'focused-env' + }) + }) +}) diff --git a/src/renderer/src/store/slices/github-project-row-owner.ts b/src/renderer/src/store/slices/github-project-row-owner.ts new file mode 100644 index 00000000000..936818c8df2 --- /dev/null +++ b/src/renderer/src/store/slices/github-project-row-owner.ts @@ -0,0 +1,25 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { lookupReposBySlugFromCache } from '@/lib/repo-slug-cache' + +type RepoOwnerState = { + repos: readonly Repo[] + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +} + +/** Resolve the runtime settings to route a GitHub Project row mutation through. + * When the row's `owner/repo` slug matches a known repo, route by that repo's + * owner host; otherwise fall back to the focused settings (the row may belong + * to a repo Orca doesn't track). */ +export function settingsForProjectRowOwner( + state: RepoOwnerState, + owner: string, + repo: string, + fallbackSettings: + | Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> + | null + | undefined = state.settings +): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined { + const matchedRepo = lookupReposBySlugFromCache(state.repos, state.settings, `${owner}/${repo}`)[0] + return matchedRepo ? getSettingsForRepoRuntimeOwner(state, matchedRepo.id) : fallbackSettings +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index c45ecf55801..d663bf21338 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -8,6 +8,7 @@ import { _getGitHubPRRefreshStartedEntryCountForTest, _getGitHubPRRequestGenerationCountForTest, createGitHubSlice, + issueCacheKey, mergePRCommentIntoList, prChecksCacheSuffix, prCommentsCacheSuffix, @@ -25,6 +26,8 @@ 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' +import type { TaskSourceContext } from '../../../../shared/task-source-context' const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -43,7 +46,12 @@ const mockApi = { resolveReviewThread: vi.fn(), listWorkItems: vi.fn(), countWorkItems: vi.fn().mockResolvedValue(0), - getProjectViewTable: vi.fn() + getProjectViewTable: vi.fn(), + updateProjectItemField: vi.fn(), + clearProjectItemField: vi.fn(), + updateIssueBySlug: vi.fn(), + updatePullRequestBySlug: vi.fn(), + updateIssueTypeBySlug: vi.fn() }, hostedReview: { forBranch: vi.fn().mockResolvedValue(null), @@ -95,6 +103,21 @@ function makePR(overrides: Partial<PRInfo> = {}): PRInfo { } } +function githubSourceContext( + hostId: TaskSourceContext['hostId'], + repoId = 'source-repo-id' +): TaskSourceContext { + return { + kind: 'task-source', + provider: 'github', + projectId: 'github:stablyai/orca', + hostId, + projectHostSetupId: 'setup-1', + repoId, + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + } +} + describe('createGitHubSlice.evictGitHubRepoCaches', () => { beforeEach(() => { vi.clearAllMocks() @@ -302,6 +325,134 @@ describe('createGitHubSlice cache bounds', () => { await vi.runOnlyPendingTimersAsync() }) + + it('routes runtime-owned issue fetches through the owning runtime when local is focused', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-issue-owner', + ok: true, + result: { + number: 123, + title: 'Runtime issue', + state: 'open', + url: 'https://example.com/issues/123' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + store.setState({ + settings: null, + repos: [ + { + id: 'repo-runtime', + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchIssue(repoPath, 123, { repoId: 'repo-runtime' }) + ).resolves.toMatchObject({ number: 123, title: 'Runtime issue' }) + + expect(mockApi.gh.issue).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.issue', + params: { repo: 'repo-runtime', number: 123 }, + timeoutMs: 30_000 + }) + expect( + store.getState().issueCache[ + issueCacheKey(repoPath, 'repo-runtime', 123, null, null, 'runtime:env-1') + ]?.data + ).toMatchObject({ number: 123 }) + }) + + it('routes explicit source-context issue fetches through the source runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-source-issue', + ok: true, + result: { + number: 19, + title: 'Source issue', + state: 'open', + url: 'https://example.com/issues/19' + }, + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'caller-repo-id' + const sourceContext = githubSourceContext('runtime:source-runtime', 'runtime-repo-id') + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchIssue(repoPath, 19, { repoId, sourceContext }) + ).resolves.toMatchObject({ number: 19, title: 'Source issue' }) + + expect(mockApi.gh.issue).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.issue', + params: { repo: 'runtime-repo-id', number: 19 }, + timeoutMs: 30_000 + }) + expect( + store.getState().issueCache[`${getTaskSourceCacheScope(sourceContext)}::${repoId}::19`]?.data + ).toMatchObject({ number: 19 }) + }) + + it('routes SSH-owned issue fetches through local IPC when a runtime is focused', async () => { + mockApi.gh.issue.mockResolvedValueOnce({ + number: 321, + title: 'SSH issue', + state: 'open', + url: 'https://example.com/issues/321' + }) + const store = createTestStore() + const repoPath = '/ssh/repo' + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + { + id: 'repo-ssh', + path: repoPath, + name: 'repo', + kind: 'git', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchIssue(repoPath, 321, { repoId: 'repo-ssh' }) + ).resolves.toMatchObject({ + number: 321, + title: 'SSH issue' + }) + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.issue).toHaveBeenCalledWith({ repoPath, repoId: 'repo-ssh', number: 321 }) + expect( + store.getState().issueCache[ + issueCacheKey(repoPath, 'repo-ssh', 321, null, 'ssh-1', 'ssh:ssh-1') + ]?.data + ).toMatchObject({ number: 321 }) + expect( + store.getState().issueCache[ + issueCacheKey(repoPath, 'repo-ssh', 321, { + activeRuntimeEnvironmentId: 'env-focused' + } as AppState['settings']) + ] + ).toBeUndefined() + }) }) describe('createGitHubSlice.patchWorkItem', () => { @@ -345,6 +496,58 @@ describe('createGitHubSlice.patchWorkItem', () => { }) expect(repoTwoPatched).toBe(repoTwoItem) }) + + it('can scope patches to one GitHub task source when hosts share a repo id and work-item id', () => { + const store = createTestStore() + const firstSourceContext = githubSourceContext('runtime:first-host', 'repo-1') + const secondSourceContext = githubSourceContext('runtime:second-host', 'repo-1') + const firstItem = { + id: 'pr:42', + repoId: 'repo-1', + type: 'pr', + number: 42, + title: 'First host PR' + } as GitHubWorkItem + const secondItem = { + id: 'pr:42', + repoId: 'repo-1', + type: 'pr', + number: 42, + title: 'Second host PR' + } as GitHubWorkItem + + store.setState({ + workItemsCache: { + [workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(firstSourceContext))]: { + data: [firstItem], + fetchedAt: 1 + }, + [workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(secondSourceContext))]: { + data: [secondItem], + fetchedAt: 1 + } + } + }) + + store.getState().patchWorkItem('pr:42', { reviewRequests: [] }, 'repo-1', { + sourceContext: firstSourceContext + }) + + const state = store.getState() + const firstPatched = + state.workItemsCache[ + workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(firstSourceContext)) + ]?.data?.[0] + const secondPatched = + state.workItemsCache[ + workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(secondSourceContext)) + ]?.data?.[0] + expect(firstPatched).toMatchObject({ + title: 'First host PR', + reviewRequests: [] + }) + expect(secondPatched).toBe(secondItem) + }) }) describe('createGitHubSlice.fetchPRChecks', () => { @@ -734,6 +937,48 @@ describe('createGitHubSlice.fetchPRChecks', () => { expect(store.getState().prCache[repoScopedKey]?.data?.checksStatus).toBe('success') expect(store.getState().prCache[pathScopedKey]?.data?.checksStatus).toBe('pending') }) + + it('routes explicit source-context PR checks through the source runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-source-checks', + ok: true, + result: [{ name: 'source-build', status: 'completed', conclusion: 'success', url: null }], + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'caller-repo-id' + const sourceContext = githubSourceContext('runtime:source-runtime', 'runtime-repo-id') + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await store.getState().fetchPRChecks(repoPath, 12, 'feature/source', 'head-1', null, { + force: true, + repoId, + sourceContext + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.prChecks', + params: { + repo: 'runtime-repo-id', + prNumber: 12, + headSha: 'head-1', + prRepo: null, + noCache: true + }, + timeoutMs: 30_000 + }) + expect( + store.getState().checksCache[ + `${getTaskSourceCacheScope(sourceContext)}::${repoId}::${prChecksCacheSuffix(12, null, 'head-1')}` + ]?.data?.[0].name + ).toBe('source-build') + expect(mockApi.gh.prChecks).not.toHaveBeenCalled() + }) }) describe('createGitHubSlice.fetchPRComments', () => { @@ -824,6 +1069,48 @@ describe('createGitHubSlice.fetchPRComments', () => { ).toBeUndefined() }) + it('routes explicit source-context PR comments through the source runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-source-comments', + ok: true, + result: [{ id: 1, author: 'source', authorAvatarUrl: '', body: '', createdAt: '', url: '' }], + _meta: { runtimeId: 'source-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'caller-repo-id' + const sourceContext = githubSourceContext('runtime:source-runtime', 'runtime-repo-id') + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as AppState['settings'], + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await store.getState().fetchPRComments(repoPath, 12, { + force: true, + repoId, + sourceContext, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'source-runtime', + method: 'github.prComments', + params: { + repo: 'runtime-repo-id', + prNumber: 12, + prRepo: { owner: 'Acme', repo: 'Widgets' }, + noCache: true + }, + timeoutMs: 30_000 + }) + expect( + store.getState().commentsCache[ + `${getTaskSourceCacheScope(sourceContext)}::${repoId}::pr-comments::acme/widgets::12` + ]?.data?.[0].author + ).toBe('source') + expect(mockApi.gh.prComments).not.toHaveBeenCalled() + }) + it('bounds PR comment cache entries across many repos', async () => { vi.useFakeTimers() @@ -847,6 +1134,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', () => { @@ -1008,6 +1351,37 @@ describe('createGitHubSlice PR comment mutations', () => { ).toBe('done') }) + it('posts top-level PR comments with explicit local source context', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + const sourceContext = githubSourceContext('local', repoId) + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }] + } as unknown as Partial<AppState>) + + await store.getState().addPRConversationComment(repoPath, 12, 'done', { + repoId, + sourceContext, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(mockApi.gh.addIssueComment).toHaveBeenCalledWith({ + repoPath, + repoId, + number: 12, + body: 'done', + type: 'pr', + prRepo: { owner: 'Acme', repo: 'Widgets' }, + sourceContext + }) + expect( + store.getState().commentsCache[ + `${getTaskSourceCacheScope(sourceContext)}::${repoId}::pr-comments::acme/widgets::12` + ]?.data?.[0].body + ).toBe('done') + }) + it('routes runtime PR review replies with prRepo and merges returned thread metadata', async () => { runtimeEnvironmentCall.mockResolvedValueOnce({ id: 'rpc-pr-reply', @@ -2241,7 +2615,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) }) - it('does not apply local GitHub PR refresh events while a runtime is active', () => { + it('applies local GitHub PR refresh events without touching runtime-scoped cache', () => { const store = createTestStore() const repoPath = '/repo' const repoId = 'repo-1' @@ -2249,6 +2623,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { const cacheKey = `${repoId}::${branch}` const settings = { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] const runtimeHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, settings, repoId) + const localHostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId) store.setState({ settings } as Partial<AppState>) @@ -2263,8 +2638,15 @@ describe('createGitHubSlice.fetchPRForBranch', () => { } }) - expect(store.getState().prCache[cacheKey]).toBeUndefined() - expect(store.getState().prRefreshSequences[cacheKey]).toBeUndefined() + expect(store.getState().prCache[cacheKey]?.data).toMatchObject({ + number: 12, + title: 'Local PR status' + }) + expect(store.getState().prRefreshSequences[cacheKey]).toBe(1) + expect(store.getState().hostedReviewCache[localHostedReviewCacheKey]?.data).toMatchObject({ + provider: 'github', + number: 12 + }) expect(store.getState().hostedReviewCache[runtimeHostedReviewCacheKey]).toBeUndefined() }) @@ -2898,6 +3280,92 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(store.getState().prCache[`repo-1::${branch}`]).toBeUndefined() }) + it('fetches PR through the owning runtime when local host is focused', async () => { + resetRemoteRuntimeMocks() + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 23, title: 'Owner runtime PR' }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/owner-runtime' + + store.setState({ + settings: null, + repos: [ + { + id: 'repo-runtime', + path: repoPath, + name: 'repo', + kind: 'git', + connectionId: null, + executionHostId: 'runtime:env-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { repoId: 'repo-runtime' }) + ).resolves.toMatchObject({ number: 23 }) + + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + expect(store.getState().prCache[`runtime:env-1::repo-runtime::${branch}`]?.data).toMatchObject({ + number: 23, + title: 'Owner runtime PR' + }) + }) + + it('fetches SSH-owned PRs through local IPC when a runtime host is focused', async () => { + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ number: 34, title: 'SSH PR' }), + fetchedAt: 10 + }) + const store = createTestStore() + const repoPath = '/ssh/repo' + const branch = 'feature/ssh-owner' + + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + { + id: 'repo-ssh', + path: repoPath, + name: 'repo', + kind: 'git', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } + ] + } as unknown as Partial<AppState>) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { repoId: 'repo-ssh' }) + ).resolves.toMatchObject({ number: 34 }) + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + cacheKey: `ssh:ssh-1::repo-ssh::${branch}`, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + }) + expect(store.getState().prCache[`ssh:ssh-1::repo-ssh::${branch}`]?.data).toMatchObject({ + number: 34, + title: 'SSH PR' + }) + expect(store.getState().prCache[`runtime:env-focused::repo-ssh::${branch}`]).toBeUndefined() + }) + it('uses the cached PR number as a fallback refresh hint when worktree metadata is not linked yet', () => { const store = createTestStore() const repoPath = '/repo' @@ -3404,12 +3872,214 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }, timeoutMs: 30_000 }) - expect(store.getState().workItemsCache['caller-repo-id::24::is:open'].data?.[0]).toMatchObject({ + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:env-1') + ].data?.[0] + ).toMatchObject({ repoId: 'caller-repo-id', number: 7 }) }) + it('routes work item fetches through the owning runtime when local is focused', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-work-items-owner', + ok: true, + result: { + items: [ + { type: 'issue', number: 17, title: 'Owner issue', url: 'https://example.test/17' } + ], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + store.setState({ + settings: null, + repos: [ + { + id: 'runtime-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + ] + } as Partial<AppState>) + + await store.getState().fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open') + + expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.listWorkItems', + params: { + repo: 'runtime-repo-id', + limit: 24, + query: 'is:open' + }, + timeoutMs: 30_000 + }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:env-1') + ]?.data?.[0] + ).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({ + items: [{ type: 'issue', number: 27, title: 'SSH issue', url: 'https://example.test/27' }], + sources: { issues: { owner: 'up', repo: 'r' }, prs: { owner: 'up', repo: 'r' } } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + { + id: 'ssh-repo-id', + path: '/ssh/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } + ] + } as Partial<AppState>) + + await store.getState().fetchWorkItems('ssh-repo-id', '/ssh/repo', 24, '') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(mockApi.gh.listWorkItems).toHaveBeenCalledWith({ + repoPath: '/ssh/repo', + repoId: 'ssh-repo-id', + limit: 24, + query: undefined + }) + expect( + store.getState().workItemsCache[workItemsCacheKey('ssh-repo-id', 24, '', 'ssh:ssh-1')] + ?.data?.[0] + ).toMatchObject({ repoId: 'ssh-repo-id', number: 27 }) + expect( + store.getState().workItemsCache[ + workItemsCacheKey('ssh-repo-id', 24, '', 'runtime:env-focused') + ] + ).toBeUndefined() + }) + it('falls back to local work-item IPC when no runtime environment is active', async () => { const store = createTestStore() mockApi.gh.listWorkItems.mockResolvedValueOnce({ @@ -3644,7 +4314,9 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) await expect(oldFetch).resolves.toEqual([{ ...oldRuntimeItem, repoId: 'caller-repo-id' }]) expect( - store.getState().workItemsCache[workItemsCacheKey('caller-repo-id', 24, 'is:open')]?.data + store.getState().workItemsCache[ + workItemsCacheKey('caller-repo-id', 24, 'is:open', 'runtime:env-new') + ]?.data ).toEqual([{ ...newRuntimeItem, repoId: 'caller-repo-id' }]) }) @@ -3922,6 +4594,271 @@ 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('routes project field mutations through the source encoded in the cache key', async () => { + const store = createTestStore() + const cacheKey = projectViewCacheKey( + 'organization', + 'acme', + 1, + 'view-1', + undefined, + 'runtime:env-project' + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' }, + projectViewCache: { + [cacheKey]: { + fetchedAt: 1, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [{ id: 'field-1', name: 'Notes', dataType: 'TEXT', kind: 'text' }], + groupByFields: [], + sortByFields: [] + }, + rows: [ + { + id: 'row-1', + itemType: 'ISSUE', + content: { + repository: 'acme/repo', + number: 12, + title: 'Issue', + body: '', + url: 'https://github.com/acme/repo/issues/12', + state: 'OPEN', + labels: [], + assignees: [], + issueType: null, + parentIssue: null + }, + fieldValuesByFieldId: {} + } + ], + totalCount: 1, + parentFieldDropped: false + } + } + } + } as unknown as Partial<AppState>) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-field', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await store + .getState() + .updateProjectFieldValue(cacheKey, 'row-1', 'field-1', { kind: 'text', text: 'next' }) + + expect(result).toEqual({ ok: true }) + expect(mockApi.gh.updateProjectItemField).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-project', + method: 'github.project.updateItemField', + params: { + projectId: 'project-1', + itemId: 'row-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'next' } + }, + timeoutMs: 30_000 + }) + }) + + it('routes slug-only project row mutations through the source encoded in the cache key', async () => { + const store = createTestStore() + const cacheKey = projectViewCacheKey( + 'organization', + 'acme', + 1, + 'view-1', + undefined, + 'runtime:env-project' + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-focused' }, + repos: [], + projectViewCache: { + [cacheKey]: { + fetchedAt: 1, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: '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: [ + { + id: 'row-1', + itemType: 'ISSUE', + content: { + repository: 'acme/repo', + number: 12, + title: 'Issue', + body: '', + url: 'https://github.com/acme/repo/issues/12', + state: 'OPEN', + labels: [], + assignees: [], + issueType: null, + parentIssue: null + }, + fieldValuesByFieldId: {} + } + ], + totalCount: 1, + parentFieldDropped: false + } + } + } + } as unknown as Partial<AppState>) + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-issue', + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await store + .getState() + .patchProjectIssueOrPr(cacheKey, 'row-1', { addLabels: ['bug'] }) + + expect(result).toEqual({ ok: true }) + expect(mockApi.gh.updateIssueBySlug).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-project', + method: 'github.project.updateIssueBySlug', + params: { + owner: 'acme', + repo: 'repo', + number: 12, + updates: { addLabels: ['bug'] } + }, + timeoutMs: 30_000 + }) + }) + 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 7aaf6a21e07..0ecfce8df20 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -20,7 +20,8 @@ import type { Repo, Worktree, GitHubWorkItem, - ListWorkItemsResult + ListWorkItemsResult, + GlobalSettings } from '../../../../shared/types' import type { GetProjectViewTableArgs, @@ -38,12 +39,27 @@ import { } from '../../../../shared/work-items' import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { settingsForProjectRowOwner } from './github-project-row-owner' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key' import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' import { translate } from '@/i18n/i18n' +import { + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + normalizeExecutionHostId, + 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) @@ -64,6 +80,10 @@ export type ProjectRowContentUpdate = { removeAssignees?: string[] } +export type GitHubPatchWorkItemOptions = { + sourceContext?: TaskSourceContext | null +} + /** Optimistic, IPC-free patch shape for `projectViewCache` rows. * Why: the dialog already issues mutations via slug-addressed IPCs and only * needs to keep the Project table view in sync optimistically. Replacing @@ -126,16 +146,130 @@ type GitHubWorkItemsListArgs = { noCache?: true } -function activeRuntimeEnvironmentId(settings: AppState['settings']): string | null { - return settings?.activeRuntimeEnvironmentId ?? null +function settingsForGitHubRepoOwner( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned GitHub lookups are served by the desktop client; + // host focus must not redirect them to the currently selected runtime. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string { + const explicitHostId = normalizeExecutionHostId(alias.executionHostId) + if (explicitHostId) { + return explicitHostId + } + const scope = alias.cacheKey.split('::', 1)[0] + return normalizeExecutionHostId(scope) ?? LOCAL_EXECUTION_HOST_ID +} + +function findRepoForGitHubOwner( + state: Partial<Pick<AppState, 'repos'>>, + repoId: string | undefined, + repoPath: string +): Repo | undefined { + return (state.repos ?? []).find((candidate) => + repoId ? candidate.id === repoId || candidate.path === repoPath : candidate.path === repoPath + ) +} + +function getGitHubRepoOwnerHostId( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined +): string { + if (repo?.executionHostId || repo?.connectionId) { + return getRepoExecutionHostId(repo) + } + return getSettingsFocusedExecutionHostId(settings) +} + +function getWorkItemsCacheKeyForOwner( + state: Partial<Pick<AppState, 'repos' | 'settings'>>, + repoId: string, + limit: number, + query: string, + repoPath?: string +): string { + const repo = findRepoForGitHubOwner(state, repoId, repoPath ?? '') + return workItemsCacheKey( + repoId, + limit, + query, + repo ? getGitHubRepoOwnerHostId(state.settings ?? null, repo) : undefined + ) +} + +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, @@ -199,12 +333,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. @@ -215,7 +350,23 @@ 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' +} + +function settingsForProjectViewCacheKey( + settings: AppState['settings'], + cacheKey: string +): Pick<NonNullable<AppState['settings']>, 'activeRuntimeEnvironmentId'> { + const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey) + if (runtimeMatch) { + return { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] } + } + return { ...settings, activeRuntimeEnvironmentId: null } } // Why: module-scope inflight map — must mirror `inflightWorkItemsRequests` @@ -407,6 +558,7 @@ export type CacheEntry<T> = { type FetchOptions = { force?: boolean noCache?: boolean + sourceContext?: TaskSourceContext | null } type RepoScopedFetchOptions = FetchOptions & { @@ -442,11 +594,12 @@ const inflightPRRequests = new Map< { promise: Promise<PRInfo | null>; force: boolean; generation: number; lookupHintKey: string } >() const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>() -type InflightChecksRequest = { +type InflightChecks = { promise: Promise<PRCheckDetail[]> + force: boolean noCache: boolean } -const inflightChecksRequests = new Map<string, InflightChecksRequest>() +const inflightChecksRequests = new Map<string, InflightChecks>() const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>() type InflightWorkItems = { promise: Promise<GitHubWorkItem[]> @@ -505,8 +658,19 @@ function releaseWorkItemSlot(): void { workItemFetchInFlight -= 1 } -export function workItemsCacheKey(repoId: string, limit: number, query: string): string { - return `${repoId}::${limit}::${query}` +export function workItemsCacheKey( + repoId: string, + limit: number, + query: string, + executionHostId?: string | null +): string { + const scope = executionHostId?.trim() ?? '' + const hostId = normalizeExecutionHostId(scope) + const owner = `${repoId}::${limit}::${query}` + if (hostId) { + return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner + } + return scope ? `${scope}::${owner}` : owner } function workItemsInflightRequestKey( @@ -518,8 +682,22 @@ function workItemsInflightRequestKey( return `${cacheKey}::${targetPart}` } -function repoScopedCacheKey(repoPath: string, repoId: string | undefined, suffix: string): string { - return `${repoId ?? repoPath}::${suffix}` +export function issueCacheKey( + repoPath: string, + repoId: string | undefined, + issueNumber: number | string, + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + connectionId?: string | null, + executionHostId?: string | null +): string { + return getGitHubRepoCacheKey( + repoPath, + repoId, + String(issueNumber), + settings, + connectionId, + executionHostId + ) } function runtimeScopedRepoCacheKey( @@ -527,9 +705,32 @@ function runtimeScopedRepoCacheKey( repoId: string | undefined, suffix: string, settings?: AppState['settings'], - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): string { - return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId) + return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId, executionHostId) +} + +function sourceScopedRepoCacheKey( + repoPath: string, + repoId: string | undefined, + suffix: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + sourceContext?: TaskSourceContext | null +): string { + if (sourceContext?.provider === 'github') { + return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}` + } + return runtimeScopedRepoCacheKey( + repoPath, + repoId, + suffix, + settings, + connectionId, + executionHostId + ) } function prCacheKey( @@ -537,9 +738,10 @@ function prCacheKey( repoId: string | undefined, branch: string, settings?: AppState['settings'], - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): string { - return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId) + return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId, executionHostId) } function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] { @@ -659,7 +861,7 @@ 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 { +function getPRChecksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | undefined): number { return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL } @@ -701,8 +903,9 @@ function buildPRRefreshCandidate( repoPath ?? repo.path, repo.id, branch, - state.settings, - repo.connectionId + settingsForGitHubRepoOwner(state.settings, repo), + repo.connectionId, + repo.executionHostId ) const cachedPR = state.prCache[cacheKey]?.data ?? null const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( @@ -710,7 +913,8 @@ function buildPRRefreshCandidate( repoPath ?? repo.path, repo.id, branch, - repo.connectionId + repo.connectionId, + repo.executionHostId ) const cachedFallbackPRNumber = cachedPR?.number ?? null const fallbackPRNumber = @@ -739,6 +943,7 @@ function buildPRRefreshCandidate( isBare: worktree.isBare, isArchived: worktree.isArchived, connectionId: repo.connectionId ?? null, + executionHostId: repo.executionHostId ?? null, connectionState: repo.connectionId ? sshStatus === 'connected' ? 'connected' @@ -758,14 +963,16 @@ function githubHostedReviewFallbackPRNumber( repoPath: string, repoId: string | undefined, branch: string, - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): number | null { const hostedReviewCacheKey = getHostedReviewCacheKey( repoPath, branch, state.settings, repoId, - connectionId + connectionId, + executionHostId ) const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data return hostedReview?.provider === 'github' ? hostedReview.number : null @@ -828,6 +1035,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { settings: AppState['settings'] repoId?: string connectionId?: string | null + executionHostId?: string | null pr: PRInfo | null fetchedAt: number linkedPRNumber?: number | null @@ -841,7 +1049,8 @@ function syncHostedReviewCacheFromGitHubPRResult(args: { args.branch, args.settings, args.repoId, - args.connectionId + args.connectionId, + args.executionHostId ) if ( args.requestStartedAt !== undefined && @@ -985,16 +1194,6 @@ function setPRRefreshStartedHostedReviewEntry( } } -function deletePRRefreshStartedEntriesForEvent( - event: GitHubPRRefreshEvent, - sequences: AppState['prRefreshSequences'] -): void { - for (const alias of event.aliases) { - deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) - deletePRRefreshStartedEntry(sequences[alias.cacheKey], alias.cacheKey) - } -} - function setGitHubPRResultCaches( state: AppState, args: { @@ -1004,6 +1203,7 @@ function setGitHubPRResultCaches( settings: AppState['settings'] repoId?: string connectionId?: string | null + executionHostId?: string | null pr: PRInfo | null fetchedAt: number linkedPRNumber?: number | null @@ -1020,6 +1220,7 @@ function setGitHubPRResultCaches( settings: args.settings, repoId: args.repoId, connectionId: args.connectionId, + executionHostId: args.executionHostId, pr: args.pr, fetchedAt: args.fetchedAt, linkedPRNumber: args.linkedPRNumber, @@ -1033,7 +1234,8 @@ function setGitHubPRResultCaches( args.branch, args.settings, args.repoId, - args.connectionId + args.connectionId, + args.executionHostId ) return { prCache: applyPRCacheResult( @@ -1071,6 +1273,7 @@ function applyGitHubPRResultToCaches(args: { settings: AppState['settings'] repoId?: string connectionId?: string | null + executionHostId?: string | null pr: PRInfo | null fetchedAt: number linkedPRNumber?: number | null @@ -1089,6 +1292,7 @@ function applyGitHubPRResultToCaches(args: { settings: args.settings, repoId: args.repoId, connectionId: args.connectionId, + executionHostId: args.executionHostId, pr: args.pr, fetchedAt: args.fetchedAt, linkedPRNumber: args.linkedPRNumber, @@ -1102,7 +1306,8 @@ function applyGitHubPRResultToCaches(args: { args.branch, args.settings, args.repoId, - args.connectionId + args.connectionId, + args.executionHostId ) return { prCache: applyPRCacheResult( @@ -1275,7 +1480,13 @@ export type GitHubSlice = { * background refresh when stale. Callers can render the cached list while * the SWR revalidate hydrates the latest. */ - getCachedWorkItems: (repoId: string, limit: number, query: string) => GitHubWorkItem[] | null + getCachedWorkItems: ( + repoId: string, + limit: number, + query: string, + repoPath?: string, + sourceContext?: TaskSourceContext | null + ) => GitHubWorkItem[] | null /** * Why: the Tasks view header reads sources from the cache to render the * "Issues from owner/repo" indicator, and the Tasks empty/partial banner @@ -1287,7 +1498,8 @@ export type GitHubSlice = { getWorkItemsSourcesAndError: ( repoId: string, limit: number, - query: string + query: string, + repoPath?: string ) => { sources: WorkItemsCacheSources | null; error: WorkItemsCacheError | null } /** * Why: the dialog renders the "Issue from owner/repo" chip for a single work @@ -1304,7 +1516,11 @@ export type GitHubSlice = { * mutated) on every write, so reference equality is preserved between * unchanged entries. */ - getWorkItemsAnySourcesForRepo: (repoId: string, limit: number) => WorkItemsCacheSources | null + getWorkItemsAnySourcesForRepo: ( + repoId: string, + limit: number, + repoPath?: string + ) => WorkItemsCacheSources | null fetchWorkItems: ( repoId: string, repoPath: string, @@ -1321,7 +1537,12 @@ export type GitHubSlice = { * the single-repo behavior of quietly serving stale data. */ fetchWorkItemsAcrossRepos: ( - repos: { repoId: string; path: string }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], perRepoLimit: number, displayLimit: number, query: string, @@ -1332,7 +1553,12 @@ export type GitHubSlice = { * pagination pages are ephemeral and managed by TaskPage state. */ fetchWorkItemsNextPage: ( - repos: { repoId: string; path: string }[], + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], perRepoLimit: number, displayLimit: number, query: string, @@ -1343,15 +1569,31 @@ export type GitHubSlice = { * Returns the sum of per-repo counts for the given query. */ countWorkItemsAcrossRepos: ( - repos: { repoId: string; path: string }[], + 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 - patchWorkItem: (itemId: string, patch: Partial<GitHubWorkItem>, repoId?: string | null) => void + prefetchWorkItems: ( + repoId: string, + repoPath: string, + limit?: number, + query?: string, + options?: { sourceContext?: TaskSourceContext | null } + ) => void + patchWorkItem: ( + itemId: string, + patch: Partial<GitHubWorkItem>, + repoId?: string | null, + options?: GitHubPatchWorkItemOptions + ) => void /** * Monotonic counter bumped whenever a repo's issue-source preference is * flipped. Subscribers (TaskPage's fetch effect) include this in their @@ -1426,7 +1668,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. @@ -1436,7 +1680,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s args.owner, args.projectNumber, args.viewId, - args.queryOverride + args.queryOverride, + sourceScope ) : null if (!options?.force && maybeKnownKey) { @@ -1461,7 +1706,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>( @@ -1478,7 +1722,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, { @@ -1560,7 +1805,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) const result = target.kind === 'environment' ? await callRuntimeRpc<GitHubProjectMutationResult>( @@ -1618,7 +1863,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } applyRowPatch(set, cacheKey, rowId, optimisticRow) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) const result = target.kind === 'environment' ? await callRuntimeRpc<GitHubProjectMutationResult>( @@ -1719,7 +1964,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s // PRs goes through updatePullRequestBySlug; for issues through // updateIssueBySlug. We dispatch both as needed. let envelope: GitHubProjectMutationResult = { ok: true } - const target = getActiveRuntimeTarget(get().settings) + // Why: Project rows may be slug-only and have no registered Orca repo. + // Fall back to the view source encoded in the cache key, not focused host. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) if ( previousRow.itemType === 'PULL_REQUEST' && (updates.title !== undefined || updates.body !== undefined) @@ -1838,7 +2092,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s content: { ...previousRow.content, issueType } } applyRowPatch(set, cacheKey, rowId, optimistic) - const target = getActiveRuntimeTarget(get().settings) + // Why: slug-only Project rows still belong to the source host that loaded + // the view; focused host may have changed after the table was fetched. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) const args = { owner, repo, @@ -1902,13 +2165,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s applyRowPatch(set, cacheKey, rowId, nextRow) }, - getCachedWorkItems: (repoId, limit, query) => { - const key = workItemsCacheKey(repoId, limit, query) + 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 }, - getWorkItemsSourcesAndError: (repoId, limit, query) => { - const key = workItemsCacheKey(repoId, limit, query) + getWorkItemsSourcesAndError: (repoId, limit, query, repoPath) => { + const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) const entry = get().workItemsCache[key] return { sources: entry?.sources ?? null, @@ -1916,14 +2183,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } }, - getWorkItemsAnySourcesForRepo: (repoId, limit) => { + getWorkItemsAnySourcesForRepo: (repoId, limit, repoPath) => { const cache = get().workItemsCache - const primaryKey = workItemsCacheKey(repoId, limit, '') + const primaryKey = getWorkItemsCacheKeyForOwner(get(), repoId, limit, '', repoPath) const primary = cache[primaryKey]?.sources if (primary) { return primary } - const prefix = `${repoId}::` + const prefix = primaryKey for (const [key, entry] of Object.entries(cache)) { if (key.startsWith(prefix) && entry.sources) { return entry.sources @@ -1933,21 +2200,28 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s }, fetchWorkItems: async (repoId, repoPath, limit, query, options): Promise<GitHubWorkItem[]> => { - const key = workItemsCacheKey(repoId, limit, query) + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) + 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) @@ -1993,9 +2267,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s issuesError && envelope.sources.issues ? { ...issuesError, source: envelope.sources.issues } : undefined - // Why: runtime switches reset server-scoped caches; queued old-runtime - // responses can still satisfy callers but must not revive reset entries. - if (activeRuntimeEnvironmentId(get().settings) !== requestRuntimeEnvironmentId) { + const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath) + 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)) { return items } // Why: clearing in-flight entries lets the next fetch start, but the @@ -2041,7 +2321,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 @@ -2052,7 +2335,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s if (isGitHubWorkItemsSshRemoteRequiredError(err)) { return [] as GitHubWorkItem[] } - const key = workItemsCacheKey(r.repoId, perRepoLimit, query) + 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) @@ -2073,12 +2364,18 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const perProjectResults = await Promise.all( repos.map(async (r) => { const requestState = get() - const requestSettings = requestState.settings + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, r.repoId, - r.path + r.path, + r.sourceContext ) await acquireWorkItemSlot() try { @@ -2122,12 +2419,18 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s repos.map(async (r) => { try { const requestState = get() - const requestSettings = requestState.settings + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + 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 { @@ -2138,15 +2441,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 = workItemsCacheKey(repoId, limit, query) - 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 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, - requestState.settings, + 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. @@ -2154,7 +2467,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(() => {}) }, @@ -2177,15 +2490,23 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = prCacheKey(repoPath, repoId, branch, requestSettings, repo?.connectionId) + const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) + const cacheKey = prCacheKey( + repoPath, + repoId, + branch, + requestSettings, + repo?.connectionId, + repo?.executionHostId + ) const cached = get().prCache[cacheKey] const hostedReviewCacheKey = getHostedReviewCacheKey( repoPath, branch, requestSettings, repoId, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) // Why: if a prior caller without a linkedPR cached `null` for this branch, // the worktree-card lookup (which has a linked PR fallback) would otherwise @@ -2198,7 +2519,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s repoPath, repoId, branch, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) const fallbackPRNumber = linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null @@ -2260,6 +2582,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s fallbackPRNumber, fallbackPRSource, connectionId: repo?.connectionId ?? null, + executionHostId: repo?.executionHostId ?? null, cachedFetchedAt: cached?.fetchedAt ?? null, cachedHasPR: cached?.data ? true : cached ? false : null, cachedPRState: cached?.data?.state ?? null, @@ -2298,6 +2621,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s settings: requestSettings, repoId, connectionId: repo?.connectionId, + executionHostId: repo?.executionHostId, pr, fetchedAt: outcome.fetchedAt, linkedPRNumber, @@ -2348,8 +2672,22 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s }, fetchIssue: async (repoPath, number, options) => { - const repoId = options?.repoId ?? get().repos?.find((repo) => repo.path === repoPath)?.id - const cacheKey = repoScopedCacheKey(repoPath, repoId, String(number)) + const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + String(number), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext + ) const cached = get().issueCache[cacheKey] if (isFresh(cached)) { return cached.data @@ -2362,7 +2700,27 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const request = (async () => { try { - const issue = await window.api.gh.issue({ repoPath, repoId, number }) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const issue = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<IssueInfo | null>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.issue', + { repo: requestContext.target.runtimeRepoId, number }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.issue({ + repoPath, + repoId, + number, + sourceContext: options?.sourceContext + }) set((s) => ({ issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { data: issue, @@ -2402,29 +2760,37 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prChecksCacheSuffix(prNumber, prRepo, headSha), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) const legacyCacheKey = headSha - ? runtimeScopedRepoCacheKey( + ? sourceScopedRepoCacheKey( repoPath, repoId, prChecksCacheSuffix(prNumber, prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) : 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 && + !options?.noCache && + isFresh(cached, getPRChecksCacheTtl(cached)) && (!headSha || cached.headSha === headSha) ) { const cachedChecks = cached.data ?? [] @@ -2437,7 +2803,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s cached.headSha, prRepo, requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) if (prStatusUpdate) { set(prStatusUpdate) @@ -2448,42 +2815,48 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const inflightRequest = inflightChecksRequests.get(inflightKey) if (inflightRequest) { - if (!requestNoCache || inflightRequest.noCache) { + if ( + (options?.force && !inflightRequest.force) || + (options?.noCache && !inflightRequest.noCache) + ) { + await inflightRequest.promise.catch(() => {}) + } else { 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 - } } const request = (async () => { try { - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - const checks = runtimeRepo - ? await callRuntimeRpc<PRCheckDetail[]>( - runtimeRepo.target, - 'github.prChecks', - { - repo: runtimeRepo.repo.id, + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const checks = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<PRCheckDetail[]>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prChecks', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + headSha, + prRepo: prRepo ?? null, + noCache: Boolean(options?.force || options?.noCache) + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prChecks({ + repoPath, + repoId, prNumber, headSha, prRepo: prRepo ?? null, - noCache: requestNoCache - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prChecks({ - repoPath, - repoId, - prNumber, - headSha, - prRepo: prRepo ?? null, - noCache: requestNoCache - })) as PRCheckDetail[]) + noCache: Boolean(options?.force || options?.noCache), + sourceContext: options?.sourceContext + })) as PRCheckDetail[]) set((s) => { const nextState: Partial<AppState> = { checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, { @@ -2502,7 +2875,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s headSha, prRepo, requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) if (prStatusUpdate?.prCache) { nextState.prCache = prStatusUpdate.prCache @@ -2524,7 +2898,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } })() - inflightChecksRequests.set(inflightKey, { promise: request, noCache: requestNoCache }) + inflightChecksRequests.set(inflightKey, { + promise: request, + force: Boolean(options?.force), + noCache: Boolean(options?.force || options?.noCache) + }) return request }, @@ -2533,14 +2911,24 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - return runtimeRepo + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + return requestContext.target.kind === 'environment' ? await callRuntimeRpc<PRCheckRunDetails | null>( - runtimeRepo.target, + { kind: 'environment', environmentId: requestContext.target.environmentId }, 'github.prCheckDetails', { - repo: runtimeRepo.repo.id, + repo: requestContext.target.runtimeRepoId, checkRunId: args.checkRunId, workflowRunId: args.workflowRunId, checkName: args.checkName, @@ -2556,7 +2944,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s workflowRunId: args.workflowRunId, checkName: args.checkName, url: args.url, - prRepo: args.prRepo ?? null + prRepo: args.prRepo ?? null, + sourceContext: options?.sourceContext })) as PRCheckRunDetails | null) }, @@ -2565,13 +2954,19 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) const cached = get().commentsCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -2585,26 +2980,34 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const request = (async () => { try { - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - const comments = runtimeRepo - ? await callRuntimeRpc<PRComment[]>( - runtimeRepo.target, - 'github.prComments', - { - repo: runtimeRepo.repo.id, + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const comments = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<PRComment[]>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prComments', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + prRepo: options?.prRepo ?? null, + noCache: options?.force + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prComments({ + repoPath, + repoId, prNumber, prRepo: options?.prRepo ?? null, - noCache: options?.force - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prComments({ - repoPath, - repoId, - prNumber, - prRepo: options?.prRepo ?? null, - noCache: options?.force - })) as PRComment[]) + noCache: options?.force, + sourceContext: options?.sourceContext + })) as PRComment[]) set((s) => ({ commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { data: comments, @@ -2629,38 +3032,52 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext ) - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) let result: GitHubCommentResult try { - result = runtimeRepo - ? await callRuntimeRpc<GitHubCommentResult>( - runtimeRepo.target, - 'github.addIssueComment', - { - repo: runtimeRepo.repo.id, + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<GitHubCommentResult>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addIssueComment', + { + repo: requestContext.target.runtimeRepoId, + number: prNumber, + body, + type: 'pr', + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addIssueComment({ + repoPath, + repoId, number: prNumber, body, type: 'pr', - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addIssueComment({ - repoPath, - repoId, - number: prNumber, - body, - type: 'pr', - prRepo: options?.prRepo ?? null - }) + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) } catch (err) { const error = err instanceof Error ? err.message : 'Failed to post comment.' return { ok: false, error } @@ -2693,44 +3110,58 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext ) - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) let result: GitHubCommentResult try { - result = runtimeRepo - ? await callRuntimeRpc<GitHubCommentResult>( - runtimeRepo.target, - 'github.addPRReviewCommentReply', - { - repo: runtimeRepo.repo.id, + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<GitHubCommentResult>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addPRReviewCommentReply', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + commentId, + body, + threadId: options?.threadId, + path: options?.path, + line: options?.line, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addPRReviewCommentReply({ + repoPath, + repoId, prNumber, commentId, body, threadId: options?.threadId, path: options?.path, line: options?.line, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addPRReviewCommentReply({ - repoPath, - repoId, - prNumber, - commentId, - body, - threadId: options?.threadId, - path: options?.path, - line: options?.line, - prRepo: options?.prRepo ?? null - }) + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) } catch (err) { const error = err instanceof Error ? err.message : 'Failed to post reply.' return { ok: false, error } @@ -2769,13 +3200,19 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = get().settings - const cacheKey = runtimeScopedRepoCacheKey( + const requestSettings = getGitHubWorkItemSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, prCommentsCacheSuffix(prNumber, options?.prRepo), requestSettings, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext ) // Optimistic update: toggle isResolved on all comments in this thread immediately @@ -2793,17 +3230,30 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s })) } - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) let ok = false try { - ok = runtimeRepo - ? await callRuntimeRpc<boolean>( - runtimeRepo.target, - 'github.resolveReviewThread', - { repo: runtimeRepo.repo.id, threadId, resolve }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.resolveReviewThread({ repoPath, repoId, threadId, resolve }) + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc<boolean>( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.resolveReviewThread', + { repo: requestContext.target.runtimeRepoId, threadId, resolve }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.resolveReviewThread({ + repoPath, + repoId, + threadId, + resolve, + sourceContext: options?.sourceContext + }) } catch (err) { console.error('Failed to update review thread:', err) ok = false @@ -2894,12 +3344,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s applyGitHubPRRefreshEvent: (event) => { set((s) => { - // Why: local main-process refresh events are keyed only by repo/branch; - // applying them while a runtime is active can leak local PR state into SSH. - if (getActiveRuntimeTarget(s.settings).kind === 'environment') { - deletePRRefreshStartedEntriesForEvent(event, s.prRefreshSequences) - return {} - } const nextSequences = { ...s.prRefreshSequences } const nextStates = { ...s.prRefreshStates } let nextPRCache = s.prCache @@ -2907,6 +3351,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s let changed = false for (const alias of event.aliases) { + const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias) const previousSequence = nextSequences[alias.cacheKey] ?? 0 if ( event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence @@ -2950,7 +3395,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s alias.repoId, prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) ] : []), @@ -2959,7 +3405,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s alias.repoId, prChecksCacheSuffix(pr.number, pr.prRepo), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) ] : []), @@ -2970,7 +3417,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s undefined, prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) ] : []), @@ -2979,21 +3427,22 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s undefined, prChecksCacheSuffix(pr.number, pr.prRepo), s.settings, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ), `${alias.repoPath}::pr-checks::${pr.number}` ] 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 < + getPRChecksCacheTtl(checksEntry) + ) { return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } } return pr @@ -3013,6 +3462,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s settings: s.settings, repoId: alias.repoId, connectionId: alias.connectionId, + executionHostId: aliasExecutionHostId, pr: data, fetchedAt: event.outcome.fetchedAt, linkedPRNumber: alias.linkedPRNumber, @@ -3036,7 +3486,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s alias.branch, s.settings, alias.repoId, - alias.connectionId + alias.connectionId, + aliasExecutionHostId ) setPRRefreshStartedHostedReviewEntry( prRefreshStartedEntryKey(event.sequence, alias.cacheKey), @@ -3111,7 +3562,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s const branch = wt.branch.replace(/^refs\/heads\//, '') if (shouldRefreshPRs && !wt.isBare && branch) { - const prKey = prCacheKey(repo.path, repo.id, branch, state.settings, repo.connectionId) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const prEntry = state.prCache[prKey] if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { const candidate = buildPRRefreshCandidate(state, wt) @@ -3126,7 +3585,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } } if (shouldRefreshIssues && wt.linkedIssue) { - const issueKey = repoScopedCacheKey(repo.path, repo.id, String(wt.linkedIssue)) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + wt.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { void get().fetchIssue(repo.path, wt.linkedIssue, { repoId: repo.id }) @@ -3138,7 +3605,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s .sort((a, b) => b.score - a.score) .slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5) for (const { candidate } of candidatesToRefresh) { - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + const candidateSettings = settingsForGitHubRepoOwner( + state.settings, + candidate as Pick<Repo, 'connectionId' | 'executionHostId'> + ) + if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, worktreeId: candidate.worktreeId, @@ -3172,9 +3643,24 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s // Invalidate this worktree's cache entries const branch = worktree.branch.replace(/^refs\/heads\//, '') - const prKey = prCacheKey(repo.path, repo.id, branch, state.settings, repo.connectionId) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const issueKey = worktree.linkedIssue - ? repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue)) + ? issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) : '' set((s) => { @@ -3214,11 +3700,20 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } }, - patchWorkItem: (itemId, patch, repoId) => { + patchWorkItem: (itemId, patch, repoId, options) => { set((s) => { const nextCache = { ...s.workItemsCache } let changed = false + const sourceScope = + options?.sourceContext?.provider === 'github' + ? getTaskSourceCacheScope(options.sourceContext) + : null for (const key of Object.keys(nextCache)) { + // Why: task edits from one host/account must not optimistically patch + // another host's visually identical GitHub issue or PR cache entry. + if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) { + continue + } const entry = nextCache[key] if (!entry?.data) { continue @@ -3262,7 +3757,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s // normalizes `'auto'` to `undefined` so the persisted record drops // the key entirely (see main/persistence.ts#updateRepo). const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } - const target = getActiveRuntimeTarget(get().settings) + // Why: persist to the repo's owner host (same routing as updateRepo) so the + // write lands where the repo lives, not on the focused runtime. + const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId)) await (target.kind === 'local' ? window.api.repos.update({ repoId, updates }) : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) @@ -3396,7 +3893,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s } if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { - const issueKey = repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue)) + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) const issueEntry = state.issueCache[issueKey] if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) diff --git a/src/renderer/src/store/slices/hosted-review-cache-identity.ts b/src/renderer/src/store/slices/hosted-review-cache-identity.ts index c8e9a3db9bc..db099a11782 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-identity.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-identity.ts @@ -1,4 +1,9 @@ import type { GlobalSettings } from '../../../../shared/types' +import { + getSettingsFocusedExecutionHostId, + normalizeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' export type LinkedReviewHints = { linkedGitHubPR?: number | null @@ -14,18 +19,29 @@ export function getHostedReviewCacheKey( branch: string, settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, repoId?: string | null, - connectionId?: string | null + connectionId?: string | null, + executionHostId?: string | null ): string { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - const sshConnectionId = connectionId?.trim() - const scope = environmentId - ? `runtime:${environmentId}` - : sshConnectionId - ? `ssh:${sshConnectionId}` - : 'local' + const scope = getHostedReviewCacheHostScope(settings, connectionId, executionHostId) return `${scope}::${repoId ?? repoPath}::${branch}` } +function getHostedReviewCacheHostScope( + settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null, + connectionId?: string | null, + executionHostId?: string | null +): string { + const hostId = normalizeExecutionHostId(executionHostId) + if (hostId) { + return hostId + } + const sshConnectionId = connectionId?.trim() + if (sshConnectionId) { + return toSshExecutionHostId(sshConnectionId) + } + return getSettingsFocusedExecutionHostId(settings) +} + // Why: a branch-keyed lookup can describe a different PR than the persisted // linked review number. Track that distinction without changing the cache key. export function linkedReviewHintKey(options?: LinkedReviewHints): string { diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 3c68df51bb2..afdc0c65113 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -189,6 +189,72 @@ describe('hosted review slice', () => { ) }) + it('routes runtime-owned review lookups through the owning runtime when local is focused', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review) + const store = makeStore(null) + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/runtime/repo', + connectionId: null, + executionHostId: 'runtime:env-1' + } as unknown as AppState['repos'][number] + ] + } as Partial<AppState>) + + await expect( + store.getState().fetchHostedReviewForBranch('/runtime/repo', 'feature/runtime', { + repoId: 'repo-1' + }) + ).resolves.toEqual(review) + + expect(mockApi.hostedReview.forBranch).not.toHaveBeenCalled() + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'hostedReview.forBranch', + expect.objectContaining({ repo: 'repo-1', branch: 'feature/runtime' }), + { timeoutMs: 30_000 } + ) + expect(store.getState().hostedReviewCache['runtime:env-1::repo-1::feature/runtime']).toEqual( + expect.objectContaining({ data: review }) + ) + }) + + it('uses SSH ownership instead of the focused runtime for branch review lookups', async () => { + mockApi.hostedReview.forBranch.mockResolvedValueOnce(review) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-focused' + } as AppState['settings']) + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/ssh/repo', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + } as unknown as AppState['repos'][number] + ] + } as Partial<AppState>) + + await expect( + store.getState().fetchHostedReviewForBranch('/ssh/repo', 'feature/ssh', { + repoId: 'repo-1' + }) + ).resolves.toEqual(review) + + expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled() + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/ssh/repo', repoId: 'repo-1', branch: 'feature/ssh' }) + ) + expect(store.getState().hostedReviewCache['ssh:ssh-1::repo-1::feature/ssh']).toEqual( + expect.objectContaining({ data: review }) + ) + expect( + store.getState().hostedReviewCache['runtime:env-focused::repo-1::feature/ssh'] + ).toBeUndefined() + }) + it('forwards the selected worktree path when creating a local pull request', async () => { mockApi.hostedReview.create.mockResolvedValueOnce({ ok: true, @@ -209,6 +275,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: null, provider: 'github', base: 'main', @@ -241,6 +308,7 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ repoPath: '/repo', + repoId: 'repo-1', connectionId: 'ssh-1', provider: 'github', base: 'main', @@ -272,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 f94dec34471..c535ba617bc 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -8,6 +8,7 @@ import type { HostedReviewCreationEligibilityArgs, HostedReviewInfo } from '../../../../shared/hosted-review' +import type { Repo } from '../../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { AppState } from '../types' import { @@ -16,11 +17,13 @@ import { type LinkedReviewHints } from './hosted-review-cache-identity' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' export { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' 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 @@ -50,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 @@ -118,6 +129,26 @@ function withHostedReviewCacheEntry( return pruned } +function settingsForHostedReviewRepoOwner( + settings: AppState['settings'], + repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned reviews are served by the desktop client's local + // IPC path, even when the sidebar is focused on a runtime host. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + export type HostedReviewSlice = { hostedReviewCache: Record<string, CacheEntry<HostedReviewInfo>> getHostedReviewCreationEligibility: ( @@ -125,7 +156,7 @@ export type HostedReviewSlice = { ) => Promise<HostedReviewCreationEligibility> createHostedReview: ( repoPath: string, - input: CreateHostedReviewInput + input: CreateHostedReviewStoreInput ) => Promise<CreateHostedReviewResult> fetchHostedReviewForBranch: ( repoPath: string, @@ -171,9 +202,10 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie getHostedReviewCreationEligibility: async (args) => { const settings = get().settings - const target = getActiveRuntimeTarget(settings) + const repo = findHostedReviewRepoByPath(get().repos, args.repoPath, args.repoId) + const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const target = getActiveRuntimeTarget(ownerSettings) if (target.kind === 'environment') { - const repo = get().repos.find((candidate) => candidate.path === args.repoPath) const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args void _repoPath return callRuntimeRpc<HostedReviewCreationEligibility>( @@ -187,19 +219,21 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie { timeoutMs: 30_000 } ) } - const repo = get().repos.find((candidate) => candidate.path === args.repoPath) return window.api.hostedReview.getCreationEligibility({ ...args, + repoId: repo?.id ?? args.repoId, connectionId: repo?.connectionId ?? null }) }, createHostedReview: async (repoPath, input) => { const settings = get().settings - const target = getActiveRuntimeTarget(settings) + 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 repo = get().repos.find((candidate) => candidate.path === repoPath) - const { worktreePath, ...runtimeInput } = input + const { worktreePath, ...runtimeInput } = hostedReviewInput return callRuntimeRpc<CreateHostedReviewResult>( target, 'hostedReview.create', @@ -211,11 +245,11 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie { timeoutMs: 60_000 } ) } - const repo = get().repos.find((candidate) => candidate.path === repoPath) return window.api.hostedReview.create({ repoPath, + repoId: repo?.id ?? inputRepoId ?? undefined, connectionId: repo?.connectionId ?? null, - ...input + ...hostedReviewInput }) }, @@ -225,17 +259,19 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie options ): Promise<HostedReviewInfo | null> => { const settings = get().settings - const target = getActiveRuntimeTarget(settings) const repo = get().repos?.find((candidate) => options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) + const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const target = getActiveRuntimeTarget(ownerSettings) const repoId = options?.repoId ?? repo?.id const cacheKey = getHostedReviewCacheKey( repoPath, branch, - settings, + ownerSettings, options?.repoId, - repo?.connectionId + repo?.connectionId, + repo?.executionHostId ) const cached = get().hostedReviewCache[cacheKey] const hintKey = linkedReviewHintKey(options) @@ -273,14 +309,17 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie ? await callRuntimeRpc<HostedReviewInfo | null>( target, 'hostedReview.forBranch', - { repo: options?.repoId ?? repoPath, repoPath, ...args }, + { repo: repo?.id ?? options?.repoId ?? repoPath, repoPath, ...args }, // Why: remote dev boxes can be slower at `git`/`gh` lookups // than local desktop repos, especially on Windows filesystem // paths. The main-process queue caps concurrency, so a longer // timeout no longer risks a background socket stampede. { timeoutMs: 30_000 } ) - : await window.api.hostedReview.forBranch({ repoPath, ...args }) + : await window.api.hostedReview.forBranch({ + repoPath, + ...args + }) if (requestGenerations.get(cacheKey) === generation) { set((state) => { if ( @@ -294,7 +333,14 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie return {} } const prCacheKeys = [ - getGitHubPRCacheKey(repoPath, repoId, branch, settings, repo?.connectionId), + getGitHubPRCacheKey( + repoPath, + repoId, + branch, + ownerSettings, + repo?.connectionId, + repo?.executionHostId + ), getLegacyGitHubPRCacheKey(repoPath, repoId, branch), getLegacyGitHubPRCacheKey(repoPath, undefined, branch) ] diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 6b320ca9c51..565467e0542 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { JiraConnectionStatus, JiraIssue, JiraViewer } from '../../../../shared/types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../../shared/task-source-context' import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { createJiraSlice } from './jira' @@ -72,6 +76,19 @@ function issue(key: string): JiraIssue { } } +function jiraSourceContext(environmentId: string, siteId = 'site-1'): TaskSourceContext { + return { + kind: 'task-source', + provider: 'jira', + projectId: 'logical-project', + hostId: `runtime:${environmentId}`, + providerIdentity: { + provider: 'jira', + siteId + } + } +} + describe('createJiraSlice runtime context', () => { beforeEach(() => { vi.clearAllMocks() @@ -117,6 +134,79 @@ describe('createJiraSlice runtime context', () => { expect(store.getState().jiraIssueCache['selected::ORC-1']?.data?.title).toBe('Remote issue') }) + it('routes explicit source reads through their source context when focused runtime changes', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } + }) + const sourceContext = jiraSourceContext('source-runtime') + const sourceResult = deferred<JiraIssue[]>() + jiraListIssues.mockReturnValueOnce(sourceResult.promise) + + const request = store.getState().listJiraIssues('assigned', 30, { sourceContext }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + sourceResult.resolve([{ ...issue('ALP-1'), title: 'Source issue' }]) + await expect(request).resolves.toMatchObject([{ key: 'ALP-1', title: 'Source issue' }]) + expect(jiraListIssues).toHaveBeenCalledWith(sourceContext, 'assigned', 30, 'site-1') + expect(Object.values(store.getState().jiraSearchCache)).toHaveLength(1) + expect(store.getState().jiraSearchCache['site-1::list::assigned::30']).toBeUndefined() + }) + + it('scopes optimistic issue patches to the selected Jira source context', () => { + const store = createTestStore() + const localSource = jiraSourceContext('local-runtime') + const remoteSource = jiraSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + + store.setState({ + jiraIssueCache: { + [`${localScope}::site-1::ALP-1`]: { + data: { ...issue('ALP-1'), title: 'Local title' }, + fetchedAt: Date.now() + }, + [`${remoteScope}::site-1::ALP-1`]: { + data: { ...issue('ALP-1'), title: 'Remote title' }, + fetchedAt: Date.now() + } + }, + jiraSearchCache: { + [`${localScope}::site-1::list::assigned::30`]: { + data: [{ ...issue('ALP-1'), title: 'Local title' }], + fetchedAt: Date.now() + }, + [`${remoteScope}::site-1::list::assigned::30`]: { + data: [{ ...issue('ALP-1'), title: 'Remote title' }], + fetchedAt: Date.now() + } + } + }) + + store.getState().patchJiraIssue( + 'ALP-1', + { title: 'Patched local title' }, + { + sourceContext: localSource + } + ) + + expect(store.getState().jiraIssueCache[`${localScope}::site-1::ALP-1`]?.data?.title).toBe( + 'Patched local title' + ) + expect(store.getState().jiraIssueCache[`${remoteScope}::site-1::ALP-1`]?.data?.title).toBe( + 'Remote title' + ) + expect( + store.getState().jiraSearchCache[`${localScope}::site-1::list::assigned::30`]?.data?.[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().jiraSearchCache[`${remoteScope}::site-1::list::assigned::30`]?.data?.[0] + ?.title + ).toBe('Remote title') + }) + it('returns a failed Jira connect result when the active runtime changes before completion', async () => { const store = createTestStore() const connectResult = deferred<{ ok: true; viewer: JiraViewer }>() diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index b34d894f5a4..9892ee8e79b 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -24,6 +24,11 @@ import { } from '@/runtime/runtime-jira-client' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' const CACHE_TTL = 60_000 const MAX_CACHE_ENTRIES = 500 @@ -59,6 +64,16 @@ type InflightJiraReadRequest<T> = { mutationGeneration: number } +type JiraReadOptions = { sourceContext?: TaskSourceContext | null } +type JiraPatchOptions = { sourceContext?: TaskSourceContext | null } + +type JiraReadScope = { + settings: AppState['settings'] | TaskSourceContext | null + contextKey: string + cachePrefix: string | null + explicitSource: boolean +} + const inflightIssueRequests = new Map<string, InflightJiraReadRequest<JiraIssue | null>>() const inflightSearchRequests = new Map<string, InflightJiraReadRequest<JiraIssue[]>>() const inflightListRequests = new Map<string, InflightJiraReadRequest<JiraIssue[]>>() @@ -100,14 +115,40 @@ function isCurrentJiraRuntimeContext(contextKey: string, settings: AppState['set function canWriteJiraReadResult( contextKey: string, mutationGeneration: number, - settings: AppState['settings'] + settings: AppState['settings'], + explicitSource = false ): boolean { return ( mutationGeneration === jiraMutationGeneration && - isCurrentJiraRuntimeContext(contextKey, settings) + (explicitSource || isCurrentJiraRuntimeContext(contextKey, settings)) ) } +function getJiraReadScope( + settings: AppState['settings'], + sourceContext?: TaskSourceContext | null +): JiraReadScope { + if (!sourceContext) { + return { + settings, + contextKey: getProviderRuntimeContextKey(settings), + cachePrefix: null, + explicitSource: false + } + } + const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return { + settings: sourceContext, + contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${getTaskSourceCacheScope(sourceContext)}`, + cachePrefix: getTaskSourceCacheScope(sourceContext), + explicitSource: true + } +} + +function scopedJiraCacheKey(scope: JiraReadScope, key: string): string { + return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key +} + export type JiraSlice = { jiraStatus: JiraConnectionStatus jiraStatusChecked: boolean @@ -126,10 +167,18 @@ export type JiraSlice = { ) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> selectJiraSite: (siteId: JiraSiteSelection) => Promise<void> disconnectJira: (siteId?: string | null) => Promise<void> - fetchJiraIssue: (key: string, siteId?: string | null) => Promise<JiraIssue | null> - searchJiraIssues: (jql: string, limit?: number) => Promise<JiraIssue[]> - listJiraIssues: (filter?: JiraIssueFilter, limit?: number) => Promise<JiraIssue[]> - patchJiraIssue: (issueKey: string, patch: Partial<JiraIssue>) => void + fetchJiraIssue: ( + key: string, + siteId?: string | null, + options?: JiraReadOptions + ) => Promise<JiraIssue | null> + searchJiraIssues: (jql: string, limit?: number, options?: JiraReadOptions) => Promise<JiraIssue[]> + listJiraIssues: ( + filter?: JiraIssueFilter, + limit?: number, + options?: JiraReadOptions + ) => Promise<JiraIssue[]> + patchJiraIssue: (issueKey: string, patch: Partial<JiraIssue>, options?: JiraPatchOptions) => void } export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, get) => ({ @@ -295,9 +344,10 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, }) }, - fetchJiraIssue: async (key, siteId) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const issueCacheKey = `${siteId ?? 'selected'}::${key}` + fetchJiraIssue: async (key, siteId, options) => { + const scope = getJiraReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const issueCacheKey = scopedJiraCacheKey(scope, `${siteId ?? 'selected'}::${key}`) const cached = get().jiraIssueCache[issueCacheKey] ?? get().jiraIssueCache[key] if (isFresh(cached)) { return cached.data @@ -312,11 +362,16 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } let entry: InflightJiraReadRequest<JiraIssue | null> const requestMutationGeneration = jiraMutationGeneration - const promise = jiraGetIssue(get().settings, key, siteId) + const promise = jiraGetIssue(scope.settings, key, siteId) .then((issue) => { if ( inflightIssueRequests.get(issueCacheKey) === entry && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set((s) => ({ jiraIssueCache: evictStaleEntries({ @@ -331,14 +386,24 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, console.warn('[jira] fetchJiraIssue failed:', error) if ( isIntegrationCredentialDecryptionError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { void get().checkJiraConnection() } } else if ( looksLikeAuthError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set({ jiraStatus: { connected: false, viewer: null } }) } @@ -350,7 +415,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } if ( shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { void get().checkJiraConnection() } @@ -360,10 +430,11 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, return promise }, - searchJiraIssues: async (jql, limit = 30) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + searchJiraIssues: async (jql, limit = 30, options) => { + const scope = getJiraReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const siteId = getSelectedSiteId(get().jiraStatus) - const cacheKey = `${siteId ?? 'default'}::${jql}::${limit}` + const cacheKey = scopedJiraCacheKey(scope, `${siteId ?? 'default'}::${jql}::${limit}`) const cached = get().jiraSearchCache[cacheKey] if (isFresh(cached)) { return cached.data ?? [] @@ -378,11 +449,16 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } let entry: InflightJiraReadRequest<JiraIssue[]> const requestMutationGeneration = jiraMutationGeneration - const promise = jiraSearchIssues(get().settings, jql, limit, siteId) + const promise = jiraSearchIssues(scope.settings, jql, limit, siteId) .then((issues) => { if ( inflightSearchRequests.get(cacheKey) === entry && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set((s) => ({ jiraSearchCache: evictStaleEntries({ @@ -397,14 +473,24 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, console.warn('[jira] searchJiraIssues failed:', error) if ( isIntegrationCredentialDecryptionError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { void get().checkJiraConnection() } } else if ( looksLikeAuthError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set({ jiraStatus: { connected: false, viewer: null } }) } @@ -416,7 +502,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } if ( shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { void get().checkJiraConnection() } @@ -426,10 +517,11 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, return promise }, - listJiraIssues: async (filter = 'assigned', limit = 30) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + listJiraIssues: async (filter = 'assigned', limit = 30, options) => { + const scope = getJiraReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const siteId = getSelectedSiteId(get().jiraStatus) - const cacheKey = `${siteId ?? 'default'}::list::${filter}::${limit}` + const cacheKey = scopedJiraCacheKey(scope, `${siteId ?? 'default'}::list::${filter}::${limit}`) const cached = get().jiraSearchCache[cacheKey] if (isFresh(cached)) { return cached.data ?? [] @@ -444,11 +536,16 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } let entry: InflightJiraReadRequest<JiraIssue[]> const requestMutationGeneration = jiraMutationGeneration - const promise = jiraListIssues(get().settings, filter, limit, siteId) + const promise = jiraListIssues(scope.settings, filter, limit, siteId) .then((issues) => { if ( inflightListRequests.get(cacheKey) === entry && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set((s) => ({ jiraSearchCache: evictStaleEntries({ @@ -463,14 +560,24 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, console.warn('[jira] listJiraIssues failed:', error) if ( isIntegrationCredentialDecryptionError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { if (!shouldRefreshStatusAfterRead(siteId, get().jiraStatus)) { void get().checkJiraConnection() } } else if ( looksLikeAuthError(error) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { set({ jiraStatus: { connected: false, viewer: null } }) } @@ -482,7 +589,12 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, } if ( shouldRefreshStatusAfterRead(siteId, get().jiraStatus) && - canWriteJiraReadResult(contextKey, requestMutationGeneration, get().settings) + canWriteJiraReadResult( + contextKey, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) ) { void get().checkJiraConnection() } @@ -492,12 +604,18 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, return promise }, - patchJiraIssue: (issueKey, patch) => { + patchJiraIssue: (issueKey, patch, options) => { + const sourceScope = + options?.sourceContext?.provider === 'jira' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const canPatchCacheKey = (key: string): boolean => + sourceScope === null || key.startsWith(`${sourceScope}::`) set((s) => { let changed = false const nextIssueCache = { ...s.jiraIssueCache } for (const [key, entry] of Object.entries(nextIssueCache)) { - if (entry?.data?.key !== issueKey) { + if (!canPatchCacheKey(key) || entry?.data?.key !== issueKey) { continue } nextIssueCache[key] = { ...entry, data: { ...entry.data, ...patch }, fetchedAt: 0 } @@ -506,7 +624,7 @@ export const createJiraSlice: StateCreator<AppState, [], [], JiraSlice> = (set, const nextSearchCache = { ...s.jiraSearchCache } for (const key of Object.keys(nextSearchCache)) { const entry = nextSearchCache[key] - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const index = entry.data.findIndex((issue) => issue.key === issueKey) diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 725470840a1..190e60773b4 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -12,6 +12,10 @@ import type { LinearTeam, LinearViewer } from '../../../../shared/types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../../shared/task-source-context' import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { createLinearSlice } from './linear' @@ -88,6 +92,22 @@ function project(id: string): LinearProjectSummary { return { id, name: id, workspaceId: 'workspace-1', workspaceName: 'Workspace' } } +function linearSourceContext( + environmentId: string, + workspaceId = 'workspace-1' +): TaskSourceContext { + return { + kind: 'task-source', + provider: 'linear', + projectId: 'logical-project', + hostId: `runtime:${environmentId}`, + providerIdentity: { + provider: 'linear', + workspaceId + } + } +} + function deferred<T>() { let resolve!: (value: T) => void const promise = new Promise<T>((res) => { @@ -752,6 +772,87 @@ describe('createLinearSlice caching', () => { expect(store.getState().getCachedLinearTeams('workspace-1')).toMatchObject([{ id: 'team-1' }]) }) + it('routes explicit source reads through their source context when focused runtime changes', async () => { + const store = createTestStore() + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' } + }) + const sourceContext = linearSourceContext('source-runtime') + const sourceResult = deferred<LinearCollectionResult<LinearIssue>>() + linearListIssues.mockReturnValueOnce(sourceResult.promise) + + const request = store.getState().listLinearIssues('all', 36, { sourceContext }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + sourceResult.resolve({ items: [issue('LIN-SOURCE')] }) + await expect(request).resolves.toMatchObject({ items: [{ id: 'LIN-SOURCE' }] }) + expect(linearListIssues).toHaveBeenCalledWith(sourceContext, 'all', 36, 'workspace-1') + expect( + store + .getState() + .getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 }, { sourceContext }) + ).toMatchObject({ items: [{ id: 'LIN-SOURCE' }] }) + expect( + store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 }) + ).toBeNull() + }) + + it('scopes cached Linear teams, projects, and views to the explicit source context', async () => { + const store = createTestStore() + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' } + }) + const localSource = linearSourceContext('local-runtime') + const remoteSource = linearSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + const fetchedAt = Date.now() + + store.setState({ + linearTeamCache: { + [`${localScope}::workspace-1::teams`]: { data: [team('local-team')], fetchedAt }, + [`${remoteScope}::workspace-1::teams`]: { data: [team('remote-team')], fetchedAt } + }, + linearProjectCache: { + [`${localScope}::workspace-1::projects::::20`]: { + data: { items: [project('local-project')] }, + fetchedAt + }, + [`${remoteScope}::workspace-1::projects::::20`]: { + data: { items: [project('remote-project')] }, + fetchedAt + } + }, + linearCustomViewCache: { + [`${localScope}::workspace-1::custom-views::issue::20`]: { + data: { items: [{ id: 'local-view', name: 'Local view', model: 'issue' }] }, + fetchedAt + }, + [`${remoteScope}::workspace-1::custom-views::issue::20`]: { + data: { items: [{ id: 'remote-view', name: 'Remote view', model: 'issue' }] }, + fetchedAt + } + } + }) + + expect( + store.getState().getCachedLinearTeams('workspace-1', { sourceContext: remoteSource }) + ).toMatchObject([{ id: 'remote-team' }]) + expect( + store + .getState() + .getCachedLinearProjects(undefined, 20, 'workspace-1', { sourceContext: remoteSource }) + ).toMatchObject({ items: [{ id: 'remote-project' }] }) + expect( + store + .getState() + .getCachedLinearCustomViews('issue', 20, 'workspace-1', { sourceContext: remoteSource }) + ).toMatchObject({ items: [{ id: 'remote-view' }] }) + expect(store.getState().getCachedLinearTeams('workspace-1')).toBeNull() + expect(store.getState().getCachedLinearProjects(undefined, 20, 'workspace-1')).toBeNull() + expect(store.getState().getCachedLinearCustomViews('issue', 20, 'workspace-1')).toBeNull() + }) + it('patches issue-cache entries keyed by workspace-qualified ids', () => { const store = createTestStore() store.setState({ @@ -794,6 +895,78 @@ describe('createLinearSlice caching', () => { .data?.items[0]?.title ).toBe('Updated') }) + + it('scopes optimistic issue patches to the selected Linear source context', () => { + const store = createTestStore() + const localSource = linearSourceContext('local-runtime') + const remoteSource = linearSourceContext('remote-runtime') + const localScope = getTaskSourceCacheScope(localSource) + const remoteScope = getTaskSourceCacheScope(remoteSource) + + store.setState({ + linearIssueCache: { + [`${localScope}::workspace-1::issue-id`]: { + data: { ...issue('issue-id'), title: 'Local title' }, + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::issue-id`]: { + data: { ...issue('issue-id'), title: 'Remote title' }, + fetchedAt: Date.now() + } + }, + linearSearchCache: { + [`${localScope}::workspace-1::search::query::20`]: { + data: [{ ...issue('issue-id'), title: 'Local title' }], + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::search::query::20`]: { + data: [{ ...issue('issue-id'), title: 'Remote title' }], + fetchedAt: Date.now() + } + }, + linearListCache: { + [`${localScope}::workspace-1::list::all::36`]: { + data: { items: [{ ...issue('issue-id'), title: 'Local title' }] }, + fetchedAt: Date.now() + }, + [`${remoteScope}::workspace-1::list::all::36`]: { + data: { items: [{ ...issue('issue-id'), title: 'Remote title' }] }, + fetchedAt: Date.now() + } + } + }) + + store.getState().patchLinearIssue( + 'issue-id', + { title: 'Patched local title' }, + { + sourceContext: localSource + } + ) + + expect( + store.getState().linearIssueCache[`${localScope}::workspace-1::issue-id`]?.data?.title + ).toBe('Patched local title') + expect( + store.getState().linearIssueCache[`${remoteScope}::workspace-1::issue-id`]?.data?.title + ).toBe('Remote title') + expect( + store.getState().linearSearchCache[`${localScope}::workspace-1::search::query::20`]?.data?.[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().linearSearchCache[`${remoteScope}::workspace-1::search::query::20`] + ?.data?.[0]?.title + ).toBe('Remote title') + expect( + store.getState().linearListCache[`${localScope}::workspace-1::list::all::36`]?.data?.items[0] + ?.title + ).toBe('Patched local title') + expect( + store.getState().linearListCache[`${remoteScope}::workspace-1::list::all::36`]?.data?.items[0] + ?.title + ).toBe('Remote title') + }) }) describe('createLinearSlice', () => { diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index d6a9bd58696..795d95605c5 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -42,6 +42,11 @@ import { } from '@/runtime/runtime-linear-client' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. @@ -323,7 +328,8 @@ function largestCachedCollectionBelowLimit<T>( function patchLinearIssueCollectionCache( cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>, issueId: string, - patch: Partial<LinearIssue> + patch: Partial<LinearIssue>, + canPatchCacheKey: (key: string) => boolean ): { cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>> changed: boolean @@ -331,7 +337,7 @@ function patchLinearIssueCollectionCache( let changed = false const nextCache = { ...cache } for (const [key, entry] of Object.entries(nextCache)) { - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const idx = entry.data.items.findIndex((item) => item.id === issueId) @@ -353,7 +359,15 @@ type LinearIssueReadArgs = | { kind: 'search'; query: string; limit?: number } | { kind: 'list'; filter?: 'assigned' | 'created' | 'all' | 'completed'; limit?: number } -type LinearFetchOptions = { force?: boolean } +type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } +type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } + +type LinearReadScope = { + settings: AppState['settings'] | TaskSourceContext | null + contextKey: string + cachePrefix: string | null + explicitSource: boolean +} function beginLinearMutation(): number { linearMutationGeneration += 1 @@ -376,15 +390,41 @@ function canWriteLinearReadResult( contextKey: string, generation: number, mutationGeneration: number, - settings: AppState['settings'] + settings: AppState['settings'], + explicitSource = false ): boolean { return ( generation === linearCacheGeneration && mutationGeneration === linearMutationGeneration && - isCurrentLinearRuntimeContext(contextKey, settings) + (explicitSource || isCurrentLinearRuntimeContext(contextKey, settings)) ) } +function getLinearReadScope( + settings: AppState['settings'], + sourceContext?: TaskSourceContext | null +): LinearReadScope { + if (!sourceContext) { + return { + settings, + contextKey: getProviderRuntimeContextKey(settings), + cachePrefix: null, + explicitSource: false + } + } + const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return { + settings: sourceContext, + contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${getTaskSourceCacheScope(sourceContext)}`, + cachePrefix: getTaskSourceCacheScope(sourceContext), + explicitSource: true + } +} + +function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { + return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key +} + export type LinearSlice = { linearStatus: LinearConnectionStatus linearStatusChecked: boolean @@ -414,12 +454,21 @@ export type LinearSlice = { selectLinearWorkspace: (workspaceId: LinearWorkspaceSelection) => Promise<void> disconnectLinear: () => Promise<void> disconnectLinearWorkspace: (workspaceId: string) => Promise<void> - fetchLinearIssue: (id: string, workspaceId?: string | null) => Promise<LinearIssue | null> - refreshLinearIssue: (id: string, workspaceId?: string | null) => Promise<LinearIssue | null> + fetchLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise<LinearIssue | null> + refreshLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise<LinearIssue | null> getCachedLinearIssues: ( - args: LinearIssueReadArgs + args: LinearIssueReadArgs, + options?: Pick<LinearFetchOptions, 'sourceContext'> ) => LinearIssue[] | LinearCollectionResult<LinearIssue> | null - prefetchLinearIssues: (args: LinearIssueReadArgs) => void + prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => void searchLinearIssues: ( query: string, limit?: number, @@ -430,7 +479,10 @@ export type LinearSlice = { limit?: number, options?: LinearFetchOptions ) => Promise<LinearCollectionResult<LinearIssue>> - getCachedLinearTeams: (workspaceId?: LinearWorkspaceSelection | null) => LinearTeam[] | null + getCachedLinearTeams: ( + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick<LinearFetchOptions, 'sourceContext'> + ) => LinearTeam[] | null listLinearTeams: ( workspaceId?: LinearWorkspaceSelection | null, options?: LinearFetchOptions @@ -438,7 +490,8 @@ export type LinearSlice = { getCachedLinearProjects: ( query?: string, limit?: number, - workspaceId?: LinearWorkspaceSelection | null + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick<LinearFetchOptions, 'sourceContext'> ) => LinearCollectionResult<LinearProjectSummary> | null listLinearProjects: ( query?: string, @@ -460,7 +513,8 @@ export type LinearSlice = { getCachedLinearCustomViews: ( model: LinearCustomViewModel, limit?: number, - workspaceId?: LinearWorkspaceSelection | null + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick<LinearFetchOptions, 'sourceContext'> ) => LinearCollectionResult<LinearCustomViewSummary> | null listLinearCustomViews: ( model: LinearCustomViewModel, @@ -486,7 +540,11 @@ export type LinearSlice = { limit?: number, options?: LinearFetchOptions ) => Promise<LinearCollectionResult<LinearProjectSummary>> - patchLinearIssue: (issueId: string, patch: Partial<LinearIssue>) => void + patchLinearIssue: ( + issueId: string, + patch: Partial<LinearIssue>, + options?: LinearPatchOptions + ) => void } export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (set, get) => ({ @@ -802,9 +860,14 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }) }, - fetchLinearIssue: async (id: string, workspaceId?: string | null) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const issueCacheKey = `${workspaceId ?? 'selected'}::${id}` + fetchLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] if (isFresh(cached)) { return cached.data @@ -822,7 +885,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearIssueRequest const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearGetIssue(get().settings, id, workspaceId) + const promise = linearGetIssue(scope.settings, id, workspaceId) .then((issue) => { const data = issue as LinearIssue | null if ( @@ -831,7 +894,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -851,7 +915,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -868,7 +933,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -885,8 +951,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - refreshLinearIssue: async (id: string, workspaceId?: string | null) => { - const issueCacheKey = `${workspaceId ?? 'selected'}::${id}` + refreshLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) inflightIssueRequests.delete(issueCacheKey) clearLinearIssueCollectionRequestMaps() set((s) => { @@ -909,26 +980,37 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s linearCustomViewIssueCache: {} } }) - return get().fetchLinearIssue(id, workspaceId) + return get().fetchLinearIssue(id, workspaceId, options) }, - getCachedLinearIssues: (args) => { + getCachedLinearIssues: (args, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const workspaceId = getSelectedWorkspaceId(get().linearStatus) if (args.kind === 'search') { - const cacheKey = linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) + ) return get().linearSearchCache[cacheKey]?.data ?? null } const limit = clampLinearIssueListLimit(args.limit) - const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + ) return get().linearListCache[cacheKey]?.data ?? null }, - prefetchLinearIssues: (args) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + prefetchLinearIssues: (args, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const workspaceId = getSelectedWorkspaceId(get().linearStatus) if (args.kind === 'search') { const limit = args.limit ?? 20 - const cacheKey = linearSearchCacheKey(workspaceId, args.query, limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, limit) + ) const inflight = inflightSearchRequests.get(cacheKey) if ( isFresh(get().linearSearchCache[cacheKey]) || @@ -939,12 +1021,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return } void get() - .searchLinearIssues(args.query, limit) + .searchLinearIssues(args.query, limit, options) .catch(() => {}) return } const limit = clampLinearIssueListLimit(args.limit) - const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) + ) const inflight = inflightListRequests.get(cacheKey) if ( isFresh(get().linearListCache[cacheKey]) || @@ -955,14 +1040,15 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return } void get() - .listLinearIssues(args.filter, limit) + .listLinearIssues(args.filter, limit, options) .catch(() => {}) }, searchLinearIssues: async (query: string, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearSearchCacheKey(workspaceId, query, limit) + const cacheKey = scopedLinearCacheKey(scope, linearSearchCacheKey(workspaceId, query, limit)) const cached = get().linearSearchCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? [] @@ -981,7 +1067,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearListRequest const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearSearchIssues(get().settings, query, limit, workspaceId) + const promise = linearSearchIssues(scope.settings, query, limit, workspaceId) .then((issues) => { const data = issues as LinearIssue[] if ( @@ -990,7 +1076,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1010,7 +1097,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { @@ -1030,7 +1118,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1049,10 +1138,14 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearIssues: async (filter = 'assigned', limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const workspaceId = getSelectedWorkspaceId(get().linearStatus) const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = linearListCacheKey(workspaceId, filter, effectiveLimit) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, filter, effectiveLimit) + ) const cached = get().linearListCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearIssue>() @@ -1072,7 +1165,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration const promise: Promise<LinearCollectionResult<LinearIssue>> = linearListIssues( - get().settings, + scope.settings, filter, effectiveLimit, workspaceId @@ -1085,7 +1178,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1105,7 +1199,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { @@ -1125,7 +1220,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1143,15 +1239,17 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - getCachedLinearTeams: (workspaceId) => { + getCachedLinearTeams: (workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const key = linearTeamsCacheKey(workspaceId ?? getSelectedWorkspaceId(get().linearStatus)) - return get().linearTeamCache[key]?.data ?? null + return get().linearTeamCache[scopedLinearCacheKey(scope, key)]?.data ?? null }, listLinearTeams: async (workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearTeamsCacheKey(resolvedWorkspaceId) + const cacheKey = scopedLinearCacheKey(scope, linearTeamsCacheKey(resolvedWorkspaceId)) const cached = get().linearTeamCache[cacheKey] if (!options?.force && isFresh(cached, TEAM_CACHE_TTL)) { return cached.data ?? [] @@ -1170,7 +1268,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearTeamRequest const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListTeams(get().settings, resolvedWorkspaceId) + const promise = linearListTeams(scope.settings, resolvedWorkspaceId) .then((teams) => { const data = teams as LinearTeam[] if ( @@ -1179,7 +1277,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1199,7 +1298,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) { @@ -1219,7 +1319,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1237,17 +1338,22 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - getCachedLinearProjects: (query, limit = 20, workspaceId) => { + getCachedLinearProjects: (query, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', query?.trim(), limit) - return get().linearProjectCache[cacheKey]?.data ?? null + return get().linearProjectCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null }, listLinearProjects: async (query, limit = 20, workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const trimmed = query?.trim() || undefined - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) + ) const cached = get().linearProjectCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearProjectSummary>() @@ -1266,7 +1372,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearCollectionRequest<LinearProjectSummary> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListProjects(get().settings, trimmed, limit, resolvedWorkspaceId, { + const promise = linearListProjects(scope.settings, trimmed, limit, resolvedWorkspaceId, { force: options?.force }) .then((result) => { @@ -1276,7 +1382,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1296,7 +1403,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1315,7 +1423,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1334,8 +1443,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, fetchLinearProject: async (id, workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const cacheKey = linearCollectionCacheKey(workspaceId, 'project-detail', id) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-detail', id) + ) const cached = get().linearProjectDetailCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data @@ -1354,7 +1467,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearDetailRequest<LinearProjectDetail | null> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearGetProject(get().settings, id, workspaceId, { + const promise = linearGetProject(scope.settings, id, workspaceId, { force: options?.force }) .then((project) => { @@ -1364,7 +1477,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1384,7 +1498,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1408,7 +1523,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1426,13 +1542,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = linearCollectionCacheKey( - workspaceId, - 'project-issues', - projectId, - effectiveLimit + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-issues', projectId, effectiveLimit) ) const cached = get().linearProjectIssueCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1453,7 +1568,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration const promise = linearListProjectIssues( - get().settings, + scope.settings, projectId, effectiveLimit, workspaceId, @@ -1468,7 +1583,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1488,7 +1604,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1515,7 +1632,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1533,16 +1651,21 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - getCachedLinearCustomViews: (model, limit = 20, workspaceId) => { + getCachedLinearCustomViews: (model, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) - return get().linearCustomViewCache[cacheKey]?.data ?? null + return get().linearCustomViewCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null }, listLinearCustomViews: async (model, limit = 20, workspaceId, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + ) const cached = get().linearCustomViewCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearCustomViewSummary>() @@ -1561,7 +1684,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearCollectionRequest<LinearCustomViewSummary> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViews(get().settings, model, limit, resolvedWorkspaceId, { + const promise = linearListCustomViews(scope.settings, model, limit, resolvedWorkspaceId, { force: options?.force }) .then((result) => { @@ -1571,7 +1694,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1591,7 +1715,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1611,7 +1736,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1630,8 +1756,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, fetchLinearCustomView: async (viewId, workspaceId, model, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) + ) const cached = get().linearCustomViewDetailCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data @@ -1650,7 +1780,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearDetailRequest<LinearCustomViewSummary | null> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearGetCustomView(get().settings, viewId, model, workspaceId, { + const promise = linearGetCustomView(scope.settings, viewId, model, workspaceId, { force: options?.force }) .then((view) => { @@ -1660,7 +1790,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1680,7 +1811,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1704,7 +1836,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1722,13 +1855,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = linearCollectionCacheKey( - workspaceId, - 'custom-view-issues', - viewId, - effectiveLimit + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, effectiveLimit) ) const cached = get().linearCustomViewIssueCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1749,7 +1881,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration const promise = linearListCustomViewIssues( - get().settings, + scope.settings, viewId, effectiveLimit, workspaceId, @@ -1764,7 +1896,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1784,7 +1917,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1811,7 +1945,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1830,8 +1965,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s }, listLinearCustomViewProjects: async (viewId, workspaceId, limit = 20, options) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) + ) const cached = get().linearCustomViewProjectCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection<LinearProjectSummary>() @@ -1850,7 +1989,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s let entry: InflightLinearCollectionRequest<LinearProjectSummary> const requestCacheGeneration = linearCacheGeneration const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViewProjects(get().settings, viewId, limit, workspaceId, { + const promise = linearListCustomViewProjects(scope.settings, viewId, limit, workspaceId, { force: options?.force }) .then((result) => { @@ -1860,7 +1999,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { set((s) => ({ @@ -1880,7 +2020,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1900,7 +2041,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s contextKey, requestCacheGeneration, requestMutationGeneration, - get().settings + get().settings, + scope.explicitSource ) ) { void get().checkLinearConnection(true) @@ -1918,13 +2060,19 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s return promise }, - patchLinearIssue: (issueId, patch) => { + patchLinearIssue: (issueId, patch, options) => { + const sourceScope = + options?.sourceContext?.provider === 'linear' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const canPatchCacheKey = (key: string): boolean => + sourceScope === null || key.startsWith(`${sourceScope}::`) set((s) => { let changed = false const nextIssueCache = { ...s.linearIssueCache } for (const [key, issueEntry] of Object.entries(nextIssueCache)) { - if (issueEntry?.data?.id !== issueId) { + if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) { continue } // Why: set fetchedAt to 0 so the next fetchLinearIssue call @@ -1940,7 +2088,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const nextSearchCache = { ...s.linearSearchCache } for (const key of Object.keys(nextSearchCache)) { const entry = nextSearchCache[key] - if (!entry?.data) { + if (!canPatchCacheKey(key) || !entry?.data) { continue } const idx = entry.data.findIndex((item) => item.id === issueId) @@ -1953,7 +2101,12 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s changed = true } - const nextListCache = patchLinearIssueCollectionCache(s.linearListCache, issueId, patch) + const nextListCache = patchLinearIssueCollectionCache( + s.linearListCache, + issueId, + patch, + canPatchCacheKey + ) if (nextListCache.changed) { changed = true } @@ -1961,7 +2114,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const nextProjectIssueCache = patchLinearIssueCollectionCache( s.linearProjectIssueCache, issueId, - patch + patch, + canPatchCacheKey ) if (nextProjectIssueCache.changed) { changed = true @@ -1970,7 +2124,8 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s const nextCustomViewIssueCache = patchLinearIssueCollectionCache( s.linearCustomViewIssueCache, issueId, - patch + patch, + canPatchCacheKey ) if (nextCustomViewIssueCache.changed) { changed = true diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts new file mode 100644 index 00000000000..8ba15f5edd9 --- /dev/null +++ b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { splitRepoReorderByHost } from './repo-reorder-host-split' + +function repo(id: string, executionHostId: string | null): Repo { + return { id, executionHostId, connectionId: null } as unknown as Repo +} + +describe('splitRepoReorderByHost', () => { + it('groups ids by owner host, preserving relative order', () => { + const repos = [ + repo('local-a', 'local'), + repo('runtime-a', 'runtime:env-1'), + repo('local-b', 'local'), + repo('runtime-b', 'runtime:env-1') + ] + const groups = splitRepoReorderByHost(['runtime-a', 'local-a', 'runtime-b', 'local-b'], repos, { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([ + { hostId: 'runtime:env-1', orderedIds: ['runtime-a', 'runtime-b'] }, + { hostId: 'local', orderedIds: ['local-a', 'local-b'] } + ]) + }) + + it('falls back to the focused host for repos without an explicit owner', () => { + const groups = splitRepoReorderByHost(['a', 'b'], [repo('a', null), repo('b', null)], { + activeRuntimeEnvironmentId: 'focused-env' + }) + expect(groups).toEqual([{ hostId: 'runtime:focused-env', orderedIds: ['a', 'b'] }]) + }) + + it('treats unowned repos as local when no runtime is focused', () => { + const groups = splitRepoReorderByHost(['a', 'b'], [repo('a', null), repo('b', null)], { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a', 'b'] }]) + }) + + it('ignores ids that no longer map to a repo', () => { + const groups = splitRepoReorderByHost(['a', 'gone'], [repo('a', 'local')], { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a'] }]) + }) +}) diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.ts b/src/renderer/src/store/slices/repo-reorder-host-split.ts new file mode 100644 index 00000000000..2ef975b4646 --- /dev/null +++ b/src/renderer/src/store/slices/repo-reorder-host-split.ts @@ -0,0 +1,47 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId +} from '../../../../shared/execution-host' + +export type RepoReorderHostGroup = { + hostId: string + orderedIds: string[] +} + +/** Split a cross-host reorder permutation into per-host permutations. + * + * Why: each host persists only its own repos and rejects any id list that is not + * a full permutation of that host's repos (persistence.ts#reorderRepos). So a + * single combined id list can only be applied on the host that owns every id — + * never the case once repos span hosts. We instead group ids by their owner host + * (preserving the user's relative order within each host) and dispatch one + * permutation per host. Repos without an explicit owner fall back to the focused + * host, matching the rest of the owner-routing helpers. + */ +export function splitRepoReorderByHost( + orderedIds: readonly string[], + repos: readonly Repo[], + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): RepoReorderHostGroup[] { + const focusedHostId = getSettingsFocusedExecutionHostId(settings) + const hostByRepoId = new Map<string, string>() + for (const repo of repos) { + const hasExplicitOwner = Boolean(repo.executionHostId?.trim() || repo.connectionId?.trim()) + hostByRepoId.set(repo.id, hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId) + } + const groups = new Map<string, string[]>() + for (const id of orderedIds) { + const hostId = hostByRepoId.get(id) + if (!hostId) { + continue + } + const existing = groups.get(hostId) + if (existing) { + existing.push(id) + } else { + groups.set(hostId, [id]) + } + } + return [...groups.entries()].map(([hostId, ids]) => ({ hostId, orderedIds: ids })) +} diff --git a/src/renderer/src/store/slices/repos-project-groups.test.ts b/src/renderer/src/store/slices/repos-project-groups.test.ts index 589ec56dc9e..019e004aab0 100644 --- a/src/renderer/src/store/slices/repos-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -525,7 +525,9 @@ describe('project group store routing', () => { expect(folderWorkspacesList).toHaveBeenCalled() expect(reposList).toHaveBeenCalled() expect(store.getState().projectGroups).toEqual([projectGroup]) - expect(store.getState().repos).toEqual([importedRepo]) + // Why: the repos slice stamps fetched repos with their owning execution + // host so multi-host routing never has to guess (multi-host design). + expect(store.getState().repos).toEqual([{ ...importedRepo, executionHostId: 'local' }]) }) it('routes local nested scan progress by scanId and unsubscribes after completion', async () => { @@ -659,7 +661,9 @@ describe('project group store routing', () => { groupId: projectGroup.id, order: 3 }) - expect(store.getState().repos).toEqual([movedRepo]) + // Why: the repos slice stamps updated repos with their owning execution + // host so multi-host routing never has to guess (multi-host design). + expect(store.getState().repos).toEqual([{ ...movedRepo, executionHostId: 'local' }]) }) it('removes local project group subtrees from renderer state after delete', async () => { diff --git a/src/renderer/src/store/slices/repos-project-host-capability.test.ts b/src/renderer/src/store/slices/repos-project-host-capability.test.ts new file mode 100644 index 00000000000..862babeb51c --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-capability.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' +import { createTestStore } from './store-test-helpers' + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 +} + +const reposList = vi.fn() +const reposClone = vi.fn() +const reposCloneRemote = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +let runtimeCapabilities: string[] = [] + +function runtimeStatusWithoutProjectHostSetup() { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-remote', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: runtimeCapabilities + }, + _meta: { runtimeId: 'runtime-remote' } + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposClone.mockReset() + reposCloneRemote.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeCapabilities = [] + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'status.get') { + return runtimeStatusWithoutProjectHostSetup() + } + return runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + clone: reposClone, + cloneRemote: reposCloneRemote + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice project-host setup runtime capability', () => { + it('falls back to repo-derived project setup state when a remote runtime lacks support', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRepos() + + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) + ]) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.list', + params: undefined, + timeoutMs: 15_000 + }) + }) + + it('blocks runtime project setup when the server does not advertise support', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: 'project-1', + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('blocks runtime project clone before mutating unsupported servers', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: 'project-1', + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('blocks runtime project setup mutations when workspace run-context support is missing', async () => { + runtimeCapabilities = [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: 'project-1', + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toBeNull() + + await expect( + store.getState().setupProjectClone({ + projectId: 'project-1', + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts new file mode 100644 index 00000000000..b8398436e02 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' + +const projectsCreateHostSetup = vi.fn() +const projectsUpdateHostSetup = vi.fn() +const projectsDeleteHostSetup = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const runtimeRepo: Repo = { + id: 'runtime-repo', + path: '/srv/project', + displayName: 'Project', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' +} + +const runtimeSetup: ProjectHostSetup = { + id: 'setup-gpu', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: '', + path: '/srv/project', + displayName: 'GPU VM', + setupState: 'ready', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 1 +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + projectsCreateHostSetup.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: vi.fn() + }, + projects: { + createHostSetup: projectsCreateHostSetup, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice project host setup lifecycle', () => { + it('creates independent project host setup metadata through local IPC', async () => { + const setup: ProjectHostSetup = { + ...runtimeSetup, + hostId: 'local', + path: '', + setupState: 'setting-up' + } + projectsCreateHostSetup.mockResolvedValue({ project, setup }) + const store = createTestStore() + + await expect( + store.getState().createProjectHostSetup({ + projectId: project.id, + hostId: 'local', + setupId: setup.id, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ).resolves.toEqual({ project, setup }) + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsCreateHostSetup).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + setupId: setup.id, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + }) + + it('updates runtime-owned project host setups through their owning runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-update-setup', + ok: true, + result: { + result: { + project, + setup: { ...runtimeSetup, displayName: 'GPU VM renamed' } + } + }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + projectHostSetups: [runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().updateProjectHostSetup({ + setupId: runtimeSetup.id, + updates: { displayName: 'GPU VM renamed' } + }) + ).resolves.toEqual({ + project, + setup: { ...runtimeSetup, displayName: 'GPU VM renamed' }, + repo: undefined + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.update', + params: { + setupId: runtimeSetup.id, + updates: { displayName: 'GPU VM renamed' } + }, + timeoutMs: 15_000 + }) + }) + + it('deletes runtime-owned project host setups through their owning runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-delete-setup', + ok: true, + result: { result: { project, setup: runtimeSetup } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + projects: [project], + projectHostSetups: [runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().deleteProjectHostSetup({ setupId: runtimeSetup.id }) + ).resolves.toEqual({ + project, + setup: runtimeSetup, + repo: undefined + }) + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.delete', + params: { setupId: runtimeSetup.id }, + timeoutMs: 15_000 + }) + }) + + it('preserves runtime-fetched setup-only states during repo hydration', async () => { + const pendingSetup: ProjectHostSetup = { + ...runtimeSetup, + id: 'setup-pending', + repoId: '', + path: '', + setupState: 'setting-up' + } + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repos', + ok: true, + result: { repos: [runtimeRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-projects', + ok: true, + result: { projects: [project] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-setups', + ok: true, + result: { setups: [pendingSetup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + throw new Error(`Unexpected runtime method: ${args.method}`) + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRepos() + + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'setup-pending', + hostId: 'runtime:env-1', + setupState: 'setting-up' + }) + ]) + ) + }) +}) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index fd12d5c7c29..0572187df52 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from './github' -import type { Repo } from '../../../../shared/types' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest @@ -24,12 +24,28 @@ const remoteRepo: Repo = { addedAt: 2 } +const sshRepo: Repo = { + id: 'ssh-repo', + path: '/home/orca/project', + displayName: 'SSH', + badgeColor: '#222', + addedAt: 3, + connectionId: 'ssh-1' +} + const reposList = vi.fn() const reposAdd = vi.fn() const reposPickFolder = vi.fn() +const reposClone = vi.fn() +const reposCloneRemote = vi.fn() const reposRemove = vi.fn() const reposUpdate = vi.fn() const reposReorder = vi.fn() +const projectsCreateHostSetup = vi.fn() +const projectsSetupExistingFolder = vi.fn() +const projectsUpdateHostSetup = vi.fn() +const projectsDeleteHostSetup = vi.fn() +const projectGroupsMoveProject = vi.fn() const ptyKill = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -39,9 +55,16 @@ beforeEach(() => { reposList.mockReset() reposAdd.mockReset() reposPickFolder.mockReset() + reposClone.mockReset() + reposCloneRemote.mockReset() reposRemove.mockReset() reposUpdate.mockReset() reposReorder.mockReset() + projectsCreateHostSetup.mockReset() + projectsSetupExistingFolder.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + projectGroupsMoveProject.mockReset() ptyKill.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() @@ -53,11 +76,22 @@ beforeEach(() => { repos: { list: reposList, add: reposAdd, + clone: reposClone, + cloneRemote: reposCloneRemote, pickFolder: reposPickFolder, remove: reposRemove, update: reposUpdate, reorder: reposReorder }, + projects: { + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + projectGroups: { + moveProject: projectGroupsMoveProject + }, pty: { kill: ptyKill }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } @@ -71,11 +105,70 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos() - expect(store.getState().repos).toEqual([localRepo]) + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:local-repo', sourceRepoIds: ['local-repo'] }) + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'local-repo', hostId: 'local' }) + ]) expect(reposList).toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('hydrates projects from local IPC when the project API is available', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: project.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + const projectsList = vi.fn().mockResolvedValue([project]) + const listHostSetups = vi.fn().mockResolvedValue([setup]) + ;( + window.api as typeof window.api & { + projects?: { + list: typeof projectsList + listHostSetups: typeof listHostSetups + createHostSetup: typeof projectsCreateHostSetup + setupExistingFolder: typeof projectsSetupExistingFolder + updateHostSetup: typeof projectsUpdateHostSetup + deleteHostSetup: typeof projectsDeleteHostSetup + } + } + ).projects = { + list: projectsList, + listHostSetups, + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + } + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + + await store.getState().fetchRepos() + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsList).toHaveBeenCalled() + expect(listHostSetups).toHaveBeenCalled() + }) + it('fetches repos from the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', @@ -92,7 +185,13 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos() - expect(store.getState().repos).toEqual([remoteRepo]) + expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:remote-repo', sourceRepoIds: ['remote-repo'] }) + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) + ]) expect(store.getState().activeRepoId).toBeNull() expect(store.getState().filterRepoIds).toEqual(['remote-repo']) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -120,6 +219,7 @@ describe('repo slice runtime routing', () => { await store.getState().updateRepo(remoteRepo.id, { displayName: 'Renamed' }) expect(store.getState().repos[0]?.displayName).toBe('Renamed') + expect(store.getState().repos[0]?.executionHostId).toBe('runtime:env-1') expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'repo.update', @@ -129,6 +229,24 @@ describe('repo slice runtime routing', () => { expect(reposUpdate).not.toHaveBeenCalled() }) + it('updates SSH-owned repos through local IPC even when a runtime is focused', async () => { + reposUpdate.mockResolvedValue({ ...sshRepo, displayName: 'SSH Renamed' }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [sshRepo] + }) + + await store.getState().updateRepo(sshRepo.id, { displayName: 'SSH Renamed' }) + + expect(store.getState().repos[0]?.displayName).toBe('SSH Renamed') + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: sshRepo.id, + updates: { displayName: 'SSH Renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('adds explicit server paths through the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-add', @@ -141,11 +259,12 @@ describe('repo slice runtime routing', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await expect(store.getState().addRepoPath('/srv/project', 'folder')).resolves.toEqual( - remoteRepo - ) + await expect(store.getState().addRepoPath('/srv/project', 'folder')).resolves.toEqual({ + ...remoteRepo, + executionHostId: 'runtime:env-1' + }) - expect(store.getState().repos).toEqual([remoteRepo]) + expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'repo.add', @@ -156,6 +275,373 @@ describe('repo slice runtime routing', () => { expect(reposPickFolder).not.toHaveBeenCalled() }) + it('sets up a project on a local host through the project setup API', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'local-repo', + projectId: project.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: localRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: project.id, + hostId: 'local', + path: '/local', + kind: 'git' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...localRepo, executionHostId: 'local' } + }) + + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + path: '/local', + kind: 'git' + }) + }) + + it('sets up a project on the active runtime host through runtime RPC', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'remote-repo', + projectId: project.id, + hostId: 'local', + repoId: 'remote-repo', + path: '/srv/project', + displayName: 'Remote', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-setup', + ok: true, + result: { result: { project, setup, repo: remoteRepo } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: project.id, + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toEqual({ + project, + setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' }, + repo: { ...remoteRepo, executionHostId: 'runtime:env-1' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.setupExistingFolder', + params: { + projectId: project.id, + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }, + timeoutMs: 15_000 + }) + }) + + it('sets up an SSH host through local IPC even when a runtime is focused', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['ssh-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'ssh-repo', + projectId: project.id, + hostId: 'ssh:openclaw%202', + repoId: 'ssh-repo', + path: '/srv/project', + displayName: 'Remote', + connectionId: 'openclaw 2', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + projectsSetupExistingFolder.mockResolvedValue({ + project, + setup, + repo: { ...remoteRepo, connectionId: 'openclaw 2' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: project.id, + hostId: 'ssh:openclaw%202', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...remoteRepo, connectionId: 'openclaw 2', executionHostId: 'ssh:openclaw%202' } + }) + + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'ssh:openclaw%202', + path: '/srv/project', + kind: 'git' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('clones a project locally before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...localRepo, path: '/workspace/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'local', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } + reposClone.mockResolvedValue(clonedRepo) + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'local', + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...clonedRepo, executionHostId: 'local' } + }) + + expect(reposClone).toHaveBeenCalledWith({ + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace' + }) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project', + setupMethod: 'cloned' + }) + }) + + it('clones a project on a runtime host before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...remoteRepo, path: '/srv/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'local', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-clone', + ok: true, + result: { repo: clonedRepo }, + _meta: { runtimeId: 'runtime-remote' } + }) + .mockResolvedValueOnce({ + id: 'rpc-setup', + ok: true, + result: { result: { project, setup, repo: clonedRepo } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' }, + repo: { ...clonedRepo, executionHostId: 'runtime:env-1' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'repo.clone', + params: { + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }, + timeoutMs: 10 * 60_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'projectHostSetup.setupExistingFolder', + params: { + projectId: project.id, + hostId: 'runtime:env-1', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project', + setupMethod: 'cloned' + }, + timeoutMs: 15_000 + }) + }) + + it('clones a project on an SSH host before aligning it as a host setup', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['ssh-repo'], + createdAt: 1, + updatedAt: 1 + } + const clonedRepo = { ...sshRepo, path: '/srv/project' } + const setup: ProjectHostSetup = { + id: clonedRepo.id, + projectId: project.id, + hostId: 'ssh:ssh-1', + repoId: clonedRepo.id, + path: clonedRepo.path, + displayName: clonedRepo.displayName, + setupState: 'ready', + setupMethod: 'cloned', + createdAt: 1, + updatedAt: 1 + } + reposCloneRemote.mockResolvedValue(clonedRepo) + projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo }) + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: project.id, + hostId: 'ssh:ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv', + displayName: 'Project' + }) + ).resolves.toEqual({ + project, + setup, + repo: { ...clonedRepo, executionHostId: 'ssh:ssh-1' } + }) + + expect(reposCloneRemote).toHaveBeenCalledWith({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'ssh:ssh-1', + path: clonedRepo.path, + kind: 'git', + displayName: 'Project', + setupMethod: 'cloned' + }) + }) + + it('keeps runtime ownership when a runtime repo is moved between groups', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-move', + ok: true, + result: { repo: { ...remoteRepo, projectGroupId: 'group-1' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [{ ...remoteRepo, executionHostId: 'runtime:env-1' }] + }) + + await expect(store.getState().moveProjectToGroup(remoteRepo.id, 'group-1')).resolves.toBe(true) + + expect(store.getState().repos).toEqual([ + { ...remoteRepo, projectGroupId: 'group-1', executionHostId: 'runtime:env-1' } + ]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.moveProject', + params: { repo: remoteRepo.id, groupId: 'group-1', order: undefined }, + timeoutMs: 15_000 + }) + expect(projectGroupsMoveProject).not.toHaveBeenCalled() + }) + it('does not open the client folder picker when a remote runtime environment is active', async () => { const store = createTestStore() store.setState({ @@ -195,6 +681,26 @@ describe('repo slice runtime routing', () => { expect(reposRemove).not.toHaveBeenCalled() }) + it('removes SSH-owned repos through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const worktreeId = `${sshRepo.id}::/home/orca/wt` + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [sshRepo], + activeRepoId: sshRepo.id, + worktreesByRepo: { + [sshRepo.id]: [makeWorktree({ id: worktreeId, repoId: sshRepo.id })] + } + }) + + await store.getState().removeProject(sshRepo.id) + + expect(store.getState().repos).toEqual([]) + expect(store.getState().activeRepoId).toBeNull() + expect(reposRemove).toHaveBeenCalledWith({ repoId: sshRepo.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('evicts GitHub caches for removed repos using repo id and legacy path keys', async () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index cee1ffa9ac2..6e781c15d52 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -6,12 +6,31 @@ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' import type { + Project, Repo, ProjectGroup, + ProjectHostSetup, FolderWorkspace, ProjectGroupImportResult, - NestedRepoScanResult + NestedRepoScanResult, + ProjectHostSetupCloneArgs, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult } from '../../../../shared/types' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from '../../../../shared/project-host-setup-projection' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { FOLDER_WORKSPACE_PATH_STATUS_TTL_MS, type FolderWorkspacePathStatus, @@ -25,12 +44,23 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { getRepoIdFromWorktreeId } from './worktree-helpers' import { reconcileFetchedRepos } from './repo-identity-reconcile' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { splitRepoReorderByHost } from './repo-reorder-host-split' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + 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' @@ -148,6 +178,204 @@ 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 + ) +} + +async function assertProjectHostSetupMutationRuntimeCapabilities( + target: ReturnType<typeof getActiveRuntimeTarget> +): Promise<void> { + if (target.kind !== 'environment') { + return + } + await assertProjectHostSetupRuntimeCapability(target) + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, + 'The selected Orca server does not support explicit workspace run hosts 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}` @@ -257,6 +485,8 @@ function getFolderWorkspacePathStatusRequestSnapshotForRead( export type RepoSlice = { repos: Repo[] + projects: Project[] + projectHostSetups: ProjectHostSetup[] projectGroups: ProjectGroup[] folderWorkspaces: FolderWorkspace[] folderWorkspacePathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry> @@ -266,6 +496,19 @@ export type RepoSlice = { fetchFolderWorkspaces: () => Promise<void> addRepo: () => Promise<Repo | null> addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null> + setupProjectExistingFolder: ( + args: ProjectHostSetupExistingFolderArgs + ) => Promise<ProjectHostSetupResult | null> + createProjectHostSetup: ( + args: ProjectHostSetupCreateArgs + ) => Promise<ProjectHostSetupCreateResult | null> + updateProjectHostSetup: ( + args: ProjectHostSetupUpdateArgs + ) => Promise<ProjectHostSetupUpdateResult | null> + deleteProjectHostSetup: ( + args: ProjectHostSetupDeleteArgs + ) => Promise<ProjectHostSetupDeleteResult | null> + setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise<ProjectHostSetupResult | null> addNonGitFolder: (path: string) => Promise<Repo | null> scanNestedRepos: ( path: string, @@ -344,6 +587,8 @@ export type RepoSlice = { export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({ repos: [], + projects: [], + projectHostSetups: [], projectGroups: [], folderWorkspaces: [], folderWorkspacePathStatuses: {}, @@ -352,7 +597,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, fetchRepos: async () => { try { const target = getActiveRuntimeTarget(get().settings) - const repos = + const fetchedRepos = target.kind === 'local' ? await window.api.repos.list() : ( @@ -366,11 +611,25 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, { timeoutMs: 15_000 } ) ).repos + const hostId = getRuntimeTargetHostId(target) + const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target)) + const fetchedProjectCompatibility = await fetchProjectHostSetupCompatibility(target, repos) set((s) => { - const validRepoIds = new Set(repos.map((repo) => repo.id)) - const reconciledRepos = reconcileFetchedRepos(s.repos, repos) + const reconciledRepos = mergeFetchedReposForHost(s.repos, repos, hostId) + const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) + const projectCompatibility = + target.kind === 'local' + ? { + projects: fetchedProjectCompatibility.projects, + projectHostSetups: fetchedProjectCompatibility.setups + } + : mergeProjectHostSetupCompatibility( + projectCompatibilityFromRepos(reconciledRepos), + fetchedProjectCompatibility + ) return { repos: reconciledRepos, + ...projectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), @@ -683,6 +942,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, updateProjectGroup: async (groupId, updates) => { try { + // Why: project groups are focused-host-scoped by design — fetch/create/update/ + // delete all route by the focused host, and the list is replaced (not merged). const target = getActiveRuntimeTarget(get().settings) const updated = target.kind === 'local' @@ -711,6 +972,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, deleteProjectGroup: async (groupId) => { try { + // Why: project groups are focused-host-scoped by design (see updateProjectGroup). const target = getActiveRuntimeTarget(get().settings) const deleted = target.kind === 'local' @@ -815,7 +1077,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, moveProjectToGroup: async (projectId, groupId, order) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' ? await window.api.projectGroups.moveProject({ @@ -834,8 +1096,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 @@ -879,6 +1142,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, openModal('confirm-non-git-folder', { folderPath: path }) return null } + repo = repoWithFetchedOwner(repo, target) const alreadyAdded = get().repos.some((r) => r.id === repo.id) if (alreadyAdded) { get().clearOrcaHookTrustForRepo(repo.id) @@ -887,7 +1151,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'), { @@ -916,15 +1185,235 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, } }, + setupProjectExistingFolder: async (args) => { + try { + const target = getProjectSetupRuntimeTarget(args.hostId) + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.setupExistingFolder(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupResult }>( + target, + 'projectHostSetup.setupExistingFolder', + args, + { timeoutMs: 15_000 } + ) + ).result + const repo = repoWithFetchedOwner(result.repo, target) + const setup = setupWithFetchedOwner(result.setup, target) + set((s) => { + const nextRepos = s.repos.some((entry) => entry.id === repo.id) + ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + : [...s.repos, repo] + const nextProjects = s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project] + const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + return { + repos: nextRepos, + projects: nextProjects, + projectHostSetups: nextSetups + } + }) + toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { + description: repo.displayName + }) + return { ...result, repo, setup } + } catch (err) { + console.error('Failed to set up project on host:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + createProjectHostSetup: async (args) => { + try { + const target = getProjectSetupRuntimeTarget(args.hostId) + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.createHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupCreateResult }>( + target, + 'projectHostSetup.create', + args, + { timeoutMs: 15_000 } + ) + ).result + const setup = setupWithFetchedOwner(result.setup, target) + set((s) => ({ + projects: s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project], + projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + })) + return { project: result.project, setup } + } catch (err) { + console.error('Failed to create project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + updateProjectHostSetup: async (args) => { + try { + const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) + const target = currentSetup + ? getProjectSetupRuntimeTarget(currentSetup.hostId) + : { kind: 'local' as const } + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.updateHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupUpdateResult }>( + target, + 'projectHostSetup.update', + args, + { timeoutMs: 15_000 } + ) + ).result + const setup = setupWithFetchedOwner(result.setup, target) + const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + set((s) => ({ + repos: repo + ? s.repos.some((entry) => entry.id === repo.id) + ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + : [...s.repos, repo] + : s.repos, + projects: s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project], + projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + })) + return { ...result, repo, setup } + } catch (err) { + console.error('Failed to update project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + deleteProjectHostSetup: async (args) => { + try { + const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) + const target = currentSetup + ? getProjectSetupRuntimeTarget(currentSetup.hostId) + : { kind: 'local' as const } + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.deleteHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupDeleteResult }>( + target, + 'projectHostSetup.delete', + args, + { timeoutMs: 15_000 } + ) + ).result + const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + set((s) => { + const projectHostSetups = s.projectHostSetups.filter( + (setup) => setup.id !== result.setup.id + ) + const repos = repo ? s.repos.filter((entry) => entry.id !== repo.id) : s.repos + const projects = + repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) + ? s.projects.filter((project) => project.id !== result.project.id) + : s.projects + return { repos, projects, projectHostSetups } + }) + return { ...result, repo } + } catch (err) { + console.error('Failed to delete project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + setupProjectClone: async (args) => { + try { + const parsedHost = parseExecutionHostId(args.hostId) + const target = getProjectSetupRuntimeTarget(args.hostId) + if (parsedHost?.kind !== 'ssh') { + await assertProjectHostSetupMutationRuntimeCapabilities(target) + } + const repo = + parsedHost?.kind === 'ssh' + ? await window.api.repos.cloneRemote({ + connectionId: parsedHost.targetId, + url: args.url, + destination: args.destination + }) + : target.kind === 'local' + ? await window.api.repos.clone({ + url: args.url, + destination: args.destination + }) + : ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { + url: args.url, + destination: args.destination + }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + return await get().setupProjectExistingFolder({ + projectId: args.projectId, + hostId: args.hostId, + path: repo.path, + kind: 'git', + displayName: args.displayName, + setupMethod: 'cloned' + }) + } catch (err) { + console.error('Failed to clone project on host:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + addRepo: async () => { const target = getActiveRuntimeTarget(get().settings) if (target.kind !== 'local') { // Why: OS folder pickers return client-local paths. Remote environments - // need an explicit server path, which the Add Project dialog handles. + // need an explicit host path, which the Add Project dialog handles. toast.error( translate( 'auto.store.slices.repos.e649269645', - 'Use a server path to add projects from a remote runtime.' + 'Use Add Project to enter a path on the selected host.' ) ) return null @@ -982,7 +1471,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, removeProject: async (projectId) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) await (target.kind === 'local' ? window.api.repos.remove({ repoId: projectId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) @@ -1074,6 +1563,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, const nextRepos = s.repos.filter((r) => r.id !== projectId) return { repos: nextRepos, + ...projectCompatibilityFromRepos(nextRepos), activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), worktreesByRepo: nextWorktrees, @@ -1116,7 +1606,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, const applyRepoUpdate = async () => { try { const sanitizedUpdates = sanitizeRepoUpdate(updates) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const updatedRepo = target.kind === 'local' ? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates }) @@ -1128,13 +1618,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, { timeoutMs: 15_000 } ) ).repo - set((s) => ({ - repos: s.repos.map((r) => { + set((s) => { + const nextRepos = s.repos.map((r) => { if (r.id !== projectId) { return r } if (updatedRepo) { - return updatedRepo + return repoWithFetchedOwner(updatedRepo, target) } if (sanitizedUpdates.sourceControlAi === null) { const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } = r @@ -1148,9 +1638,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) @@ -1191,19 +1685,34 @@ 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 { - const target = getActiveRuntimeTarget(get().settings) - const result = - target.kind === 'local' - ? await window.api.repos.reorder({ orderedIds }) - : await callRuntimeRpc<{ status: 'applied' | 'rejected' }>( - target, - 'repo.reorder', - { orderedIds }, - { timeoutMs: 15_000 } - ) - if (result.status === 'rejected') { + // 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 + // reorder per owner host. + const groups = splitRepoReorderByHost(orderedIds, next, get().settings) + const results = await Promise.all( + groups.map(async (group) => { + const parsed = parseExecutionHostId(group.hostId) + const target = + parsed?.kind === 'runtime' + ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) + : ({ kind: 'local' } as const) + return target.kind === 'local' + ? window.api.repos.reorder({ orderedIds: group.orderedIds }) + : callRuntimeRpc<{ status: 'applied' | 'rejected' }>( + target, + 'repo.reorder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + }) + ) + if (results.some((result) => result.status === 'rejected')) { await get().fetchRepos() } } catch (err) { diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts new file mode 100644 index 00000000000..975823de779 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { create } from 'zustand' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' + +function createSliceStore() { + return create<RuntimeStatusSlice>()((...a) => ({ + ...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>)) + })) +} + +function makeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus { + return { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3, + ...overrides + } as RuntimeStatus +} + +describe('runtime-status slice', () => { + it('starts with an empty map', () => { + const store = createSliceStore() + expect(store.getState().runtimeEnvironments).toEqual([]) + expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(0) + }) + + it('stores saved runtime environments and trims stale statuses', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('drop', { status: makeStatus(), checkedAt: 1 }) + + store.getState().setRuntimeEnvironments([ + { + id: 'keep', + name: 'Dev Box', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'ws-keep', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], + preferredEndpointId: 'ws-keep' + } + ]) + + expect(store.getState().runtimeEnvironments.map((environment) => environment.name)).toEqual([ + 'Dev Box' + ]) + expect(store.getState().runtimeStatusByEnvironmentId.has('keep')).toBe(true) + expect(store.getState().runtimeStatusByEnvironmentId.has('drop')).toBe(false) + }) + + it('merges per environment id and produces a new map reference', () => { + const store = createSliceStore() + const before = store.getState().runtimeStatusByEnvironmentId + + store.getState().setRuntimeEnvironmentStatus('env-a', { + status: makeStatus(), + checkedAt: 1 + }) + const afterFirst = store.getState().runtimeStatusByEnvironmentId + expect(afterFirst).not.toBe(before) + expect(afterFirst.get('env-a')?.checkedAt).toBe(1) + + store.getState().setRuntimeEnvironmentStatus('env-b', { + status: null, + checkedAt: 2 + }) + const afterSecond = store.getState().runtimeStatusByEnvironmentId + expect(afterSecond.size).toBe(2) + expect(afterSecond.get('env-a')?.checkedAt).toBe(1) + expect(afterSecond.get('env-b')?.status).toBeNull() + }) + + it('overwrites the prior entry for the same id', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 5 }) + + const map = store.getState().runtimeStatusByEnvironmentId + expect(map.size).toBe(1) + expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5 }) + }) + + it('clears a single environment entry', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('env-b', { status: makeStatus(), checkedAt: 1 }) + + store.getState().clearRuntimeEnvironmentStatus('env-a') + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(true) + }) + + it('no-ops clearing an unknown id without creating a new reference', () => { + const store = createSliceStore() + const before = store.getState().runtimeStatusByEnvironmentId + store.getState().clearRuntimeEnvironmentStatus('missing') + expect(store.getState().runtimeStatusByEnvironmentId).toBe(before) + }) + + it('retains only saved environment ids', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('drop', { status: makeStatus(), checkedAt: 1 }) + + store.getState().retainRuntimeEnvironmentStatuses(['keep']) + const map = store.getState().runtimeStatusByEnvironmentId + expect(map.has('keep')).toBe(true) + expect(map.has('drop')).toBe(false) + }) + + it('no-ops retain when nothing is dropped', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 }) + const before = store.getState().runtimeStatusByEnvironmentId + + store.getState().retainRuntimeEnvironmentStatuses(['keep', 'unrelated']) + expect(store.getState().runtimeStatusByEnvironmentId).toBe(before) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts new file mode 100644 index 00000000000..dd93c61ac19 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -0,0 +1,120 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' + +/** Live status for one saved runtime environment, as last observed by the + * renderer. `status === null` records a probe that failed or timed out so the + * sidebar can still distinguish "unknown/unreachable" from "never checked". */ +export type RuntimeEnvironmentStatus = { + status: RuntimeStatus | null + appVersion?: string | null + checkedAt: number +} + +export type RuntimeStatusSlice = { + /** Saved remote Orca servers. Host pickers use this to show user-chosen names + * instead of opaque runtime ids. */ + runtimeEnvironments: PublicKnownRuntimeEnvironment[] + /** Keyed by runtime environment id. Fed into buildExecutionHostRegistry so + * compat verdicts/blocked health show live in the sidebar host pickers. */ + runtimeStatusByEnvironmentId: Map<string, RuntimeEnvironmentStatus> + /** Replaces the saved-environment list and trims stale status entries. */ + setRuntimeEnvironments: (environments: PublicKnownRuntimeEnvironment[]) => void + /** Merges one environment's status. Replaces the prior entry for that id. */ + setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void + /** Drops a removed environment so stale hosts don't linger in the registry. */ + clearRuntimeEnvironmentStatus: (environmentId: string) => void + /** Drops every entry whose id is not in the saved-environments set. */ + retainRuntimeEnvironmentStatuses: (environmentIds: Iterable<string>) => void + /** Best-effort: list saved environments and probe each so the sidebar shows + * live health at boot, before the settings pane is ever opened. */ + hydrateRuntimeEnvironmentStatuses: () => Promise<void> +} + +export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeStatusSlice> = ( + set, + get +) => ({ + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map(), + + setRuntimeEnvironments: (environments) => + set((s) => { + const keep = new Set(environments.map((environment) => environment.id)) + const nextStatuses = new Map(s.runtimeStatusByEnvironmentId) + let statusesChanged = false + for (const id of nextStatuses.keys()) { + if (!keep.has(id)) { + nextStatuses.delete(id) + statusesChanged = true + } + } + return { + runtimeEnvironments: environments, + ...(statusesChanged ? { runtimeStatusByEnvironmentId: nextStatuses } : {}) + } + }), + + setRuntimeEnvironmentStatus: (environmentId, status) => + set((s) => { + const next = new Map(s.runtimeStatusByEnvironmentId) + next.set(environmentId, status) + return { runtimeStatusByEnvironmentId: next } + }), + + clearRuntimeEnvironmentStatus: (environmentId) => + set((s) => { + if (!s.runtimeStatusByEnvironmentId.has(environmentId)) { + return s + } + const next = new Map(s.runtimeStatusByEnvironmentId) + next.delete(environmentId) + return { runtimeStatusByEnvironmentId: next } + }), + + retainRuntimeEnvironmentStatuses: (environmentIds) => + set((s) => { + const keep = new Set(environmentIds) + let changed = false + const next = new Map(s.runtimeStatusByEnvironmentId) + for (const id of next.keys()) { + if (!keep.has(id)) { + next.delete(id) + changed = true + } + } + return changed ? { runtimeStatusByEnvironmentId: next } : s + }), + + hydrateRuntimeEnvironmentStatuses: async () => { + let environments: PublicKnownRuntimeEnvironment[] + try { + environments = await window.api.runtimeEnvironments.list() + } catch (err) { + console.error('Failed to list runtime environments for status hydration:', err) + return + } + get().setRuntimeEnvironments(environments) + // Why: fire-and-forget per env; one unreachable server must not block the + // others, and a failure records a null status rather than nothing. + await Promise.allSettled( + environments.map(async (environment) => { + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 10_000 + }) + const status = unwrapRuntimeRpcResult<RuntimeStatus>(response) + get().setRuntimeEnvironmentStatus(environment.id, { status, checkedAt: Date.now() }) + } catch { + get().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + } + }) + ) + } +}) diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index c2261859b05..dd113fa668a 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { AppState } from '../types' @@ -23,6 +22,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentGetStatus = vi.fn() const settingsSet = vi.fn().mockResolvedValue(undefined) +const worktreesListDetected = vi.fn() const env2Lineage: WorktreeLineage = { worktreeId: 'repo-env-2::/env-2/repo', @@ -49,70 +49,92 @@ beforeEach(() => { }, _meta: { runtimeId: 'runtime-2' } }) - runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { - const result = - method === 'status.get' - ? { - runtimeId: 'runtime-2', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION - } - : method === 'repo.list' + runtimeEnvironmentCall.mockImplementation( + ({ method, params }: { method: string; params?: { repo?: string } }) => { + const detectedRepoId = params?.repo ?? 'repo-env-2' + const detectedPath = detectedRepoId === 'repo-env-1' ? '/env-1/repo' : '/env-2/repo' + const result = + method === 'status.get' ? { - repos: [ - { - id: 'repo-env-2', - path: '/env-2/repo', - displayName: 'Env 2', - badgeColor: 'blue', - addedAt: 1 - } - ] + runtimeId: 'runtime-2', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } - : method === 'worktree.list' + : method === 'repo.list' ? { - worktrees: [ - makeWorktree({ - id: 'repo-env-2::/env-2/repo', - repoId: 'repo-env-2', - path: '/env-2/repo' - }) - ], - totalCount: 1, - truncated: false + repos: [ + { + id: 'repo-env-2', + path: '/env-2/repo', + displayName: 'Env 2', + badgeColor: 'blue', + addedAt: 1 + } + ] } - : method === 'worktree.detectedList' + : method === 'worktree.list' ? { - repoId: 'repo-env-2', - authoritative: true, - source: 'git', worktrees: [ - { - ...makeWorktree({ - id: 'repo-env-2::/env-2/repo', - repoId: 'repo-env-2', - path: '/env-2/repo' - }), - ownership: 'orca-managed', - selectedCheckout: true, - visible: true - } - ] + makeWorktree({ + id: 'repo-env-2::/env-2/repo', + repoId: 'repo-env-2', + path: '/env-2/repo' + }) + ], + totalCount: 1, + truncated: false } - : method === 'browser.profile.list' - ? { profiles: [] } - : method === 'projectGroup.list' - ? { groups: [] } - : method === 'worktree.lineageList' - ? { lineage: { [env2Lineage.worktreeId]: env2Lineage } } - : {} - return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } }) + : method === 'worktree.detectedList' + ? { + repoId: detectedRepoId, + authoritative: true, + source: 'git', + worktrees: [ + { + ...makeWorktree({ + id: `${detectedRepoId}::${detectedPath}`, + repoId: detectedRepoId, + path: detectedPath + }), + ownership: 'orca-managed', + selectedCheckout: true, + visible: true + } + ] + } + : method === 'browser.profileList' + ? { profiles: [] } + : method === 'projectGroup.list' + ? { groups: [] } + : method === 'worktree.lineageList' + ? { lineage: { [env2Lineage.worktreeId]: env2Lineage } } + : {} + return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } }) + } + ) + worktreesListDetected.mockResolvedValue({ + repoId: 'repo-env-1', + authoritative: true, + source: 'git', + worktrees: [ + { + ...makeWorktree({ + id: 'repo-env-1::/env-1/repo', + repoId: 'repo-env-1', + path: '/env-1/repo' + }), + ownership: 'orca-managed', + selectedCheckout: true, + visible: true + } + ] }) vi.stubGlobal('window', { api: { settings: { set: settingsSet }, - runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus } + runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus }, + worktrees: { listDetected: worktreesListDetected } } }) }) @@ -163,11 +185,18 @@ describe('createSettingsSlice runtime switching', () => { ]) }) - it('clears stale runtime-owned state before loading the selected environment', async () => { + it('preserves existing host state while loading the selected environment', async () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + repos: [ + { + id: 'repo-env-1', + path: '/env-1/repo', + displayName: 'Env 1', + executionHostId: 'runtime:env-1' + } as never + ], projectGroups: [ { id: 'group-env-1', @@ -229,7 +258,7 @@ describe('createSettingsSlice runtime switching', () => { selector: 'env-2', timeoutMs: 15_000 }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-2', method: 'status.get' }) ) expect(runtimeEnvironmentCall).toHaveBeenCalledWith( @@ -238,49 +267,59 @@ describe('createSettingsSlice runtime switching', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-2', method: 'worktree.lineageList' }) ) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'terminal.close', - params: { terminal: 'terminal-a' } - }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) ) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'terminal.close', - params: { terminal: 'legacy-terminal' } - }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' }) ) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'browser.tabClose', - params: { worktree: 'id:repo-env-1::/env-1/repo', page: 'remote-page-1' } - }) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-1', 'repo-env-2']) + expect(store.getState().repos.find((repo) => repo.id === 'repo-env-2')?.executionHostId).toBe( + 'runtime:env-2' ) - expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-2']) - expect(store.getState().projectGroups).toEqual([]) + expect(store.getState().projectGroups.map((group) => group.id)).toEqual(['group-env-1']) + expect(store.getState().worktreesByRepo['repo-env-1']?.map((worktree) => worktree.id)).toEqual([ + 'repo-env-1::/env-1/repo' + ]) expect(store.getState().worktreesByRepo['repo-env-2']?.map((worktree) => worktree.id)).toEqual([ 'repo-env-2::/env-2/repo' ]) expect(store.getState().worktreeLineageById).toEqual({ + 'repo-env-1::/env-1/repo': { + ...env2Lineage, + worktreeId: 'repo-env-1::/env-1/repo', + parentWorktreeId: 'repo-env-1::/env-1/parent' + }, [env2Lineage.worktreeId]: env2Lineage }) - expect(store.getState().activeWorktreeId).toBeNull() - expect(store.getState().openFiles).toEqual([]) - expect(store.getState().editorDrafts).toEqual({}) - expect(store.getState().markdownViewMode).toEqual({}) - expect(store.getState().editorViewMode).toEqual({}) - expect(store.getState().markdownFrontmatterVisible).toEqual({}) - expect(store.getState().editorCursorLine).toEqual({}) - expect(store.getState().showDotfilesByWorktree).toEqual({}) - expect(store.getState().gitIgnoredPathsByWorktree).toEqual({}) - expect(store.getState().ptyIdsByTabId).toEqual({}) - expect(store.getState().browserTabsByWorktree).toEqual({}) - expect(store.getState().prCache).toEqual({}) - expect(store.getState().linearIssueCache).toEqual({}) - expect(store.getState().jiraIssueCache).toEqual({}) + expect(store.getState().activeWorktreeId).toBe('repo-env-1::/env-1/repo') + expect(store.getState().openFiles).toEqual([ + { id: '/env-1/repo/a.md', worktreeId: 'repo-env-1::/env-1/repo' } + ]) + expect(store.getState().editorDrafts).toEqual({ '/env-1/repo/stale.md': 'stale' }) + expect(store.getState().markdownViewMode).toEqual({ '/env-1/repo/stale.md': 'rich' }) + expect(store.getState().editorViewMode).toEqual({ '/env-1/repo/stale.md': 'changes' }) + expect(store.getState().markdownFrontmatterVisible).toEqual({ + '/env-1/repo/stale.md': true + }) + expect(store.getState().editorCursorLine).toEqual({ '/env-1/repo/stale.md': 4 }) + expect(store.getState().showDotfilesByWorktree).toEqual({ 'repo-env-1::/env-1/repo': false }) + expect(store.getState().gitIgnoredPathsByWorktree).toEqual({ + 'repo-env-1::/env-1/repo': ['dist/'] + }) + expect(store.getState().ptyIdsByTabId).toEqual({ tab1: ['remote:env-1@@terminal-a'] }) + expect(store.getState().browserTabsByWorktree).toEqual({ + 'repo-env-1::/env-1/repo': [{ id: 'browser-env-1' }] + }) + expect(store.getState().prCache).toEqual({ + '/env-1/repo::main': expect.objectContaining({ data: null }) + }) + expect(store.getState().linearIssueCache).toEqual({ + 'LIN-1': expect.objectContaining({ data: { id: 'LIN-1' } }) + }) + expect(store.getState().jiraIssueCache).toEqual({ + 'JIRA-1': expect.objectContaining({ data: { key: 'JIRA-1' } }) + }) }) it('does not close host-owned mirrored resources when a paired web client switches servers', async () => { @@ -288,7 +327,14 @@ describe('createSettingsSlice runtime switching', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], - repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never], + repos: [ + { + id: 'repo-env-1', + path: '/env-1/repo', + displayName: 'Env 1', + executionHostId: 'runtime:env-1' + } as never + ], worktreesByRepo: { 'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })] }, @@ -335,11 +381,108 @@ describe('createSettingsSlice runtime switching', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' }) ) - expect(store.getState().ptyIdsByTabId).toEqual({}) - expect(store.getState().remoteBrowserPageHandlesByPageId).toEqual({}) + expect(store.getState().ptyIdsByTabId).toEqual({ + 'web-terminal-host-tab-1': ['remote:env-1@@terminal-a'] + }) + expect(store.getState().remoteBrowserPageHandlesByPageId).toEqual({ + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + }) }) - it('refuses to switch environments while editor tabs have unsaved state', async () => { + it('keeps the previous host live terminal and browser resources intact on switch (multi-host keepalive)', async () => { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [ + { + id: 'repo-env-1', + path: '/env-1/repo', + displayName: 'Env 1', + executionHostId: 'runtime:env-1' + } as never + ], + worktreesByRepo: { + 'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })] + }, + activeWorktreeId: 'repo-env-1::/env-1/repo', + tabsByWorktree: { + 'repo-env-1::/env-1/repo': [ + { + id: 'host-tab-1', + ptyId: 'remote:env-1@@terminal-a', + worktreeId: 'repo-env-1::/env-1/repo', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'host-tab-1': ['remote:env-1@@terminal-a'] }, + terminalLayoutsByTabId: { + 'host-tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-1@@terminal-a' } + } + }, + browserPagesByWorkspace: { + 'browser-env-1': [{ id: 'page-env-1', worktreeId: 'repo-env-1::/env-1/repo' }] as never + }, + remoteBrowserPageHandlesByPageId: { + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + }) + + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) + + // No teardown RPC was issued against the previous host's live resources. + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'terminal.close' }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' }) + ) + + // Every previous-host map is byte-for-byte unchanged after the switch. + expect(store.getState().tabsByWorktree).toEqual({ + 'repo-env-1::/env-1/repo': [ + { + id: 'host-tab-1', + ptyId: 'remote:env-1@@terminal-a', + worktreeId: 'repo-env-1::/env-1/repo', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }) + expect(store.getState().ptyIdsByTabId).toEqual({ + 'host-tab-1': ['remote:env-1@@terminal-a'] + }) + expect(store.getState().terminalLayoutsByTabId).toEqual({ + 'host-tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'remote:env-1@@terminal-a' } + } + }) + expect(store.getState().browserPagesByWorkspace).toEqual({ + 'browser-env-1': [{ id: 'page-env-1', worktreeId: 'repo-env-1::/env-1/repo' }] + }) + expect(store.getState().remoteBrowserPageHandlesByPageId).toEqual({ + 'page-env-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + }) + }) + + it('allows switching focus while editor tabs have unsaved state', async () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], @@ -353,16 +496,16 @@ describe('createSettingsSlice runtime switching', () => { editorDrafts: { '/env-1/repo/dirty.md': 'draft' } }) - await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false) + await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true) - expect(settingsSet).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1') + expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.list' }) + ) + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-2') expect(store.getState().openFiles).toHaveLength(1) expect(store.getState().editorDrafts).toEqual({ '/env-1/repo/dirty.md': 'draft' }) - expect(toast.error).toHaveBeenCalledWith( - 'Save or close unsaved editor tabs before switching servers.' - ) + expect(toast.error).not.toHaveBeenCalled() }) it('keeps the current environment when the selected remote server is unreachable', async () => { diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index f1fb069401e..f7b05e003f3 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -1,20 +1,13 @@ -/* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GlobalSettings } from '../../../../shared/types' import { toast } from 'sonner' import { - callRuntimeRpc, clearRuntimeCompatibilityCache, markRuntimeEnvironmentCompatible, unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { assertRuntimeStatusCompatible } from '@/runtime/runtime-protocol-compat' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { - getRemoteRuntimePtyEnvironmentId, - getRemoteRuntimeTerminalHandle -} from '@/runtime/runtime-terminal-stream' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands' import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' @@ -49,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 @@ -276,6 +65,10 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> try { const settings = await window.api.settings.get() set({ settings }) + // Why: best-effort boot probe so sidebar host pickers show live runtime + // health before the settings pane is ever opened. Fire-and-forget to keep + // startup off the network round-trips. + void get().hydrateRuntimeEnvironmentStatuses() } catch (err) { console.error('Failed to fetch settings:', err) } @@ -343,40 +136,23 @@ 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) - if (!isPairedWebClient()) { - // Why: desktop-created remote resources live on their owning server. - // Paired web clients only mirror host-owned tabs/PTYs, so switching - // pairings must detach local state without killing the host session. - await closeRemoteTerminalsBeforeRuntimeSwitch(get(), previousId) - await closeRemoteBrowserPagesBeforeRuntimeSwitch(get()) - } const nextSettings = await window.api.settings.set({ activeRuntimeEnvironmentId: nextId }) bumpProviderRuntimeSessionGeneration() set((s) => ({ - ...runtimeScopedStateReset(), + // Why: in the multi-host model this is a focus/default-host change, + // not a teardown boundary. Existing host-owned sessions stay alive. settings: (nextSettings as GlobalSettings | undefined) ?? (s.settings ? { ...s.settings, activeRuntimeEnvironmentId: nextId } : null) })) - // Why: server-owned state is cleared before refetch so old worktree, - // terminal, browser, and issue IDs cannot be used against the new server - // while the new environment is loading. + // Why: hydration is host-merged by downstream slices. Switching focus + // should add/update the selected host without discarding other hosts. await get().fetchRepos() - await get().fetchProjectGroups() await get().fetchAllWorktrees() await get().fetchWorktreeLineage() await get().fetchBrowserSessionProfiles() diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 7373d4b9587..c44eb2fe8ff 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -2107,6 +2107,105 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { expect(capture).toHaveBeenCalledWith({ includeLocalBuffers: false }) }) + it('does not stop the active runtime when sleeping an SSH-owned worktree', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + repos: [ + { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'ssh:ssh-1@@pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['ssh:ssh-1@@pty-1'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.stop' }) + ) + expect(mockApi.pty.kill).toHaveBeenCalledWith('ssh:ssh-1@@pty-1', { keepHistory: true }) + }) + + it('stops the owner runtime when sleeping a runtime-owned compatibility worktree', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'runtime-1', + method: 'terminal.stop' + }) + ) + }) + + it('stops the explicit owner runtime when another host is focused', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [ + { + id: 'repo1', + path: '/path/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:owner-runtime' + } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + + expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'owner-runtime', + method: 'terminal.stop' + }) + ) + expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'focused-runtime', + method: 'terminal.stop' + }) + ) + }) + it('drops live agentStatusByPaneKey entries on sleep so the working row disappears', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' 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/terminals.ts b/src/renderer/src/store/slices/terminals.ts index cd1b95a4947..a39e9c7e519 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -41,7 +41,7 @@ import { } from '@/components/terminal-pane/pty-transport' import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -49,6 +49,7 @@ import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connectio import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { const usedOrdinals = new Set<number>() @@ -233,6 +234,13 @@ export function worktreeUsesRemoteConnection( return Boolean(repo?.connectionId) } +function resolveTerminalStopRuntimeEnvironmentId( + state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>, + worktreeId: string +): string | null { + return getRuntimeEnvironmentIdForWorktree(state, worktreeId) +} + export type TerminalSlice = { tabsByWorktree: Record<string, TerminalTab[]> activeTabId: string | null @@ -759,7 +767,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> } const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }) .__ORCA_WEB_CLIENT__ - ? state.settings?.activeRuntimeEnvironmentId?.trim() + ? getRuntimeEnvironmentIdForWorktree(state, worktreeId) : null if (pairedWebRuntimeEnvironmentId) { const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session') @@ -1765,10 +1773,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> return } - const target = getActiveRuntimeTarget(get().settings) - if (target.kind === 'environment') { + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + if (runtimeEnvironmentId) { await callRuntimeRpc( - target, + { kind: 'environment', environmentId: runtimeEnvironmentId }, 'terminal.stop', { worktree: toRuntimeWorktreeSelector(worktreeId) }, { timeoutMs: 15_000 } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index a8f4e8fbc0d..57f05c45484 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -4,12 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../../shared/constants' import type { GitHubWorkItem, + JiraIssue, LinearIssue, PersistedUIState, TerminalTab, Worktree, WorktreeCardProperty } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import { createUISlice } from './ui' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createSettingsSearchState } from './settings-search-state' @@ -19,6 +21,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(), @@ -140,6 +143,40 @@ function makeLinearIssue(overrides: Partial<LinearIssue> = {}): LinearIssue { } as LinearIssue } +function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWorkItem { + return { + id: 'mr-12', + type: 'mr', + number: 12, + title: 'Fix runner routing', + state: 'opened', + url: 'https://gitlab.com/acme/repo/-/merge_requests/12', + labels: [], + updatedAt: '2026-05-30T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1', + ...overrides + } +} + +function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue { + return { + id: 'ORC-1', + key: 'ORC-1', + title: 'Fix task source context', + url: 'https://example.atlassian.net/browse/ORC-1', + siteId: 'site-1', + siteName: 'Example Jira', + project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + ...overrides + } +} + function makePersistedUI(overrides: Partial<PersistedUIState> = {}): PersistedUIState { return { ...getDefaultUIState(), @@ -582,6 +619,15 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().showSleepingWorkspaces).toBe(true) }) + it('defaults workspace host scope to all hosts', () => { + expect(getDefaultUIState().workspaceHostScope).toBe('all') + expect(createUIStore().getState().workspaceHostScope).toBe('all') + expect(getDefaultUIState().visibleWorkspaceHostIds).toBeNull() + expect(createUIStore().getState().visibleWorkspaceHostIds).toBeNull() + expect(getDefaultUIState().workspaceHostOrder).toEqual([]) + expect(createUIStore().getState().workspaceHostOrder).toEqual([]) + }) + it('preserves the current right sidebar width when older persisted UI omits it', () => { const store = createUIStore() @@ -641,6 +687,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() @@ -1264,11 +1410,94 @@ 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('prefetches direct Linear task opens with their source context', () => { + const store = createUIStore() + const prefetchLinearIssues = vi.fn() + const linearIssue = makeLinearIssue() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'linear', + projectId: 'project-1', + hostId: 'runtime:remote-server', + providerIdentity: { provider: 'linear', workspaceId: 'workspace-1' } + } + + store.setState({ + settings: { + visibleTaskProviders: ['linear'], + defaultTaskSource: 'linear' + } as unknown as AppState['settings'], + linearStatus: { connected: true } as AppState['linearStatus'], + prefetchLinearIssues + } as unknown as Partial<AppState>) + + store.getState().openTaskPage({ + taskSource: 'linear', + openLinearIssue: linearIssue, + openLinearSourceContext: sourceContext + }) + + expect(prefetchLinearIssues).toHaveBeenCalledWith( + { kind: 'list', filter: 'all', limit: expect.any(Number) }, + { sourceContext } + ) + }) + it('returns to the tasks page after visiting settings from an in-progress draft', () => { const store = createUIStore() @@ -1394,7 +1623,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) @@ -1411,13 +1646,122 @@ describe('createUISlice page navigation history', () => { store.setState({ recordFeatureInteraction } as Partial<AppState>) const workItem = makeGitHubWorkItem() const linearIssue = makeLinearIssue() + const jiraIssue = makeJiraIssue() store.getState().openTaskPage({ taskSource: 'github', openGitHubWorkItem: workItem }) store.getState().openTaskPage({ taskSource: 'linear', openLinearIssue: linearIssue }) + store.getState().openTaskPage({ taskSource: 'jira', openJiraIssue: jiraIssue }) expect(recordFeatureInteraction).toHaveBeenCalledWith('tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('github-tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('linear-tasks') + expect(recordFeatureInteraction).toHaveBeenCalledWith('jira-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('preserves Linear task detail source context in navigation history', () => { + const store = createUIStore() + const linearIssue = makeLinearIssue() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'linear', + projectId: 'project-1', + hostId: 'runtime:remote-server', + providerIdentity: { provider: 'linear', workspaceId: 'workspace-1' } + } + + store.getState().openTaskPage({ + taskSource: 'linear', + openLinearIssue: linearIssue, + openLinearSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'linear', + issue: linearIssue, + sourceContext + }) + }) + + it('preserves GitLab task detail source context in navigation history', () => { + const store = createUIStore() + const workItem = makeGitLabWorkItem({ repoId: 'repo-remote' }) + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-remote', + providerIdentity: { provider: 'gitlab', projectId: '1234' } + } + + store.getState().openTaskPage({ + taskSource: 'gitlab', + openGitLabWorkItem: workItem, + openGitLabSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'gitlab', + workItem, + sourceContext + }) + }) + + it('preserves Jira task detail source context in navigation history', () => { + const store = createUIStore() + const issue = makeJiraIssue() + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'runtime:remote-server', + providerIdentity: { provider: 'jira', siteId: 'site-1' }, + accountLabel: 'Example Jira' + } + + store.getState().openTaskPage({ + taskSource: 'jira', + openJiraIssue: issue, + openJiraSourceContext: sourceContext + }) + + expect(store.getState().worktreeNavHistory.at(-1)).toEqual({ + kind: 'task-detail', + source: 'jira', + issue, + sourceContext + }) }) it('can suppress the Tasks surface interaction for in-page provider navigation', () => { @@ -1426,6 +1770,7 @@ describe('createUISlice page navigation history', () => { store.setState({ recordFeatureInteraction } as Partial<AppState>) const workItem = makeGitHubWorkItem() const linearIssue = makeLinearIssue() + const jiraIssue = makeJiraIssue() store .getState() @@ -1439,10 +1784,17 @@ describe('createUISlice page navigation history', () => { { taskSource: 'linear', openLinearIssue: linearIssue }, { recordTasksInteraction: false } ) + store + .getState() + .openTaskPage( + { taskSource: 'jira', openJiraIssue: jiraIssue }, + { recordTasksInteraction: false } + ) expect(recordFeatureInteraction).not.toHaveBeenCalledWith('tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('github-tasks') expect(recordFeatureInteraction).toHaveBeenCalledWith('linear-tasks') + expect(recordFeatureInteraction).toHaveBeenCalledWith('jira-tasks') }) it('skips the whole Tasks detail stack on close', () => { @@ -1456,7 +1808,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 b03ea7cd3e2..de7aab3cdea 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -10,6 +10,7 @@ import type { ChangelogData, CustomPet, GitHubWorkItem, + JiraIssue, LinearIssue, PersistedTrustedOrcaHooks, PersistedUIState, @@ -22,9 +23,14 @@ import type { WorkspaceStatusDefinition, AgentActivityDisplayMode, ProjectOrderBy, - WorktreeCardProperty + WorktreeCardProperty, + WorkspaceHostOrder, + WorkspaceHostScope, + VisibleWorkspaceHostIds } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-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 { @@ -62,6 +68,12 @@ import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, normalizeBrowserPageZoomLevel } from '../../../../shared/browser-page-zoom' +import { + normalizeExecutionHostOrder, + normalizeExecutionHostScope, + normalizeVisibleExecutionHostIds, + type ExecutionHostId +} from '../../../../shared/execution-host' import { WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, clampWorkspaceBoardColumnWidth, @@ -243,6 +255,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 @@ -584,8 +605,14 @@ export type UISlice = { prefilledName?: string taskSource?: TaskProvider openGitHubWorkItem?: GitHubWorkItem + openGitHubSourceContext?: TaskSourceContext | null openGitHubInitialTab?: 'conversation' | 'checks' | 'files' + openGitLabWorkItem?: GitLabWorkItem + openGitLabSourceContext?: TaskSourceContext | null openLinearIssue?: LinearIssue + openLinearSourceContext?: TaskSourceContext | null + openJiraIssue?: JiraIssue + openJiraSourceContext?: TaskSourceContext | null } taskResumeState: TaskResumeState | undefined setTaskResumeState: (updates: Partial<TaskResumeState>) => void @@ -593,6 +620,11 @@ export type UISlice = { setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void newWorkspaceDraft: { repoId: string | null + // Why: project-first workspace creation resolves through these when present, + // while old drafts can keep using only repoId during the additive migration. + projectId?: string | null + hostId?: ExecutionHostId | null + projectHostSetupId?: string | null name: string prompt: string note: string @@ -604,6 +636,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 @@ -731,6 +766,12 @@ export type UISlice = { setShowActiveOnly: (v: boolean) => void showSleepingWorkspaces: boolean setShowSleepingWorkspaces: (v: boolean) => void + workspaceHostScope: WorkspaceHostScope + setWorkspaceHostScope: (scope: WorkspaceHostScope) => void + visibleWorkspaceHostIds: VisibleWorkspaceHostIds + setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void + workspaceHostOrder: WorkspaceHostOrder + setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void hideDefaultBranchWorkspace: boolean setHideDefaultBranchWorkspace: (v: boolean) => void showDotfilesByWorktree: Record<string, boolean> @@ -755,8 +796,10 @@ export type UISlice = { statusBarVisible: boolean setStatusBarVisible: (v: boolean) => void workspacePortScan: { key: string; result: WorkspacePortScanResult } | null + workspacePortScansByKey: Record<string, WorkspacePortScanResult> workspacePortScanRefreshing: boolean setWorkspacePortScan: (scan: { key: string; result: WorkspacePortScanResult } | null) => void + setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void setWorkspacePortScanRefreshing: (refreshing: boolean) => void /** Whether the experimental pet overlay is currently visible. Persisted * so "Hide pet" from the status-bar menu survives reload. Independent @@ -1051,9 +1094,15 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) if (data.openGitHubWorkItem) { get().recordFeatureInteraction?.('github-tasks') } + if (data.openGitLabWorkItem) { + get().recordFeatureInteraction?.('gitlab-tasks') + } if (data.openLinearIssue) { get().recordFeatureInteraction?.('linear-tasks') } + if (data.openJiraIssue) { + get().recordFeatureInteraction?.('jira-tasks') + } // Why: record a Tasks visit in the shared back/forward history so the // titlebar Back/Forward buttons can return to Tasks. All task-source // variants (github/linear presets) collapse to a single 'tasks' entry; @@ -1065,15 +1114,31 @@ 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 + : data.openGitLabWorkItem ? ({ kind: 'task-detail', - source: 'linear', - issue: data.openLinearIssue + source: 'gitlab', + workItem: data.openGitLabWorkItem, + sourceContext: data.openGitLabSourceContext } as const) - : null + : data.openLinearIssue + ? ({ + kind: 'task-detail', + source: 'linear', + issue: data.openLinearIssue, + sourceContext: data.openLinearSourceContext + } as const) + : data.openJiraIssue + ? ({ + kind: 'task-detail', + source: 'jira', + issue: data.openJiraIssue, + sourceContext: data.openJiraSourceContext + } as const) + : null const currentEntry = get().worktreeNavHistory[get().worktreeNavHistoryIndex] const currentIsTaskStack = currentEntry === 'tasks' || @@ -1142,22 +1207,36 @@ 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') { const resume = state.taskResumeState const query = (resume?.linearQuery ?? '').trim() + const sourceContext = + data.openLinearSourceContext?.provider === 'linear' ? data.openLinearSourceContext : null if (query) { - state.prefetchLinearIssues({ kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }) + state.prefetchLinearIssues( + { kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }, + { sourceContext } + ) } else { // Why: TaskPage no longer exposes Linear preset filters; keep warm // prefetch aligned with the default unsearched issue list. - state.prefetchLinearIssues({ - kind: 'list', - filter: 'all', - limit: LINEAR_TASK_PREFETCH_LIMIT - }) + state.prefetchLinearIssues( + { + kind: 'list', + filter: 'all', + limit: LINEAR_TASK_PREFETCH_LIMIT + }, + { sourceContext } + ) } } }, @@ -1738,6 +1817,40 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }), + workspaceHostScope: 'all', + // Why (multi-host design): host scope is presentation/filtering only — it must + // never trigger resource teardown (terminals, browser pages, etc.). + setWorkspaceHostScope: (scope) => { + const normalized = normalizeExecutionHostScope(scope) + const visibleWorkspaceHostIds = normalized === 'all' ? null : [normalized] + set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + window.api.ui + .set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + .catch(console.error) + }, + visibleWorkspaceHostIds: null, + setVisibleWorkspaceHostIds: (ids) => { + const normalized = normalizeVisibleExecutionHostIds(ids) + // Why: workspaceHostScope remains the compatibility/default-host signal + // for creation flows while visibility can now be multi-select. + let workspaceHostScope: WorkspaceHostScope = get().workspaceHostScope + if (normalized === null) { + workspaceHostScope = 'all' + } else if (normalized.length === 1) { + workspaceHostScope = normalized[0] + } + set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + window.api.ui + .set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + .catch(console.error) + }, + workspaceHostOrder: [], + setWorkspaceHostOrder: (ids) => { + const workspaceHostOrder = normalizeExecutionHostOrder(ids) + set({ workspaceHostOrder }) + window.api.ui.set({ workspaceHostOrder }).catch(console.error) + }, + hideDefaultBranchWorkspace: false, setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), @@ -1847,8 +1960,36 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) set({ statusBarVisible: v }) }, workspacePortScan: null, + workspacePortScansByKey: {}, workspacePortScanRefreshing: false, - setWorkspacePortScan: (scan) => set({ workspacePortScan: scan }), + setWorkspacePortScan: (scan) => + set((state) => { + if (!scan) { + return { workspacePortScan: null, workspacePortScansByKey: {} } + } + return { + workspacePortScan: scan, + workspacePortScansByKey: { ...state.workspacePortScansByKey, [scan.key]: scan.result } + } + }), + setWorkspacePortScanForKey: (key, result) => + set((state) => { + const nextScansByKey = { ...state.workspacePortScansByKey } + if (result) { + nextScansByKey[key] = result + } else { + delete nextScansByKey[key] + } + return { + workspacePortScansByKey: nextScansByKey, + workspacePortScan: + state.workspacePortScan?.key === key + ? result + ? { key, result } + : null + : state.workspacePortScan + } + }), setWorkspacePortScanRefreshing: (refreshing) => set({ workspacePortScanRefreshing: refreshing }), // Why: default true so a user who enables experimentalPet sees the @@ -2009,6 +2150,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) // Older positive-form keys are intentionally ignored so old profiles // start from the new default: sleeping workspaces visible. showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES), + workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), + visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), + workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), 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..70d4475ba3a 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 @@ -1,7 +1,9 @@ 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 { GitHubWorkItem, JiraIssue, Worktree } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import { createWorktreeNavHistorySlice, findPrevLiveWorktreeHistoryIndex, @@ -61,6 +63,40 @@ function makeGitHubWorkItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWork } } +function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWorkItem { + return { + id: 'mr-12', + type: 'mr', + number: 12, + title: 'Fix runner routing', + state: 'opened', + url: 'https://gitlab.com/acme/repo/-/merge_requests/12', + labels: [], + updatedAt: '2026-05-20T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1', + ...overrides + } +} + +function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue { + return { + id: 'ORC-1', + key: 'ORC-1', + title: 'Fix task source context', + url: 'https://example.atlassian.net/browse/ORC-1', + siteId: 'site-1', + siteName: 'Example Jira', + project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-05-30T00:00:00.000Z', + updatedAt: '2026-05-30T00:00:00.000Z', + ...overrides + } +} + describe('worktree-nav-history slice: view entries', () => { afterEach(() => { setWorktreeNavActivator(null) @@ -173,4 +209,104 @@ 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) + }) + + it('keeps same GitLab item details separate when the source host differs', () => { + const store = createHistoryStore(['a']) + const workItem = makeGitLabWorkItem() + const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'gitlab', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + providerIdentity: { provider: 'gitlab', projectId: '1234' } + } + const sshSource: TaskSourceContext = { + ...localSource, + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-ssh' + } + + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'gitlab', + workItem, + sourceContext: localSource + }) + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'gitlab', + workItem, + sourceContext: sshSource + }) + + expect(store.getState().worktreeNavHistory).toHaveLength(2) + expect(store.getState().worktreeNavHistoryIndex).toBe(1) + }) + + it('keeps same Jira issue details separate when the source host differs', () => { + const store = createHistoryStore(['a']) + const issue = makeJiraIssue() + const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'local', + providerIdentity: { provider: 'jira', siteId: 'site-1' } + } + const remoteSource: TaskSourceContext = { + ...localSource, + hostId: 'runtime:remote-server' + } + + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'jira', + issue, + sourceContext: localSource + }) + store.getState().recordViewVisit({ + kind: 'task-detail', + source: 'jira', + issue, + sourceContext: remoteSource + }) + + 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..38bd57a9870 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -1,7 +1,12 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { findWorktreeById } from './worktree-helpers' -import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types' +import type { GitHubWorkItem, JiraIssue, LinearIssue } from '../../../../shared/types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-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,9 +25,27 @@ export type WorktreeNavHistoryTaskDetailEntry = kind: 'task-detail' source: 'github' workItem: GitHubWorkItem + sourceContext?: TaskSourceContext | null initialTab?: 'conversation' | 'checks' | 'files' } - | { kind: 'task-detail'; source: 'linear'; issue: LinearIssue } + | { + kind: 'task-detail' + source: 'linear' + issue: LinearIssue + sourceContext?: TaskSourceContext | null + } + | { + kind: 'task-detail' + source: 'gitlab' + workItem: GitLabWorkItem + sourceContext?: TaskSourceContext | null + } + | { + kind: 'task-detail' + source: 'jira' + issue: JiraIssue + sourceContext?: TaskSourceContext | null + } export type WorktreeNavHistoryViewEntry = | WorktreeNavHistorySimpleViewEntry | WorktreeNavHistoryTaskDetailEntry @@ -82,9 +105,31 @@ 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}` + if (entry.source === 'gitlab') { + const sourceScope = + entry.sourceContext?.provider === 'gitlab' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:gitlab:${sourceScope}:${entry.workItem.repoId}:${entry.workItem.type}:${entry.workItem.number}` + } + if (entry.source === 'jira') { + const sourceScope = + entry.sourceContext?.provider === 'jira' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:jira:${sourceScope}:${entry.issue.siteId ?? 'selected'}:${entry.issue.key}` + } + const sourceScope = + entry.sourceContext?.provider === 'linear' + ? getTaskSourceCacheScope(entry.sourceContext) + : 'legacy' + return `view:task-detail:linear:${sourceScope}:${entry.issue.workspaceId ?? 'selected'}:${entry.issue.id}` } function isLiveEntry(entry: WorktreeNavHistoryEntry, state: AppState): boolean { diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 53ef24c0fa5..e48b2fad849 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -685,6 +685,38 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) + it('fetches SSH repo worktrees through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const sshWorktree = makeWorktree({ + id: 'repo-ssh::/home/orca/wt1', + repoId: 'repo-ssh', + path: '/home/orca/wt1', + branch: 'refs/heads/ssh' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ] + } as Partial<AppState>) + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' }) + ) + + await store.getState().fetchWorktrees('repo-ssh') + + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([sshWorktree]) + }) + it('falls back to legacy remote worktree.list when detectedList is unavailable', async () => { const store = createTestStore() const remote = makeWorktree({ @@ -2314,6 +2346,40 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([]) }) + it('removes SSH-owned worktrees through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo-ssh::/home/orca/wt1', + repoId: 'repo-ssh', + path: '/home/orca/wt1' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [wt] } + } as Partial<AppState>) + + const result = await store.getState().removeWorktree(wt.id) + + expect(result).toEqual({ ok: true }) + expect(mockApi.worktrees.remove).toHaveBeenCalledWith({ + worktreeId: wt.id, + force: undefined, + skipArchive: false + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([]) + }) + it('persists worktree metadata through the active remote runtime environment', async () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) @@ -2340,6 +2406,38 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note') }) + it('persists SSH-owned worktree metadata through local IPC even when a runtime is focused', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo-ssh::/home/orca/wt1', + repoId: 'repo-ssh', + path: '/home/orca/wt1' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [wt] } + } as Partial<AppState>) + + await store.getState().updateWorktreeMeta(wt.id, { comment: 'ssh note' }) + + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId: wt.id, + updates: expect.objectContaining({ comment: 'ssh note' }) + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh'][0]?.comment).toBe('ssh note') + }) + it('clears pending first-agent rename when the title is updated', async () => { const store = createTestStore() const wt = makeWorktree({ @@ -3180,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' }) @@ -3776,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 fbd74c5f7fb..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, @@ -197,6 +203,9 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b worktree.id === candidate.id && worktree.instanceId === candidate.instanceId && worktree.repoId === candidate.repoId && + worktree.projectId === candidate.projectId && + worktree.hostId === candidate.hostId && + worktree.projectHostSetupId === candidate.projectHostSetupId && worktree.path === candidate.path && worktree.head === candidate.head && worktree.branch === candidate.branch && @@ -375,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( @@ -570,6 +574,37 @@ function replaceWorktreeInRepoLists( } } +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 by the desktop client/SSH provider, not the + // currently focused runtime server. + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +function settingsForWorktreeOwner(state: Pick<AppState, 'repos' | 'settings'>, worktreeId: string) { + return settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId)) +} + async function listDetectedWorktreesForRepo( settings: AppState['settings'], repoId: string @@ -625,13 +660,21 @@ async function listWorktreeLineageForRuntime( async function refreshRemoteWorktreeLineageBestEffort( settings: AppState['settings'], + get: () => AppState, set: (partial: Partial<AppState>) => void ): Promise<void> { if (getActiveRuntimeTarget(settings).kind === 'local') { return } try { - set({ worktreeLineageById: await listWorktreeLineageForRuntime(settings) }) + const lineage = await listWorktreeLineageForRuntime(settings) + set({ + worktreeLineageById: mergeLineageForHost( + get(), + getSettingsFocusedExecutionHostId(settings), + lineage + ) + }) } catch (err) { // Why: lineage is supplemental to the worktree list. A remote timeout here // must not discard a successful worktree refresh. @@ -639,6 +682,29 @@ async function refreshRemoteWorktreeLineageBestEffort( } } +function getWorktreeHostId( + state: Pick<AppState, 'repos'>, + worktreeId: string +): ExecutionHostId | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos.find((entry) => entry.id === repoId) + return repo ? getRepoExecutionHostId(repo) : null +} + +function mergeLineageForHost( + state: Pick<AppState, 'repos' | 'worktreeLineageById'>, + hostId: ExecutionHostId, + lineage: Record<string, WorktreeLineage> +): Record<string, WorktreeLineage> { + const next: Record<string, WorktreeLineage> = {} + for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) { + if (getWorktreeHostId(state, worktreeId) !== hostId) { + next[worktreeId] = existing + } + } + return { ...next, ...lineage } +} + async function persistWorktreeMeta( settings: AppState['settings'], worktreeId: string, @@ -879,7 +945,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> fetchDetectedWorktrees: async (repoId) => { try { - const result = await listDetectedWorktreesForRepo(get().settings, repoId) + const result = await listDetectedWorktreesForRepo(settingsForRepoOwner(get(), repoId), repoId) set((s) => areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], result) ? s @@ -894,7 +960,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> fetchWorktrees: async (repoId, options) => { try { - const settings = get().settings + const settings = settingsForRepoOwner(get(), repoId) const detected = await listDetectedWorktreesForRepo(settings, repoId) if (options?.requireAuthoritative && !detected.authoritative) { return false @@ -915,7 +981,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) - await refreshRemoteWorktreeLineageBestEffort(settings, set) + await refreshRemoteWorktreeLineageBestEffort(settings, get, set) return detected.authoritative } @@ -949,7 +1015,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) - await refreshRemoteWorktreeLineageBestEffort(settings, set) + await refreshRemoteWorktreeLineageBestEffort(settings, get, set) return detected.authoritative } catch (err) { console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) @@ -982,7 +1048,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> const results = await Promise.all( repos.map(async (r) => { try { - const detected = await listDetectedWorktreesForRepo(get().settings, r.id) + const detected = await listDetectedWorktreesForRepo( + settingsForRepoOwner(get(), r.id), + r.id + ) const list = toVisibleWorktrees(detected) const current = get().worktreesByRepo[r.id] if ( @@ -1040,7 +1109,17 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> fetchWorktreeLineage: async () => { try { - set({ worktreeLineageById: await listWorktreeLineageForRuntime(get().settings) }) + // Why: lineage is a focused-host refresh — fetch from the focused host and + // host-merge so other hosts' previously fetched lineage is preserved. + const settings = get().settings + const lineage = await listWorktreeLineageForRuntime(settings) + set((s) => ({ + worktreeLineageById: mergeLineageForHost( + s, + getSettingsFocusedExecutionHostId(settings), + lineage + ) + })) } catch (err) { console.error('Failed to fetch worktree lineage:', err) } @@ -1048,7 +1127,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> updateWorktreeLineage: async (worktreeId, args) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) let updatedRemoteWorktree: WorktreeWithLineage | undefined const lineage = target.kind === 'local' @@ -1160,7 +1239,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> prefetchWorktreeCreateBase: async (repoId, baseBranch) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) if (target.kind === 'local') { await window.api.worktrees.prefetchCreateBase({ repoId, @@ -1255,7 +1334,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> ...(startup ? { startup } : {}), ...(creationId ? { creationId } : {}) } - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) const result = target.kind === 'local' ? await window.api.worktrees.create(createArgs) @@ -1434,7 +1513,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> const worktreeBeforeRemoval = get() .allWorktrees() .find((entry) => entry.id === worktreeId) - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) const removalResult = await (target.kind === 'local' ? window.api.worktrees.remove({ worktreeId, force, skipArchive }) : callRuntimeRpc<RemoveWorktreeResult>( @@ -1730,7 +1809,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> forceDeletePreservedBranch: async (worktreeId, branchName, expectedHead) => { try { - const target = getActiveRuntimeTarget(get().settings) + const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) const result = await (target.kind === 'local' ? window.api.worktrees.forceDeletePreservedBranch({ worktreeId, @@ -1797,7 +1876,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> existingWorktree.linkedPR !== linkedPrForPushTarget && !existingWorktree.pushTarget ? await resolveLinkedPrPushTarget( - get().settings, + settingsForRepoOwner(get(), existingWorktree.repoId), existingWorktree.repoId, linkedPrForPushTarget ) @@ -1851,7 +1930,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> reviewBranch, s.settings, reviewRepo.id, - reviewRepo.connectionId + reviewRepo.connectionId, + reviewRepo.executionHostId ) : null const prCacheKey = @@ -1861,7 +1941,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> reviewRepo.id, reviewBranch, s.settings, - reviewRepo.connectionId + reviewRepo.connectionId, + reviewRepo.executionHostId ) : null const prCacheKeys = @@ -1919,7 +2000,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> } try { - await persistWorktreeMeta(get().settings, worktreeId, enriched) + await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched) if (reviewRepo && reviewBranch && typeof get().fetchHostedReviewForBranch === 'function') { // Why: the old cache entry may have been populated solely by linkedPR. // Force a no-linked refetch so an in-flight linked lookup cannot keep @@ -1970,11 +2051,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> } }) - const settings = get().settings await Promise.all( Array.from(updatesByWorktreeId, async ([worktreeId, updates]) => { try { - await persistWorktreeMeta(settings, worktreeId, updates) + await persistWorktreeMeta( + settingsForWorktreeOwner(get(), worktreeId), + worktreeId, + updates + ) } catch (err) { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) @@ -2057,7 +2141,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> return } - void persistWorktreeMeta(get().settings, worktreeId, { + void persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, { isUnread: true, lastActivityAt: now }).catch((err) => { @@ -2168,7 +2252,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> return } - void persistWorktreeMeta(get().settings, worktreeId, { isUnread: false }).catch((err) => { + void persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, { + isUnread: false + }).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) return @@ -2224,7 +2310,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> return } - void persistWorktreeMeta(get().settings, worktreeId, { lastActivityAt: now }).catch((err) => { + void persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, { + lastActivityAt: now + }).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { return } @@ -2642,7 +2730,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> isUnread: false } - void persistWorktreeMeta(get().settings, worktreeId, updates).catch((err) => { + void persistWorktreeMeta( + settingsForWorktreeOwner(get(), worktreeId), + worktreeId, + updates + ).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) return 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 541393b26b4..0501812d2c0 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PreloadApi } from '../../../preload/api-types' import type { FeatureInteractionState } from '../../../shared/feature-interactions' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { TaskSourceContext } from '../../../shared/task-source-context' class MemoryStorage implements Storage { private readonly values = new Map<string, string>() @@ -1143,6 +1144,18 @@ describe('web file preload API', () => { ).rejects.toThrow('Remote file download is unavailable in paired web clients.') }) + it('rejects SSH clone requests in paired web clients', async () => { + const { api } = await installApi('Linux') + + await expect( + api.repos.cloneRemote({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace' + }) + ).rejects.toThrow('SSH clone is unavailable in paired web clients.') + }) + it('returns false for runtime missing-path errors from fs.pathExists', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] const worktree = { @@ -1215,42 +1228,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() @@ -1998,6 +1975,101 @@ describe('web GitLab preload API', () => { ) }) + it('routes GitLab repo selectors through repo id when provided', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: method === 'gitlab.workItemDetails' ? null : { ok: true, items: [] }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const api = globals.window.api + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'gitlab', + projectId: 'gitlab:gitlab.example.com/group/project', + hostId: 'runtime:web-env-1', + repoId: 'repo-gitlab-runtime', + providerIdentity: { + provider: 'gitlab', + projectId: '42', + namespace: 'group', + project: 'project', + webUrl: 'https://gitlab.example.com/group/project' + } + } + + await api.gl.listIssues({ + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + state: 'opened' + }) + await api.gl.updateMR({ + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + iid: 9, + updates: { title: 'New title' } + }) + await api.gl.workItemDetails({ + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + iid: 9, + type: 'mr' + }) + + expect(runtimeCalls).toEqual([ + { + method: 'gitlab.listIssues', + params: { + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + repo: 'id:repo-gitlab-runtime', + state: 'opened' + } + }, + { + method: 'gitlab.updateMR', + params: { + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + repo: 'id:repo-gitlab-runtime', + iid: 9, + updates: { title: 'New title' } + } + }, + { + method: 'gitlab.workItemDetails', + params: { + repoPath: '/workspace/repo', + repoId: 'repo-gitlab-runtime', + sourceContext, + repo: 'id:repo-gitlab-runtime', + iid: 9, + type: 'mr' + } + } + ]) + }) + it('exposes the GitLab task methods used by the shared Tasks page', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d7d422f6b59..1f51bfa2804 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -36,6 +36,7 @@ import { import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId } from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { @@ -170,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' @@ -500,22 +500,25 @@ function createWebPreloadApi(): Partial<PreloadApi> { deleteBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.')) }, session: { - get: () => Promise.resolve(getStoredWorkspaceSession()), - set: async (session) => { - writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session)) + // hostId mirrors the desktop bridge: omitted/'local' targets the existing + // storage key; non-local hosts persist under a host-suffixed key so their + // sessions stay isolated from the local one. + get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), + set: async (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) }, - patch: async (patch: WorkspaceSessionPatch) => { + patch: async (patch: WorkspaceSessionPatch, hostId) => { writeJson( - SESSION_STORAGE_KEY, + sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession({ - ...getStoredWorkspaceSession(), + ...getStoredWorkspaceSession(hostId), ...patch }) ) }, readTerminalScrollback: () => null, - setSync: (session) => { - writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session)) + setSync: (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) } }, onboarding: { @@ -556,7 +559,6 @@ function createWebPreloadApi(): Partial<PreloadApi> { browser: createBrowserApi(), emulator: createEmulatorApi(), gh: createGitHubApi(), - starNag: createStarNagApi(), gl: createGitLabApi(), hostedReview: createRuntimeNamespaceApi('hostedReview'), linear: createRuntimeNamespaceApi('linear'), @@ -975,6 +977,13 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim } return { removed: redactStoredWebRuntimeEnvironment(environment) } }, + disconnect: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (activeEnvironment?.id === environment.id) { + disconnectActiveRuntimeEnvironment() + } + return { disconnected: redactStoredWebRuntimeEnvironment(environment) } + }, getStatus: ({ selector, timeoutMs }) => callEnvironmentEnvelope<RuntimeStatus>(selector, 'status.get', undefined, timeoutMs), call: ({ selector, method, params, timeoutMs }) => @@ -1009,6 +1018,16 @@ function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> { await callRuntimeResult<{ repo: Repo }>('repo.clone', { url, destination }, 10 * 60_000) ).repo }, + cloneRemote: async () => { + // Why: SSH relay cloning is owned by the desktop main process; paired web + // clients must not pretend they can run that local IPC path directly. + throw new Error('SSH clone is unavailable in paired web clients.') + }, + createRemote: async () => { + // Why: SSH relay project creation is owned by the desktop main process; + // paired web clients cannot create folders through local SSH IPC. + throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') + }, cloneAbort: () => Promise.resolve(), addRemote: async ({ remotePath, displayName, kind }) => { invalidateRuntimeWorktreeCaches() @@ -1575,18 +1594,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)) @@ -2088,9 +2095,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']> } @@ -2592,7 +2599,22 @@ function getStoredOnboarding(): OnboardingState { return closed } -function getStoredWorkspaceSession(): WorkspaceSessionState { +/** Resolve the localStorage key for a session partition. Non-'local' hosts get + * a host-suffixed key so their sessions never clobber the local one. */ +function sessionStorageKeyForHost(hostId?: string | null): string { + const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + return resolved === LOCAL_EXECUTION_HOST_ID + ? SESSION_STORAGE_KEY + : `${SESSION_STORAGE_KEY}.${resolved}` +} + +function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { + const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + return sanitizeWebRuntimeWorkspaceSession( + readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) + ) + } const localSession = sanitizeWebRuntimeWorkspaceSession( readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) ) diff --git a/src/shared/automation-run-identity.test.ts b/src/shared/automation-run-identity.test.ts new file mode 100644 index 00000000000..1738cba3fce --- /dev/null +++ b/src/shared/automation-run-identity.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import { + getAutomationLegacyRepoId, + getAutomationRunProjectId, + getAutomationRunRepoId +} from './automation-run-identity' + +function automation(overrides: Partial<Automation> = {}): Automation { + return { + id: 'auto-1', + name: 'Automation', + prompt: 'Run this', + precheck: null, + agentId: 'claude', + runContext: null, + sourceContext: null, + projectId: 'legacy-repo', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 1, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('automation run identity', () => { + it('uses explicit run context identity when present', () => { + const value = automation({ + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:builder', + projectHostSetupId: 'setup-builder', + repoId: 'remote-repo', + path: '/remote/orca' + } + }) + + expect(getAutomationLegacyRepoId(value)).toBe('legacy-repo') + expect(getAutomationRunRepoId(value)).toBe('remote-repo') + expect(getAutomationRunProjectId(value)).toBe('github:stablyai/orca') + }) + + it('falls back to the legacy repo id for pre-host-context automations', () => { + const value = automation() + + expect(getAutomationLegacyRepoId(value)).toBe('legacy-repo') + expect(getAutomationRunRepoId(value)).toBe('legacy-repo') + expect(getAutomationRunProjectId(value)).toBe('legacy-repo') + }) +}) diff --git a/src/shared/automation-run-identity.ts b/src/shared/automation-run-identity.ts new file mode 100644 index 00000000000..0497f300f89 --- /dev/null +++ b/src/shared/automation-run-identity.ts @@ -0,0 +1,15 @@ +import type { Automation } from './automations-types' + +type AutomationRunIdentityFields = Pick<Automation, 'projectId' | 'runContext'> + +export function getAutomationLegacyRepoId(automation: Pick<Automation, 'projectId'>): string { + return automation.projectId +} + +export function getAutomationRunRepoId(automation: AutomationRunIdentityFields): string { + return automation.runContext?.repoId ?? getAutomationLegacyRepoId(automation) +} + +export function getAutomationRunProjectId(automation: AutomationRunIdentityFields): string { + return automation.runContext?.projectId ?? getAutomationLegacyRepoId(automation) +} diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 71d7293a701..776085ab9c2 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,17 @@ export type Automation = { prompt: string precheck: AutomationPrecheck | null agentId: TuiAgent + /** Why: runContext carries the logical project + host setup identity for + * multi-host projects; projectId remains only as the legacy repo-id storage + * field for pre-host-context automations. + * @deprecated Use runContext.projectId/runContext.repoId or + * getAutomationRunRepoId(). */ + 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 + /** @deprecated Legacy repo-id compatibility field. New code should persist + * runContext and use getAutomationRunRepoId() for fallback reads. */ projectId: string executionTargetType: AutomationExecutionTargetType executionTargetId: string @@ -103,6 +115,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 +142,10 @@ export type AutomationCreateInput = { prompt: string precheck?: AutomationPrecheck | null agentId: TuiAgent + runContext?: WorkspaceRunContext | null + sourceContext?: TaskSourceContext | null + /** @deprecated Legacy repo-id compatibility field required for older stored + * automations and clients. Pair it with runContext for new writes. */ projectId: string workspaceMode: AutomationWorkspaceMode workspaceId?: string | null @@ -147,6 +165,8 @@ export type AutomationUpdateInput = Partial< | 'prompt' | 'precheck' | 'agentId' + | 'runContext' + | 'sourceContext' | 'projectId' | 'workspaceMode' | 'workspaceId' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 19d0f8e70b2..d1ec7141772 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -389,6 +389,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState { return { schemaVersion: SCHEMA_VERSION, repos: [], + projects: [], + projectHostSetups: [], projectGroups: [], folderWorkspaces: [], sparsePresetsByRepo: {}, @@ -398,6 +400,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { ui: getDefaultUIState(), githubCache: { pr: {}, issue: {} }, workspaceSession: getDefaultWorkspaceSession(), + workspaceSessionsByHostId: {}, sshTargets: [], sshRemotePtyLeases: [], migrationUnsupportedPtyEntries: [], @@ -423,6 +426,9 @@ export function getDefaultUIState(): PersistedUIState { projectOrderBy: 'manual', showActiveOnly: false, hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, + workspaceHostScope: 'all', + visibleWorkspaceHostIds: null, + workspaceHostOrder: [], showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, showDotfilesByWorktree: {}, diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts new file mode 100644 index 00000000000..10c3aed194c --- /dev/null +++ b/src/shared/execution-host-registry.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import { buildExecutionHostRegistry } from './execution-host-registry' + +describe('execution host registry', () => { + it('returns only the local host for local-only state', () => { + expect( + buildExecutionHostRegistry({ + repos: [{ connectionId: null }], + settings: { activeRuntimeEnvironmentId: null } + }) + ).toEqual([ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + } + ]) + }) + + it('includes saved and repo-derived SSH hosts with connection health', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: 'repo-ssh' }], + settings: { activeRuntimeEnvironmentId: null }, + sshTargetLabels: new Map([['saved-ssh', 'Saved SSH']]), + sshConnectionStates: new Map([ + [ + 'repo-ssh', + { + targetId: 'repo-ssh', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ], + [ + 'saved-ssh', + { + targetId: 'saved-ssh', + status: 'auth-failed', + error: 'Permission denied', + reconnectAttempt: 1 + } + ] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { id: 'ssh:saved-ssh', label: 'Saved SSH', health: 'error', connectionStatus: 'auth-failed' }, + { id: 'ssh:repo-ssh', label: 'repo-ssh', health: 'available', connectionStatus: 'connected' } + ]) + }) + + it('adds saved runtime environments and preserves compatibility state per host', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: { activeRuntimeEnvironmentId: 'old-server' }, + runtimeEnvironments: [{ id: 'builder', name: 'Linux Builder' }], + runtimeStatusByEnvironmentId: new Map([ + [ + 'builder', + { + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-builder', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: ['terminal.binary-stream.v1'], + hostPlatform: 'linux' + } + } + ], + [ + 'old-server', + { + appVersion: '1.6.0', + status: { + runtimeId: 'runtime-old', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + minCompatibleRuntimeClientVersion: 1, + capabilities: [] + } + } + ] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { + id: 'runtime:builder', + label: 'Linux Builder', + health: 'available', + appVersion: '1.8.0', + protocolVersion: RUNTIME_PROTOCOL_VERSION, + capabilities: ['terminal.binary-stream.v1'], + platform: 'linux', + compatibility: { kind: 'ok' } + }, + { + id: 'runtime:old-server', + label: 'old-server', + health: 'blocked', + appVersion: '1.6.0', + compatibility: { kind: 'blocked', reason: 'server-too-old' } + } + ]) + }) + + it('applies per-host display-label overrides to derived labels', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: 'repo-ssh' }], + settings: { activeRuntimeEnvironmentId: null }, + sshTargetLabels: new Map([['repo-ssh', 'Derived SSH']]), + hostLabelOverrides: new Map([ + ['ssh:repo-ssh', 'Renamed Box'], + ['local', 'My Laptop'] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', label: 'My Laptop' }, + { id: 'ssh:repo-ssh', label: 'Renamed Box' } + ]) + }) + + it('keeps derived labels for hosts without an override', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: 'repo-ssh' }], + settings: { activeRuntimeEnvironmentId: null }, + sshTargetLabels: new Map([['repo-ssh', 'Derived SSH']]), + hostLabelOverrides: new Map([['ssh:other', 'Unrelated']]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', label: 'Local Mac' }, + { id: 'ssh:repo-ssh', label: 'Derived SSH' } + ]) + }) + + it('includes runtime hosts from repo ownership even when they are not focused', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }], + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'available' } + ]) + }) + + it('includes runtime hosts from hydrated status even when they are not focused', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: { activeRuntimeEnvironmentId: null }, + runtimeStatusByEnvironmentId: new Map([ + [ + 'gpu', + { + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-gpu', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: ['project-host-setup.v1'], + hostPlatform: 'linux' + } + } + ] + ]) + }) + + expect(hosts).toMatchObject([ + { id: 'local', health: 'local' }, + { + id: 'runtime:gpu', + kind: 'runtime', + label: 'gpu', + health: 'available', + capabilities: ['project-host-setup.v1'], + platform: 'linux' + } + ]) + }) +}) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts new file mode 100644 index 00000000000..bfe12e0dbf7 --- /dev/null +++ b/src/shared/execution-host-registry.ts @@ -0,0 +1,227 @@ +import { + LOCAL_EXECUTION_HOST_ID, + getSettingsFocusedExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId, + type ExecutionHostKind +} from './execution-host' +import { evaluateRuntimeCompat, type RuntimeCompatVerdict } from './protocol-compat' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import type { RuntimeStatus } from './runtime-types' +import type { SshConnectionState, SshConnectionStatus } from './ssh-types' +import type { GlobalSettings, Repo } from './types' + +export type ExecutionHostHealth = + | 'local' + | 'available' + | 'connecting' + | 'blocked' + | 'disconnected' + | 'error' + +export type ExecutionHostRegistryEntry = { + id: ExecutionHostId + kind: ExecutionHostKind + label: string + detail: string + health: ExecutionHostHealth + connectionStatus?: SshConnectionStatus + compatibility?: RuntimeCompatVerdict + capabilities?: readonly string[] + appVersion?: string | null + protocolVersion?: number | null + minCompatibleClientVersion?: number | null + platform?: NodeJS.Platform | null +} + +type RuntimeEnvironmentSummary = { + id: string + name?: string | null +} + +type RuntimeHostStatus = { + status?: RuntimeStatus | null + appVersion?: string | null +} + +type RuntimeStatusByEnvironmentId = ReadonlyMap<string, RuntimeHostStatus> + +function normalizeHostPart(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function runtimeCompatibility( + status: RuntimeStatus | null | undefined +): RuntimeCompatVerdict | null { + if (!status) { + return null + } + return evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) +} + +function runtimeHealth(compatibility: RuntimeCompatVerdict | null): ExecutionHostHealth { + if (!compatibility) { + return 'available' + } + return compatibility.kind === 'blocked' ? 'blocked' : 'available' +} + +function sshHealth(state: SshConnectionState | undefined): ExecutionHostHealth { + switch (state?.status) { + case 'connected': + return 'available' + case 'connecting': + case 'deploying-relay': + case 'reconnecting': + return 'connecting' + case 'auth-failed': + case 'error': + case 'reconnection-failed': + return 'error' + case 'disconnected': + case undefined: + return 'disconnected' + } +} + +function setHost( + hosts: Map<ExecutionHostId, ExecutionHostRegistryEntry>, + entry: ExecutionHostRegistryEntry +): void { + const existing = hosts.get(entry.id) + if (!existing || existing.health === 'disconnected') { + hosts.set(entry.id, entry) + } +} + +function addRuntimeHost( + hosts: Map<ExecutionHostId, ExecutionHostRegistryEntry>, + environmentId: string, + label: string, + statusByEnvironmentId: RuntimeStatusByEnvironmentId | undefined +): void { + const hostId = toRuntimeExecutionHostId(environmentId) + const runtimeStatus = statusByEnvironmentId?.get(environmentId) + const status = runtimeStatus?.status + const compatibility = runtimeCompatibility(status) + setHost(hosts, { + id: hostId, + kind: 'runtime', + label, + detail: 'Orca server', + health: runtimeHealth(compatibility), + compatibility: compatibility ?? undefined, + capabilities: status?.capabilities, + appVersion: runtimeStatus?.appVersion ?? null, + protocolVersion: status?.runtimeProtocolVersion ?? status?.protocolVersion ?? null, + minCompatibleClientVersion: + status?.minCompatibleRuntimeClientVersion ?? status?.minCompatibleMobileVersion ?? null, + platform: status?.hostPlatform ?? null + }) +} + +export function buildExecutionHostRegistry(args: { + repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[] + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined + sshTargetLabels?: ReadonlyMap<string, string> + sshConnectionStates?: ReadonlyMap<string, SshConnectionState> + runtimeEnvironments?: readonly RuntimeEnvironmentSummary[] + runtimeStatusByEnvironmentId?: RuntimeStatusByEnvironmentId + // Why: user-chosen per-host display labels override the derived label so a + // rename in the host menu/settings shows everywhere the registry feeds. + hostLabelOverrides?: ReadonlyMap<ExecutionHostId, string> +}): ExecutionHostRegistryEntry[] { + const hosts = new Map<ExecutionHostId, ExecutionHostRegistryEntry>() + hosts.set(LOCAL_EXECUTION_HOST_ID, { + id: LOCAL_EXECUTION_HOST_ID, + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }) + + for (const environment of args.runtimeEnvironments ?? []) { + const environmentId = normalizeHostPart(environment.id) + if (!environmentId) { + continue + } + addRuntimeHost( + hosts, + environmentId, + normalizeHostPart(environment.name) ?? environmentId, + args.runtimeStatusByEnvironmentId + ) + } + for (const environmentId of args.runtimeStatusByEnvironmentId?.keys() ?? []) { + addRuntimeHost(hosts, environmentId, environmentId, args.runtimeStatusByEnvironmentId) + } + + const focusedHost = getSettingsFocusedExecutionHostId(args.settings) + const parsedFocusedHost = parseExecutionHostId(focusedHost) + if (parsedFocusedHost?.kind === 'runtime') { + addRuntimeHost( + hosts, + parsedFocusedHost.environmentId, + parsedFocusedHost.environmentId, + args.runtimeStatusByEnvironmentId + ) + } + + const sshTargetIds = new Set<string>() + for (const repo of args.repos) { + const parsedHost = parseExecutionHostId(repo.executionHostId) + if (parsedHost?.kind === 'runtime') { + addRuntimeHost( + hosts, + parsedHost.environmentId, + parsedHost.environmentId, + args.runtimeStatusByEnvironmentId + ) + } + if (parsedHost?.kind === 'ssh') { + sshTargetIds.add(parsedHost.targetId) + } + } + for (const targetId of args.sshTargetLabels?.keys() ?? []) { + const normalized = normalizeHostPart(targetId) + if (normalized) { + sshTargetIds.add(normalized) + } + } + for (const repo of args.repos) { + const targetId = normalizeHostPart(repo.connectionId) + if (targetId) { + sshTargetIds.add(targetId) + } + } + + for (const targetId of sshTargetIds) { + const state = args.sshConnectionStates?.get(targetId) + setHost(hosts, { + id: toSshExecutionHostId(targetId), + kind: 'ssh', + label: args.sshTargetLabels?.get(targetId) || targetId, + detail: 'SSH', + health: sshHealth(state), + connectionStatus: state?.status + }) + } + + const overrides = args.hostLabelOverrides + if (!overrides || overrides.size === 0) { + return [...hosts.values()] + } + return [...hosts.values()].map((host) => { + const label = overrides.get(host.id) + return label ? { ...host, label } : host + }) +} diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts new file mode 100644 index 00000000000..657396da971 --- /dev/null +++ b/src/shared/execution-host.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + normalizeExecutionHostOrder, + normalizeExecutionHostScope, + normalizeVisibleExecutionHostIds, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from './execution-host' + +describe('execution host identity', () => { + it('normalizes local, SSH, and runtime host ids', () => { + expect(parseExecutionHostId('local')).toEqual({ kind: 'local', id: 'local' }) + expect(parseExecutionHostId(toSshExecutionHostId('win vm'))).toEqual({ + kind: 'ssh', + id: 'ssh:win%20vm', + targetId: 'win vm' + }) + expect(parseExecutionHostId(toRuntimeExecutionHostId('prod/server'))).toEqual({ + kind: 'runtime', + id: 'runtime:prod%2Fserver', + environmentId: 'prod/server' + }) + }) + + it('falls back invalid scopes to all hosts', () => { + expect(normalizeExecutionHostScope(null)).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('bogus')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('ssh:')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + expect(normalizeExecutionHostScope('all')).toBe(ALL_EXECUTION_HOSTS_SCOPE) + }) + + it('normalizes visible host id arrays', () => { + expect(normalizeVisibleExecutionHostIds(null)).toBeNull() + expect(normalizeVisibleExecutionHostIds([])).toBeNull() + expect(normalizeVisibleExecutionHostIds(['local', 'bogus', 'ssh:win%20vm', 'local'])).toEqual([ + 'local', + 'ssh:win%20vm' + ]) + }) + + it('normalizes host order arrays', () => { + expect(normalizeExecutionHostOrder(null)).toEqual([]) + expect(normalizeExecutionHostOrder([])).toEqual([]) + expect(normalizeExecutionHostOrder(['ssh:win%20vm', 'bogus', 'local', 'ssh:win%20vm'])).toEqual( + ['ssh:win%20vm', 'local'] + ) + }) + + it('derives repo ownership from SSH connection ids', () => { + expect(getRepoExecutionHostId({ connectionId: null })).toBe(LOCAL_EXECUTION_HOST_ID) + expect(getRepoExecutionHostId({ connectionId: 'ssh-target-1' })).toBe('ssh:ssh-target-1') + }) + + it('derives focused host compatibility from active runtime settings', () => { + expect(getSettingsFocusedExecutionHostId(null)).toBe(LOCAL_EXECUTION_HOST_ID) + expect(getSettingsFocusedExecutionHostId({ activeRuntimeEnvironmentId: 'runtime-1' })).toBe( + 'runtime:runtime-1' + ) + }) +}) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts new file mode 100644 index 00000000000..f447f28e880 --- /dev/null +++ b/src/shared/execution-host.ts @@ -0,0 +1,138 @@ +import type { GlobalSettings, Repo } from './types' + +export const LOCAL_EXECUTION_HOST_ID = 'local' +export const ALL_EXECUTION_HOSTS_SCOPE = 'all' + +export type ExecutionHostKind = 'local' | 'ssh' | 'runtime' +export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}` + +export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId + +export type ParsedExecutionHost = + | { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID } + | { kind: 'ssh'; id: `ssh:${string}`; targetId: string } + | { kind: 'runtime'; id: `runtime:${string}`; environmentId: string } + +function normalizeHostPart(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +export function toSshExecutionHostId(targetId: string): `ssh:${string}` { + return `ssh:${encodeURIComponent(targetId)}` +} + +export function toRuntimeExecutionHostId(environmentId: string): `runtime:${string}` { + return `runtime:${encodeURIComponent(environmentId)}` +} + +export function parseExecutionHostId(value: string | null | undefined): ParsedExecutionHost | null { + const normalized = normalizeHostPart(value) + if (!normalized) { + return null + } + if (normalized === LOCAL_EXECUTION_HOST_ID) { + return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID } + } + if (normalized.startsWith('ssh:')) { + const encoded = normalized.slice('ssh:'.length) + if (!encoded) { + return null + } + try { + const targetId = decodeURIComponent(encoded) + return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null + } catch { + return null + } + } + if (normalized.startsWith('runtime:')) { + const encoded = normalized.slice('runtime:'.length) + if (!encoded) { + return null + } + try { + const environmentId = decodeURIComponent(encoded) + return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null + } catch { + return null + } + } + return null +} + +export function normalizeExecutionHostId(value: string | null | undefined): ExecutionHostId | null { + return parseExecutionHostId(value)?.id ?? null +} + +export function normalizeExecutionHostScope(value: string | null | undefined): ExecutionHostScope { + const normalized = normalizeHostPart(value) + if (!normalized || normalized === ALL_EXECUTION_HOSTS_SCOPE) { + return ALL_EXECUTION_HOSTS_SCOPE + } + return normalizeExecutionHostId(normalized) ?? ALL_EXECUTION_HOSTS_SCOPE +} + +export function normalizeVisibleExecutionHostIds( + value: readonly string[] | null | undefined +): ExecutionHostId[] | null { + if (!Array.isArray(value)) { + return null + } + const ids: ExecutionHostId[] = [] + const seen = new Set<ExecutionHostId>() + for (const raw of value) { + const id = normalizeExecutionHostId(raw) + if (!id || seen.has(id)) { + continue + } + seen.add(id) + ids.push(id) + } + return ids.length > 0 ? ids : null +} + +export function normalizeExecutionHostOrder( + value: readonly string[] | null | undefined +): ExecutionHostId[] { + const normalized = normalizeVisibleExecutionHostIds(value) + return normalized ?? [] +} + +export function getRepoExecutionHostId( + repo: Pick<Repo, 'connectionId' | 'executionHostId'> +): ExecutionHostId { + const executionHostId = normalizeExecutionHostId(repo.executionHostId) + if (executionHostId) { + return executionHostId + } + const connectionId = normalizeHostPart(repo.connectionId) + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +export function getSettingsFocusedExecutionHostId( + settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined +): ExecutionHostId { + const runtimeEnvironmentId = normalizeHostPart(settings?.activeRuntimeEnvironmentId) + return runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID +} + +export function getExecutionHostLabel(id: ExecutionHostScope): string { + if (id === ALL_EXECUTION_HOSTS_SCOPE) { + return 'All hosts' + } + const parsed = parseExecutionHostId(id) + if (!parsed) { + return 'All hosts' + } + switch (parsed.kind) { + case 'local': + return 'Local Mac' + case 'ssh': + return parsed.targetId + case 'runtime': + return parsed.environmentId + } +} diff --git a/src/shared/feature-interaction-catalog.ts b/src/shared/feature-interaction-catalog.ts index 917a9295b65..132c8af149a 100644 --- a/src/shared/feature-interaction-catalog.ts +++ b/src/shared/feature-interaction-catalog.ts @@ -14,6 +14,7 @@ export type FeatureInteractionId = | 'github-tasks' | 'gitlab-tasks' | 'linear-tasks' + | 'jira-tasks' | 'automations' | 'automation-created' | 'automation-run' @@ -80,6 +81,7 @@ export const FEATURE_INTERACTIONS = [ { id: 'github-tasks', interaction: 'GitHub task item workflow used' }, { id: 'gitlab-tasks', interaction: 'GitLab task item workflow used' }, { id: 'linear-tasks', interaction: 'Linear task item workflow used' }, + { id: 'jira-tasks', interaction: 'Jira task item workflow used' }, { id: 'automations', interaction: 'Automations page opened' }, { id: 'automation-created', interaction: 'automation created' }, { id: 'automation-run', interaction: 'automation run queued' }, diff --git a/src/shared/feature-interaction-categories.ts b/src/shared/feature-interaction-categories.ts index 72b05ceca7e..062b8821432 100644 --- a/src/shared/feature-interaction-categories.ts +++ b/src/shared/feature-interaction-categories.ts @@ -35,6 +35,7 @@ export const FEATURE_INTERACTION_CATEGORY_BY_ID = { 'github-tasks': 'task_management', 'gitlab-tasks': 'task_management', 'linear-tasks': 'task_management', + 'jira-tasks': 'task_management', automations: 'automation', 'automation-created': 'automation', 'automation-run': 'automation', diff --git a/src/shared/feature-interactions.test.ts b/src/shared/feature-interactions.test.ts index 3778f979925..f9c55aee557 100644 --- a/src/shared/feature-interactions.test.ts +++ b/src/shared/feature-interactions.test.ts @@ -46,6 +46,7 @@ describe('feature interactions', () => { 'github-tasks', 'gitlab-tasks', 'linear-tasks', + 'jira-tasks', 'automations', 'automation-created', 'automation-run', @@ -171,6 +172,7 @@ describe('feature interactions', () => { ) expect(FEATURE_INTERACTION_CATEGORY_BY_ID.tasks).toBe('task_management') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['github-tasks']).toBe('task_management') + expect(FEATURE_INTERACTION_CATEGORY_BY_ID['jira-tasks']).toBe('task_management') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['markdown-file-created']).toBe('notes') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['agent-browser-setup']).toBe('setup') expect(FEATURE_INTERACTION_CATEGORY_BY_ID['terminal-tabs']).toBe('terminal') diff --git a/src/shared/git-clone-failure-message.test.ts b/src/shared/git-clone-failure-message.test.ts new file mode 100644 index 00000000000..c869ef49a1a --- /dev/null +++ b/src/shared/git-clone-failure-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { getGitCloneFailureMessage } from './git-clone-failure-message' + +describe('getGitCloneFailureMessage', () => { + 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(''), + { clonePath: '/work/orca' } + ) + ).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', () => { + expect( + getGitCloneFailureMessage( + "fatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n" + ) + ).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', () => { + expect(getGitCloneFailureMessage('warning: retrying\nnetwork vanished\n')).toBe( + 'network vanished' + ) + }) +}) diff --git a/src/shared/git-clone-failure-message.ts b/src/shared/git-clone-failure-message.ts new file mode 100644 index 00000000000..fe19a72f131 --- /dev/null +++ b/src/shared/git-clone-failure-message.ts @@ -0,0 +1,40 @@ +export function getGitCloneFailureMessage( + stderr: string, + options: { clonePath?: string | null } = {} +): string { + const lines = stderr + .replace(/\r/g, '\n') + .split('\n') + .map((line) => stripAnsi(line).trim()) + .filter(Boolean) + + for (let index = lines.length - 1; index >= 0; index--) { + const line = lines[index] + const fatalIndex = line.indexOf('fatal:') + if (fatalIndex !== -1) { + return formatGitCloneFailureLine(line.slice(fatalIndex), options) + } + const errorIndex = line.indexOf('error:') + if (errorIndex !== -1) { + return formatGitCloneFailureLine(line.slice(errorIndex), options) + } + } + + 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/host-setting-overrides.test.ts b/src/shared/host-setting-overrides.test.ts new file mode 100644 index 00000000000..58871e77892 --- /dev/null +++ b/src/shared/host-setting-overrides.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { + clearHostSettingOverride, + getEffectiveHostSetting, + getHostDisplayLabelOverrides, + getHostSettingOverride, + setHostSettingOverride +} from './host-setting-overrides' +import type { GlobalSettings } from './types' + +function settingsWith( + overrides: GlobalSettings['hostSettingOverrides'] +): Pick<GlobalSettings, 'hostSettingOverrides'> { + return { hostSettingOverrides: overrides } +} + +describe('getEffectiveHostSetting', () => { + it('prefers a host override over the client default', () => { + const settings = settingsWith({ 'ssh:box': { defaultWorktreeLocation: '/remote/work' } }) + expect( + getEffectiveHostSetting(settings, 'ssh:box', 'defaultWorktreeLocation', '/local/work') + ).toBe('/remote/work') + }) + + it('falls back to the client default when no override exists', () => { + const settings = settingsWith({}) + expect( + getEffectiveHostSetting(settings, 'ssh:box', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/work') + }) + + it('falls back for an unknown host', () => { + const settings = settingsWith({ 'ssh:other': { defaultWorktreeLocation: '/x' } }) + expect( + getEffectiveHostSetting(settings, 'runtime:env', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/work') + }) + + it('treats a whitespace override as absent and falls back', () => { + const settings = settingsWith({ 'ssh:box': { defaultWorktreeLocation: ' ' } }) + expect( + getEffectiveHostSetting(settings, 'ssh:box', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/work') + }) + + it('allows local-host overrides', () => { + const settings = settingsWith({ local: { defaultWorktreeLocation: '/local/override' } }) + expect( + getEffectiveHostSetting(settings, 'local', 'defaultWorktreeLocation', '/local/work') + ).toBe('/local/override') + }) + + it('falls back when settings are null/undefined', () => { + expect(getEffectiveHostSetting(null, 'ssh:box', 'displayLabel', 'Default')).toBe('Default') + expect(getEffectiveHostSetting(undefined, 'ssh:box', 'displayLabel', 'Default')).toBe('Default') + }) +}) + +describe('getHostSettingOverride', () => { + it('returns the override when present', () => { + const settings = settingsWith({ 'ssh:box': { displayLabel: 'My Box' } }) + expect(getHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toBe('My Box') + }) + + it('returns undefined when missing', () => { + expect(getHostSettingOverride(settingsWith({}), 'ssh:box', 'displayLabel')).toBeUndefined() + }) +}) + +describe('setHostSettingOverride', () => { + it('adds an override for a new host', () => { + const next = setHostSettingOverride(settingsWith({}), 'ssh:box', 'displayLabel', 'Box') + expect(next).toEqual({ 'ssh:box': { displayLabel: 'Box' } }) + }) + + it('merges into an existing host without clobbering other keys', () => { + const settings = settingsWith({ 'ssh:box': { displayLabel: 'Box' } }) + const next = setHostSettingOverride(settings, 'ssh:box', 'defaultWorktreeLocation', '/w') + expect(next).toEqual({ 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } }) + }) + + it('does not mutate the input map', () => { + const overrides = { 'ssh:box': { displayLabel: 'Box' } } + const settings = settingsWith(overrides) + setHostSettingOverride(settings, 'ssh:box', 'displayLabel', 'Renamed') + expect(overrides).toEqual({ 'ssh:box': { displayLabel: 'Box' } }) + }) + + it('clears the key when given an empty value', () => { + const settings = settingsWith({ + 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } + }) + const next = setHostSettingOverride(settings, 'ssh:box', 'displayLabel', ' ') + expect(next).toEqual({ 'ssh:box': { defaultWorktreeLocation: '/w' } }) + }) +}) + +describe('clearHostSettingOverride', () => { + it('removes a single key but keeps remaining overrides', () => { + const settings = settingsWith({ + 'ssh:box': { displayLabel: 'Box', defaultWorktreeLocation: '/w' } + }) + expect(clearHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toEqual({ + 'ssh:box': { defaultWorktreeLocation: '/w' } + }) + }) + + it('drops the host entry when no overrides remain', () => { + const settings = settingsWith({ 'ssh:box': { displayLabel: 'Box' } }) + expect(clearHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toEqual({}) + }) + + it('is a no-op for an unknown host', () => { + const settings = settingsWith({ 'ssh:other': { displayLabel: 'Other' } }) + expect(clearHostSettingOverride(settings, 'ssh:box', 'displayLabel')).toEqual({ + 'ssh:other': { displayLabel: 'Other' } + }) + }) + + it('does not mutate the input map', () => { + const overrides = { 'ssh:box': { displayLabel: 'Box' } } + const settings = settingsWith(overrides) + clearHostSettingOverride(settings, 'ssh:box', 'displayLabel') + expect(overrides).toEqual({ 'ssh:box': { displayLabel: 'Box' } }) + }) +}) + +describe('getHostDisplayLabelOverrides', () => { + it('collects non-empty display labels keyed by host id', () => { + const settings = settingsWith({ + 'ssh:box': { displayLabel: 'Box' }, + 'runtime:env': { defaultWorktreeLocation: '/w' }, + local: { displayLabel: ' ' } + }) + const map = getHostDisplayLabelOverrides(settings) + expect(map.get('ssh:box')).toBe('Box') + expect(map.has('runtime:env')).toBe(false) + expect(map.has('local')).toBe(false) + }) + + it('returns an empty map when no overrides exist', () => { + expect(getHostDisplayLabelOverrides(null).size).toBe(0) + }) +}) diff --git a/src/shared/host-setting-overrides.ts b/src/shared/host-setting-overrides.ts new file mode 100644 index 00000000000..908da41fd0e --- /dev/null +++ b/src/shared/host-setting-overrides.ts @@ -0,0 +1,89 @@ +import type { ExecutionHostId } from './execution-host' +import type { GlobalSettings, HostSettingOverrides } from './types' + +// Why: per-host preferences follow `effective = host override ?? client default`. +// These pure helpers centralize that rule so the UI, registry, and tests share a +// single implementation instead of re-deriving the fallback at each call site. + +export type HostSettingOverrideKey = keyof HostSettingOverrides + +type HostSettingsSlice = Pick<GlobalSettings, 'hostSettingOverrides'> + +function normalize(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +/** Returns the host's override for `key` if present and non-empty, else `undefined`. */ +export function getHostSettingOverride( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey +): string | undefined { + return normalize(settings?.hostSettingOverrides?.[hostId]?.[key]) +} + +/** `host override ?? client default`. Unknown hosts and cleared overrides fall back. */ +export function getEffectiveHostSetting( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey, + clientDefault: string +): string { + return getHostSettingOverride(settings, hostId, key) ?? clientDefault +} + +/** Pure update: returns the next `hostSettingOverrides` map with the override set. + * An empty/whitespace value clears the key instead of persisting blank text. */ +export function setHostSettingOverride( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey, + value: string +): Partial<Record<ExecutionHostId, HostSettingOverrides>> { + const normalized = normalize(value) + if (normalized === undefined) { + return clearHostSettingOverride(settings, hostId, key) + } + const current = settings?.hostSettingOverrides ?? {} + return { + ...current, + [hostId]: { ...current[hostId], [key]: normalized } + } +} + +/** Pure update: returns the next map with the key removed, dropping the host + * entry entirely once it has no remaining overrides. */ +export function clearHostSettingOverride( + settings: HostSettingsSlice | null | undefined, + hostId: ExecutionHostId, + key: HostSettingOverrideKey +): Partial<Record<ExecutionHostId, HostSettingOverrides>> { + const current = settings?.hostSettingOverrides + const hostOverrides = current?.[hostId] + if (!current || !hostOverrides || !(key in hostOverrides)) { + return current ?? {} + } + const { [key]: _removed, ...remaining } = hostOverrides + const next = { ...current } + if (Object.keys(remaining).length === 0) { + delete next[hostId] + } else { + next[hostId] = remaining + } + return next +} + +/** Builds the `displayLabel` lookup map the host registry consumes. */ +export function getHostDisplayLabelOverrides( + settings: HostSettingsSlice | null | undefined +): ReadonlyMap<ExecutionHostId, string> { + const result = new Map<ExecutionHostId, string>() + for (const [hostId, overrides] of Object.entries(settings?.hostSettingOverrides ?? {})) { + const label = normalize(overrides?.displayLabel) + if (label) { + result.set(hostId as ExecutionHostId, label) + } + } + return result +} 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 new file mode 100644 index 00000000000..57f848afae7 --- /dev/null +++ b/src/shared/project-host-setup-projection.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { + projectHostSetupProjectionFromRepos, + getProjectHostSetupsForProject, + getProjectHostSetupWorktreeMeta +} from './project-host-setup-projection' +import type { Repo } from './types' + +function repo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path' | 'displayName'>): Repo { + return { + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + ...overrides + } +} + +describe('project host setup projection', () => { + it('projects a legacy local repo into one project and one ready local setup', () => { + const projection = projectHostSetupProjectionFromRepos( + [repo({ id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca' })], + 500 + ) + + expect(projection.projects).toEqual([ + { + id: 'repo:repo-1', + displayName: 'orca', + badgeColor: '#737373', + kind: 'git', + sourceRepoIds: ['repo-1'], + createdAt: 100, + updatedAt: 100 + } + ]) + expect(projection.setups).toEqual([ + { + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + kind: 'git', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 100, + updatedAt: 100 + } + ]) + }) + + it('preserves host-local setup fields on SSH repos', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'openclaw 2', + worktreeBasePath: '../worktrees', + gitUsername: 'alice' + }) + ]) + + expect(projection.setups[0]).toMatchObject({ + id: 'remote-repo', + hostId: 'ssh:openclaw%202', + connectionId: 'openclaw 2', + worktreeBasePath: '../worktrees', + gitUsername: 'alice' + }) + }) + + it('preserves repo-backed setup method metadata', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + projectHostSetupMethod: 'cloned' + }) + ]) + + expect(projection.setups[0]?.setupMethod).toBe('cloned') + }) + + it('groups repo checkouts with the same provider identity under one project', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'local-repo', + path: '/Users/alice/orca', + displayName: 'Orca', + upstream: { owner: 'StablyAI', repo: 'Orca' } + }), + repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'gpu-vm', + upstream: { owner: 'stablyai', repo: '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('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' }), + repo({ + id: 'remote-repo', + path: '/srv/app', + displayName: 'app', + connectionId: 'work-server' + }) + ]) + + expect(projection.projects.map((project) => project.id)).toEqual([ + 'repo:local-repo', + 'repo:remote-repo' + ]) + }) + + it('ignores malformed provider identity values', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + id: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + upstream: { owner: 'stablyai', repo: 42 } as never + }) + ]) + + expect(projection.projects[0]?.id).toBe('repo:repo-1') + expect(projection.projects[0]?.providerIdentity).toBeUndefined() + }) + + it('derives workspace ownership metadata from the repo setup', () => { + const targetRepo = repo({ + id: 'remote-repo', + path: '/home/alice/orca', + displayName: 'orca', + connectionId: 'openclaw 2', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + const projection = projectHostSetupProjectionFromRepos([targetRepo]) + + expect(getProjectHostSetupWorktreeMeta(projection.setups, targetRepo)).toEqual({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw%202', + projectHostSetupId: 'remote-repo' + }) + }) +}) diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts new file mode 100644 index 00000000000..f90a9aca77f --- /dev/null +++ b/src/shared/project-host-setup-projection.ts @@ -0,0 +1,161 @@ +import { getRepoExecutionHostId } from './execution-host' +import type { + Project, + ProjectHostSetup, + ProjectProviderIdentity, + Repo, + WorktreeMeta +} from './types' + +type ProjectAccumulator = { + project: Project +} + +export type ProjectHostSetupProjection = { + projects: Project[] + setups: ProjectHostSetup[] +} + +function normalizeIdentityPart(value: string): string { + return value.trim().toLowerCase() +} + +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() : '' + 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 +} + +export function getProjectIdentityKey(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string { + const identity = getProjectProviderIdentity(repo) + if (!identity) { + return `repo:${repo.id}` + } + return `github:${normalizeIdentityPart(identity.owner)}/${normalizeIdentityPart(identity.repo)}` +} + +function getProjectId(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string { + return getProjectIdentityKey(repo) +} + +function createProjectFromRepo(repo: Repo, now: number): Project { + const identity = getProjectProviderIdentity(repo) + return { + id: getProjectId(repo), + displayName: repo.displayName, + badgeColor: repo.badgeColor, + ...(repo.repoIcon !== undefined ? { repoIcon: repo.repoIcon } : {}), + ...(repo.kind ? { kind: repo.kind } : {}), + ...(identity ? { providerIdentity: identity } : {}), + sourceRepoIds: [repo.id], + createdAt: repo.addedAt || now, + updatedAt: repo.addedAt || now + } +} + +function mergeProjectRepo(project: Project, repo: Repo): Project { + const sourceRepoIds = project.sourceRepoIds.includes(repo.id) + ? project.sourceRepoIds + : [...project.sourceRepoIds, repo.id] + return { + ...project, + sourceRepoIds, + createdAt: Math.min(project.createdAt, repo.addedAt || project.createdAt), + updatedAt: Math.max(project.updatedAt, repo.addedAt || project.updatedAt) + } +} + +function createSetupFromRepo(repo: Repo, projectId: string, now: number): ProjectHostSetup { + const hostId = getRepoExecutionHostId(repo) + const createdAt = repo.addedAt || now + const setupMethod = repo.projectHostSetupMethod ?? 'legacy-repo' + return { + id: repo.id, + projectId, + hostId, + repoId: repo.id, + path: repo.path, + displayName: repo.displayName, + ...(repo.kind ? { kind: repo.kind } : {}), + ...(repo.connectionId !== undefined ? { connectionId: repo.connectionId } : {}), + ...(repo.executionHostId !== undefined ? { executionHostId: repo.executionHostId } : {}), + ...(repo.worktreeBasePath ? { worktreeBasePath: repo.worktreeBasePath } : {}), + ...(repo.hookSettings ? { hookSettings: repo.hookSettings } : {}), + ...(repo.gitUsername ? { gitUsername: repo.gitUsername } : {}), + ...(repo.sourceControlAi ? { sourceControlAi: repo.sourceControlAi } : {}), + setupState: 'ready', + setupMethod, + createdAt, + updatedAt: createdAt + } +} + +export function projectHostSetupProjectionFromRepos( + repos: readonly Repo[], + now = Date.now() +): ProjectHostSetupProjection { + const projectById = new Map<string, ProjectAccumulator>() + const setups: ProjectHostSetup[] = [] + + for (const repo of repos) { + const projectId = getProjectId(repo) + const existing = projectById.get(projectId) + const project = existing + ? mergeProjectRepo(existing.project, repo) + : createProjectFromRepo(repo, now) + const setup = createSetupFromRepo(repo, projectId, now) + projectById.set(projectId, { + project + }) + setups.push(setup) + } + + return { + projects: [...projectById.values()].map((entry) => entry.project), + setups + } +} + +export function getProjectHostSetupsForProject( + setups: readonly ProjectHostSetup[], + projectId: string +): ProjectHostSetup[] { + return setups.filter((setup) => setup.projectId === projectId) +} + +export function getProjectHostSetupForRepo( + setups: readonly ProjectHostSetup[], + repo: Repo +): ProjectHostSetup { + return ( + setups.find((setup) => setup.repoId === repo.id) ?? + projectHostSetupProjectionFromRepos([repo]).setups[0] + ) +} + +export function getProjectHostSetupWorktreeMeta( + setups: readonly ProjectHostSetup[], + repo: Repo +): Pick<WorktreeMeta, 'projectId' | 'hostId' | 'projectHostSetupId'> { + const setup = getProjectHostSetupForRepo(setups, repo) + return { + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id + } +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 907e4259541..778196c1adc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -19,7 +19,11 @@ export const RUNTIME_PROTOCOL_VERSION = 3 export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 -export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 3 +export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 + +export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const +export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const +export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -28,7 +32,10 @@ export const RUNTIME_CAPABILITIES = [ 'terminal.binary-stream.v1', 'terminal.multiplex.v1', 'workspace-ports.v1', - 'mobile.tasks.v1' + 'mobile.tasks.v1', + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 64954fd6ead..f851a3ba6fc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -349,6 +349,7 @@ export type RuntimeTerminalSend = { export type RuntimeTerminalCreate = { handle: string + tabId?: string worktreeId: string title: string | null surface?: 'background' | 'visible' diff --git a/src/shared/task-source-context.test.ts b/src/shared/task-source-context.test.ts new file mode 100644 index 00000000000..5af28c763cb --- /dev/null +++ b/src/shared/task-source-context.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import { + LOCAL_EXECUTION_HOST_ID, + 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('serializes provider identities for GitLab, Linear, and Jira cache scopes', () => { + const base = { + projectId: 'project-1', + hostId: LOCAL_EXECUTION_HOST_ID, + repoId: 'repo-1' + } as const + + expect( + getTaskSourceCacheScope({ + ...base, + provider: 'gitlab', + providerIdentity: { provider: 'gitlab', namespace: 'stably', project: 'orca' } + }) + ).toContain(encodeURIComponent('stably/orca')) + expect( + getTaskSourceCacheScope({ + ...base, + provider: 'linear', + providerIdentity: { provider: 'linear', workspaceId: 'workspace-1', teamKey: 'ENG' } + }) + ).toContain(encodeURIComponent('workspace-1/ENG')) + expect( + getTaskSourceCacheScope({ + ...base, + provider: 'jira', + providerIdentity: { + provider: 'jira', + siteUrl: 'https://example.atlassian.net', + projectKey: 'OPS' + } + }) + ).toContain(encodeURIComponent('https://example.atlassian.net/OPS')) + }) + + it('drops provider identities that do not match the source provider', () => { + expect( + normalizeTaskSourceContext({ + provider: 'gitlab', + projectId: 'project-1', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } + })?.providerIdentity + ).toBeNull() + }) + + 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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 3678838a91d..43228342e2c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import type { ExecutionHostId } from './execution-host' import type { SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' @@ -87,6 +88,128 @@ export type RepoKind = 'git' | 'folder' export type IssueSourcePreference = 'upstream' | 'origin' | 'auto' export type ExternalWorktreeVisibility = 'hide' | 'show' +export type ProjectProviderIdentity = { + provider: 'github' + owner: string + repo: string +} + +export type Project = { + id: string + displayName: string + badgeColor: string + repoIcon?: RepoIcon | null + kind?: RepoKind + providerIdentity?: ProjectProviderIdentity + sourceRepoIds: string[] + createdAt: number + updatedAt: number +} + +export type ProjectHostSetupState = 'ready' | 'not-set-up' | 'setting-up' | 'error' | 'unsupported' +export type ProjectHostSetupMethod = + | 'legacy-repo' + | 'imported-existing-folder' + | 'cloned' + | 'provisioned' +export type RepoProjectHostSetupMethod = Extract< + ProjectHostSetupMethod, + 'imported-existing-folder' | 'cloned' +> + +export type ProjectHostSetup = { + id: string + projectId: string + hostId: ExecutionHostId + repoId: string + path: string + displayName: string + kind?: RepoKind + connectionId?: string | null + executionHostId?: ExecutionHostId | null + worktreeBasePath?: string + hookSettings?: RepoHookSettings + gitUsername?: string + setupState: ProjectHostSetupState + setupMethod: ProjectHostSetupMethod + sourceControlAi?: RepoSourceControlAiOverrides + createdAt: number + updatedAt: number +} + +export type ProjectHostSetupExistingFolderArgs = { + projectId: string + hostId: ExecutionHostId + path: string + kind?: RepoKind + displayName?: string + setupMethod?: RepoProjectHostSetupMethod +} + +export type ProjectHostSetupCreateArgs = { + projectId: string + hostId: ExecutionHostId + setupId?: string + path?: string + kind?: RepoKind + displayName?: string + worktreeBasePath?: string + gitUsername?: string + setupState?: ProjectHostSetupState + setupMethod?: Exclude<ProjectHostSetupMethod, 'legacy-repo'> +} + +export type ProjectHostSetupCloneArgs = { + projectId: string + hostId: ExecutionHostId + url: string + destination: string + displayName?: string +} + +export type ProjectHostSetupUpdateArgs = { + setupId: string + updates: Partial< + Pick< + ProjectHostSetup, + | 'displayName' + | 'path' + | 'worktreeBasePath' + | 'setupState' + | 'setupMethod' + | 'gitUsername' + | 'kind' + > + > +} + +export type ProjectHostSetupDeleteArgs = { + setupId: string +} + +export type ProjectHostSetupResult = { + project: Project + setup: ProjectHostSetup + repo: Repo +} + +export type ProjectHostSetupCreateResult = { + project: Project + setup: ProjectHostSetup +} + +export type ProjectHostSetupUpdateResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + +export type ProjectHostSetupDeleteResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + export type Repo = { id: string path: string @@ -106,6 +229,11 @@ export type Repo = { hookSettings?: RepoHookSettings /** SSH target ID for remote repos. null/undefined = local. */ connectionId?: string | null + /** + * Explicit execution owner for this repo. Runtime-host repos need this + * because they otherwise look identical to local repos (`connectionId: null`). + */ + executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null /** Per-repo override for issue-source resolution. `undefined` is treated * identically to `'auto'`; writers leave it undefined on creation so * existing persisted records stay forward-compatible. */ @@ -128,6 +256,8 @@ export type Repo = { projectGroupOrder?: number /** Repo-specific source-control AI overrides. Missing fields inherit global settings. */ sourceControlAi?: RepoSourceControlAiOverrides + /** Transitional source for ProjectHostSetup.setupMethod while Repo remains compatibility storage. */ + projectHostSetupMethod?: RepoProjectHostSetupMethod } export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration' @@ -286,6 +416,12 @@ export type Worktree = { id: string // `${repoId}::${path}` instanceId?: string repoId: string + /** Durable project identity. Optional while legacy repo-only workspaces migrate. */ + projectId?: string + /** Execution host that owns the workspace. Optional for pre-project-host metadata. */ + hostId?: ExecutionHostId + /** Host-specific setup used to create/run this workspace. */ + projectHostSetupId?: string displayName: string comment: string linkedIssue: number | null @@ -363,6 +499,12 @@ export type GitHubPrStartPoint = { export type WorktreeMeta = { /** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */ instanceId?: string + /** See Worktree.projectId. Persisted for project-first workspace ownership. */ + projectId?: string + /** See Worktree.hostId. Persisted for project-first workspace ownership. */ + hostId?: ExecutionHostId + /** See Worktree.projectHostSetupId. Persisted for project-first workspace ownership. */ + projectHostSetupId?: string displayName: string comment: string linkedIssue: number | null @@ -657,6 +799,9 @@ export type BrowserPage = { canGoForward: boolean loadError: BrowserLoadError | null createdAt: number + // Why: remote-owned worktrees can still host client-local fallback browser + // pages until headless remote runtimes support real browser panes. + browserRuntimeEnvironmentId?: string | null /** Active CDP viewport emulation preset. null = default (fill pane, no CDP override) */ viewportPresetId?: BrowserViewportPresetId | null } @@ -903,6 +1048,7 @@ export type GitHubPRRefreshAlias = { branch: string worktreeId?: string connectionId?: string | null + executionHostId?: string | null linkedPRNumber?: number | null fallbackPRNumber?: number | null fallbackPRSource?: 'explicit' | 'pr-cache' | 'hosted-review' | null @@ -914,6 +1060,7 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { isBare?: boolean isArchived?: boolean connectionId?: string | null + executionHostId?: string | null connectionState?: 'connected' | 'disconnected' | 'unknown' cachedFetchedAt?: number | null cachedHasPR?: boolean | null @@ -1763,6 +1910,8 @@ export type CreateWorktreeResult = { localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion startupTerminal?: { spawned: boolean + handle?: string + tabId?: string surface?: 'visible' | 'background' } timing?: WorktreeCreateTiming @@ -2077,8 +2226,23 @@ export type FloatingTerminalCwdRequest = { requireTrusted?: boolean } +/** Per-host overrides for client preferences that genuinely vary by execution + * host. NARROW by design: only settings whose value is meaningless to share + * across hosts belong here. + * - `displayLabel`: a client-side rename for the host shown in sidebar/pickers. + * - `defaultWorktreeLocation`: the host's root worktree directory; a remote + * SSH/runtime host has a different filesystem layout than the local Mac, so + * the client `workspaceDir` default cannot apply unchanged. */ +export type HostSettingOverrides = { + displayLabel?: string + defaultWorktreeLocation?: string +} + export type GlobalSettings = { workspaceDir: string + /** Per-host overrides keyed by ExecutionHostId. Effective value for a + * host-varying setting is `host override ?? client default`. */ + hostSettingOverrides?: Partial<Record<ExecutionHostId, HostSettingOverrides>> nestWorkspaces: boolean workspaceDirHistory?: OrcaWorkspaceLayout[] refreshLocalBaseRefOnWorktreeCreate: boolean @@ -2707,6 +2871,9 @@ export type ActiveRightSidebarTab = Exclude<RightSidebarTab, 'search'> export type RightSidebarExplorerView = 'files' | 'search' export type ProjectOrderBy = 'manual' | 'recent' +export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}` +export type VisibleWorkspaceHostIds = Exclude<WorkspaceHostScope, 'all'>[] | null +export type WorkspaceHostOrder = Exclude<WorkspaceHostScope, 'all'>[] export type PersistedUIState = { lastActiveRepoId: string | null @@ -2727,6 +2894,16 @@ export type PersistedUIState = { showActiveOnly: boolean /** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */ hideSleepingWorkspaces?: boolean + /** Which execution hosts the workspace sidebar shows. `all` keeps the mixed + * command-center view; specific host IDs focus the sidebar without tearing + * down sessions owned by other hosts. */ + workspaceHostScope?: WorkspaceHostScope + /** Which execution hosts the workspace sidebar shows. `null` means sticky + * all-hosts so newly-added hosts appear automatically. */ + visibleWorkspaceHostIds?: VisibleWorkspaceHostIds + /** User-defined sidebar order for host sections. Missing/new hosts append in + * the discovered host order. */ + workspaceHostOrder?: WorkspaceHostOrder /** Deprecated legacy positive-form setting. Ignored on hydration. */ showSleepingWorkspaces?: boolean /** Deprecated legacy name used by a short-lived build. Ignored on hydration. */ @@ -2979,6 +3156,8 @@ export type LegacyPaneKeyAliasEntry = { export type PersistedState = { schemaVersion: number repos: Repo[] + projects: Project[] + projectHostSetups: ProjectHostSetup[] projectGroups: ProjectGroup[] folderWorkspaces: FolderWorkspace[] /** Sparse-checkout presets keyed by repoId. Empty record on first launch; @@ -2992,7 +3171,14 @@ export type PersistedState = { pr: Record<string, { data: PRInfo | null; fetchedAt: number }> issue: Record<string, { data: IssueInfo | null; fetchedAt: number }> } + /** Legacy single-blob session. Retained as the canonical 'local' execution + * host partition so an app downgrade still reads its workspace. Non-local + * hosts live in workspaceSessionsByHostId, keyed by ExecutionHostId. */ workspaceSession: WorkspaceSessionState + /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:). + * Mixed-host writes stay isolated here; 'local' stays in workspaceSession so + * pre-partition builds keep working. Optional/absent on legacy files. */ + workspaceSessionsByHostId?: Partial<Record<ExecutionHostId, WorkspaceSessionState>> sshTargets: SshTarget[] sshRemotePtyLeases: SshRemotePtyLease[] migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 20fb3f84c0f..387728cefbb 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -245,6 +245,9 @@ const browserPageSchema = z.object({ canGoForward: z.boolean(), loadError: browserLoadErrorSchema.nullable(), createdAt: z.number(), + // Why: explicit null marks a browser page as client-local even when its + // worktree is remote-owned; older sessions omit it and keep inferred runtime. + browserRuntimeEnvironmentId: z.string().nullable().optional(), // Why: optional+nullable so sessions persisted before viewport presets were // added still validate; without this, zod would strip the field during // restore and reset the user's chosen preset on every app restart.